From 5a7c9c708952b74b240edd69a03ae818083c82d7 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 13:22:40 -0800 Subject: [PATCH 001/417] Update README.md --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 19c6babb..7aa05cb5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ +

+ image +

AlphaClaw

-

Setup UI. Gateway manager. Watchdog. Zero config to production.
One package. One command. Full OpenClaw infrastructure. @@ -11,22 +13,20 @@ License: MIT

-The management harness for [OpenClaw](https://github.com/openclaw/openclaw) — a web-based Setup UI, gateway lifecycle manager, crash watchdog, and channel orchestrator that turns a bare OpenClaw install into a production-ready deployment. - -``` -1 command · Web dashboard · Crash recovery · Telegram/Discord/Google · Webhooks · Git sync -``` +

The management harness for [OpenClaw](https://github.com/openclaw/openclaw) — a web-based Setup UI, gateway lifecycle manager, crash watchdog, and channel orchestrator that turns a bare OpenClaw install into a production-ready deployment.

Deploy on Railway Deploy to Render

-> **Platform:** AlphaClaw currently targets Docker/Linux deployments (Railway, Render, VPS). macOS local development is not yet supported. - --- -### Features +> **Platform:** AlphaClaw currently targets Docker/Linux deployments. macOS local development is not yet supported. + + + +## Features - **Setup UI:** Password-protected web dashboard for onboarding, configuration, and day-to-day management. - **Guided Onboarding:** Step-by-step setup wizard — model selection, provider credentials, GitHub repo, channel pairing. @@ -40,7 +40,7 @@ The management harness for [OpenClaw](https://github.com/openclaw/openclaw) — - **Version Management:** In-place updates for both AlphaClaw and OpenClaw with changelog review and one-click apply. - **Codex OAuth:** Built-in PKCE flow for OpenAI Codex CLI model access. -### Why AlphaClaw +## Why AlphaClaw - **Zero to production in one deploy:** Railway/Render templates ship a complete stack — no manual gateway setup. - **Self-healing:** Watchdog detects crashes, enters repair mode, relaunches the gateway, and notifies you. @@ -49,16 +49,16 @@ The management harness for [OpenClaw](https://github.com/openclaw/openclaw) — --- -### Quick Start +## Quick Start -#### Deploy (recommended) +### Deploy (recommended) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/openclaw-fast-start?referralCode=jcFhp_&utm_medium=integration&utm_source=template&utm_campaign=generic) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/chrysb/openclaw-render-template) Set `SETUP_PASSWORD` at deploy time and visit your deployment URL. The welcome wizard handles the rest. -#### Local / Docker +### Local / Docker ```bash npm install @chrysb/alphaclaw @@ -81,7 +81,7 @@ CMD ["alphaclaw", "start"] --- -### Setup UI +## Setup UI | Tab | What it manages | | ------------- | ---------------------------------------------------------------------------------------------------------- | @@ -93,7 +93,7 @@ CMD ["alphaclaw", "start"] --- -### CLI +## CLI | Command | Description | | ---------------------------------------------------------- | --------------------------------------------- | @@ -105,7 +105,7 @@ CMD ["alphaclaw", "start"] --- -### Architecture +## Architecture ``` ┌─────────────────────────────────────────────────┐ @@ -133,7 +133,7 @@ CMD ["alphaclaw", "start"] --- -### Watchdog +## Watchdog The built-in watchdog monitors gateway health and recovers from failures automatically. @@ -150,7 +150,7 @@ The built-in watchdog monitors gateway health and recovers from failures automat --- -### Environment Variables +## Environment Variables | Variable | Required | Description | | --------------------------------- | -------- | -------------------------------------------------- | @@ -168,7 +168,7 @@ The built-in watchdog monitors gateway health and recovers from failures automat --- -### Development +## Development ```bash npm install From 1d1538cbfd6e30e9cafe47559225ea465d7c97f1 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 13:23:14 -0800 Subject: [PATCH 002/417] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7aa05cb5..5380517a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ License: MIT

-

The management harness for [OpenClaw](https://github.com/openclaw/openclaw) — a web-based Setup UI, gateway lifecycle manager, crash watchdog, and channel orchestrator that turns a bare OpenClaw install into a production-ready deployment.

+

The management harness for OpenClaw — a web-based Setup UI, gateway lifecycle manager, crash watchdog, and channel orchestrator that turns a bare OpenClaw install into a production-ready deployment.

Deploy on Railway From a1cd35e253db968f5afcec31900df4c5dd173393 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 13:31:04 -0800 Subject: [PATCH 003/417] Add security notes section and resolve merge conflict Made-with: Cursor --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5380517a..0983966b 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,6 @@ > **Platform:** AlphaClaw currently targets Docker/Linux deployments. macOS local development is not yet supported. - - ## Features - **Setup UI:** Password-protected web dashboard for onboarding, configuration, and day-to-day management. @@ -168,6 +166,22 @@ The built-in watchdog monitors gateway health and recovers from failures automat --- +## Security Notes + +AlphaClaw is a convenience wrapper — it intentionally trades some of OpenClaw's default hardening for ease of setup. You should understand what's different: + +| Area | What AlphaClaw does | Trade-off | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| **Setup password** | All gateway access is gated behind a single `SETUP_PASSWORD`. Brute-force protection is built in (exponential backoff lockout). | Simpler than OpenClaw's pairing code flow, but the password must be strong. | +| **One-click pairing** | Channel pairings (Telegram/Discord) can be approved from the Setup UI instead of the CLI. | No terminal access required, but anyone with the setup password can approve pairings. | +| **Auto CLI approval** | The first CLI device pairing is auto-approved so you can connect without a second screen. Subsequent requests appear in the UI. | Removes the manual pairing step for the initial CLI connection. | +| **Query-string tokens** | Webhook URLs support `?token=` for providers that don't support `Authorization` headers. Warnings are shown in the UI. | Tokens may appear in server logs and referrer headers. Use header auth when your provider supports it. | +| **Gateway token** | `OPENCLAW_GATEWAY_TOKEN` is auto-generated and injected into the environment so the proxy can authenticate with the gateway. | The token lives in the `.env` file on the server — standard for managed deployments but worth noting. | + +If you need OpenClaw's full security posture (manual pairing codes, no query-string tokens, no auto-approval), use OpenClaw directly without AlphaClaw. + +--- + ## Development ```bash @@ -182,6 +196,6 @@ npm run test:coverage # Coverage report --- -### License +## License MIT From 7c2971ee8226d936b93c627cdf44fff9c2188242 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 13:32:16 -0800 Subject: [PATCH 004/417] Replace ASCII architecture diagram with Mermaid Made-with: Cursor --- README.md | 56 ++++++++++++++----------------------------------------- 1 file changed, 14 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0983966b..c5007ebe 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,6 @@ Deploy to Render

---- - > **Platform:** AlphaClaw currently targets Docker/Linux deployments. macOS local development is not yet supported. ## Features @@ -45,8 +43,6 @@ - **Everything in the browser:** No SSH, no config files to hand-edit, no CLI required after first deploy. - **Stays out of the way:** AlphaClaw manages infrastructure; OpenClaw handles the AI. ---- - ## Quick Start ### Deploy (recommended) @@ -77,8 +73,6 @@ EXPOSE 3000 CMD ["alphaclaw", "start"] ``` ---- - ## Setup UI | Tab | What it manages | @@ -89,8 +83,6 @@ CMD ["alphaclaw", "start"] | **Envars** | Environment variables — view, edit, add — with gateway restart prompts | | **Webhooks** | Webhook endpoints, transform modules, request history, payload inspection | ---- - ## CLI | Command | Description | @@ -101,36 +93,24 @@ CMD ["alphaclaw", "start"] | `alphaclaw version` | Print version | | `alphaclaw help` | Show help | ---- - ## Architecture -``` -┌─────────────────────────────────────────────────┐ -│ AlphaClaw │ -│ │ -│ ┌───────────┐ ┌───────────┐ ┌─────────────┐ │ -│ │ Setup UI │ │ Watchdog │ │ Webhooks │ │ -│ │ (Preact) │ │ │ │ │ │ -│ └─────┬─────┘ └─────┬─────┘ └──────┬──────┘ │ -│ │ │ │ │ -│ ┌─────┴──────────────┴───────────────┴───────┐ │ -│ │ Express Server (API) │ │ -│ └─────────────────────┬──────────────────────┘ │ -│ │ proxy │ -│ ┌─────────────────────┴──────────────────────┐ │ -│ │ OpenClaw Gateway (child process) │ │ -│ └────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ +```mermaid +graph TD + subgraph AlphaClaw + UI["Setup UI
Preact + htm + Wouter"] + WD["Watchdog
Crash recovery · Notifications"] + WH["Webhooks
Transforms · Request logging"] + UI --> API + WD --> API + WH --> API + API["Express Server
JSON APIs · Auth · Proxy"] + end + + API -- "proxy" --> GW["OpenClaw Gateway
Child process · 127.0.0.1:18789"] + GW --> DATA["ALPHACLAW_ROOT_DIR
.openclaw/ · .env · logs · SQLite"] ``` -- **Frontend:** Preact + htm + Wouter (hash routing), served as static assets. -- **Backend:** Express with JSON APIs, http-proxy for gateway passthrough. -- **Gateway:** OpenClaw runs as a managed subprocess on `127.0.0.1:18789`. -- **Data:** `ALPHACLAW_ROOT_DIR` (default `/data`) holds `.openclaw/`, `.env`, credentials, logs, and SQLite databases. - ---- - ## Watchdog The built-in watchdog monitors gateway health and recovers from failures automatically. @@ -146,8 +126,6 @@ The built-in watchdog monitors gateway health and recovers from failures automat | **Expected restart window** | Suppresses alerts during intentional restarts | | **Event log** | SQLite-backed incident history with API and UI access | ---- - ## Environment Variables | Variable | Required | Description | @@ -164,8 +142,6 @@ The built-in watchdog monitors gateway health and recovers from failures automat | `ALPHACLAW_ROOT_DIR` | Optional | Data directory (default `/data`) | | `TRUST_PROXY_HOPS` | Optional | Trust proxy hop count for correct client IP | ---- - ## Security Notes AlphaClaw is a convenience wrapper — it intentionally trades some of OpenClaw's default hardening for ease of setup. You should understand what's different: @@ -180,8 +156,6 @@ AlphaClaw is a convenience wrapper — it intentionally trades some of OpenClaw' If you need OpenClaw's full security posture (manual pairing codes, no query-string tokens, no auto-approval), use OpenClaw directly without AlphaClaw. ---- - ## Development ```bash @@ -194,8 +168,6 @@ npm run test:coverage # Coverage report **Requirements:** Node.js ≥ 22.12.0 ---- - ## License MIT From 307a9e886db57f735b2dc6f7fffc3cc7ef4b3da4 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 13:36:26 -0800 Subject: [PATCH 005/417] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index c5007ebe..edba9570 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,6 @@ The built-in watchdog monitors gateway health and recovers from failures automat | **Crash-loop detection** | Threshold-based (default: 3 crashes in 300s) | | **Auto-repair** | Runs `openclaw doctor --fix --yes`, relaunches gateway | | **Notifications** | Telegram and Discord alerts for crashes, repairs, and recovery | -| **Startup grace** | Suppresses false positives during initial boot | -| **Expected restart window** | Suppresses alerts during intentional restarts | | **Event log** | SQLite-backed incident history with API and UI access | ## Environment Variables From 1fe688011f51f7a37eae95eb638d6c331babc30a Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 13:41:26 -0800 Subject: [PATCH 006/417] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index edba9570..6efd3a58 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@

- image + image +

AlphaClaw

From 1f414095ec2a0195f9476d268ca8a4ea8ae55dd7 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 14:09:40 -0800 Subject: [PATCH 007/417] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6efd3a58..063fe8e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@

- image - + image

AlphaClaw

From a59c494be0358c839ab362ef66effe7de03771a5 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 14:43:47 -0800 Subject: [PATCH 008/417] Refine README copy and add Telegram topic wizard feature Made-with: Cursor --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 063fe8e3..c6e3e5b4 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

AlphaClaw

- Setup UI. Gateway manager. Watchdog. Zero config to production.
- One package. One command. Full OpenClaw infrastructure. + The ops layer for OpenClaw. Deploy in minutes. Stay running for months. No CLI required.
+ Setup wizard. Self-healing watchdog. Anti-drift prompt hardening.

@@ -13,7 +13,9 @@ License: MIT

-

The management harness for OpenClaw — a web-based Setup UI, gateway lifecycle manager, crash watchdog, and channel orchestrator that turns a bare OpenClaw install into a production-ready deployment.

+

AlphaClaw wraps OpenClaw with a convenient setup wizard, self-healing watchdog, Git-backed rollback, and full browser-based observability. Ships with anti-drift prompt hardening to keep your agent disciplined, and simplifies integrations (e.g. Google Workspace) so you can manage everything from one UI instead of config files.

+ +

First deploy to first message in under five minutes.

Deploy on Railway @@ -28,7 +30,7 @@ - **Guided Onboarding:** Step-by-step setup wizard — model selection, provider credentials, GitHub repo, channel pairing. - **Gateway Manager:** Spawns, monitors, restarts, and proxies the OpenClaw gateway as a managed child process. - **Watchdog:** Crash detection, crash-loop recovery, auto-repair (`openclaw doctor --fix`), and Telegram/Discord notifications. -- **Channel Orchestration:** Telegram and Discord bot pairing, topic management, and credential sync from a single UI. +- **Channel Orchestration:** Telegram and Discord bot pairing, credential sync, and a guided wizard for splitting Telegram into multi-threaded topic groups as your usage grows. - **Webhooks:** Named webhook endpoints with per-hook transform modules, request logging, and payload inspection. - **Google Workspace:** OAuth integration for Gmail, Calendar, Drive, Docs, Sheets, Tasks, Contacts, and Meet. - **Prompt Hardening:** Ships anti-drift bootstrap prompts (`AGENTS.md`, `TOOLS.md`) injected into your agent's system prompt on every message — enforcing safe practices, commit discipline, and change summaries out of the box. From 13da5d1e770c807417e93b797ffcba87c7c9a52d Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 14:53:06 -0800 Subject: [PATCH 009/417] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c6e3e5b4..5c9cd66d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

- image + image

AlphaClaw

- The ops layer for OpenClaw. Deploy in minutes. Stay running for months. No CLI required.
- Setup wizard. Self-healing watchdog. Anti-drift prompt hardening. + The ops layer for OpenClaw. Deploy in minutes. Stay running for months.
+ Observability. Reliability. Agent discipline. Zero SSH rescue missions.

@@ -13,7 +13,7 @@ License: MIT

-

AlphaClaw wraps OpenClaw with a convenient setup wizard, self-healing watchdog, Git-backed rollback, and full browser-based observability. Ships with anti-drift prompt hardening to keep your agent disciplined, and simplifies integrations (e.g. Google Workspace) so you can manage everything from one UI instead of config files.

+

AlphaClaw wraps OpenClaw with a convenient setup wizard, self-healing watchdog, Git-backed rollback, and full browser-based observability. Ships with anti-drift prompt hardening to keep your agent disciplined, and simplifies integrations (e.g. Google Workspace, Telegram Topics) so you can manage everything from one UI instead of config files.

First deploy to first message in under five minutes.

From 8be5a0103443bcc873e5fc8deb2d2e2b65e77c96 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 15:22:48 -0800 Subject: [PATCH 010/417] Add no-lock-in section and hide watchdog vars from Envars UI Made-with: Cursor --- README.md | 6 ++++++ lib/server/constants.js | 2 ++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 5c9cd66d..04d89dba 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ - **Everything in the browser:** No SSH, no config files to hand-edit, no CLI required after first deploy. - **Stays out of the way:** AlphaClaw manages infrastructure; OpenClaw handles the AI. +## No Lock-in. Eject Anytime. + +AlphaClaw writes standard OpenClaw config (`openclaw.json`, `.env`). Remove AlphaClaw and your agent keeps running — nothing proprietary, nothing to migrate. + +--- + ## Quick Start ### Deploy (recommended) diff --git a/lib/server/constants.js b/lib/server/constants.js index 4c472af2..c7b2c453 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -143,6 +143,8 @@ const kSystemVars = new Set([ "OPENCLAW_GATEWAY_TOKEN", "SETUP_PASSWORD", "PORT", + "WATCHDOG_AUTO_REPAIR", + "WATCHDOG_NOTIFICATIONS_DISABLED", ]); const kKnownVars = [ { From 904ef694a37981ba6022460e73470859f8178082 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 15:22:54 -0800 Subject: [PATCH 011/417] 0.3.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e34cd654..43869380 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index 569353ff..fb151e47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.0", + "version": "0.3.1", "publishConfig": { "access": "public" }, From 76b40b5ce4a9551fceca3d943dcb8fd58a595650 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 18:32:04 -0800 Subject: [PATCH 012/417] Add system resource monitoring to Watchdog tab Made-with: Cursor --- lib/public/js/components/watchdog-tab.js | 149 +++++++++++++++++------ lib/public/js/lib/api.js | 5 + lib/server/routes/watchdog.js | 10 ++ lib/server/system-resources.js | 104 ++++++++++++++++ 4 files changed, 232 insertions(+), 36 deletions(-) create mode 100644 lib/server/system-resources.js diff --git a/lib/public/js/components/watchdog-tab.js b/lib/public/js/components/watchdog-tab.js index bfe3f7ad..450b5fcd 100644 --- a/lib/public/js/components/watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab.js @@ -4,6 +4,7 @@ import htm from "https://esm.sh/htm"; import { fetchWatchdogEvents, fetchWatchdogLogs, + fetchWatchdogResources, fetchWatchdogSettings, updateWatchdogSettings, triggerWatchdogRepair, @@ -16,9 +17,48 @@ import { showToast } from "./toast.js"; const html = htm.bind(h); +const formatBytes = (bytes) => { + if (bytes == null) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +}; + +const barColor = (percent) => { + if (percent == null) return "bg-gray-600"; + if (percent >= 90) return "bg-red-500"; + if (percent >= 70) return "bg-yellow-400"; + return "bg-cyan-400"; +}; + +const ResourceBar = ({ label, percent, detail }) => html` +
+ ${label} +
+
+
+ ${detail} +
+`; + const getIncidentStatusTone = (event) => { - const eventType = String(event?.eventType || "").trim().toLowerCase(); - const status = String(event?.status || "").trim().toLowerCase(); + const eventType = String(event?.eventType || "") + .trim() + .toLowerCase(); + const status = String(event?.status || "") + .trim() + .toLowerCase(); if (status === "failed") { return { dotClass: "bg-red-500/90", @@ -53,6 +93,7 @@ export const WatchdogTab = ({ restartSignal = 0, }) => { const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000); + const resourcesPoll = usePolling(() => fetchWatchdogResources(), 5000); const [settings, setSettings] = useState({ autoRepair: false, notificationsEnabled: true, @@ -66,7 +107,8 @@ export const WatchdogTab = ({ const currentWatchdogStatus = watchdogStatus || {}; const events = eventsPoll.data?.events || []; - const isRepairInProgress = repairing || !!currentWatchdogStatus?.operationInProgress; + const isRepairInProgress = + repairing || !!currentWatchdogStatus?.operationInProgress; useEffect(() => { let active = true; @@ -172,7 +214,10 @@ export const WatchdogTab = ({ }, ); onRefreshStatuses(); - showToast(`Notifications ${nextValue ? "enabled" : "disabled"}`, "success"); + showToast( + `Notifications ${nextValue ? "enabled" : "disabled"}`, + "success", + ); } catch (err) { showToast(err.message || "Could not update notifications", "error"); } finally { @@ -210,6 +255,32 @@ export const WatchdogTab = ({ repairing=${isRepairInProgress} /> + ${(() => { + const r = resourcesPoll.data?.resources; + if (!r) return null; + return html` +
+
+ <${ResourceBar} + label="Memory" + percent=${r.memory?.percent} + detail=${`${formatBytes(r.memory?.usedBytes)} / ${formatBytes(r.memory?.totalBytes)}`} + /> + <${ResourceBar} + label="Disk" + percent=${r.disk?.percent} + detail=${`${formatBytes(r.disk?.usedBytes)} / ${formatBytes(r.disk?.totalBytes)}`} + /> + <${ResourceBar} + label=${`CPU${r.cpu?.cores ? ` (${r.cpu.cores} core${r.cpu.cores > 1 ? "s" : ""})` : ""}`} + percent=${r.cpu?.percent} + detail=${r.cpu?.percent != null ? `${r.cpu.percent}%` : "—"} + /> +
+
+ `; + })()} +
@@ -256,7 +327,9 @@ export const WatchdogTab = ({
${loadingLogs ? "Loading logs..." : logs || "No logs yet."}
+ > +${loadingLogs ? "Loading logs..." : logs || "No logs yet."}
@@ -272,39 +345,43 @@ export const WatchdogTab = ({
${events.length === 0 && html`

No incidents recorded.

`} - ${events.map( - (event) => { - const tone = getIncidentStatusTone(event); - return html` -
- -
- - - - ${event.createdAt || ""} · ${event.eventType || "event"} · ${event.status || - "unknown"} - - + ${events.map((event) => { + const tone = getIncidentStatusTone(event); + return html` +
+ +
+ -
-
-
-
Source: ${event.source || "unknown"}
-
${typeof event.details === "string"
-                        ? event.details
-                        : JSON.stringify(event.details || {}, null, 2)}
+ class="ac-history-toggle shrink-0" + aria-hidden="true" + >▸ + + ${event.createdAt || ""} · ${event.eventType || "event"} + · ${event.status || "unknown"} + + +
-
- `; - }, - )} +
+
+
Source: ${event.source || "unknown"}
+
+${typeof event.details === "string"
+                      ? event.details
+                      : JSON.stringify(event.details || {}, null, 2)}
+
+
+ `; + })}
diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 6ab987d8..02dc397d 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -93,6 +93,11 @@ export async function triggerWatchdogRepair() { return parseJsonOrThrow(res, 'Could not trigger watchdog repair'); } +export async function fetchWatchdogResources() { + const res = await authFetch('/api/watchdog/resources'); + return parseJsonOrThrow(res, 'Could not load system resources'); +} + export async function fetchWatchdogSettings() { const res = await authFetch('/api/watchdog/settings'); return parseJsonOrThrow(res, 'Could not load watchdog settings'); diff --git a/lib/server/routes/watchdog.js b/lib/server/routes/watchdog.js index c9949edd..ff1d3308 100644 --- a/lib/server/routes/watchdog.js +++ b/lib/server/routes/watchdog.js @@ -1,3 +1,5 @@ +const { getSystemResources } = require("../system-resources"); + const registerWatchdogRoutes = ({ app, requireAuth, @@ -55,6 +57,14 @@ const registerWatchdogRoutes = ({ } }); + app.get("/api/watchdog/resources", requireAuth, (req, res) => { + try { + res.json({ ok: true, resources: getSystemResources() }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } + }); + app.put("/api/watchdog/settings", requireAuth, (req, res) => { try { const settings = watchdog.updateSettings(req.body || {}); diff --git a/lib/server/system-resources.js b/lib/server/system-resources.js new file mode 100644 index 00000000..d0e231bb --- /dev/null +++ b/lib/server/system-resources.js @@ -0,0 +1,104 @@ +const os = require("os"); +const fs = require("fs"); + +const readCgroupFile = (filePath) => { + try { + return fs.readFileSync(filePath, "utf8").trim(); + } catch { + return null; + } +}; + +const parseCgroupMemory = () => { + const current = readCgroupFile("/sys/fs/cgroup/memory.current"); + const max = readCgroupFile("/sys/fs/cgroup/memory.max"); + if (!current) return null; + const usedBytes = Number.parseInt(current, 10); + if (Number.isNaN(usedBytes)) return null; + const limitBytes = + max && max !== "max" ? Number.parseInt(max, 10) : null; + return { + usedBytes, + totalBytes: Number.isNaN(limitBytes) ? null : limitBytes, + }; +}; + +const parseCgroupCpu = () => { + const stat = readCgroupFile("/sys/fs/cgroup/cpu.stat"); + if (!stat) return null; + const lines = stat.split("\n"); + const map = {}; + for (const line of lines) { + const [key, val] = line.split(/\s+/); + if (key && val) map[key] = Number.parseInt(val, 10); + } + return { + usageUsec: map.usage_usec ?? null, + userUsec: map.user_usec ?? null, + systemUsec: map.system_usec ?? null, + }; +}; + +let prevCpuSnapshot = null; +let prevCpuSnapshotAt = 0; + +const getSystemResources = () => { + const cgroupMem = parseCgroupMemory(); + const mem = { + usedBytes: cgroupMem?.usedBytes ?? process.memoryUsage().rss, + totalBytes: cgroupMem?.totalBytes ?? os.totalmem(), + }; + + let diskUsedBytes = null; + let diskTotalBytes = null; + try { + const stat = fs.statfsSync("/"); + diskTotalBytes = stat.bsize * stat.blocks; + diskUsedBytes = stat.bsize * (stat.blocks - stat.bfree); + } catch { + // statfsSync unavailable + } + + const cgroupCpu = parseCgroupCpu(); + let cpuPercent = null; + if (cgroupCpu?.usageUsec != null) { + const now = Date.now(); + if (prevCpuSnapshot && prevCpuSnapshotAt) { + const elapsedMs = now - prevCpuSnapshotAt; + if (elapsedMs > 0) { + const usageDeltaUs = cgroupCpu.usageUsec - prevCpuSnapshot.usageUsec; + const elapsedUs = elapsedMs * 1000; + cpuPercent = Math.min(100, Math.max(0, (usageDeltaUs / elapsedUs) * 100)); + } + } + prevCpuSnapshot = cgroupCpu; + prevCpuSnapshotAt = now; + } else { + const load = os.loadavg(); + const cpus = os.cpus().length || 1; + cpuPercent = Math.min(100, Math.max(0, (load[0] / cpus) * 100)); + } + + return { + memory: { + usedBytes: mem.usedBytes, + totalBytes: mem.totalBytes, + percent: mem.totalBytes + ? Math.round((mem.usedBytes / mem.totalBytes) * 1000) / 10 + : null, + }, + disk: { + usedBytes: diskUsedBytes, + totalBytes: diskTotalBytes, + percent: diskTotalBytes + ? Math.round((diskUsedBytes / diskTotalBytes) * 1000) / 10 + : null, + }, + cpu: { + percent: cpuPercent != null ? Math.round(cpuPercent * 10) / 10 : null, + cores: os.cpus().length, + }, + }; +}; + +module.exports = { getSystemResources }; From f5e355c71d133581ba364568340ed8d05d002bb4 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 18:40:50 -0800 Subject: [PATCH 013/417] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 04d89dba..89a6a870 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- image + image

AlphaClaw

@@ -47,7 +47,7 @@ ## No Lock-in. Eject Anytime. -AlphaClaw writes standard OpenClaw config (`openclaw.json`, `.env`). Remove AlphaClaw and your agent keeps running — nothing proprietary, nothing to migrate. +AlphaClaw simply wraps OpenClaw, it's not a dependency. Remove AlphaClaw and your agent keeps running. Nothing proprietary, nothing to migrate. --- From 86dea9dee3c1d7760657f2447b1870022fcc21bd Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 18:41:16 -0800 Subject: [PATCH 014/417] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 89a6a870..ecbc6d51 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,6 @@ AlphaClaw simply wraps OpenClaw, it's not a dependency. Remove AlphaClaw and your agent keeps running. Nothing proprietary, nothing to migrate. ---- - ## Quick Start ### Deploy (recommended) From 5bed15a842bb066892bfd62a3c3e707bdd1f7f28 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 18:50:44 -0800 Subject: [PATCH 015/417] Add per-process memory breakdown with expandable stacked bar Made-with: Cursor --- lib/public/js/components/watchdog-tab.js | 163 ++++++++++++++++++----- lib/server/routes/watchdog.js | 3 +- lib/server/system-resources.js | 47 ++++++- lib/server/watchdog.js | 5 +- 4 files changed, 182 insertions(+), 36 deletions(-) diff --git a/lib/public/js/components/watchdog-tab.js b/lib/public/js/components/watchdog-tab.js index 450b5fcd..ad5df786 100644 --- a/lib/public/js/components/watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab.js @@ -33,22 +33,70 @@ const barColor = (percent) => { return "bg-cyan-400"; }; -const ResourceBar = ({ label, percent, detail }) => html` -

- ${label} -
-
-
- ${detail} html` +
+ ${label} +
+ ${expanded && segments + ? segments.map( + (seg) => html` +
+ `, + ) + : html` +
+ `} +
+
+ ${detail} + ${expanded && + segments && + segments + .filter((s) => s.label) + .map( + (seg) => html` + + + ${seg.label} + + `, + )} +
`; @@ -94,6 +142,7 @@ export const WatchdogTab = ({ }) => { const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000); const resourcesPoll = usePolling(() => fetchWatchdogResources(), 5000); + const [memoryExpanded, setMemoryExpanded] = useState(false); const [settings, setSettings] = useState({ autoRepair: false, notificationsEnabled: true, @@ -260,23 +309,71 @@ export const WatchdogTab = ({ if (!r) return null; return html`
-
- <${ResourceBar} - label="Memory" - percent=${r.memory?.percent} - detail=${`${formatBytes(r.memory?.usedBytes)} / ${formatBytes(r.memory?.totalBytes)}`} - /> - <${ResourceBar} - label="Disk" - percent=${r.disk?.percent} - detail=${`${formatBytes(r.disk?.usedBytes)} / ${formatBytes(r.disk?.totalBytes)}`} - /> - <${ResourceBar} - label=${`CPU${r.cpu?.cores ? ` (${r.cpu.cores} core${r.cpu.cores > 1 ? "s" : ""})` : ""}`} - percent=${r.cpu?.percent} - detail=${r.cpu?.percent != null ? `${r.cpu.percent}%` : "—"} - /> -
+ ${memoryExpanded + ? html` + <${ResourceBar} + label="Memory" + detail=${`${formatBytes(r.memory?.usedBytes)} / ${formatBytes(r.memory?.totalBytes)}`} + percent=${r.memory?.percent} + expanded=${true} + onToggle=${() => setMemoryExpanded(false)} + segments=${(() => { + const p = r.processes; + const total = r.memory?.totalBytes; + const used = r.memory?.usedBytes; + if (!p || !total || !used) return null; + const segs = []; + let tracked = 0; + if (p.gateway?.rssBytes != null) { + tracked += p.gateway.rssBytes; + segs.push({ + percent: (p.gateway.rssBytes / total) * 100, + color: "#22d3ee", + label: `Gateway ${formatBytes(p.gateway.rssBytes)}`, + }); + } + if (p.alphaclaw?.rssBytes != null) { + tracked += p.alphaclaw.rssBytes; + segs.push({ + percent: (p.alphaclaw.rssBytes / total) * 100, + color: "#a78bfa", + label: `AlphaClaw ${formatBytes(p.alphaclaw.rssBytes)}`, + }); + } + const other = Math.max(0, used - tracked); + if (other > 0) { + segs.push({ + percent: (other / total) * 100, + color: "#4b5563", + label: `Other ${formatBytes(other)}`, + }); + } + return segs.length ? segs : null; + })()} + /> + ` + : html` +
+ <${ResourceBar} + label="Memory" + percent=${r.memory?.percent} + detail=${`${formatBytes(r.memory?.usedBytes)} / ${formatBytes(r.memory?.totalBytes)}`} + onToggle=${() => setMemoryExpanded(true)} + /> + <${ResourceBar} + label="Disk" + percent=${r.disk?.percent} + detail=${`${formatBytes(r.disk?.usedBytes)} / ${formatBytes(r.disk?.totalBytes)}`} + /> + <${ResourceBar} + label=${`CPU${r.cpu?.cores ? ` (${r.cpu.cores} core${r.cpu.cores > 1 ? "s" : ""})` : ""}`} + percent=${r.cpu?.percent} + detail=${r.cpu?.percent != null + ? `${r.cpu.percent}%` + : "—"} + /> +
+ `}
`; })()} @@ -300,7 +397,7 @@ export const WatchdogTab = ({
Notifications <${InfoTooltip} - text="Sends Telegram notices for watchdog alerts and auto-repair outcomes." + text="Sends channel notices for watchdog alerts and auto-repair outcomes." />
<${ToggleSwitch} diff --git a/lib/server/routes/watchdog.js b/lib/server/routes/watchdog.js index ff1d3308..59d015bb 100644 --- a/lib/server/routes/watchdog.js +++ b/lib/server/routes/watchdog.js @@ -59,7 +59,8 @@ const registerWatchdogRoutes = ({ app.get("/api/watchdog/resources", requireAuth, (req, res) => { try { - res.json({ ok: true, resources: getSystemResources() }); + const status = watchdog.getStatus(); + res.json({ ok: true, resources: getSystemResources({ gatewayPid: status.gatewayPid }) }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } diff --git a/lib/server/system-resources.js b/lib/server/system-resources.js index d0e231bb..5bbbca13 100644 --- a/lib/server/system-resources.js +++ b/lib/server/system-resources.js @@ -1,5 +1,6 @@ const os = require("os"); const fs = require("fs"); +const { execSync } = require("child_process"); const readCgroupFile = (filePath) => { try { @@ -39,10 +40,46 @@ const parseCgroupCpu = () => { }; }; +const readProcStatus = (pid) => { + try { + const status = fs.readFileSync(`/proc/${pid}/status`, "utf8"); + const vmRss = status.match(/VmRSS:\s+(\d+)\s+kB/); + return { rssBytes: vmRss ? Number.parseInt(vmRss[1], 10) * 1024 : null }; + } catch { + return null; + } +}; + +const readPsStats = (pid) => { + try { + const out = execSync(`ps -o rss=,pcpu= -p ${pid}`, { + encoding: "utf8", + timeout: 2000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const [rss, pcpu] = out.split(/\s+/); + return { + rssBytes: rss ? Number.parseInt(rss, 10) * 1024 : null, + cpuPercent: pcpu ? Number.parseFloat(pcpu) : null, + }; + } catch { + return null; + } +}; + +const getProcessUsage = (pid) => { + if (!pid) return null; + const proc = readProcStatus(pid); + if (proc) return { rssBytes: proc.rssBytes }; + const ps = readPsStats(pid); + if (ps) return { rssBytes: ps.rssBytes }; + return null; +}; + let prevCpuSnapshot = null; let prevCpuSnapshotAt = 0; -const getSystemResources = () => { +const getSystemResources = ({ gatewayPid = null } = {}) => { const cgroupMem = parseCgroupMemory(); const mem = { usedBytes: cgroupMem?.usedBytes ?? process.memoryUsage().rss, @@ -79,6 +116,10 @@ const getSystemResources = () => { cpuPercent = Math.min(100, Math.max(0, (load[0] / cpus) * 100)); } + const alphaclawRss = process.memoryUsage().rss; + const gatewayUsage = getProcessUsage(gatewayPid); + const gatewayRss = gatewayUsage?.rssBytes ?? null; + return { memory: { usedBytes: mem.usedBytes, @@ -98,6 +139,10 @@ const getSystemResources = () => { percent: cpuPercent != null ? Math.round(cpuPercent * 10) / 10 : null, cores: os.cpus().length, }, + processes: { + alphaclaw: { rssBytes: alphaclawRss }, + gateway: { rssBytes: gatewayRss, pid: gatewayPid }, + }, }; }; diff --git a/lib/server/watchdog.js b/lib/server/watchdog.js index 7120ebfd..708189f8 100644 --- a/lib/server/watchdog.js +++ b/lib/server/watchdog.js @@ -59,6 +59,7 @@ const createWatchdog = ({ notificationsDisabled: isTruthy(process.env.WATCHDOG_NOTIFICATIONS_DISABLED), operationInProgress: false, gatewayStartedAt: null, + gatewayPid: null, crashRecoveryActive: false, expectedRestartInProgress: false, expectedRestartUntilMs: 0, @@ -502,13 +503,14 @@ const createWatchdog = ({ void restartAfterCrash(correlationId); }; - const onGatewayLaunch = ({ startedAt = Date.now() } = {}) => { + const onGatewayLaunch = ({ startedAt = Date.now(), pid = null } = {}) => { state.lifecycle = "running"; state.health = "unknown"; state.crashRecoveryActive = false; clearExpectedRestartWindow(); state.uptimeStartedAt = startedAt; state.gatewayStartedAt = startedAt; + state.gatewayPid = pid; startBootstrapHealthChecks(); }; @@ -566,6 +568,7 @@ const createWatchdog = ({ crashLoopThreshold: kWatchdogCrashLoopThreshold, crashLoopWindowMs: kWatchdogCrashLoopWindowMs, operationInProgress: state.operationInProgress, + gatewayPid: state.gatewayPid, }; }; From 83d25692bd3681085122f864815a675c0497d1bd Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 18:50:44 -0800 Subject: [PATCH 016/417] 0.3.2-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 43869380..7ccf6bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.1", + "version": "0.3.2-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.1", + "version": "0.3.2-beta.0", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index fb151e47..6052ce68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.1", + "version": "0.3.2-beta.0", "publishConfig": { "access": "public" }, From bd2ec1289ea51e195b66d7375993f08ebd857b1f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:00:05 -0800 Subject: [PATCH 017/417] Fix update check to use semver comparison instead of string equality Made-with: Cursor --- lib/server/alphaclaw-version.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/server/alphaclaw-version.js b/lib/server/alphaclaw-version.js index fd954cad..111e1bcb 100644 --- a/lib/server/alphaclaw-version.js +++ b/lib/server/alphaclaw-version.js @@ -10,6 +10,20 @@ const { kRootDir, } = require("./constants"); +const isNewerVersion = (latest, current) => { + if (!latest || !current) return false; + const parse = (v) => { + const [core] = String(v).replace(/^v/, "").split("-"); + const parts = core.split(".").map(Number); + return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }; + }; + const l = parse(latest); + const c = parse(current); + if (l.major !== c.major) return l.major > c.major; + if (l.minor !== c.minor) return l.minor > c.minor; + return l.patch > c.patch; +}; + const createAlphaclawVersionService = () => { let kUpdateStatusCache = { latestVersion: null, @@ -82,11 +96,7 @@ const createAlphaclawVersionService = () => { } const currentVersion = readAlphaclawVersion(); const latestVersion = await fetchLatestVersionFromRegistry(); - const hasUpdate = !!( - currentVersion && - latestVersion && - latestVersion !== currentVersion - ); + const hasUpdate = isNewerVersion(latestVersion, currentVersion); kUpdateStatusCache = { latestVersion, hasUpdate, fetchedAt: Date.now() }; if (hasUpdate) { console.log( From defae25d50febde7380717a4909ac9ee77af8ab2 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:00:06 -0800 Subject: [PATCH 018/417] 0.3.2-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7ccf6bc7..ef12bde6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.0", + "version": "0.3.2-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.0", + "version": "0.3.2-beta.1", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index 6052ce68..a2018154 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.0", + "version": "0.3.2-beta.1", "publishConfig": { "access": "public" }, From 5e845eb581e570a53f386f4e649fe7e86287e96c Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:15:32 -0800 Subject: [PATCH 019/417] Fix Discord notifications using italic instead of bold Made-with: Cursor --- lib/server/watchdog-notify.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/server/watchdog-notify.js b/lib/server/watchdog-notify.js index 6c2971a8..b1ddb6b6 100644 --- a/lib/server/watchdog-notify.js +++ b/lib/server/watchdog-notify.js @@ -33,7 +33,8 @@ const getPairedIds = (channel) => { return Array.from(ids); }; -const formatDiscordMessage = (message) => String(message || ""); +const formatDiscordMessage = (message) => + String(message || "").replace(/(? { const notify = async (message) => { From 33ee62bb705bca7974f9f957bcd88541be10e32f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:15:32 -0800 Subject: [PATCH 020/417] 0.3.2-beta.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef12bde6..414def04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.1", + "version": "0.3.2-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.1", + "version": "0.3.2-beta.2", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index a2018154..e5857452 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.1", + "version": "0.3.2-beta.2", "publishConfig": { "access": "public" }, From 03e61cc3513145e48defb56990b98df18db69eaf Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:37:04 -0800 Subject: [PATCH 021/417] Align disk metric with data volume and show disk source path Made-with: Cursor --- lib/public/js/components/watchdog-tab.js | 2 +- lib/server/system-resources.js | 37 +++++++++++++++--------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/public/js/components/watchdog-tab.js b/lib/public/js/components/watchdog-tab.js index ad5df786..f1de07c9 100644 --- a/lib/public/js/components/watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab.js @@ -361,7 +361,7 @@ export const WatchdogTab = ({ onToggle=${() => setMemoryExpanded(true)} /> <${ResourceBar} - label="Disk" + label=${`Disk${r.disk?.path ? ` (${r.disk.path})` : ""}`} percent=${r.disk?.percent} detail=${`${formatBytes(r.disk?.usedBytes)} / ${formatBytes(r.disk?.totalBytes)}`} /> diff --git a/lib/server/system-resources.js b/lib/server/system-resources.js index 5bbbca13..75d8c87f 100644 --- a/lib/server/system-resources.js +++ b/lib/server/system-resources.js @@ -1,6 +1,7 @@ const os = require("os"); const fs = require("fs"); const { execSync } = require("child_process"); +const { kRootDir } = require("./constants"); const readCgroupFile = (filePath) => { try { @@ -76,6 +77,23 @@ const getProcessUsage = (pid) => { return null; }; +const readDiskUsage = () => { + const paths = [kRootDir, "/data", "/"]; + for (const diskPath of paths) { + try { + const stat = fs.statfsSync(diskPath); + return { + usedBytes: stat.bsize * (stat.blocks - stat.bfree), + totalBytes: stat.bsize * stat.blocks, + path: diskPath, + }; + } catch { + // Try next path. + } + } + return { usedBytes: null, totalBytes: null, path: null }; +}; + let prevCpuSnapshot = null; let prevCpuSnapshotAt = 0; @@ -86,15 +104,7 @@ const getSystemResources = ({ gatewayPid = null } = {}) => { totalBytes: cgroupMem?.totalBytes ?? os.totalmem(), }; - let diskUsedBytes = null; - let diskTotalBytes = null; - try { - const stat = fs.statfsSync("/"); - diskTotalBytes = stat.bsize * stat.blocks; - diskUsedBytes = stat.bsize * (stat.blocks - stat.bfree); - } catch { - // statfsSync unavailable - } + const diskUsage = readDiskUsage(); const cgroupCpu = parseCgroupCpu(); let cpuPercent = null; @@ -129,10 +139,11 @@ const getSystemResources = ({ gatewayPid = null } = {}) => { : null, }, disk: { - usedBytes: diskUsedBytes, - totalBytes: diskTotalBytes, - percent: diskTotalBytes - ? Math.round((diskUsedBytes / diskTotalBytes) * 1000) / 10 + usedBytes: diskUsage.usedBytes, + totalBytes: diskUsage.totalBytes, + path: diskUsage.path, + percent: diskUsage.totalBytes + ? Math.round((diskUsage.usedBytes / diskUsage.totalBytes) * 1000) / 10 : null, }, cpu: { From 19b9b50ac720788cc5c8658d70c69b423f8c174e Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:37:04 -0800 Subject: [PATCH 022/417] 0.3.2-beta.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 414def04..5551d466 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.2", + "version": "0.3.2-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.2", + "version": "0.3.2-beta.3", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index e5857452..46683c76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.2", + "version": "0.3.2-beta.3", "publishConfig": { "access": "public" }, From c83967b06e67c3b0d08969a6ce3d3a939f2d74de Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:53:54 -0800 Subject: [PATCH 023/417] Improve resource metrics across cgroup v1/v2 containers Made-with: Cursor --- lib/public/js/components/watchdog-tab.js | 2 +- lib/server/system-resources.js | 106 +++++++++++++++++++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/lib/public/js/components/watchdog-tab.js b/lib/public/js/components/watchdog-tab.js index f1de07c9..b0af1bdd 100644 --- a/lib/public/js/components/watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab.js @@ -366,7 +366,7 @@ export const WatchdogTab = ({ detail=${`${formatBytes(r.disk?.usedBytes)} / ${formatBytes(r.disk?.totalBytes)}`} /> <${ResourceBar} - label=${`CPU${r.cpu?.cores ? ` (${r.cpu.cores} core${r.cpu.cores > 1 ? "s" : ""})` : ""}`} + label=${`CPU${r.cpu?.cores ? ` (${r.cpu.cores} vCPU)` : ""}`} percent=${r.cpu?.percent} detail=${r.cpu?.percent != null ? `${r.cpu.percent}%` diff --git a/lib/server/system-resources.js b/lib/server/system-resources.js index 75d8c87f..0815109c 100644 --- a/lib/server/system-resources.js +++ b/lib/server/system-resources.js @@ -11,17 +11,54 @@ const readCgroupFile = (filePath) => { } }; +const readFirstCgroupFile = (paths) => { + for (const filePath of paths) { + const value = readCgroupFile(filePath); + if (value != null) return value; + } + return null; +}; + +const countCpuSet = (cpuSet) => { + if (!cpuSet) return null; + let count = 0; + const parts = String(cpuSet) + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + for (const part of parts) { + const [startRaw, endRaw] = part.split("-"); + const start = Number.parseInt(startRaw, 10); + const end = endRaw == null ? start : Number.parseInt(endRaw, 10); + if (Number.isNaN(start) || Number.isNaN(end)) continue; + count += Math.max(0, end - start + 1); + } + return count > 0 ? count : null; +}; + const parseCgroupMemory = () => { - const current = readCgroupFile("/sys/fs/cgroup/memory.current"); - const max = readCgroupFile("/sys/fs/cgroup/memory.max"); + const current = readFirstCgroupFile([ + "/sys/fs/cgroup/memory.current", + "/sys/fs/cgroup/memory/memory.usage_in_bytes", + ]); + const max = readFirstCgroupFile([ + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", + ]); if (!current) return null; const usedBytes = Number.parseInt(current, 10); if (Number.isNaN(usedBytes)) return null; - const limitBytes = + const parsedLimit = max && max !== "max" ? Number.parseInt(max, 10) : null; + const limitBytes = Number.isNaN(parsedLimit) ? null : parsedLimit; + // Cgroup v1 uses huge sentinel values to mean "no limit". + const unlimited = + limitBytes == null || + limitBytes <= 0 || + limitBytes >= 9_000_000_000_000_000_000; return { usedBytes, - totalBytes: Number.isNaN(limitBytes) ? null : limitBytes, + totalBytes: unlimited ? null : limitBytes, }; }; @@ -41,6 +78,54 @@ const parseCgroupCpu = () => { }; }; +const parseCgroupCpuV1 = () => { + const usageNs = readFirstCgroupFile([ + "/sys/fs/cgroup/cpuacct/cpuacct.usage", + "/sys/fs/cgroup/cpu/cpuacct.usage", + ]); + if (!usageNs) return null; + const usageNsParsed = Number.parseInt(usageNs, 10); + if (Number.isNaN(usageNsParsed)) return null; + return { + usageUsec: Math.floor(usageNsParsed / 1000), + userUsec: null, + systemUsec: null, + }; +}; + +const getAllocatedCpuCores = () => { + const cpuMax = readCgroupFile("/sys/fs/cgroup/cpu.max"); + if (cpuMax) { + const [quotaRaw, periodRaw] = cpuMax.split(/\s+/); + const quota = Number.parseInt(quotaRaw, 10); + const period = Number.parseInt(periodRaw, 10); + if (quotaRaw !== "max" && !Number.isNaN(quota) && !Number.isNaN(period) && period > 0) { + return quota / period; + } + } + + const quotaV1 = readFirstCgroupFile([ + "/sys/fs/cgroup/cpu/cpu.cfs_quota_us", + "/sys/fs/cgroup/cpuacct/cpu.cfs_quota_us", + ]); + const periodV1 = readFirstCgroupFile([ + "/sys/fs/cgroup/cpu/cpu.cfs_period_us", + "/sys/fs/cgroup/cpuacct/cpu.cfs_period_us", + ]); + if (quotaV1 && periodV1) { + const quota = Number.parseInt(quotaV1, 10); + const period = Number.parseInt(periodV1, 10); + if (!Number.isNaN(quota) && !Number.isNaN(period) && quota > 0 && period > 0) { + return quota / period; + } + } + + const cpuSet = + readCgroupFile("/sys/fs/cgroup/cpuset.cpus.effective") || + readCgroupFile("/sys/fs/cgroup/cpuset.cpus"); + return countCpuSet(cpuSet); +}; + const readProcStatus = (pid) => { try { const status = fs.readFileSync(`/proc/${pid}/status`, "utf8"); @@ -98,6 +183,8 @@ let prevCpuSnapshot = null; let prevCpuSnapshotAt = 0; const getSystemResources = ({ gatewayPid = null } = {}) => { + const hostCores = os.cpus().length || 1; + const allocatedCores = getAllocatedCpuCores() || hostCores; const cgroupMem = parseCgroupMemory(); const mem = { usedBytes: cgroupMem?.usedBytes ?? process.memoryUsage().rss, @@ -106,7 +193,7 @@ const getSystemResources = ({ gatewayPid = null } = {}) => { const diskUsage = readDiskUsage(); - const cgroupCpu = parseCgroupCpu(); + const cgroupCpu = parseCgroupCpu() || parseCgroupCpuV1(); let cpuPercent = null; if (cgroupCpu?.usageUsec != null) { const now = Date.now(); @@ -115,15 +202,15 @@ const getSystemResources = ({ gatewayPid = null } = {}) => { if (elapsedMs > 0) { const usageDeltaUs = cgroupCpu.usageUsec - prevCpuSnapshot.usageUsec; const elapsedUs = elapsedMs * 1000; - cpuPercent = Math.min(100, Math.max(0, (usageDeltaUs / elapsedUs) * 100)); + const rawPercent = (usageDeltaUs / elapsedUs) * 100; + cpuPercent = Math.min(100, Math.max(0, rawPercent / allocatedCores)); } } prevCpuSnapshot = cgroupCpu; prevCpuSnapshotAt = now; } else { const load = os.loadavg(); - const cpus = os.cpus().length || 1; - cpuPercent = Math.min(100, Math.max(0, (load[0] / cpus) * 100)); + cpuPercent = Math.min(100, Math.max(0, (load[0] / allocatedCores) * 100)); } const alphaclawRss = process.memoryUsage().rss; @@ -148,7 +235,8 @@ const getSystemResources = ({ gatewayPid = null } = {}) => { }, cpu: { percent: cpuPercent != null ? Math.round(cpuPercent * 10) / 10 : null, - cores: os.cpus().length, + cores: Math.round(allocatedCores * 10) / 10, + hostCores, }, processes: { alphaclaw: { rssBytes: alphaclawRss }, From 5b8f761c62ba6779ce5f146718c936c916cf4cbe Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 19:53:54 -0800 Subject: [PATCH 024/417] 0.3.2-beta.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5551d466..877cb8f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.3", + "version": "0.3.2-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.3", + "version": "0.3.2-beta.4", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index 46683c76..d5908293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.3", + "version": "0.3.2-beta.4", "publishConfig": { "access": "public" }, From 11d572c450dc4c0a29761ee2e73c5ad455880b1a Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 20:10:38 -0800 Subject: [PATCH 025/417] Document beta-to-production release flow in AGENTS --- lib/setup/core-prompts/AGENTS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/setup/core-prompts/AGENTS.md b/lib/setup/core-prompts/AGENTS.md index 81f1496f..29aca2c8 100644 --- a/lib/setup/core-prompts/AGENTS.md +++ b/lib/setup/core-prompts/AGENTS.md @@ -32,6 +32,24 @@ Changes committed ([abc1234](commit url)): <-- linked commit hash • path/or/resource (new|edit|delete) — brief description ``` +### Release Flow (Beta -> Production) + +Use this release flow when promoting tested beta builds to production: + +1. Ensure `main` is clean and synced, and tests pass. +2. Publish beta iterations as needed: + - `npm version prerelease --preid=beta` + - `git push && git push --tags` + - `npm publish --tag beta` +3. For beta template testing, pin exact beta in template `package.json` (for example `0.3.2-beta.4`) to avoid Docker layer cache reusing old installs. +4. When ready for production, publish a stable release version (for example `0.3.2`): + - `npm version 0.3.2` + - `git push && git push --tags` + - `npm publish` (publishes to `latest`) +5. Return templates to production channel: + - `@chrysb/alphaclaw: "latest"` +6. Optionally keep beta branch/tag flows active for next release cycle. + ### Telegram Notice Format (AlphaClaw) Use this format for any Telegram notices sent from AlphaClaw services (watchdog, system alerts, repair notices): From ad5f3341cbb1d9ca6f7704452035721b07877dd5 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 20:10:38 -0800 Subject: [PATCH 026/417] 0.3.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 877cb8f4..28ed1a50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.4", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.4", + "version": "0.3.2", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", diff --git a/package.json b/package.json index d5908293..0766743c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2-beta.4", + "version": "0.3.2", "publishConfig": { "access": "public" }, From 50752da6ab11ce85cceb50131e0e34da4ddbda5f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 1 Mar 2026 23:00:04 -0800 Subject: [PATCH 027/417] Add browse explorer with syntax-aware editing and git summary panel. This introduces a full sidebar browse workflow with resizable panes, file-type icons/highlighting, markdown preview improvements, and scoped git sync/summary updates for faster in-app editing feedback. --- bin/alphaclaw.js | 32 +- lib/public/css/explorer.css | 955 ++++++++++++++++++ lib/public/css/shell.css | 34 +- lib/public/css/theme.css | 5 + lib/public/icons/folder-line.svg | 1 + lib/public/icons/hashtag.svg | 3 + lib/public/icons/home-5-line.svg | 1 + lib/public/js/app.js | 394 +++++--- lib/public/js/components/file-tree.js | 263 +++++ lib/public/js/components/file-viewer.js | 524 ++++++++++ lib/public/js/components/icons.js | 169 ++++ lib/public/js/components/sidebar-git-panel.js | 149 +++ lib/public/js/components/sidebar.js | 267 +++++ lib/public/js/lib/api.js | 26 + lib/public/js/lib/file-highlighting.js | 6 + lib/public/js/lib/syntax-highlighters/css.js | 124 +++ .../js/lib/syntax-highlighters/frontmatter.js | 49 + lib/public/js/lib/syntax-highlighters/html.js | 209 ++++ .../js/lib/syntax-highlighters/index.js | 28 + .../js/lib/syntax-highlighters/javascript.js | 134 +++ lib/public/js/lib/syntax-highlighters/json.js | 61 ++ .../js/lib/syntax-highlighters/markdown.js | 37 + .../js/lib/syntax-highlighters/utils.js | 13 + lib/public/setup.html | 1 + lib/server.js | 6 + lib/server/constants.js | 1 + lib/server/routes/browse.js | 295 ++++++ 27 files changed, 3643 insertions(+), 144 deletions(-) create mode 100644 lib/public/css/explorer.css create mode 100644 lib/public/icons/folder-line.svg create mode 100644 lib/public/icons/hashtag.svg create mode 100644 lib/public/icons/home-5-line.svg create mode 100644 lib/public/js/components/file-tree.js create mode 100644 lib/public/js/components/file-viewer.js create mode 100644 lib/public/js/components/sidebar-git-panel.js create mode 100644 lib/public/js/components/sidebar.js create mode 100644 lib/public/js/lib/file-highlighting.js create mode 100644 lib/public/js/lib/syntax-highlighters/css.js create mode 100644 lib/public/js/lib/syntax-highlighters/frontmatter.js create mode 100644 lib/public/js/lib/syntax-highlighters/html.js create mode 100644 lib/public/js/lib/syntax-highlighters/index.js create mode 100644 lib/public/js/lib/syntax-highlighters/javascript.js create mode 100644 lib/public/js/lib/syntax-highlighters/json.js create mode 100644 lib/public/js/lib/syntax-highlighters/markdown.js create mode 100644 lib/public/js/lib/syntax-highlighters/utils.js create mode 100644 lib/server/routes/browse.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 51cd77d1..ea717db8 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -84,6 +84,7 @@ start options: git-sync options: --message, -m Commit message + --file, -f Optional file path in .openclaw to sync only one file telegram topic add options: --thread Telegram thread ID @@ -93,6 +94,7 @@ telegram topic add options: Examples: alphaclaw git-sync --message "sync workspace" + alphaclaw git-sync --message "update config" --file "workspace/app/config.json" alphaclaw telegram topic add --thread 12 --name "Testing" alphaclaw telegram topic add --thread 12 --name "Testing" --system "Handle QA requests" `); @@ -234,10 +236,26 @@ const runGitSync = () => { const commitMessage = String( flagValue(commandArgs, "--message", "-m") || "", ).trim(); + const requestedFilePath = String( + flagValue(commandArgs, "--file", "-f") || "", + ).trim(); + const normalizedFilePath = requestedFilePath + ? requestedFilePath.replace(/\\/g, "/").replace(/^\.\/+/, "") + : ""; if (!commitMessage) { console.error("[alphaclaw] Missing --message for git-sync"); return 1; } + if (normalizedFilePath) { + if ( + normalizedFilePath.startsWith("/") || + normalizedFilePath.startsWith("../") || + normalizedFilePath.includes("/../") + ) { + console.error("[alphaclaw] --file must stay within /data/.openclaw"); + return 1; + } + } if (!githubToken) { console.error("[alphaclaw] Missing GITHUB_TOKEN for git-sync"); return 1; @@ -312,13 +330,23 @@ const runGitSync = () => { `[alphaclaw] Remote branch "${branch}" not found, skipping pull`, ); } - runGit("add -A"); + if (normalizedFilePath) { + runGit(`add -A -- ${quoteArg(normalizedFilePath)}`); + } else { + runGit("add -A"); + } try { runGit("diff --cached --quiet"); console.log("[alphaclaw] No changes to commit"); return 0; } catch {} - runGit(`commit -m ${quoteArg(commitMessage)}`); + if (normalizedFilePath) { + runGit( + `commit -m ${quoteArg(commitMessage)} -- ${quoteArg(normalizedFilePath)}`, + ); + } else { + runGit(`commit -m ${quoteArg(commitMessage)}`); + } runGit(`push origin ${quoteArg(branch)}`, { withAuth: true }); const hash = String(runGit("rev-parse --short HEAD")).trim(); console.log(`[alphaclaw] Git sync complete (${hash})`); diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css new file mode 100644 index 00000000..eada1ee6 --- /dev/null +++ b/lib/public/css/explorer.css @@ -0,0 +1,955 @@ +/* ── Browse/Explorer mode ─────────────────────── */ + +.app-content.browse-mode { + padding: 0; +} + +.sidebar-tabs { + display: flex; + align-items: center; + gap: 8px; + padding: 0px 12px 6px; + background: transparent; +} + +.sidebar-tab { + width: 30px; + height: 30px; + padding: 0; + color: rgba(99, 235, 255, 0.8); + border: 0; + border-radius: 10px; + background: transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.12s, background 0.12s, box-shadow 0.12s, transform 0.12s; +} + +.sidebar-tab:hover { + color: #a9eefb; +} + +.sidebar-tab.active { + color: #b9f5ff; + background: rgba(99, 235, 255, 0.08); + box-shadow: 0 0 14px rgba(99, 235, 255, 0.16); +} + +.sidebar-tab-icon { + width: 17px; + height: 17px; + display: block; + opacity: 0.92; +} + +.sidebar-tab:hover .sidebar-tab-icon, +.sidebar-tab.active .sidebar-tab-icon { + opacity: 1; +} + +.sidebar-browse-layout { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.sidebar-browse-panel { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.sidebar-browse-resizer { + height: 6px; + cursor: row-resize; + position: relative; +} + +.sidebar-browse-resizer::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 2px; + height: 2px; + background: transparent; + transition: background 0.12s; +} + +.sidebar-browse-resizer:hover::before, +.sidebar-browse-resizer.is-resizing::before { + background: rgba(99, 235, 255, 0.55); +} + +.sidebar-browse-bottom { + flex: 0 0 auto; + min-height: 0; + overflow: hidden; + padding-top: 0; +} + +.sidebar-browse-bottom-inner { + min-height: 120px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.file-tree-wrap { + width: 100%; + overflow-y: auto; + padding: 8px 0; +} + +.file-tree-wrap::-webkit-scrollbar { + width: 6px; +} + +.file-tree-wrap::-webkit-scrollbar-track { + background: transparent; +} + +.file-tree-wrap::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.file-tree { + list-style: none; +} + +.tree-item { + position: relative; +} + +.tree-item > a { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 10px 2px 18px; + color: var(--text-muted); + text-decoration: none; + font-size: 13px; + font-weight: 400; + transition: background 0.1s, color 0.1s; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; +} + +.tree-item > a:hover { + background: var(--bg-hover); + color: var(--text); +} + +.tree-item > a.active { + background: var(--bg-active); + color: var(--accent); +} + +.tree-item > a.active::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--accent); +} + +.tree-folder { + padding: 2px 10px 2px 12px; + display: flex; + align-items: center; + gap: 6px; + color: var(--text); + font-weight: 400; + cursor: pointer; + user-select: none; + white-space: nowrap; + overflow: hidden; +} + +.tree-folder:hover { + background: var(--bg-hover); +} + +.arrow { + font-size: 10px; + transition: transform 0.15s; + color: var(--text-dim); + flex-shrink: 0; +} + +.tree-folder.collapsed .arrow { + transform: rotate(-90deg); +} + +.file-icon { + flex-shrink: 0; + width: 15px; + height: 15px; + display: block; + color: var(--text-dim); +} + +.file-icon-md { + color: var(--accent); +} + +.file-icon-js { + color: #f4d03f; +} + +.file-icon-json { + color: #9b7bff; +} + +.file-icon-css { + color: #7ec8ff; +} + +.file-icon-html { + color: #ff9d57; +} + +.file-icon-image { + color: #ff7ac6; +} + +.file-icon-shell { + color: #71f8a7; +} + +.file-icon-db { + color: #67b3ff; +} + +.file-icon-generic { + color: var(--text-muted); +} + +.tree-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-children { + list-style: none; +} + +.tree-children.hidden { + display: none; +} + +.file-tree-state { + padding: 10px 14px; + font-size: 12px; + color: var(--text-muted); +} + +.file-tree-state-error { + color: #f87171; +} + +.file-viewer { + width: 100%; + min-height: 100%; + height: calc(100vh - 24px); + display: flex; + flex-direction: column; + background: transparent; +} + +.file-viewer-tabbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border); + height: 35px; +} + +.file-viewer-tabbar-spacer { + flex: 1; +} + +.file-viewer-tab { + display: flex; + align-items: center; + gap: 6px; + height: 35px; + padding: 0 16px; + font-size: 12px; + line-height: 1; + color: var(--text-muted); + border-right: 1px solid var(--border); + white-space: nowrap; +} + +.file-viewer-tab.active { + color: var(--text); + background: var(--bg); + border-bottom: 1px solid var(--accent); + margin-bottom: -1px; +} + +.file-viewer-breadcrumb { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.file-viewer-breadcrumb-item { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text-muted); +} + +.file-viewer-breadcrumb-item .is-current { + color: var(--text); +} + +.file-viewer-sep { + color: var(--text-dim); +} + +.frontmatter-box { + margin-top: 16px; + margin-bottom: 4px; + margin-right: 20px; + margin-left: 36px; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: rgba(99, 235, 255, 0.025); +} + +.frontmatter-title { + width: 100%; + border: 0; + text-align: left; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + background: rgba(13, 17, 23, 0.55); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + padding: 4px 10px; +} + +.frontmatter-title:hover { + background: rgba(13, 17, 23, 0.75); +} + +.frontmatter-chevron { + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); + transition: transform 0.15s ease; +} + +.frontmatter-chevron.open { + transform: rotate(90deg); +} + +.frontmatter-chevron svg { + width: 12px; + height: 12px; + display: block; +} + +.frontmatter-chevron path { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.frontmatter-grid { + display: grid; +} + +.frontmatter-row { + display: grid; + grid-template-columns: 160px 1fr; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.frontmatter-row:first-child { + border-top: 0; +} + +.frontmatter-key { + padding: 6px 10px; + color: var(--keyword); + border-right: 1px solid rgba(255, 255, 255, 0.05); + word-break: break-word; + opacity: 0.85; +} + +.frontmatter-value { + padding: 6px 10px; + color: var(--string); + white-space: pre-wrap; + word-break: break-word; + opacity: 0.88; +} + +.frontmatter-value-pre { + margin: 0; + font-family: inherit; + font-size: 12px; +} + +.file-viewer-save-btn { + display: inline-flex; + align-items: center; + margin-right: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + color: var(--text-muted); + font-family: inherit; + font-size: 11px; + letter-spacing: 0.03em; + padding: 5px 10px; + cursor: pointer; +} + +.file-viewer-save-btn:hover:not(:disabled) { + color: var(--text); + background: rgba(255, 255, 255, 0.03); +} + +.file-viewer-save-btn.active { + color: var(--accent); + background: var(--bg-active); +} + +.file-viewer-save-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.file-viewer-view-toggle { + display: flex; + align-items: center; + margin-right: 8px; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); +} + +.file-viewer-view-toggle-button { + border: 0; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 11px; + text-transform: lowercase; + letter-spacing: 0.03em; + padding: 5px 10px; + cursor: pointer; +} + +.file-viewer-view-toggle-button:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.03); +} + +.file-viewer-view-toggle-button.active { + color: var(--accent); + background: var(--bg-active); +} + +.file-viewer-editor-shell { + width: 100%; + min-height: 0; + height: 100%; + flex: 1; + display: flex; + align-items: stretch; +} + +.file-viewer-editor-line-num-col { + width: 56px; + flex-shrink: 0; + overflow: hidden; + padding: 16px 16px 112px 0; + text-align: right; +} + +.file-viewer-editor-line-num { + min-height: 22px; + line-height: 22px; + color: var(--text-dim); + font-size: 12px; + user-select: none; + display: flex; + justify-content: flex-end; + align-items: flex-start; +} + +.file-viewer-editor { + width: 100%; + min-height: 0; + height: 100%; + flex: 1; + border: 0; + outline: none; + resize: none; + overflow-y: auto; + background: transparent; + color: var(--text); + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 22px; + padding: 16px 20px 112px 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.file-viewer-editor-stack { + position: relative; + flex: 1; + min-height: 0; +} + +.file-viewer-editor-highlight { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + background: transparent; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 22px; + color: var(--text); + padding: 16px 20px 112px 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.file-viewer-editor-highlight-line { + min-height: 22px; +} + +.file-viewer-editor-highlight-line-content { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.file-viewer-editor-overlay { + position: absolute; + inset: 0; + color: transparent; + caret-color: var(--text); + -webkit-text-fill-color: transparent; +} + +.file-viewer-editor-overlay::selection { + background: rgba(99, 235, 255, 0.25); +} + +.file-viewer-preview { + flex: 1; + overflow-y: auto; + padding: 0 20px 112px 36px; + line-height: 1.75; + background: transparent; +} + +.file-viewer-pane-hidden { + display: none; +} + +.file-viewer-preview h1, +.file-viewer-preview h2, +.file-viewer-preview h3, +.file-viewer-preview h4, +.file-viewer-preview h5, +.file-viewer-preview h6 { + color: var(--accent); + margin: 18px 0 10px; + font-weight: 600; +} + +.file-viewer-preview h1 { + font-size: 2em; +} + +.file-viewer-preview h2 { + font-size: 1.5em; +} + +.file-viewer-preview h3 { + font-size: 1.25em; +} + +.file-viewer-preview h4 { + font-size: 1.1em; +} + +.file-viewer-preview h5 { + font-size: 1em; +} + +.file-viewer-preview h6 { + font-size: 0.9em; +} + +.file-viewer-preview p, +.file-viewer-preview ul, +.file-viewer-preview ol, +.file-viewer-preview blockquote, +.file-viewer-preview pre, +.file-viewer-preview table { + margin: 10px 0; +} + +.file-viewer-preview ul, +.file-viewer-preview ol { + padding-left: 20px; +} + +.file-viewer-preview ul { + list-style: disc; +} + +.file-viewer-preview ol { + list-style: decimal; +} + +.file-viewer-preview a { + color: var(--accent); +} + +.file-viewer-preview code { + color: var(--string); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 6px; +} + +.file-viewer-preview pre { + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px; + overflow-x: auto; +} + +.file-viewer-preview pre code { + background: transparent; + border: 0; + padding: 0; +} + +.file-viewer-preview blockquote { + border-left: 2px solid var(--accent-dim); + padding-left: 12px; + color: var(--comment); +} + +.file-viewer-preview table { + width: 100%; + border-collapse: collapse; +} + +.file-viewer-preview th, +.file-viewer-preview td { + border: 1px solid var(--border); + padding: 6px 8px; + text-align: left; +} + +.file-viewer-preview th { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} + +.file-viewer-editor-highlight-line-content .hl-comment { + color: var(--comment); + font-style: italic; +} + +.file-viewer-editor-highlight-line-content .hl-heading { + color: var(--accent); + font-weight: 700; +} + +.file-viewer-editor-highlight-line-content .hl-string { + color: var(--string); +} + +.file-viewer-editor-highlight-line-content .hl-bullet { + color: var(--orange); +} + +.file-viewer-editor-highlight-line-content .hl-bold { + color: var(--text); + font-weight: 700; +} + +.file-viewer-editor-highlight-line-content .hl-link { + color: var(--accent); + text-decoration: underline; + text-decoration-style: dotted; +} + +.file-viewer-editor-highlight-line-content .hl-meta { + color: var(--text-dim); +} + +.file-viewer-editor-highlight-line-content .hl-key { + color: var(--keyword); +} + +.file-viewer-editor-highlight-line-content .hl-keyword { + color: var(--keyword); +} + +.file-viewer-editor-highlight-line-content .hl-tag { + color: var(--accent); +} + +.file-viewer-editor-highlight-line-content .hl-attr { + color: var(--keyword); +} + +.file-viewer-editor-highlight-line-content .hl-entity { + color: var(--number); +} + +.file-viewer-editor-highlight-line-content .hl-number { + color: var(--number); +} + +.file-viewer-editor-highlight-line-content .hl-boolean, +.file-viewer-editor-highlight-line-content .hl-null { + color: var(--orange); +} + +.file-viewer-editor-highlight-line-content .hl-punc { + color: #5f6674; +} + +.file-viewer-state { + padding: 20px; + color: var(--text-muted); + font-size: 12px; +} + +.file-viewer-state-error { + color: #f87171; +} + +.file-viewer-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: calc(100vh - 120px); + color: var(--text-dim); + text-align: center; + padding: 48px; + gap: 10px; +} + +.file-viewer-empty-mark { + font-size: 26px; + font-weight: 500; + letter-spacing: 0.08em; + color: var(--accent); + text-shadow: + 0 0 12px rgba(99, 235, 255, 0.38), + 0 0 22px rgba(99, 235, 255, 0.2); +} + +.file-viewer-empty-title { + font-size: 13px; + font-weight: 400; + color: var(--text); + letter-spacing: 0.01em; +} + +.sidebar-git-panel { + padding: 0; + margin: 0; + font-size: 11px; + display: flex; + flex-direction: column; + gap: 0; + flex: 1 1 auto; + min-height: 0; +} + +.sidebar-git-loading { + min-height: 58px; + display: flex; + align-items: center; + justify-content: center; +} + +.sidebar-git-panel-error { + color: #f87171; +} + +.sidebar-git-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + min-height: 28px; + padding: 0 12px; + border-top: 1px solid var(--border); + background: rgba(255, 255, 255, 0.018); +} + +.sidebar-git-bar:first-child { + border-bottom: 1px double var(--border); +} + +.sidebar-git-bar-secondary { + margin-top: 0; + border-top: 0; + border-bottom: 0; + background: rgba(255, 255, 255, 0.012); +} + +.sidebar-git-bar-main { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.sidebar-git-link { + color: inherit; + text-decoration: none; +} + +.sidebar-git-link:hover .sidebar-git-repo-name { + color: var(--accent); +} + +.sidebar-git-bar-icon { + width: 14px; + height: 14px; + color: var(--text-muted); + flex-shrink: 0; +} + +.sidebar-git-repo-name { + color: var(--text); + font-size: 11px; + letter-spacing: 0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-git-branch { + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-git-dirty { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.sidebar-git-dirty.is-clean { + color: #71f8a7; +} + +.sidebar-git-dirty.is-dirty { + color: #f3a86a; +} + +.sidebar-git-meta { + color: var(--text-muted); +} + +.sidebar-git-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 10px 0; + overflow-y: auto; + min-height: 0; + flex: 1 1 auto; +} + +.sidebar-git-list li { + display: flex; + align-items: baseline; + flex: 0 0 auto; + gap: 6px; + color: var(--text-muted); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-git-commit-link { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + color: inherit; + text-decoration: none; + min-width: 0; + line-height: 1.4; +} + +.sidebar-git-commit-link:hover { + color: var(--text); +} + +.sidebar-git-commit-link:hover .sidebar-git-hash { + color: var(--accent); +} + +.sidebar-git-hash { + color: var(--accent); + flex-shrink: 0; +} + +@media (max-width: 768px) { + .sidebar-browse-resizer { + display: none; + } + + .app-content.browse-mode { + padding: 0; + } +} diff --git a/lib/public/css/shell.css b/lib/public/css/shell.css index 4f865863..b81394de 100644 --- a/lib/public/css/shell.css +++ b/lib/public/css/shell.css @@ -2,7 +2,7 @@ .app-shell { display: grid; - grid-template-columns: 220px 1fr; + grid-template-columns: 220px 6px minmax(0, 1fr); grid-template-rows: auto 1fr 24px; height: 100vh; position: relative; @@ -42,6 +42,7 @@ } .app-content { + grid-column: 3; grid-row: 2; overflow-y: auto; padding: 24px 32px; @@ -52,6 +53,7 @@ /* ── Sidebar ───────────────────────────────────── */ .app-sidebar { + grid-column: 1; grid-row: 2; background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), @@ -62,6 +64,29 @@ flex-direction: column; } +.sidebar-resizer { + grid-column: 2; + grid-row: 2; + cursor: col-resize; + position: relative; +} + +.sidebar-resizer::before { + content: ""; + position: absolute; + left: 2px; + top: 0; + bottom: 0; + width: 2px; + background: transparent; + transition: background 0.12s; +} + +.sidebar-resizer:hover::before, +.sidebar-resizer.is-resizing::before { + background: rgba(99, 235, 255, 0.55); +} + .sidebar-brand { padding: 16px; font-size: 14px; @@ -122,7 +147,7 @@ .sidebar-footer:empty { display: none; } .sidebar-footer:not(:empty) { - padding: 12px 16px; + padding: 0 16px 12px 16px; border-top: 1px solid var(--border); } @@ -267,10 +292,15 @@ transform: none; } .app-content { + grid-column: 1; grid-row: 2; padding: 0 14px 12px; } + .sidebar-resizer { + display: none; + } + .mobile-topbar { display: flex; align-items: center; diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css index 4766a94c..c19d5978 100644 --- a/lib/public/css/theme.css +++ b/lib/public/css/theme.css @@ -12,6 +12,11 @@ --accent: #63ebff; --accent-dim: rgba(99, 235, 255, 0.4); --accent-link: rgba(99, 235, 255, 0.6); + --orange: #d98a58; + --comment: #6a737d; + --keyword: #ff7b72; + --string: #a5d6ff; + --number: #79c0ff; --panel-bg-contrast: rgba(255, 255, 255, 0.028); --panel-border-contrast: rgba(255, 255, 255, 0.11); --field-bg-contrast: rgba(0, 0, 0, 0.3); diff --git a/lib/public/icons/folder-line.svg b/lib/public/icons/folder-line.svg new file mode 100644 index 00000000..4a0d1dfb --- /dev/null +++ b/lib/public/icons/folder-line.svg @@ -0,0 +1 @@ + diff --git a/lib/public/icons/hashtag.svg b/lib/public/icons/hashtag.svg new file mode 100644 index 00000000..f67763ae --- /dev/null +++ b/lib/public/icons/hashtag.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/public/icons/home-5-line.svg b/lib/public/icons/home-5-line.svg new file mode 100644 index 00000000..904517d6 --- /dev/null +++ b/lib/public/icons/home-5-line.svg @@ -0,0 +1 @@ + diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 1f8608ae..28bc6cb3 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -40,8 +40,29 @@ import { UpdateActionButton } from "./components/update-action-button.js"; import { GlobalRestartBanner } from "./components/global-restart-banner.js"; import { LoadingSpinner } from "./components/loading-spinner.js"; import { WatchdogTab } from "./components/watchdog-tab.js"; +import { FileViewer } from "./components/file-viewer.js"; +import { AppSidebar } from "./components/sidebar.js"; const html = htm.bind(h); const kDefaultUiTab = "general"; +const kUiSettingsStorageKey = "alphaclawUiSettings"; +const kDefaultSidebarWidthPx = 220; +const kSidebarMinWidthPx = 180; +const kSidebarMaxWidthPx = 460; +const kBrowseLastPathUiSettingKey = "browseLastPath"; + +const clampSidebarWidth = (value) => + Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value)); + +const readUiSettings = () => { + try { + const raw = window.localStorage.getItem(kUiSettingsStorageKey); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +}; const getHashPath = () => { const hash = window.location.hash.replace(/^#/, ""); @@ -377,6 +398,7 @@ const GeneralTab = ({ }; const App = () => { + const appShellRef = useRef(null); const [onboarded, setOnboarded] = useState(null); const [location, setLocation] = useLocation(); const [acVersion, setAcVersion] = useState(null); @@ -386,6 +408,19 @@ const App = () => { const [acDismissed, setAcDismissed] = useState(false); const [authEnabled, setAuthEnabled] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [sidebarTab, setSidebarTab] = useState("menu"); + const [sidebarWidthPx, setSidebarWidthPx] = useState(() => { + const settings = readUiSettings(); + if (!Number.isFinite(settings.sidebarWidthPx)) return kDefaultSidebarWidthPx; + return clampSidebarWidth(settings.sidebarWidthPx); + }); + const [lastBrowsePath, setLastBrowsePath] = useState(() => { + const settings = readUiSettings(); + return typeof settings[kBrowseLastPathUiSettingKey] === "string" + ? settings[kBrowseLastPathUiSettingKey] + : ""; + }); + const [isResizingSidebar, setIsResizingSidebar] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false); const [restartRequired, setRestartRequired] = useState(false); @@ -568,10 +603,45 @@ const App = () => { `; } + const buildBrowseRoute = (relativePath) => { + const encodedPath = String(relativePath || "") + .split("/") + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return encodedPath ? `/browse/${encodedPath}` : "/browse"; + }; const navigateToSubScreen = (screen) => { setLocation(`/${screen}`); setMobileSidebarOpen(false); }; + const navigateToBrowseFile = (relativePath) => { + setLocation(buildBrowseRoute(relativePath)); + setMobileSidebarOpen(false); + }; + const handleSidebarLogout = async () => { + setMenuOpen(false); + await logout(); + try { + window.localStorage.clear(); + window.sessionStorage.clear(); + } catch {} + window.location.href = "/login.html"; + }; + const handleSelectSidebarTab = (nextTab) => { + setSidebarTab(nextTab); + if (nextTab === "menu" && location.startsWith("/browse")) { + setLocation("/general"); + return; + } + if (nextTab === "browse" && !location.startsWith("/browse")) { + setLocation(buildBrowseRoute(lastBrowsePath)); + } + }; + const handleSelectNavItem = (itemId) => { + setLocation(`/${itemId}`); + setMobileSidebarOpen(false); + }; const exitSubScreen = () => { setLocation(`/${kDefaultUiTab}`); setMobileSidebarOpen(false); @@ -601,18 +671,91 @@ const App = () => { }, ]; - const selectedNavId = - location === "/telegram" - ? "" - : location.startsWith("/providers") - ? "providers" - : location.startsWith("/watchdog") - ? "watchdog" - : location.startsWith("/envars") - ? "envars" - : location.startsWith("/webhooks") - ? "webhooks" - : "general"; + const isBrowseRoute = location.startsWith("/browse"); + const selectedBrowsePath = isBrowseRoute + ? location + .replace(/^\/browse\/?/, "") + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/") + : ""; + const selectedNavId = isBrowseRoute + ? "browse" + : location === "/telegram" + ? "" + : location.startsWith("/providers") + ? "providers" + : location.startsWith("/watchdog") + ? "watchdog" + : location.startsWith("/envars") + ? "envars" + : location.startsWith("/webhooks") + ? "webhooks" + : "general"; + + useEffect(() => { + setSidebarTab((currentTab) => { + if (location.startsWith("/browse")) return "browse"; + if (currentTab === "browse") return "menu"; + return currentTab; + }); + }, [location]); + + useEffect(() => { + if (!isBrowseRoute) return; + if (!selectedBrowsePath) return; + setLastBrowsePath((currentPath) => + currentPath === selectedBrowsePath ? currentPath : selectedBrowsePath, + ); + }, [isBrowseRoute, selectedBrowsePath]); + + useEffect(() => { + const settings = readUiSettings(); + settings.sidebarWidthPx = sidebarWidthPx; + settings[kBrowseLastPathUiSettingKey] = lastBrowsePath; + try { + window.localStorage.setItem(kUiSettingsStorageKey, JSON.stringify(settings)); + } catch {} + }, [sidebarWidthPx, lastBrowsePath]); + + const resizeSidebarWithClientX = useCallback((clientX) => { + const shellElement = appShellRef.current; + if (!shellElement) return; + const shellBounds = shellElement.getBoundingClientRect(); + const nextWidth = clampSidebarWidth(Math.round(clientX - shellBounds.left)); + setSidebarWidthPx(nextWidth); + }, []); + + const onSidebarResizerPointerDown = (event) => { + event.preventDefault(); + setIsResizingSidebar(true); + resizeSidebarWithClientX(event.clientX); + }; + + useEffect(() => { + if (!isResizingSidebar) return () => {}; + const onPointerMove = (event) => resizeSidebarWithClientX(event.clientX); + const onPointerUp = () => setIsResizingSidebar(false); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + const previousUserSelect = document.body.style.userSelect; + const previousCursor = document.body.style.cursor; + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + document.body.style.userSelect = previousUserSelect; + document.body.style.cursor = previousCursor; + }; + }, [isResizingSidebar, resizeSidebarWithClientX]); const renderWebhooks = (hookName = "") => html`
@@ -626,91 +769,53 @@ const App = () => { `; return html` -
+
<${GlobalRestartBanner} visible=${restartRequired} restarting=${restartingGateway} onRestart=${handleGatewayRestart} /> -
- - ${kNavSections.map( - (section) => html` - - - `, - )} - -
+ <${AppSidebar} + mobileSidebarOpen=${mobileSidebarOpen} + authEnabled=${authEnabled} + menuRef=${menuRef} + menuOpen=${menuOpen} + onToggleMenu=${() => setMenuOpen((open) => !open)} + onLogout=${handleSidebarLogout} + sidebarTab=${sidebarTab} + onSelectSidebarTab=${handleSelectSidebarTab} + navSections=${kNavSections} + selectedNavId=${selectedNavId} + onSelectNavItem=${handleSelectNavItem} + selectedBrowsePath=${selectedBrowsePath} + onSelectBrowseFile=${navigateToBrowseFile} + acHasUpdate=${acHasUpdate} + acLatest=${acLatest} + acDismissed=${acDismissed} + acUpdating=${acUpdating} + onAcUpdate=${handleAcUpdate} + /> +
setMobileSidebarOpen(false)} /> -
+
-
- <${Switch}> - <${Route} path="/telegram"> -
- <${TelegramWorkspace} onBack=${exitSubScreen} /> -
- - <${Route} path="/general"> -
- <${GeneralTab} - statusData=${sharedStatus} - watchdogData=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} - onNavigate=${navigateToSubScreen} - isActive=${location === "/general"} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} - /> -
- - <${Route} path="/providers"> -
- <${Providers} onRestartRequired=${setRestartRequired} /> -
- - <${Route} path="/watchdog"> -
- <${WatchdogTab} - gatewayStatus=${sharedStatus?.gateway || null} - openclawVersion=${sharedStatus?.openclawVersion || null} - watchdogStatus=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} +
+ ${isBrowseRoute + ? html` + <${FileViewer} + filePath=${selectedBrowsePath} /> -
- - <${Route} path="/envars"> -
- <${Envars} onRestartRequired=${setRestartRequired} /> -
- - <${Route} path="/webhooks/:hookName"> - ${(params) => renderWebhooks(decodeURIComponent(params.hookName || ""))} - - <${Route} path="/webhooks"> - ${() => renderWebhooks("")} - - <${Route}> - <${RouteRedirect} to="/general" /> - - + ` + : html` + <${Switch}> + <${Route} path="/telegram"> +
+ <${TelegramWorkspace} onBack=${exitSubScreen} /> +
+ + <${Route} path="/general"> +
+ <${GeneralTab} + statusData=${sharedStatus} + watchdogData=${sharedWatchdogStatus} + onRefreshStatuses=${refreshSharedStatuses} + onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} + onNavigate=${navigateToSubScreen} + isActive=${location === "/general"} + restartingGateway=${restartingGateway} + onRestartGateway=${handleGatewayRestart} + restartSignal=${gatewayRestartSignal} + /> +
+ + <${Route} path="/providers"> +
+ <${Providers} onRestartRequired=${setRestartRequired} /> +
+ + <${Route} path="/watchdog"> +
+ <${WatchdogTab} + gatewayStatus=${sharedStatus?.gateway || null} + openclawVersion=${sharedStatus?.openclawVersion || null} + watchdogStatus=${sharedWatchdogStatus} + onRefreshStatuses=${refreshSharedStatuses} + restartingGateway=${restartingGateway} + onRestartGateway=${handleGatewayRestart} + restartSignal=${gatewayRestartSignal} + /> +
+ + <${Route} path="/envars"> +
+ <${Envars} onRestartRequired=${setRestartRequired} /> +
+ + <${Route} path="/webhooks/:hookName"> + ${(params) => + renderWebhooks(decodeURIComponent(params.hookName || ""))} + + <${Route} path="/webhooks"> + ${() => renderWebhooks("")} + + <${Route}> + <${RouteRedirect} to="/general" /> + + + `}
<${ToastContainer} className="fixed bottom-10 right-4 z-50 space-y-2 pointer-events-none" diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js new file mode 100644 index 00000000..da058acb --- /dev/null +++ b/lib/public/js/components/file-tree.js @@ -0,0 +1,263 @@ +import { h } from "https://esm.sh/preact"; +import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { fetchBrowseTree } from "../lib/api.js"; +import { + MarkdownFillIcon, + JavascriptFillIcon, + File3LineIcon, + Image2FillIcon, + TerminalFillIcon, + BracesLineIcon, + FileCodeLineIcon, + Database2LineIcon, + HashtagIcon, +} from "./icons.js"; + +const html = htm.bind(h); +const kTreeIndentPx = 9; +const kFolderBasePaddingPx = 10; +const kFileBasePaddingPx = 14; +const kCollapsedFoldersStorageKey = "alphaclawBrowseCollapsedFolders"; + +const readStoredCollapsedPaths = () => { + try { + const rawValue = window.localStorage.getItem(kCollapsedFoldersStorageKey); + if (!rawValue) return null; + const parsedValue = JSON.parse(rawValue); + if (!Array.isArray(parsedValue)) return null; + return new Set(parsedValue.map((entry) => String(entry))); + } catch { + return null; + } +}; + +const collectFolderPaths = (node, folderPaths) => { + if (!node || node.type !== "folder") return; + if (node.path) folderPaths.add(node.path); + (node.children || []).forEach((childNode) => + collectFolderPaths(childNode, folderPaths), + ); +}; + +const getFileIconMeta = (fileName) => { + const normalizedName = String(fileName || "").toLowerCase(); + if (normalizedName.endsWith(".md")) { + return { + icon: MarkdownFillIcon, + className: "file-icon file-icon-md", + }; + } + if (normalizedName.endsWith(".js") || normalizedName.endsWith(".mjs")) { + return { + icon: JavascriptFillIcon, + className: "file-icon file-icon-js", + }; + } + if (normalizedName.endsWith(".json") || normalizedName.endsWith(".jsonl")) { + return { + icon: BracesLineIcon, + className: "file-icon file-icon-json", + }; + } + if (normalizedName.endsWith(".css") || normalizedName.endsWith(".scss")) { + return { + icon: HashtagIcon, + className: "file-icon file-icon-css", + }; + } + if (/\.(html?)$/i.test(normalizedName)) { + return { + icon: FileCodeLineIcon, + className: "file-icon file-icon-html", + }; + } + if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(normalizedName)) { + return { + icon: Image2FillIcon, + className: "file-icon file-icon-image", + }; + } + if ( + /\.(sh|bash|zsh|command)$/i.test(normalizedName) || + [ + ".bashrc", + ".zshrc", + ".profile", + ".bash_profile", + ".zprofile", + ".zshenv", + ].includes(normalizedName) + ) { + return { + icon: TerminalFillIcon, + className: "file-icon file-icon-shell", + }; + } + if (/\.(db|sqlite|sqlite3|db3|sdb|sqlitedb|duckdb|mdb|accdb)$/i.test(normalizedName)) { + return { + icon: Database2LineIcon, + className: "file-icon file-icon-db", + }; + } + return { + icon: File3LineIcon, + className: "file-icon file-icon-generic", + }; +}; + +const TreeNode = ({ + node, + depth = 0, + collapsedPaths, + onToggleFolder, + onSelectFile, + selectedPath = "", +}) => { + if (!node) return null; + if (node.type === "file") { + const isActive = selectedPath === node.path; + const fileIconMeta = getFileIconMeta(node.name); + const FileTypeIcon = fileIconMeta.icon; + return html` +
  • + onSelectFile(node.path)} + style=${{ + paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px`, + }} + title=${node.path || node.name} + > + <${FileTypeIcon} className=${fileIconMeta.className} /> + ${node.name} + +
  • + `; + } + + const folderPath = node.path || ""; + const isCollapsed = collapsedPaths.has(folderPath); + return html` +
  • +
    onToggleFolder(folderPath)} + style=${{ + paddingLeft: `${kFolderBasePaddingPx + depth * kTreeIndentPx}px`, + }} + title=${folderPath || node.name} + > + + ${node.name} +
    +
      + ${(node.children || []).map( + (childNode) => html` + <${TreeNode} + key=${childNode.path || `${folderPath}/${childNode.name}`} + node=${childNode} + depth=${depth + 1} + collapsedPaths=${collapsedPaths} + onToggleFolder=${onToggleFolder} + onSelectFile=${onSelectFile} + selectedPath=${selectedPath} + /> + `, + )} +
    +
  • + `; +}; + +export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { + const [treeRoot, setTreeRoot] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [collapsedPaths, setCollapsedPaths] = useState( + readStoredCollapsedPaths, + ); + + useEffect(() => { + let active = true; + const loadTree = async () => { + setLoading(true); + setError(""); + try { + const data = await fetchBrowseTree(); + if (!active) return; + setTreeRoot(data.root || null); + setCollapsedPaths((previousPaths) => { + if (previousPaths instanceof Set) return previousPaths; + const nextPaths = new Set(); + collectFolderPaths(data.root, nextPaths); + return nextPaths; + }); + } catch (loadError) { + if (!active) return; + setError(loadError.message || "Could not load file tree"); + } finally { + if (active) setLoading(false); + } + }; + loadTree(); + return () => { + active = false; + }; + }, []); + + const rootChildren = useMemo(() => treeRoot?.children || [], [treeRoot]); + const safeCollapsedPaths = + collapsedPaths instanceof Set ? collapsedPaths : new Set(); + + useEffect(() => { + if (!(collapsedPaths instanceof Set)) return; + try { + window.localStorage.setItem( + kCollapsedFoldersStorageKey, + JSON.stringify(Array.from(collapsedPaths)), + ); + } catch {} + }, [collapsedPaths]); + + const toggleFolder = (folderPath) => { + setCollapsedPaths((previousPaths) => { + const nextPaths = + previousPaths instanceof Set ? new Set(previousPaths) : new Set(); + if (nextPaths.has(folderPath)) nextPaths.delete(folderPath); + else nextPaths.add(folderPath); + return nextPaths; + }); + }; + + if (loading) { + return html`
    Loading files...
    `; + } + if (error) { + return html`
    + ${error} +
    `; + } + if (!rootChildren.length) { + return html`
    No files found.
    `; + } + + return html` +
    +
      + ${rootChildren.map( + (node) => html` + <${TreeNode} + key=${node.path || node.name} + node=${node} + collapsedPaths=${safeCollapsedPaths} + onToggleFolder=${toggleFolder} + onSelectFile=${onSelectFile} + selectedPath=${selectedPath} + /> + `, + )} +
    +
    + `; +}; diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js new file mode 100644 index 00000000..4fa7ca53 --- /dev/null +++ b/lib/public/js/components/file-viewer.js @@ -0,0 +1,524 @@ +import { h } from "https://esm.sh/preact"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { marked } from "https://esm.sh/marked"; +import { fetchFileContent, saveFileContent } from "../lib/api.js"; +import { + formatFrontmatterValue, + getFileSyntaxKind, + highlightEditorLines, + parseFrontmatter, +} from "../lib/syntax-highlighters/index.js"; +import { showToast } from "./toast.js"; + +const html = htm.bind(h); +const kFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode"; + +const parsePathSegments = (inputPath) => + String(inputPath || "") + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + +const readStoredFileViewerMode = () => { + try { + const storedMode = String( + window.localStorage.getItem(kFileViewerModeStorageKey) || "", + ).trim(); + return storedMode === "preview" ? "preview" : "edit"; + } catch { + return "edit"; + } +}; + +export const FileViewer = ({ filePath = "" }) => { + const normalizedPath = String(filePath || "").trim(); + const [content, setContent] = useState(""); + const [initialContent, setInitialContent] = useState(""); + const [viewMode, setViewMode] = useState(readStoredFileViewerMode); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [isFolderPath, setIsFolderPath] = useState(false); + const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); + const editorLineNumbersRef = useRef(null); + const editorHighlightRef = useRef(null); + const editorTextareaRef = useRef(null); + const previewRef = useRef(null); + const viewScrollRatioRef = useRef(0); + const isSyncingScrollRef = useRef(false); + const editorLineNumberRowRefs = useRef([]); + const editorHighlightLineRefs = useRef([]); + + const pathSegments = useMemo( + () => parsePathSegments(normalizedPath), + [normalizedPath], + ); + const hasSelectedPath = normalizedPath.length > 0; + const canEditFile = hasSelectedPath && !isFolderPath; + const isDirty = canEditFile && content !== initialContent; + const syntaxKind = useMemo( + () => getFileSyntaxKind(normalizedPath), + [normalizedPath], + ); + const isMarkdownFile = syntaxKind === "markdown"; + const shouldUseHighlightedEditor = syntaxKind !== "plain"; + const parsedFrontmatter = useMemo( + () => + isMarkdownFile + ? parseFrontmatter(content) + : { entries: [], body: content }, + [content, isMarkdownFile], + ); + const highlightedEditorLines = useMemo( + () => + shouldUseHighlightedEditor + ? highlightEditorLines(content, syntaxKind) + : [], + [content, shouldUseHighlightedEditor, syntaxKind], + ); + const editorLineNumbers = useMemo(() => { + const lineCount = String(content || "").split("\n").length; + return Array.from({ length: lineCount }, (_, index) => index + 1); + }, [content]); + + const syncEditorLineNumberHeights = useCallback(() => { + if (!shouldUseHighlightedEditor || viewMode !== "edit") return; + const numberRows = editorLineNumberRowRefs.current; + const highlightRows = editorHighlightLineRefs.current; + const rowCount = Math.min(numberRows.length, highlightRows.length); + for (let index = 0; index < rowCount; index += 1) { + const numberRow = numberRows[index]; + const highlightRow = highlightRows[index]; + if (!numberRow || !highlightRow) continue; + numberRow.style.height = `${highlightRow.offsetHeight}px`; + } + }, [shouldUseHighlightedEditor, viewMode]); + + useEffect(() => { + syncEditorLineNumberHeights(); + }, [content, syncEditorLineNumberHeights]); + + useEffect(() => { + if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {}; + const onResize = () => syncEditorLineNumberHeights(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]); + const previewHtml = useMemo( + () => + isMarkdownFile + ? marked.parse(parsedFrontmatter.body || "", { + gfm: true, + breaks: true, + }) + : "", + [parsedFrontmatter.body, isMarkdownFile], + ); + + useEffect(() => { + if (!isMarkdownFile && viewMode !== "edit") { + setViewMode("edit"); + } + }, [isMarkdownFile, viewMode]); + + useEffect(() => { + try { + window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); + } catch {} + }, [viewMode]); + + useEffect(() => { + let active = true; + if (!hasSelectedPath) { + setContent(""); + setInitialContent(""); + setError(""); + setIsFolderPath(false); + viewScrollRatioRef.current = 0; + return () => { + active = false; + }; + } + + const loadFile = async () => { + setLoading(true); + setError(""); + setIsFolderPath(false); + try { + const data = await fetchFileContent(normalizedPath); + if (!active) return; + const nextContent = data.content || ""; + setContent(nextContent); + setInitialContent(nextContent); + viewScrollRatioRef.current = 0; + } catch (loadError) { + if (!active) return; + const message = loadError.message || "Could not load file"; + if (/path is not a file/i.test(message)) { + setContent(""); + setInitialContent(""); + setIsFolderPath(true); + setError(""); + return; + } + setError(message); + } finally { + if (active) setLoading(false); + } + }; + loadFile(); + return () => { + active = false; + }; + }, [hasSelectedPath, normalizedPath]); + + const handleSave = async () => { + if (!canEditFile || saving || !isDirty) return; + setSaving(true); + setError(""); + try { + const data = await saveFileContent(normalizedPath, content); + setInitialContent(content); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-saved", { + detail: { path: normalizedPath }, + }), + ); + if (data.synced === false) { + showToast("Saved, but git sync failed", "error"); + } else { + showToast("Saved and synced", "success"); + } + } catch (saveError) { + const message = saveError.message || "Could not save file"; + setError(message); + showToast(message, "error"); + } finally { + setSaving(false); + } + }; + + const getScrollRatio = (element) => { + if (!element) return 0; + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) return 0; + return element.scrollTop / maxScrollTop; + }; + + const setScrollByRatio = (element, ratio) => { + if (!element) return; + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) { + element.scrollTop = 0; + return; + } + const clampedRatio = Math.max(0, Math.min(1, ratio)); + element.scrollTop = maxScrollTop * clampedRatio; + }; + + const handleEditorScroll = (event) => { + if (isSyncingScrollRef.current) return; + const nextScrollTop = event.currentTarget.scrollTop; + const nextRatio = getScrollRatio(event.currentTarget); + viewScrollRatioRef.current = nextRatio; + if (!editorLineNumbersRef.current) return; + editorLineNumbersRef.current.scrollTop = nextScrollTop; + if (editorHighlightRef.current) { + editorHighlightRef.current.scrollTop = nextScrollTop; + editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft; + } + if (previewRef.current) { + isSyncingScrollRef.current = true; + setScrollByRatio(previewRef.current, nextRatio); + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = false; + }); + } + }; + + const handlePreviewScroll = (event) => { + if (isSyncingScrollRef.current) return; + const nextRatio = getScrollRatio(event.currentTarget); + viewScrollRatioRef.current = nextRatio; + isSyncingScrollRef.current = true; + setScrollByRatio(editorTextareaRef.current, nextRatio); + setScrollByRatio(editorLineNumbersRef.current, nextRatio); + setScrollByRatio(editorHighlightRef.current, nextRatio); + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = false; + }); + }; + + const handleChangeViewMode = (nextMode) => { + if (nextMode === viewMode) return; + const nextRatio = + viewMode === "preview" + ? getScrollRatio(previewRef.current) + : getScrollRatio(editorTextareaRef.current); + viewScrollRatioRef.current = nextRatio; + setViewMode(nextMode); + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = true; + if (nextMode === "preview") { + setScrollByRatio(previewRef.current, nextRatio); + } else { + setScrollByRatio(editorTextareaRef.current, nextRatio); + setScrollByRatio(editorLineNumbersRef.current, nextRatio); + setScrollByRatio(editorHighlightRef.current, nextRatio); + } + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = false; + }); + }); + }; + + if (!hasSelectedPath) { + return html` +
    +
    [ ]
    +
    + Browse and edit files
    Syncs to git +
    +
    + `; + } + + return html` +
    +
    +
    + f + + ${pathSegments.map( + (segment, index) => html` + + + ${segment} + + ${index < pathSegments.length - 1 && + html`>`} + + `, + )} + +
    +
    + ${isMarkdownFile && + html` +
    + + +
    + `} + +
    + ${isMarkdownFile && parsedFrontmatter.entries.length > 0 + ? html` +
    + + ${!frontmatterCollapsed + ? html` +
    + ${parsedFrontmatter.entries.map((entry) => { + const formattedValue = formatFrontmatterValue( + entry.rawValue, + ); + const isMultilineValue = formattedValue.includes("\n"); + return html` +
    +
    ${entry.key}
    + ${isMultilineValue + ? html` +
    +${formattedValue}
    + ` + : html`
    + ${formattedValue} +
    `} +
    + `; + })} +
    + ` + : null} +
    + ` + : null} + ${loading + ? html`
    Loading file...
    ` + : error + ? html`
    + ${error} +
    ` + : isFolderPath + ? html` +
    + Folder selected. Choose a file from this folder in the tree. +
    + ` + : html` + ${isMarkdownFile + ? html` +
    +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html` +
    { + editorLineNumberRowRefs.current[ + lineNumber - 1 + ] = element; + }} + > + ${lineNumber} +
    + `, + )} +
    +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[ + line.lineNumber - 1 + ] = element; + }} + > + +
    + `, + )} +
    + +
    +
    + ` + : html` +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html` +
    { + editorLineNumberRowRefs.current[ + lineNumber - 1 + ] = element; + }} + > + ${lineNumber} +
    + `, + )} +
    + +
    + `} + `} +
    + `; +}; diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 49591620..3e497540 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -40,3 +40,172 @@ export const CloseIcon = ({ className = "" }) => html` /> `; + +export const HomeLineIcon = ({ className = "" }) => html` + +`; + +export const FolderLineIcon = ({ className = "" }) => html` + +`; + +export const MarkdownFillIcon = ({ className = "" }) => html` + +`; + +export const File3LineIcon = ({ className = "" }) => html` + +`; + +export const JavascriptFillIcon = ({ className = "" }) => html` + +`; + +export const Image2FillIcon = ({ className = "" }) => html` + +`; + +export const TerminalFillIcon = ({ className = "" }) => html` + +`; + +export const BracesLineIcon = ({ className = "" }) => html` + +`; + +export const FileCodeLineIcon = ({ className = "" }) => html` + +`; + +export const Database2LineIcon = ({ className = "" }) => html` + +`; + +export const HashtagIcon = ({ className = "" }) => html` + +`; + +export const GitBranchLineIcon = ({ className = "" }) => html` + +`; + +export const GithubFillIcon = ({ className = "" }) => html` + +`; diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js new file mode 100644 index 00000000..1cf3a5e9 --- /dev/null +++ b/lib/public/js/components/sidebar-git-panel.js @@ -0,0 +1,149 @@ +import { h } from "https://esm.sh/preact"; +import { useEffect, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { fetchBrowseGitSummary } from "../lib/api.js"; +import { GitBranchLineIcon, GithubFillIcon } from "./icons.js"; +import { LoadingSpinner } from "./loading-spinner.js"; + +const html = htm.bind(h); +const kRefreshMs = 10000; + +const formatCommitTime = (unixSeconds) => { + if (!unixSeconds) return ""; + try { + return new Date(unixSeconds * 1000).toLocaleString(); + } catch { + return ""; + } +}; + +const getRepoName = (summary) => { + const slug = String(summary?.repoSlug || "").trim(); + if (slug) return slug; + const pathValue = String(summary?.repoPath || ""); + const segment = pathValue.split("/").filter(Boolean).pop(); + return segment || "repo"; +}; + +export const SidebarGitPanel = () => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [summary, setSummary] = useState(null); + + useEffect(() => { + let active = true; + let intervalId = null; + + const loadSummary = async () => { + if (!active) return; + try { + const data = await fetchBrowseGitSummary(); + if (!active) return; + setSummary(data); + setError(""); + } catch (nextError) { + if (!active) return; + setError(nextError.message || "Could not load git summary"); + } finally { + if (active) setLoading(false); + } + }; + + const handleFileSaved = () => { + loadSummary(); + }; + + loadSummary(); + intervalId = window.setInterval(loadSummary, kRefreshMs); + window.addEventListener("alphaclaw:browse-file-saved", handleFileSaved); + + return () => { + active = false; + if (intervalId) window.clearInterval(intervalId); + window.removeEventListener("alphaclaw:browse-file-saved", handleFileSaved); + }; + }, []); + + if (loading) { + return html` + + `; + } + + if (error) { + return html``; + } + + if (!summary?.isRepo) { + return html` + + `; + } + + return html` + + `; +}; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js new file mode 100644 index 00000000..0f89bfc0 --- /dev/null +++ b/lib/public/js/components/sidebar.js @@ -0,0 +1,267 @@ +import { h } from "https://esm.sh/preact"; +import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { HomeLineIcon, FolderLineIcon } from "./icons.js"; +import { FileTree } from "./file-tree.js"; +import { UpdateActionButton } from "./update-action-button.js"; +import { SidebarGitPanel } from "./sidebar-git-panel.js"; + +const html = htm.bind(h); +const kUiSettingsStorageKey = "alphaclawUiSettings"; +const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx"; +const kBrowsePanelMinHeightPx = 120; +const kBrowseBottomMinHeightPx = 120; +const kBrowseResizerHeightPx = 6; +const kDefaultBrowseBottomPanelHeightPx = 160; +const kLegacyBrowsePanelUiStorageKey = "alphaclawBrowsePanelHeightPx"; + +const readStoredBrowseBottomPanelHeight = () => { + try { + const rawSettings = window.localStorage.getItem(kUiSettingsStorageKey); + if (rawSettings) { + const parsedSettings = JSON.parse(rawSettings); + const fromSharedSettings = Number.parseInt( + String(parsedSettings?.[kBrowseBottomPanelUiSettingKey] || ""), + 10, + ); + if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) { + return fromSharedSettings; + } + } + const legacyRawValue = window.localStorage.getItem(kLegacyBrowsePanelUiStorageKey); + const parsedValue = Number.parseInt(String(legacyRawValue || ""), 10); + return Number.isFinite(parsedValue) && parsedValue > 0 + ? parsedValue + : kDefaultBrowseBottomPanelHeightPx; + } catch { + return kDefaultBrowseBottomPanelHeightPx; + } +}; + +export const AppSidebar = ({ + mobileSidebarOpen = false, + authEnabled = false, + menuRef = null, + menuOpen = false, + onToggleMenu = () => {}, + onLogout = () => {}, + sidebarTab = "menu", + onSelectSidebarTab = () => {}, + navSections = [], + selectedNavId = "", + onSelectNavItem = () => {}, + selectedBrowsePath = "", + onSelectBrowseFile = () => {}, + acHasUpdate = false, + acLatest = "", + acDismissed = false, + acUpdating = false, + onAcUpdate = () => {}, +}) => { + const browseLayoutRef = useRef(null); + const browseBottomPanelRef = useRef(null); + const browseResizeStartRef = useRef({ startY: 0, startHeight: 0 }); + const [browseBottomPanelHeightPx, setBrowseBottomPanelHeightPx] = useState( + readStoredBrowseBottomPanelHeight, + ); + const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false); + + useEffect(() => { + try { + const rawSettings = window.localStorage.getItem(kUiSettingsStorageKey); + const parsedSettings = rawSettings ? JSON.parse(rawSettings) : {}; + const nextSettings = + parsedSettings && typeof parsedSettings === "object" + ? { ...parsedSettings } + : {}; + nextSettings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx; + window.localStorage.setItem(kUiSettingsStorageKey, JSON.stringify(nextSettings)); + } catch {} + }, [browseBottomPanelHeightPx]); + + const getClampedBrowseBottomPanelHeight = (value) => { + const layoutElement = browseLayoutRef.current; + if (!layoutElement) return value; + const layoutRect = layoutElement.getBoundingClientRect(); + const maxHeight = Math.max( + kBrowseBottomMinHeightPx, + layoutRect.height - kBrowsePanelMinHeightPx - kBrowseResizerHeightPx, + ); + return Math.max( + kBrowseBottomMinHeightPx, + Math.min(maxHeight, value), + ); + }; + + const resizeBrowsePanelWithClientY = (clientY) => { + const { startY, startHeight } = browseResizeStartRef.current; + const proposedHeight = startHeight + (startY - clientY); + setBrowseBottomPanelHeightPx(getClampedBrowseBottomPanelHeight(proposedHeight)); + }; + + useEffect(() => { + const layoutElement = browseLayoutRef.current; + if (!layoutElement || typeof ResizeObserver === "undefined") return () => {}; + const observer = new ResizeObserver(() => { + setBrowseBottomPanelHeightPx((currentHeight) => + getClampedBrowseBottomPanelHeight(currentHeight), + ); + }); + observer.observe(layoutElement); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!isResizingBrowsePanels) return () => {}; + const handlePointerMove = (event) => resizeBrowsePanelWithClientY(event.clientY); + const handlePointerUp = () => setIsResizingBrowsePanels(false); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [isResizingBrowsePanels]); + + const onBrowsePanelResizerPointerDown = (event) => { + event.preventDefault(); + const measuredHeight = + browseBottomPanelRef.current?.getBoundingClientRect().height || + browseBottomPanelHeightPx; + browseResizeStartRef.current = { + startY: event.clientY, + startHeight: measuredHeight, + }; + setBrowseBottomPanelHeightPx(getClampedBrowseBottomPanelHeight(measuredHeight)); + setIsResizingBrowsePanels(true); + }; + + return html` +
    + + + ${sidebarTab === "menu" + ? navSections.map( + (section) => html` + + + `, + ) + : html` + + `} + ${sidebarTab === "menu" + ? html` + + ` + : null} +
    +`; +}; diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 02dc397d..b34ebd0b 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -343,3 +343,29 @@ export async function fetchWebhookRequest(name, id) { ); return parseJsonOrThrow(res, 'Could not load webhook request'); } + +export const fetchBrowseTree = async (depth = 10) => { + const params = new URLSearchParams({ depth: String(depth) }); + const res = await authFetch(`/api/browse/tree?${params.toString()}`); + return parseJsonOrThrow(res, 'Could not load file tree'); +}; + +export const fetchFileContent = async (filePath) => { + const params = new URLSearchParams({ path: String(filePath || '') }); + const res = await authFetch(`/api/browse/read?${params.toString()}`); + return parseJsonOrThrow(res, 'Could not load file content'); +}; + +export const saveFileContent = async (filePath, content) => { + const res = await authFetch('/api/browse/write', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + return parseJsonOrThrow(res, 'Could not save file'); +}; + +export const fetchBrowseGitSummary = async () => { + const res = await authFetch('/api/browse/git-summary'); + return parseJsonOrThrow(res, 'Could not load git summary'); +}; diff --git a/lib/public/js/lib/file-highlighting.js b/lib/public/js/lib/file-highlighting.js new file mode 100644 index 00000000..0704d4d6 --- /dev/null +++ b/lib/public/js/lib/file-highlighting.js @@ -0,0 +1,6 @@ +export { + formatFrontmatterValue, + getFileSyntaxKind, + highlightEditorLines, + parseFrontmatter, +} from "./syntax-highlighters/index.js"; diff --git a/lib/public/js/lib/syntax-highlighters/css.js b/lib/public/js/lib/syntax-highlighters/css.js new file mode 100644 index 00000000..013c7749 --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/css.js @@ -0,0 +1,124 @@ +import { escapeHtml } from "./utils.js"; + +const kCssNumberRegex = + /(^|[^\w.#-])(-?\d+(?:\.\d+)?(?:px|em|rem|vh|vw|%|deg|s|ms)?)(?=$|[^\w-])/g; + +const highlightCssTextSegment = (text) => { + let content = escapeHtml(text); + content = content.replace(/@[a-zA-Z-]+/g, '$&'); + content = content.replace(/#[0-9a-fA-F]{3,8}\b/g, '$&'); + content = content.replace(kCssNumberRegex, '$1$2'); + content = content.replace( + /(^|[;{\s])([a-zA-Z-]+)(\s*:)/g, + '$1$2$3', + ); + return content; +}; + +const findClosingQuote = (source, startIndex, quote) => { + let index = startIndex + 1; + while (index < source.length) { + if (source[index] === "\\" && index + 1 < source.length) { + index += 2; + continue; + } + if (source[index] === quote) return index; + index += 1; + } + return -1; +}; + +const tokenizeCssLine = (line, inBlockComment) => { + const source = String(line || ""); + const parts = []; + let cursor = 0; + let nextInBlockComment = inBlockComment; + + while (cursor < source.length) { + if (nextInBlockComment) { + const blockEnd = source.indexOf("*/", cursor); + if (blockEnd === -1) { + parts.push({ kind: "comment", value: source.slice(cursor) }); + return { parts, inBlockComment: true }; + } + parts.push({ kind: "comment", value: source.slice(cursor, blockEnd + 2) }); + cursor = blockEnd + 2; + nextInBlockComment = false; + continue; + } + + const blockCommentIndex = source.indexOf("/*", cursor); + const singleQuoteIndex = source.indexOf("'", cursor); + const doubleQuoteIndex = source.indexOf('"', cursor); + const indexes = [blockCommentIndex, singleQuoteIndex, doubleQuoteIndex].filter( + (index) => index !== -1, + ); + + if (indexes.length === 0) { + parts.push({ kind: "text", value: source.slice(cursor) }); + break; + } + + const nextIndex = Math.min(...indexes); + if (nextIndex > cursor) { + parts.push({ kind: "text", value: source.slice(cursor, nextIndex) }); + cursor = nextIndex; + } + + if (blockCommentIndex === nextIndex) { + const blockEnd = source.indexOf("*/", nextIndex + 2); + if (blockEnd === -1) { + parts.push({ kind: "comment", value: source.slice(nextIndex) }); + nextInBlockComment = true; + break; + } + parts.push({ kind: "comment", value: source.slice(nextIndex, blockEnd + 2) }); + cursor = blockEnd + 2; + continue; + } + + const quote = source[nextIndex]; + const quoteEnd = findClosingQuote(source, nextIndex, quote); + if (quoteEnd === -1) { + parts.push({ kind: "string", value: source.slice(nextIndex) }); + break; + } + parts.push({ kind: "string", value: source.slice(nextIndex, quoteEnd + 1) }); + cursor = quoteEnd + 1; + } + + return { parts, inBlockComment: nextInBlockComment }; +}; + +export const highlightCssLine = (line, state = { inBlockComment: false }) => { + const tokens = tokenizeCssLine(line, Boolean(state?.inBlockComment)); + const html = tokens.parts + .map((part) => { + if (part.kind === "comment") { + return `${escapeHtml(part.value)}`; + } + if (part.kind === "string") { + return `${escapeHtml(part.value)}`; + } + return highlightCssTextSegment(part.value); + }) + .join(""); + + return { + html, + state: { inBlockComment: tokens.inBlockComment }, + }; +}; + +export const highlightCssContent = (content) => { + const lines = String(content || "").split("\n"); + let state = { inBlockComment: false }; + return lines.map((line, index) => { + const renderedLine = highlightCssLine(line, state); + state = renderedLine.state; + return { + lineNumber: index + 1, + html: renderedLine.html, + }; + }); +}; diff --git a/lib/public/js/lib/syntax-highlighters/frontmatter.js b/lib/public/js/lib/syntax-highlighters/frontmatter.js new file mode 100644 index 00000000..9a6e1565 --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/frontmatter.js @@ -0,0 +1,49 @@ +export const parseFrontmatter = (markdown) => { + const value = String(markdown || ""); + if (!(value.startsWith("---\n") || value === "---")) { + return { entries: [], body: value }; + } + const lines = value.split("\n"); + if (lines[0] !== "---") { + return { entries: [], body: value }; + } + const closingFenceIndex = lines.findIndex( + (line, index) => index > 0 && line === "---", + ); + if (closingFenceIndex === -1) { + return { entries: [], body: value }; + } + const frontmatterLines = lines.slice(1, closingFenceIndex); + const bodyLines = lines.slice(closingFenceIndex + 1); + const entries = frontmatterLines + .map((line) => { + const separatorIndex = line.indexOf(":"); + if (separatorIndex <= 0) return null; + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 1).trim(); + if (!key) return null; + return { key, rawValue }; + }) + .filter((entry) => entry !== null); + return { + entries, + body: bodyLines.join("\n").replace(/^\n+/, ""), + }; +}; + +export const formatFrontmatterValue = (rawValue) => { + const trimmedValue = String(rawValue || "").trim(); + if (!trimmedValue) return trimmedValue; + if ( + (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || + (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) + ) { + try { + const parsedValue = JSON.parse(trimmedValue); + return JSON.stringify(parsedValue, null, 2); + } catch { + return trimmedValue; + } + } + return trimmedValue; +}; diff --git a/lib/public/js/lib/syntax-highlighters/html.js b/lib/public/js/lib/syntax-highlighters/html.js new file mode 100644 index 00000000..21f7d30c --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/html.js @@ -0,0 +1,209 @@ +import { escapeHtml } from "./utils.js"; +import { highlightCssLine } from "./css.js"; +import { highlightJavaScriptLine } from "./javascript.js"; + +const highlightHtmlTextSegment = (text) => + escapeHtml(text).replace( + /(&[a-zA-Z][a-zA-Z0-9]+;|&#\d+;|&#x[0-9a-fA-F]+;)/g, + '$1', + ); + +const highlightHtmlAttributeValue = (valueWithSpace) => { + const leadingWhitespace = valueWithSpace.match(/^\s*/)?.[0] || ""; + const rawValue = valueWithSpace.slice(leadingWhitespace.length); + return `${escapeHtml(leadingWhitespace)}${escapeHtml(rawValue)}`; +}; + +const highlightHtmlAttributes = (attributesText) => { + const attrRegex = /([:@A-Za-z_][\w:.-]*)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g; + let html = ""; + let cursor = 0; + let match = attrRegex.exec(attributesText); + + while (match) { + const fullMatch = match[0]; + const attrName = match[1]; + const attrAssignment = match[2] || ""; + const start = match.index; + const end = start + fullMatch.length; + + if (start > cursor) { + html += escapeHtml(attributesText.slice(cursor, start)); + } + html += `${escapeHtml(attrName)}`; + + if (attrAssignment) { + const equalsIndex = attrAssignment.indexOf("="); + if (equalsIndex !== -1) { + const beforeEquals = attrAssignment.slice(0, equalsIndex); + const afterEquals = attrAssignment.slice(equalsIndex + 1); + html += `${escapeHtml(beforeEquals)}=${highlightHtmlAttributeValue(afterEquals)}`; + } else { + html += escapeHtml(attrAssignment); + } + } + + cursor = end; + match = attrRegex.exec(attributesText); + } + + if (cursor < attributesText.length) { + html += escapeHtml(attributesText.slice(cursor)); + } + return html; +}; + +const renderHighlightedHtmlTag = (tagText) => { + if (/^$/.test(tagText) || /^${escapeHtml(tagText)}`; + } + + const tagMatch = tagText.match(/^<\s*(\/?)\s*([A-Za-z][\w:-]*)([\s\S]*?)(\/?)\s*>$/); + if (!tagMatch) { + return `${escapeHtml(tagText)}`; + } + + const isClosing = tagMatch[1] === "/"; + const tagName = tagMatch[2]; + const attributesText = tagMatch[3] || ""; + const isSelfClosing = tagMatch[4] === "/"; + const open = isClosing ? "</" : "<"; + const attrsHtml = isClosing ? "" : highlightHtmlAttributes(attributesText); + const close = isSelfClosing ? "/>" : ">"; + + return `${open}${escapeHtml(tagName)}${attrsHtml}${close}`; +}; + +const renderHighlightedHtmlLine = (line) => { + const tokenRegex = /|]*>|<\/?[A-Za-z][^>]*>/gi; + const source = String(line || ""); + let html = ""; + let cursor = 0; + let match = tokenRegex.exec(source); + + while (match) { + const token = match[0]; + const start = match.index; + const end = start + token.length; + if (start > cursor) { + html += highlightHtmlTextSegment(source.slice(cursor, start)); + } + html += renderHighlightedHtmlTag(token); + cursor = end; + match = tokenRegex.exec(source); + } + + if (cursor < source.length) { + html += highlightHtmlTextSegment(source.slice(cursor)); + } + return html; +}; + +const findNextTag = (source, tagName) => { + const regex = new RegExp(`<\\/?\\s*${tagName}\\b[^>]*>`, "ig"); + const match = regex.exec(source); + if (!match) return null; + return { + text: match[0], + start: match.index, + end: match.index + match[0].length, + isClosing: /^<\s*\//.test(match[0]), + }; +}; + +const highlightInlineSection = (line, state) => { + let html = ""; + let cursor = 0; + let nextMode = state.mode; + let nextLanguageState = state.languageState; + + while (cursor < line.length) { + if (nextMode === "script") { + const closeTag = findNextTag(line.slice(cursor), "script"); + if (!closeTag || !closeTag.isClosing) { + const renderedJs = highlightJavaScriptLine(line.slice(cursor), nextLanguageState); + html += renderedJs.html; + nextLanguageState = renderedJs.state; + cursor = line.length; + break; + } + const absoluteCloseStart = cursor + closeTag.start; + const absoluteCloseEnd = cursor + closeTag.end; + const jsPart = line.slice(cursor, absoluteCloseStart); + const renderedJs = highlightJavaScriptLine(jsPart, nextLanguageState); + html += renderedJs.html; + html += renderHighlightedHtmlLine(line.slice(absoluteCloseStart, absoluteCloseEnd)); + nextMode = "html"; + nextLanguageState = { inBlockComment: false }; + cursor = absoluteCloseEnd; + continue; + } + + if (nextMode === "style") { + const closeTag = findNextTag(line.slice(cursor), "style"); + if (!closeTag || !closeTag.isClosing) { + const renderedCss = highlightCssLine(line.slice(cursor), nextLanguageState); + html += renderedCss.html; + nextLanguageState = renderedCss.state; + cursor = line.length; + break; + } + const absoluteCloseStart = cursor + closeTag.start; + const absoluteCloseEnd = cursor + closeTag.end; + const cssPart = line.slice(cursor, absoluteCloseStart); + const renderedCss = highlightCssLine(cssPart, nextLanguageState); + html += renderedCss.html; + html += renderHighlightedHtmlLine(line.slice(absoluteCloseStart, absoluteCloseEnd)); + nextMode = "html"; + nextLanguageState = { inBlockComment: false }; + cursor = absoluteCloseEnd; + continue; + } + + const remaining = line.slice(cursor); + const nextScript = findNextTag(remaining, "script"); + const nextStyle = findNextTag(remaining, "style"); + const candidates = [nextScript, nextStyle] + .filter((candidate) => candidate && !candidate.isClosing) + .sort((left, right) => left.start - right.start); + + if (candidates.length === 0) { + html += renderHighlightedHtmlLine(remaining); + cursor = line.length; + break; + } + + const nextTag = candidates[0]; + const absoluteTagStart = cursor + nextTag.start; + const absoluteTagEnd = cursor + nextTag.end; + html += renderHighlightedHtmlLine(line.slice(cursor, absoluteTagStart)); + html += renderHighlightedHtmlLine(line.slice(absoluteTagStart, absoluteTagEnd)); + nextMode = /<\s*script\b/i.test(nextTag.text) ? "script" : "style"; + nextLanguageState = { inBlockComment: false }; + cursor = absoluteTagEnd; + } + + return { + html, + state: { + mode: nextMode, + languageState: nextLanguageState, + }, + }; +}; + +export const highlightHtmlContent = (content) => { + const lines = String(content || "").split("\n"); + let state = { + mode: "html", + languageState: { inBlockComment: false }, + }; + return lines.map((line, index) => { + const renderedLine = highlightInlineSection(line, state); + state = renderedLine.state; + return { + lineNumber: index + 1, + html: renderedLine.html, + }; + }); +}; diff --git a/lib/public/js/lib/syntax-highlighters/index.js b/lib/public/js/lib/syntax-highlighters/index.js new file mode 100644 index 00000000..37c91e70 --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/index.js @@ -0,0 +1,28 @@ +import { formatFrontmatterValue, parseFrontmatter } from "./frontmatter.js"; +import { highlightCssContent } from "./css.js"; +import { highlightHtmlContent } from "./html.js"; +import { highlightJavaScriptContent } from "./javascript.js"; +import { highlightJsonContent } from "./json.js"; +import { highlightMarkdownContent } from "./markdown.js"; +import { escapeHtml, toLineObjects } from "./utils.js"; + +export const getFileSyntaxKind = (filePath) => { + const normalizedPath = String(filePath || "").toLowerCase(); + if (/\.(md|markdown|mdx)$/i.test(normalizedPath)) return "markdown"; + if (/\.(json|jsonl)$/i.test(normalizedPath)) return "json"; + if (/\.(html|htm)$/i.test(normalizedPath)) return "html"; + if (/\.(js|mjs|cjs)$/i.test(normalizedPath)) return "javascript"; + if (/\.(css|scss)$/i.test(normalizedPath)) return "css"; + return "plain"; +}; + +export const highlightEditorLines = (content, syntaxKind) => { + if (syntaxKind === "markdown") return highlightMarkdownContent(content); + if (syntaxKind === "json") return highlightJsonContent(content); + if (syntaxKind === "html") return highlightHtmlContent(content); + if (syntaxKind === "javascript") return highlightJavaScriptContent(content); + if (syntaxKind === "css") return highlightCssContent(content); + return toLineObjects(content, (line) => escapeHtml(line)); +}; + +export { formatFrontmatterValue, parseFrontmatter }; diff --git a/lib/public/js/lib/syntax-highlighters/javascript.js b/lib/public/js/lib/syntax-highlighters/javascript.js new file mode 100644 index 00000000..666627e8 --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/javascript.js @@ -0,0 +1,134 @@ +import { escapeHtml } from "./utils.js"; + +const kJavaScriptKeywordsRegex = + /\b(await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/g; + +const kNumberRegex = + /(^|[^\w.])(-?(?:0x[a-fA-F0-9]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?))(?=$|[^\w.])/g; + +const highlightJavaScriptTextSegment = (text) => { + let content = escapeHtml(text); + content = content.replace(kJavaScriptKeywordsRegex, '$1'); + content = content.replace(/\b(true|false)\b/g, '$1'); + content = content.replace(/\b(null|undefined)\b/g, '$1'); + content = content.replace(kNumberRegex, '$1$2'); + return content; +}; + +const findClosingQuote = (source, startIndex, quote) => { + let index = startIndex + 1; + while (index < source.length) { + if (source[index] === "\\" && index + 1 < source.length) { + index += 2; + continue; + } + if (source[index] === quote) return index; + index += 1; + } + return -1; +}; + +const tokenizeJavaScriptLine = (line, inBlockComment) => { + const source = String(line || ""); + const parts = []; + let cursor = 0; + let nextInBlockComment = inBlockComment; + + while (cursor < source.length) { + if (nextInBlockComment) { + const blockEnd = source.indexOf("*/", cursor); + if (blockEnd === -1) { + parts.push({ kind: "comment", value: source.slice(cursor) }); + return { parts, inBlockComment: true }; + } + parts.push({ kind: "comment", value: source.slice(cursor, blockEnd + 2) }); + cursor = blockEnd + 2; + nextInBlockComment = false; + continue; + } + + const lineCommentIndex = source.indexOf("//", cursor); + const blockCommentIndex = source.indexOf("/*", cursor); + const singleQuoteIndex = source.indexOf("'", cursor); + const doubleQuoteIndex = source.indexOf('"', cursor); + const templateQuoteIndex = source.indexOf("`", cursor); + const indexes = [ + lineCommentIndex, + blockCommentIndex, + singleQuoteIndex, + doubleQuoteIndex, + templateQuoteIndex, + ].filter((index) => index !== -1); + + if (indexes.length === 0) { + parts.push({ kind: "text", value: source.slice(cursor) }); + break; + } + + const nextIndex = Math.min(...indexes); + if (nextIndex > cursor) { + parts.push({ kind: "text", value: source.slice(cursor, nextIndex) }); + cursor = nextIndex; + } + + if (lineCommentIndex === nextIndex) { + parts.push({ kind: "comment", value: source.slice(nextIndex) }); + break; + } + + if (blockCommentIndex === nextIndex) { + const blockEnd = source.indexOf("*/", nextIndex + 2); + if (blockEnd === -1) { + parts.push({ kind: "comment", value: source.slice(nextIndex) }); + nextInBlockComment = true; + break; + } + parts.push({ kind: "comment", value: source.slice(nextIndex, blockEnd + 2) }); + cursor = blockEnd + 2; + continue; + } + + const quote = source[nextIndex]; + const quoteEnd = findClosingQuote(source, nextIndex, quote); + if (quoteEnd === -1) { + parts.push({ kind: "string", value: source.slice(nextIndex) }); + break; + } + parts.push({ kind: "string", value: source.slice(nextIndex, quoteEnd + 1) }); + cursor = quoteEnd + 1; + } + + return { parts, inBlockComment: nextInBlockComment }; +}; + +export const highlightJavaScriptLine = (line, state = { inBlockComment: false }) => { + const tokens = tokenizeJavaScriptLine(line, Boolean(state?.inBlockComment)); + const html = tokens.parts + .map((part) => { + if (part.kind === "comment") { + return `${escapeHtml(part.value)}`; + } + if (part.kind === "string") { + return `${escapeHtml(part.value)}`; + } + return highlightJavaScriptTextSegment(part.value); + }) + .join(""); + return { + html, + state: { inBlockComment: tokens.inBlockComment }, + }; +}; + +export const highlightJavaScriptContent = (content) => { + const lines = String(content || "").split("\n"); + let state = { inBlockComment: false }; + return lines.map((line, index) => { + const renderedLine = highlightJavaScriptLine(line, state); + state = renderedLine.state; + return { + lineNumber: index + 1, + html: renderedLine.html, + }; + }); +}; diff --git a/lib/public/js/lib/syntax-highlighters/json.js b/lib/public/js/lib/syntax-highlighters/json.js new file mode 100644 index 00000000..8f666b92 --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/json.js @@ -0,0 +1,61 @@ +import { escapeHtml, toLineObjects } from "./utils.js"; + +const tokenizeJsonLine = (line) => { + const parts = []; + const source = String(line || ""); + const stringRegex = /"([^"\\]|\\.)*"/g; + let lastIndex = 0; + let match = stringRegex.exec(source); + + while (match) { + const start = match.index; + const end = stringRegex.lastIndex; + const value = match[0]; + const trailing = source.slice(end); + const isKey = /^\s*:/.test(trailing); + + if (start > lastIndex) { + parts.push({ kind: "text", value: source.slice(lastIndex, start) }); + } + parts.push({ kind: isKey ? "key" : "string", value }); + lastIndex = end; + match = stringRegex.exec(source); + } + + if (lastIndex < source.length) { + parts.push({ kind: "text", value: source.slice(lastIndex) }); + } + + if (parts.length === 0) { + return [{ kind: "text", value: source }]; + } + return parts; +}; + +const highlightJsonTextSegment = (text) => { + let content = escapeHtml(text); + content = content.replace( + /(^|[^\w.])(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=$|[^\w.])/g, + '$1$2', + ); + content = content.replace(/\b(true|false)\b/g, '$1'); + content = content.replace(/\bnull\b/g, 'null'); + content = content.replace(/([{}\[\],:])/g, '$1'); + return content; +}; + +const renderHighlightedJsonLine = (line) => + tokenizeJsonLine(line) + .map((part) => { + if (part.kind === "key") { + return `${escapeHtml(part.value)}`; + } + if (part.kind === "string") { + return `${escapeHtml(part.value)}`; + } + return highlightJsonTextSegment(part.value); + }) + .join(""); + +export const highlightJsonContent = (content) => + toLineObjects(content, renderHighlightedJsonLine); diff --git a/lib/public/js/lib/syntax-highlighters/markdown.js b/lib/public/js/lib/syntax-highlighters/markdown.js new file mode 100644 index 00000000..332a854f --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/markdown.js @@ -0,0 +1,37 @@ +import { escapeHtml, toLineObjects } from "./utils.js"; + +const renderInlineMarkdown = (line) => { + let content = escapeHtml(line); + content = content.replace(/`([^`]+)`/g, '`$1`'); + content = content.replace(/\*\*([^*]+)\*\*/g, '**$1**'); + content = content.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '[$1]($2)', + ); + return content; +}; + +const renderHighlightedMarkdownLine = (line) => { + if (/^#{1,6}\s/.test(line)) { + return `${escapeHtml(line)}`; + } + if (/^>\s/.test(line)) { + return `${escapeHtml(line)}`; + } + if (/^```/.test(line)) { + return `${escapeHtml(line)}`; + } + if (/^\|[-\s|]+\|$/.test(line)) { + return `${escapeHtml(line)}`; + } + if (/^\s*[-*]\s/.test(line)) { + return renderInlineMarkdown(line).replace( + /^(\s*)([-*])/, + '$1$2', + ); + } + return renderInlineMarkdown(line); +}; + +export const highlightMarkdownContent = (content) => + toLineObjects(content, renderHighlightedMarkdownLine); diff --git a/lib/public/js/lib/syntax-highlighters/utils.js b/lib/public/js/lib/syntax-highlighters/utils.js new file mode 100644 index 00000000..10a245bf --- /dev/null +++ b/lib/public/js/lib/syntax-highlighters/utils.js @@ -0,0 +1,13 @@ +export const escapeHtml = (value) => + String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +export const toLineObjects = (content, renderer) => + String(content || "") + .split("\n") + .map((line, index) => ({ + lineNumber: index + 1, + html: renderer(line), + })); diff --git a/lib/public/setup.html b/lib/public/setup.html index 27456ccd..7d296010 100644 --- a/lib/public/setup.html +++ b/lib/public/setup.html @@ -8,6 +8,7 @@ + ", + ].join("\n"), + "html", + ); + + expect(lines[0].html).toContain('style'); + expect(lines[0].html).toContain('color'); + expect(lines[1].html).toContain('script'); + expect(lines[1].html).toContain('const'); + }); +}); diff --git a/tests/server/git-sync-path.test.js b/tests/server/git-sync-path.test.js new file mode 100644 index 00000000..7c4aae03 --- /dev/null +++ b/tests/server/git-sync-path.test.js @@ -0,0 +1,32 @@ +const { + normalizeGitSyncFilePath, + validateGitSyncFilePath, +} = require("../../lib/cli/git-sync"); + +describe("cli/git-sync path guards", () => { + it("normalizes file paths for --file input", () => { + expect(normalizeGitSyncFilePath(" ./workspace\\app\\config.json ")).toBe( + "workspace/app/config.json", + ); + expect(normalizeGitSyncFilePath("")).toBe(""); + }); + + it("rejects unsafe file paths outside openclaw root", () => { + expect(validateGitSyncFilePath("../secret.txt")).toEqual( + expect.objectContaining({ ok: false }), + ); + expect(validateGitSyncFilePath("/absolute/path.txt")).toEqual( + expect.objectContaining({ ok: false }), + ); + expect(validateGitSyncFilePath("nested/../escape.txt")).toEqual( + expect.objectContaining({ ok: false }), + ); + }); + + it("accepts safe relative file paths", () => { + expect(validateGitSyncFilePath("workspace/app/config.json")).toEqual({ + ok: true, + }); + expect(validateGitSyncFilePath("")).toEqual({ ok: true }); + }); +}); diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js new file mode 100644 index 00000000..132b5adc --- /dev/null +++ b/tests/server/routes-browse.test.js @@ -0,0 +1,108 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const express = require("express"); +const request = require("supertest"); + +const { registerBrowseRoutes } = require("../../lib/server/routes/browse"); + +const createTestRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-browse-test-")); + +const createApp = (kRootDir) => { + const app = express(); + app.use(express.json()); + registerBrowseRoutes({ app, fs, kRootDir }); + return app; +}; + +describe("server/routes/browse", () => { + it("returns browse tree rooted at configured directory", async () => { + const rootDir = createTestRoot(); + fs.mkdirSync(path.join(rootDir, "devices"), { recursive: true }); + fs.writeFileSync(path.join(rootDir, "openclaw.json"), '{"ok":true}\n', "utf8"); + fs.writeFileSync(path.join(rootDir, "devices", "paired.json"), "[]\n", "utf8"); + const app = createApp(rootDir); + + const res = await request(app).get("/api/browse/tree"); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.root).toEqual( + expect.objectContaining({ + type: "folder", + path: "", + name: path.basename(rootDir), + }), + ); + expect(res.body.root.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "folder", path: "devices", name: "devices" }), + expect.objectContaining({ type: "file", path: "openclaw.json", name: "openclaw.json" }), + ]), + ); + }); + + it("rejects path traversal on read", async () => { + const rootDir = createTestRoot(); + const app = createApp(rootDir); + + const res = await request(app) + .get("/api/browse/read") + .query({ path: "../outside.txt" }); + + expect(res.status).toBe(400); + expect(res.body.ok).toBe(false); + expect(res.body.error).toContain("Path must stay within"); + }); + + it("rejects likely binary files on read", async () => { + const rootDir = createTestRoot(); + const binaryFilePath = path.join(rootDir, "image.bin"); + fs.writeFileSync(binaryFilePath, Buffer.from([0x41, 0x00, 0x42])); + const app = createApp(rootDir); + + const res = await request(app) + .get("/api/browse/read") + .query({ path: "image.bin" }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + ok: false, + error: "Binary files are not editable", + }); + }); + + it("writes file content and returns write result", async () => { + const rootDir = createTestRoot(); + const filePath = path.join(rootDir, "openclaw.json"); + fs.writeFileSync(filePath, '{"before":true}\n', "utf8"); + const app = createApp(rootDir); + + const res = await request(app).put("/api/browse/write").send({ + path: "openclaw.json", + content: '{"after":true}\n', + }); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.path).toBe("openclaw.json"); + expect(typeof res.body.synced).toBe("boolean"); + expect(fs.readFileSync(filePath, "utf8")).toBe('{"after":true}\n'); + }); + + it("returns non-repo git summary outside git repositories", async () => { + const rootDir = createTestRoot(); + const app = createApp(rootDir); + + const res = await request(app).get("/api/browse/git-summary"); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + ok: true, + isRepo: false, + repoPath: path.resolve(rootDir), + }), + ); + }); +}); From c25db456a16105be8f331f3bf9996dfffde662b5 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 00:04:34 -0800 Subject: [PATCH 031/417] Refresh startup-managed setup docs and skill sync behavior. Sync the Control UI skill during server startup alongside bootstrap prompts, and update setup docs to include Watchdog and Browse tabs in the Setup UI capabilities. --- lib/server.js | 21 +++++++++++++++--- lib/server/onboarding/workspace.js | 5 +++-- lib/setup/core-prompts/TOOLS.md | 4 +++- lib/setup/skills/control-ui/SKILL.md | 32 +++++++++++----------------- package-lock.json | 1 + 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/server.js b/lib/server.js index 91539dd6..e0d1814b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -65,7 +65,10 @@ const { createAlphaclawVersionService } = require("./server/alphaclaw-version"); const { createRestartRequiredState, } = require("./server/restart-required-state"); -const { syncBootstrapPromptFiles } = require("./server/onboarding/workspace"); +const { + installControlUiSkill, + syncBootstrapPromptFiles, +} = require("./server/onboarding/workspace"); const { createTelegramApi } = require("./server/telegram-api"); const { createDiscordApi } = require("./server/discord-api"); const { createWatchdogNotifier } = require("./server/watchdog-notify"); @@ -235,8 +238,20 @@ const watchdog = createWatchdog({ }); setGatewayExitHandler((payload) => watchdog.onGatewayExit(payload)); setGatewayLaunchHandler((payload) => watchdog.onGatewayLaunch(payload)); -const doSyncPromptFiles = () => - syncBootstrapPromptFiles({ fs, workspaceDir: constants.WORKSPACE_DIR }); +const doSyncPromptFiles = () => { + const setupUiUrl = resolveSetupUrl(); + syncBootstrapPromptFiles({ + fs, + workspaceDir: constants.WORKSPACE_DIR, + baseUrl: setupUiUrl, + }); + installControlUiSkill({ + fs, + openclawDir: constants.OPENCLAW_DIR, + baseUrl: setupUiUrl, + }); +}; +doSyncPromptFiles(); registerTelegramRoutes({ app, telegramApi, diff --git a/lib/server/onboarding/workspace.js b/lib/server/onboarding/workspace.js index 7754bfb5..9f35f839 100644 --- a/lib/server/onboarding/workspace.js +++ b/lib/server/onboarding/workspace.js @@ -61,12 +61,13 @@ const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => { const installControlUiSkill = ({ fs, openclawDir, baseUrl }) => { try { + const setupUiUrl = resolveSetupUiUrl(baseUrl); const skillDir = `${openclawDir}/skills/control-ui`; fs.mkdirSync(skillDir, { recursive: true }); const skillTemplate = fs.readFileSync(path.join(kSetupDir, "skills", "control-ui", "SKILL.md"), "utf8"); - const skillContent = skillTemplate.replace(/\{\{BASE_URL\}\}/g, baseUrl); + const skillContent = skillTemplate.replace(/\{\{BASE_URL\}\}/g, setupUiUrl); fs.writeFileSync(`${skillDir}/SKILL.md`, skillContent); - console.log(`[onboard] Control UI skill installed (${baseUrl})`); + console.log(`[onboard] Control UI skill installed (${setupUiUrl})`); } catch (e) { console.error("[onboard] Skill install error:", e.message); } diff --git a/lib/setup/core-prompts/TOOLS.md b/lib/setup/core-prompts/TOOLS.md index 8900a93b..d75fe8ff 100644 --- a/lib/setup/core-prompts/TOOLS.md +++ b/lib/setup/core-prompts/TOOLS.md @@ -9,9 +9,11 @@ AlphaClaw UI: `{{SETUP_UI_URL}}` | Tab | URL | What it manages | | --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | General | `{{SETUP_UI_URL}}#general` | Gateway status & restart, channel health (Telegram/Discord), pending pairings, feature health (Embeddings/Audio), Google Workspace connection, repo auto-sync schedule, OpenClaw dashboard | +| Watchdog | `{{SETUP_UI_URL}}#watchdog` | Gateway watchdog lifecycle, crash-loop visibility, restart diagnostics, and auto-repair feature | | Providers | `{{SETUP_UI_URL}}#providers` | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram), feature capabilities, Codex OAuth | | Envars | `{{SETUP_UI_URL}}#envars` | View/edit/add environment variables (saved to `/data/.env`), gateway restart to apply changes | | Webhooks | `{{SETUP_UI_URL}}#webhooks` | Webhook endpoint visibility, create flow, request history, and gateway delivery debugging | +| Browse | `{{SETUP_UI_URL}}#browse` | File browser and editor rooted at `.openclaw`, markdown preview/edit flow, and git-aware save workflow | ### Environment variables @@ -38,7 +40,7 @@ After pushing, include a link to the commit using the abbreviated hash: [abc1234 ## Webhooks -You can create webhooks yourself or the user can create them through the Setup UI. +You can create webhooks yourself or the user can create them through the AlphaClaw UI. Webhook transform files must follow this convention: diff --git a/lib/setup/skills/control-ui/SKILL.md b/lib/setup/skills/control-ui/SKILL.md index 11f12fd1..60ce7a7c 100644 --- a/lib/setup/skills/control-ui/SKILL.md +++ b/lib/setup/skills/control-ui/SKILL.md @@ -1,9 +1,9 @@ --- -name: control-ui -description: Know when and how to direct the user to the Setup UI for configuration tasks. +name: alphaclaw +description: Know when and how to direct the user to the AlphaClaw UI for configuration tasks. --- -# Control UI +# AlphaClaw UI There is a web-based Setup UI at `{{BASE_URL}}`. The **user** manages runtime configuration through it. You should NOT call these API endpoints yourself or write config files directly — instead, tell the user what they need to do and where to do it. @@ -35,16 +35,16 @@ When a user asks about pairing their Telegram or Discord account: Supported Google services (user selects which to enable during OAuth): -| Service | Read | Write | Google API | -|---------|------|-------|------------| -| Gmail | `gmail:read` | `gmail:write` | `gmail.googleapis.com` | +| Service | Read | Write | Google API | +| -------- | --------------- | ---------------- | ------------------------------ | +| Gmail | `gmail:read` | `gmail:write` | `gmail.googleapis.com` | | Calendar | `calendar:read` | `calendar:write` | `calendar-json.googleapis.com` | -| Drive | `drive:read` | `drive:write` | `drive.googleapis.com` | -| Sheets | `sheets:read` | `sheets:write` | `sheets.googleapis.com` | -| Docs | `docs:read` | `docs:write` | `docs.googleapis.com` | -| Tasks | `tasks:read` | `tasks:write` | `tasks.googleapis.com` | -| Contacts | `contacts:read` | `contacts:write` | `people.googleapis.com` | -| Meet | `meet:read` | `meet:write` | `meet.googleapis.com` | +| Drive | `drive:read` | `drive:write` | `drive.googleapis.com` | +| Sheets | `sheets:read` | `sheets:write` | `sheets.googleapis.com` | +| Docs | `docs:read` | `docs:write` | `docs.googleapis.com` | +| Tasks | `tasks:read` | `tasks:write` | `tasks.googleapis.com` | +| Contacts | `contacts:read` | `contacts:write` | `people.googleapis.com` | +| Meet | `meet:read` | `meet:write` | `meet.googleapis.com` | Default enabled: Gmail (read), Calendar (read+write), Drive (read), Sheets (read), Docs (read). @@ -60,11 +60,3 @@ gog contacts list --account user@gmail.com ``` Config lives at `/data/.openclaw/gogcli/`. - -## What the Setup UI provides (for your awareness) - -This is a reference so you know what's available — not an invitation to call these endpoints. - -- **General tab** (`{{BASE_URL}}#general`): Gateway status/restart, OpenClaw version + update, channel health, pending pairings, feature health (Embeddings/Audio), Google Workspace -- **Providers tab** (`{{BASE_URL}}#providers`): Primary model selection, AI provider credentials, feature capabilities, Codex OAuth -- **Envars tab** (`{{BASE_URL}}#envars`): View/edit/add environment variables, save to `/data/.env` diff --git a/package-lock.json b/package-lock.json index 28ed1a50..da4b88f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@chrysb/alphaclaw", "version": "0.3.2", + "license": "MIT", "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", From 47c18d388f27f1fdb59798ccdafe4b47adbe2bcb Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 00:05:42 -0800 Subject: [PATCH 032/417] 0.3.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index da4b88f9..04788827 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 0766743c..a937eec4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.2", + "version": "0.3.3", "publishConfig": { "access": "public" }, From 8ba2afd5c7e980ea100997d11d817595ac734daf Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 00:14:19 -0800 Subject: [PATCH 033/417] Improve file loading UX with delayed spinner. Replace the loading text with a centered spinner that appears only after a 1s delay so fast file switches avoid flashing a janky loading state. --- lib/public/css/explorer.css | 9 +++++++++ lib/public/js/components/file-viewer.js | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 3a2d82cc..5ad4c647 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -791,6 +791,15 @@ font-size: 12px; } +.file-viewer-loading-shell { + flex: 1 1 auto; + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + .file-viewer-state-error { color: #f87171; } diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index 05676905..af62e325 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -22,6 +22,7 @@ import { writeStoredFileDraft, } from "../lib/browse-draft-state.js"; import { ActionButton } from "./action-button.js"; +import { LoadingSpinner } from "./loading-spinner.js"; import { SaveFillIcon } from "./icons.js"; import { showToast } from "./toast.js"; @@ -29,6 +30,7 @@ const html = htm.bind(h); const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode"; const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode"; const kProtectedBrowsePaths = new Set(["openclaw.json", "devices/paired.json"]); +const kLoadingIndicatorDelayMs = 1000; const parsePathSegments = (inputPath) => String(inputPath || "") @@ -65,6 +67,8 @@ export const FileViewer = ({ filePath = "" }) => { const [initialContent, setInitialContent] = useState(""); const [viewMode, setViewMode] = useState(readStoredFileViewerMode); const [loading, setLoading] = useState(false); + const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = + useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [isFolderPath, setIsFolderPath] = useState(false); @@ -164,6 +168,17 @@ export const FileViewer = ({ filePath = "" }) => { } catch {} }, [viewMode]); + useEffect(() => { + if (!loading) { + setShowDelayedLoadingSpinner(false); + return () => {}; + } + const timer = window.setTimeout(() => { + setShowDelayedLoadingSpinner(true); + }, kLoadingIndicatorDelayMs); + return () => window.clearTimeout(timer); + }, [loading]); + useEffect(() => { let active = true; loadedFilePathRef.current = ""; @@ -508,7 +523,13 @@ ${formattedValue}Loading file...
    ` + ? html` +
    + ${showDelayedLoadingSpinner + ? html`<${LoadingSpinner} className="h-4 w-4" />` + : null} +
    + ` : error ? html`
    ${error} From 5fa1a7ac85f46ee8be458c5c6722b9b2019881f4 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 07:42:10 -0800 Subject: [PATCH 034/417] Add keyboard-first file explorer search flow. Introduce inline tree filtering with arrow-key navigation, slash-to-focus, soft selection preview, and explicit enter-to-open behavior while keeping the selected file unchanged until committed. --- lib/public/css/explorer.css | 43 +++++- lib/public/js/app.js | 15 +- lib/public/js/components/file-tree.js | 187 +++++++++++++++++++++++- lib/public/js/components/file-viewer.js | 16 +- lib/public/js/components/sidebar.js | 2 + 5 files changed, 250 insertions(+), 13 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 5ad4c647..97b2b631 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -102,7 +102,33 @@ .file-tree-wrap { width: 100%; overflow-y: auto; - padding: 8px 0; + padding: 6px 0 8px; +} + +.file-tree-search { + padding: 0 8px 6px; +} + +.file-tree-search-input { + width: 100%; + height: 28px; + border-radius: 7px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); + color: var(--text); + font-size: 12px; + padding: 0 9px; + outline: none; + font-family: inherit; +} + +.file-tree-search-input::placeholder { + color: var(--text-dim); +} + +.file-tree-search-input:focus { + border-color: rgba(99, 235, 255, 0.45); + box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18); } .file-tree-wrap::-webkit-scrollbar { @@ -153,6 +179,11 @@ color: var(--accent); } +.tree-item > a.soft-active:not(.active) { + background: rgba(99, 235, 255, 0.06); + color: var(--text); +} + .tree-item > a.active::before { content: ''; position: absolute; @@ -317,6 +348,16 @@ flex: 1; } +.file-viewer-preview-pill { + margin-right: 8px; + font-size: 11px; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 8px; + line-height: 1; +} + .file-viewer-tab { display: flex; align-items: center; diff --git a/lib/public/js/app.js b/lib/public/js/app.js index ef3d0984..2940a065 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -408,6 +408,7 @@ const App = () => { : ""; }); const [isResizingSidebar, setIsResizingSidebar] = useState(false); + const [browsePreviewPath, setBrowsePreviewPath] = useState(""); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false); const [restartRequired, setRestartRequired] = useState(false); @@ -603,6 +604,7 @@ const App = () => { setMobileSidebarOpen(false); }; const navigateToBrowseFile = (relativePath) => { + setBrowsePreviewPath(""); setLocation(buildBrowseRoute(relativePath)); setMobileSidebarOpen(false); }; @@ -618,6 +620,7 @@ const App = () => { const handleSelectSidebarTab = (nextTab) => { setSidebarTab(nextTab); if (nextTab === "menu" && location.startsWith("/browse")) { + setBrowsePreviewPath(""); setLocation("/general"); return; } @@ -695,6 +698,11 @@ const App = () => { }); }, [location]); + useEffect(() => { + if (location.startsWith("/browse")) return; + setBrowsePreviewPath(""); + }, [location]); + useEffect(() => { if (!isBrowseRoute) return; if (!selectedBrowsePath) return; @@ -780,6 +788,7 @@ const App = () => { onSelectNavItem=${handleSelectNavItem} selectedBrowsePath=${selectedBrowsePath} onSelectBrowseFile=${navigateToBrowseFile} + onPreviewBrowseFile=${setBrowsePreviewPath} acHasUpdate=${acHasUpdate} acLatest=${acLatest} acDismissed=${acDismissed} @@ -824,7 +833,11 @@ const App = () => { ${isBrowseRoute ? html` <${FileViewer} - filePath=${selectedBrowsePath} + filePath=${browsePreviewPath || selectedBrowsePath} + isPreviewOnly=${Boolean( + browsePreviewPath && + browsePreviewPath !== selectedBrowsePath, + )} /> ` : html` diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index 711eaec7..037f8c56 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -1,5 +1,5 @@ import { h } from "https://esm.sh/preact"; -import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { fetchBrowseTree } from "../lib/api.js"; import { @@ -48,6 +48,37 @@ const collectFolderPaths = (node, folderPaths) => { ); }; +const collectFilePaths = (node, filePaths) => { + if (!node) return; + if (node.type === "file") { + if (node.path) filePaths.push(node.path); + return; + } + (node.children || []).forEach((childNode) => + collectFilePaths(childNode, filePaths), + ); +}; + +const filterTreeNode = (node, normalizedQuery) => { + if (!node) return null; + const query = String(normalizedQuery || "").trim().toLowerCase(); + if (!query) return node; + const nodeName = String(node.name || "").toLowerCase(); + const nodePath = String(node.path || "").toLowerCase(); + const isDirectMatch = nodeName.includes(query) || nodePath.includes(query); + if (node.type === "file") { + return isDirectMatch ? node : null; + } + const filteredChildren = (node.children || []) + .map((childNode) => filterTreeNode(childNode, query)) + .filter(Boolean); + if (!isDirectMatch && filteredChildren.length === 0) return null; + return { + ...node, + children: filteredChildren, + }; +}; + const getFileIconMeta = (fileName) => { const normalizedName = String(fileName || "").toLowerCase(); if (normalizedName.endsWith(".md")) { @@ -126,17 +157,20 @@ const TreeNode = ({ onSelectFile, selectedPath = "", draftPaths, + isSearchActive = false, + searchActivePath = "", }) => { if (!node) return null; if (node.type === "file") { const isActive = selectedPath === node.path; + const isSearchActiveNode = searchActivePath === node.path; const hasDraft = draftPaths.has(node.path || ""); const fileIconMeta = getFileIconMeta(node.name); const FileTypeIcon = fileIconMeta.icon; return html`
  • onSelectFile(node.path)} style=${{ paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px`, @@ -152,7 +186,7 @@ const TreeNode = ({ } const folderPath = node.path || ""; - const isCollapsed = collapsedPaths.has(folderPath); + const isCollapsed = isSearchActive ? false : collapsedPaths.has(folderPath); return html`
  • `, )} @@ -186,7 +222,11 @@ const TreeNode = ({ `; }; -export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { +export const FileTree = ({ + onSelectFile = () => {}, + selectedPath = "", + onPreviewFile = () => {}, +}) => { const [treeRoot, setTreeRoot] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -194,6 +234,9 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { readStoredCollapsedPaths, ); const [draftPaths, setDraftPaths] = useState(readStoredDraftPaths); + const [searchQuery, setSearchQuery] = useState(""); + const [searchActivePath, setSearchActivePath] = useState(""); + const searchInputRef = useRef(null); useEffect(() => { let active = true; @@ -223,9 +266,22 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { }; }, []); - const rootChildren = useMemo(() => treeRoot?.children || [], [treeRoot]); + const normalizedSearchQuery = String(searchQuery || "").trim().toLowerCase(); + const rootChildren = useMemo(() => { + const children = treeRoot?.children || []; + if (!normalizedSearchQuery) return children; + return children + .map((node) => filterTreeNode(node, normalizedSearchQuery)) + .filter(Boolean); + }, [treeRoot, normalizedSearchQuery]); const safeCollapsedPaths = collapsedPaths instanceof Set ? collapsedPaths : new Set(); + const isSearchActive = normalizedSearchQuery.length > 0; + const filteredFilePaths = useMemo(() => { + const filePaths = []; + rootChildren.forEach((node) => collectFilePaths(node, filePaths)); + return filePaths; + }, [rootChildren]); useEffect(() => { if (!(collapsedPaths instanceof Set)) return; @@ -278,6 +334,39 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { }; }, []); + useEffect(() => { + const handleGlobalSearchShortcut = (event) => { + if (event.key !== "/") return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + const tagName = String(target?.tagName || "").toLowerCase(); + const isTypingTarget = + tagName === "input" || + tagName === "textarea" || + tagName === "select" || + target?.isContentEditable; + if (isTypingTarget && target !== searchInputRef.current) return; + event.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }; + window.addEventListener("keydown", handleGlobalSearchShortcut); + return () => { + window.removeEventListener("keydown", handleGlobalSearchShortcut); + }; + }, []); + + useEffect(() => { + if (!isSearchActive) { + setSearchActivePath(""); + onPreviewFile(""); + return; + } + if (searchActivePath && filteredFilePaths.includes(searchActivePath)) return; + setSearchActivePath(""); + onPreviewFile(""); + }, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]); + const toggleFolder = (folderPath) => { setCollapsedPaths((previousPaths) => { const nextPaths = @@ -288,6 +377,58 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { }); }; + const updateSearchQuery = (nextQuery) => { + setSearchQuery(nextQuery); + }; + + const clearSearch = () => { + setSearchQuery(""); + setSearchActivePath(""); + onPreviewFile(""); + }; + + const moveSearchSelection = (direction) => { + if (!filteredFilePaths.length) return; + const currentIndex = filteredFilePaths.indexOf(searchActivePath); + const delta = direction === "up" ? -1 : 1; + const baseIndex = currentIndex === -1 ? (direction === "up" ? 0 : -1) : currentIndex; + const nextIndex = + (baseIndex + delta + filteredFilePaths.length) % filteredFilePaths.length; + const nextPath = filteredFilePaths[nextIndex]; + setSearchActivePath(nextPath); + onPreviewFile(nextPath); + }; + + const commitSearchSelection = () => { + const [singlePath = ""] = filteredFilePaths; + const targetPath = searchActivePath || (filteredFilePaths.length === 1 ? singlePath : ""); + if (!targetPath) return; + onSelectFile(targetPath); + clearSearch(); + }; + + const onSearchKeyDown = (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveSearchSelection("down"); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + moveSearchSelection("up"); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + commitSearchSelection(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + clearSearch(); + } + }; + if (loading) { return html`
    Loading files...
    `; } @@ -297,11 +438,43 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => {
    `; } if (!rootChildren.length) { - return html`
    No files found.
    `; + return html` +
    + +
    + ${isSearchActive ? "No matching files." : "No files found."} +
    +
    + `; } return html`
    +
      ${rootChildren.map( (node) => html` @@ -313,6 +486,8 @@ export const FileTree = ({ onSelectFile = () => {}, selectedPath = "" }) => { onSelectFile=${onSelectFile} selectedPath=${selectedPath} draftPaths=${draftPaths} + isSearchActive=${isSearchActive} + searchActivePath=${searchActivePath} /> `, )} diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index af62e325..d995d58e 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -60,7 +60,10 @@ const readStoredFileViewerMode = () => { }; -export const FileViewer = ({ filePath = "" }) => { +export const FileViewer = ({ + filePath = "", + isPreviewOnly = false, +}) => { const normalizedPath = String(filePath || "").trim(); const normalizedPolicyPath = normalizePolicyPath(normalizedPath); const [content, setContent] = useState(""); @@ -91,7 +94,7 @@ export const FileViewer = ({ filePath = "" }) => { [normalizedPath], ); const hasSelectedPath = normalizedPath.length > 0; - const canEditFile = hasSelectedPath && !isFolderPath; + const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly; const isDirty = canEditFile && content !== initialContent; const isProtectedFile = canEditFile && kProtectedBrowsePaths.has(normalizedPolicyPath); @@ -297,7 +300,7 @@ export const FileViewer = ({ filePath = "" }) => { }; const handleContentInput = (event) => { - if (isProtectedLocked) return; + if (isProtectedLocked || isPreviewOnly) return; const nextContent = event.target.value; setContent(nextContent); if (hasSelectedPath && canEditFile) { @@ -423,6 +426,9 @@ export const FileViewer = ({ filePath = "" }) => { : null}
    + ${isPreviewOnly + ? html`
    Preview
    ` + : null} ${isMarkdownFile && html`
    @@ -674,7 +680,7 @@ ${formattedValue} {}, selectedBrowsePath = "", onSelectBrowseFile = () => {}, + onPreviewBrowseFile = () => {}, acHasUpdate = false, acLatest = "", acDismissed = false, @@ -217,6 +218,7 @@ export const AppSidebar = ({ <${FileTree} onSelectFile=${onSelectBrowseFile} selectedPath=${selectedBrowsePath} + onPreviewFile=${onPreviewBrowseFile} />
    Date: Mon, 2 Mar 2026 11:16:28 -0800 Subject: [PATCH 035/417] Fix auth crash on websocket upgrade requests. Preserve request headers for upgrade auth checks and harden cookie parsing so missing headers cannot throw. --- lib/server.js | 2 +- lib/server/routes/auth.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/server.js b/lib/server.js index e0d1814b..bea9a881 100644 --- a/lib/server.js +++ b/lib/server.js @@ -294,7 +294,7 @@ server.on("upgrade", (req, socket, head) => { ); if (requestUrl.pathname.startsWith("/openclaw")) { const upgradeReq = { - ...req, + headers: req.headers, path: requestUrl.pathname, query: Object.fromEntries(requestUrl.searchParams.entries()), }; diff --git a/lib/server/routes/auth.js b/lib/server/routes/auth.js index a55ddc79..469eace1 100644 --- a/lib/server/routes/auth.js +++ b/lib/server/routes/auth.js @@ -48,7 +48,11 @@ const registerAuthRoutes = ({ app, loginThrottle }) => { const cookieParser = (req) => { const cookies = {}; - (req.headers.cookie || "").split(";").forEach((c) => { + const cookieHeader = + req && req.headers && typeof req.headers.cookie === "string" + ? req.headers.cookie + : ""; + cookieHeader.split(";").forEach((c) => { const [k, ...v] = c.trim().split("="); if (k) cookies[k] = v.join("="); }); From d0ba1a884ed3b70a54780afa906e12c14d67ad71 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 15:11:27 -0800 Subject: [PATCH 036/417] Add usage tracking endpoints and usage dashboard UI. Introduce a SQLite-backed usage tracker with API routes and a new Usage tab so token/cost activity is visible at a glance, with inline session breakdowns and persistent UI preferences for faster monitoring. --- bin/alphaclaw.js | 32 + lib/plugin/usage-tracker/index.js | 308 ++++++++++ lib/plugin/usage-tracker/openclaw.plugin.json | 8 + lib/public/css/shell.css | 4 +- lib/public/css/theme.css | 35 ++ lib/public/js/app.js | 82 ++- lib/public/js/components/file-viewer.js | 24 +- lib/public/js/components/segmented-control.js | 33 + lib/public/js/components/sidebar.js | 44 +- lib/public/js/components/usage-tab.js | 528 ++++++++++++++++ lib/public/js/lib/api.js | 26 +- lib/public/js/lib/ui-settings.js | 38 ++ lib/public/setup.html | 1 + lib/server.js | 20 + lib/server/routes/usage.js | 133 ++++ lib/server/usage-db.js | 576 ++++++++++++++++++ 16 files changed, 1821 insertions(+), 71 deletions(-) create mode 100644 lib/plugin/usage-tracker/index.js create mode 100644 lib/plugin/usage-tracker/openclaw.plugin.json create mode 100644 lib/public/js/components/segmented-control.js create mode 100644 lib/public/js/components/usage-tab.js create mode 100644 lib/public/js/lib/ui-settings.js create mode 100644 lib/server/routes/usage.js create mode 100644 lib/server/usage-db.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 289aefb9..d94ca85d 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -11,6 +11,19 @@ const { } = require("../lib/cli/git-sync"); const { buildSecretReplacements } = require("../lib/server/helpers"); +const kUsageTrackerPluginId = "usage-tracker"; +const kLegacyUsageTrackerPluginIds = [ + "alphaclaw-usage-tracker", + "${GOG_KEYRING_PASSWORD}-usage-tracker", +]; +const kUsageTrackerPluginPath = path.resolve( + __dirname, + "..", + "lib", + "plugin", + "usage-tracker", +); + // --------------------------------------------------------------------------- // Parse CLI flags // --------------------------------------------------------------------------- @@ -772,6 +785,8 @@ if (fs.existsSync(configPath)) { const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); if (!cfg.channels) cfg.channels = {}; if (!cfg.plugins) cfg.plugins = {}; + if (!cfg.plugins.load) cfg.plugins.load = {}; + if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = []; if (!cfg.plugins.entries) cfg.plugins.entries = {}; let changed = false; @@ -798,6 +813,23 @@ if (fs.existsSync(configPath)) { console.log("[alphaclaw] Discord added"); changed = true; } + if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { + cfg.plugins.load.paths.push(kUsageTrackerPluginPath); + changed = true; + } + for (const legacyPluginId of kLegacyUsageTrackerPluginIds) { + if ( + legacyPluginId !== kUsageTrackerPluginId && + cfg.plugins.entries[legacyPluginId] + ) { + delete cfg.plugins.entries[legacyPluginId]; + changed = true; + } + } + if (cfg.plugins.entries[kUsageTrackerPluginId]?.enabled !== true) { + cfg.plugins.entries[kUsageTrackerPluginId] = { enabled: true }; + changed = true; + } if (changed) { let content = JSON.stringify(cfg, null, 2); diff --git a/lib/plugin/usage-tracker/index.js b/lib/plugin/usage-tracker/index.js new file mode 100644 index 00000000..1720dd3f --- /dev/null +++ b/lib/plugin/usage-tracker/index.js @@ -0,0 +1,308 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { DatabaseSync } = require("node:sqlite"); + +const kPluginId = "usage-tracker"; +const kFallbackRootDir = path.join(os.homedir(), ".alphaclaw"); + +const coerceCount = (value) => { + const parsed = Number.parseInt(String(value ?? 0), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +}; + +const resolveRootDir = () => + process.env.ALPHACLAW_ROOT_DIR || + process.env.OPENCLAW_HOME || + process.env.OPENCLAW_ROOT_DIR || + kFallbackRootDir; + +const safeAlterTable = (database, sql) => { + try { + database.exec(sql); + } catch (err) { + const message = String(err?.message || "").toLowerCase(); + if (!message.includes("duplicate column name")) throw err; + } +}; + +const ensureSchema = (database) => { + database.exec("PRAGMA journal_mode=WAL;"); + database.exec("PRAGMA synchronous=NORMAL;"); + database.exec("PRAGMA busy_timeout=5000;"); + database.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + session_id TEXT, + session_key TEXT, + run_id TEXT, + provider TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0 + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_usage_events_ts + ON usage_events(timestamp DESC); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_usage_events_session + ON usage_events(session_id); + `); + safeAlterTable( + database, + "ALTER TABLE usage_events ADD COLUMN session_key TEXT;", + ); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_usage_events_session_key + ON usage_events(session_key); + `); + database.exec(` + CREATE TABLE IF NOT EXISTS usage_daily ( + date TEXT NOT NULL, + model TEXT NOT NULL, + provider TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + turn_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, model) + ); + `); + database.exec(` + CREATE TABLE IF NOT EXISTS tool_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + session_id TEXT, + session_key TEXT, + tool_name TEXT NOT NULL, + success INTEGER NOT NULL DEFAULT 1, + duration_ms INTEGER + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_tool_events_session + ON tool_events(session_id); + `); + safeAlterTable( + database, + "ALTER TABLE tool_events ADD COLUMN session_key TEXT;", + ); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_tool_events_session_key + ON tool_events(session_key); + `); +}; + +const createPlugin = () => { + let database = null; + let dbPath = ""; + let insertUsageEventStmt = null; + let upsertUsageDailyStmt = null; + let insertToolEventStmt = null; + + const getDatabase = () => { + if (database) return database; + const rootDir = resolveRootDir(); + const dbDir = path.join(rootDir, "db"); + fs.mkdirSync(dbDir, { recursive: true }); + dbPath = path.join(dbDir, "usage.db"); + database = new DatabaseSync(dbPath); + ensureSchema(database); + insertUsageEventStmt = database.prepare(` + INSERT INTO usage_events ( + timestamp, + session_id, + session_key, + run_id, + provider, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens + ) VALUES ( + $timestamp, + $session_id, + $session_key, + $run_id, + $provider, + $model, + $input_tokens, + $output_tokens, + $cache_read_tokens, + $cache_write_tokens, + $total_tokens + ) + `); + upsertUsageDailyStmt = database.prepare(` + INSERT INTO usage_daily ( + date, + model, + provider, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + turn_count + ) VALUES ( + $date, + $model, + $provider, + $input_tokens, + $output_tokens, + $cache_read_tokens, + $cache_write_tokens, + $total_tokens, + 1 + ) + ON CONFLICT(date, model) DO UPDATE SET + provider = COALESCE(excluded.provider, usage_daily.provider), + input_tokens = usage_daily.input_tokens + excluded.input_tokens, + output_tokens = usage_daily.output_tokens + excluded.output_tokens, + cache_read_tokens = usage_daily.cache_read_tokens + excluded.cache_read_tokens, + cache_write_tokens = usage_daily.cache_write_tokens + excluded.cache_write_tokens, + total_tokens = usage_daily.total_tokens + excluded.total_tokens, + turn_count = usage_daily.turn_count + 1 + `); + insertToolEventStmt = database.prepare(` + INSERT INTO tool_events ( + timestamp, + session_id, + session_key, + tool_name, + success, + duration_ms + ) VALUES ( + $timestamp, + $session_id, + $session_key, + $tool_name, + $success, + $duration_ms + ) + `); + return database; + }; + + const writeUsageEvent = (event, ctx, logger) => { + const usage = event?.usage ?? {}; + const timestamp = Date.now(); + const date = new Date(timestamp).toISOString().slice(0, 10); + const inputTokens = coerceCount(usage.input); + const outputTokens = coerceCount(usage.output); + const cacheReadTokens = coerceCount(usage.cacheRead); + const cacheWriteTokens = coerceCount(usage.cacheWrite); + const fallbackTotal = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + const totalTokens = coerceCount(usage.total) || fallbackTotal; + if (totalTokens <= 0) return; + getDatabase(); + insertUsageEventStmt.run({ + $timestamp: timestamp, + $session_id: String(event?.sessionId || ctx?.sessionId || ""), + $session_key: String(ctx?.sessionKey || ""), + $run_id: String(event?.runId || ""), + $provider: String(event?.provider || "unknown"), + $model: String(event?.model || "unknown"), + $input_tokens: inputTokens, + $output_tokens: outputTokens, + $cache_read_tokens: cacheReadTokens, + $cache_write_tokens: cacheWriteTokens, + $total_tokens: totalTokens, + }); + upsertUsageDailyStmt.run({ + $date: date, + $model: String(event?.model || "unknown"), + $provider: String(event?.provider || "unknown"), + $input_tokens: inputTokens, + $output_tokens: outputTokens, + $cache_read_tokens: cacheReadTokens, + $cache_write_tokens: cacheWriteTokens, + $total_tokens: totalTokens, + }); + if (logger?.debug) { + logger.debug( + `[${kPluginId}] usage event recorded model=${String(event?.model || "unknown")} total=${totalTokens}`, + ); + } + }; + + const deriveToolSuccess = (event) => { + const message = event?.message; + if (!message || typeof message !== "object") { + return event?.error ? 0 : 1; + } + if (message?.isError === true) return 0; + if (message?.ok === false) return 0; + if (typeof message?.error === "string" && message.error.trim()) return 0; + return 1; + }; + + const writeToolEvent = (event, ctx) => { + const toolName = String(event?.toolName || "").trim(); + if (!toolName) return; + const sessionKey = String(ctx?.sessionKey || "").trim(); + const sessionId = String(ctx?.sessionId || "").trim(); + if (!sessionKey && !sessionId) return; + getDatabase(); + insertToolEventStmt.run({ + $timestamp: Date.now(), + $session_id: sessionId, + $session_key: sessionKey, + $tool_name: toolName, + $success: deriveToolSuccess(event), + $duration_ms: coerceCount(event?.durationMs) || null, + }); + }; + + return { + id: kPluginId, + name: "AlphaClaw Usage Tracker", + description: "Captures LLM and tool usage into SQLite for Usage UI", + register: (api) => { + const logger = api?.logger; + try { + getDatabase(); + logger?.info?.(`[${kPluginId}] initialized db=${dbPath}`); + } catch (err) { + logger?.error?.(`[${kPluginId}] failed to initialize database: ${err?.message || err}`); + return; + } + api.on("llm_output", (event, ctx) => { + try { + writeUsageEvent(event, ctx, logger); + } catch (err) { + logger?.error?.(`[${kPluginId}] llm_output write error: ${err?.message || err}`); + } + }); + api.on("tool_result_persist", (event, ctx) => { + try { + writeToolEvent( + { + ...event, + toolName: String(event?.toolName || ctx?.toolName || ""), + durationMs: event?.durationMs, + }, + ctx, + ); + } catch (err) { + logger?.error?.(`[${kPluginId}] tool_result_persist write error: ${err?.message || err}`); + } + return {}; + }); + }, + }; +}; + +const plugin = createPlugin(); +module.exports = plugin; +module.exports.default = plugin; diff --git a/lib/plugin/usage-tracker/openclaw.plugin.json b/lib/plugin/usage-tracker/openclaw.plugin.json new file mode 100644 index 00000000..baec7b66 --- /dev/null +++ b/lib/plugin/usage-tracker/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "usage-tracker", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/lib/public/css/shell.css b/lib/public/css/shell.css index 8f58b88a..350287ec 100644 --- a/lib/public/css/shell.css +++ b/lib/public/css/shell.css @@ -1,8 +1,9 @@ /* ── App shell grid ─────────────────────────────── */ .app-shell { + --sidebar-width: 220px; display: grid; - grid-template-columns: 220px 0 minmax(0, 1fr); + grid-template-columns: var(--sidebar-width) 0px minmax(0, 1fr); grid-template-rows: auto 1fr 24px; height: 100vh; position: relative; @@ -286,6 +287,7 @@ @media (max-width: 768px) { .app-shell { + --sidebar-width: 0px !important; grid-template-columns: 1fr; grid-template-rows: auto 1fr 24px; } diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css index b284e946..af575047 100644 --- a/lib/public/css/theme.css +++ b/lib/public/css/theme.css @@ -451,3 +451,38 @@ textarea:focus { animation-timing-function: linear; } } + +/* Reusable segmented control (pill toggle). */ +.ac-segmented-control { + display: inline-flex; + align-items: center; + border: 1px solid var(--panel-border-contrast); + border-radius: 8px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); + height: 28px; +} + +.ac-segmented-control-button { + border: 0; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 12px; + letter-spacing: 0.03em; + height: 100%; + line-height: 1; + padding: 0 10px; + cursor: pointer; + transition: color 0.12s, background 0.12s; +} + +.ac-segmented-control-button:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.03); +} + +.ac-segmented-control-button.active { + color: var(--accent); + background: var(--bg-active); +} diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 2940a065..463844b1 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -34,7 +34,7 @@ import { Welcome } from "./components/welcome.js"; import { Envars } from "./components/envars.js"; import { Webhooks } from "./components/webhooks.js"; import { ToastContainer, showToast } from "./components/toast.js"; -import { TelegramWorkspace } from "./components/telegram-workspace.js"; +import { TelegramWorkspace } from "./components/telegram-workspace/index.js"; import { ChevronDownIcon } from "./components/icons.js"; import { UpdateActionButton } from "./components/update-action-button.js"; import { GlobalRestartBanner } from "./components/global-restart-banner.js"; @@ -42,31 +42,19 @@ import { LoadingSpinner } from "./components/loading-spinner.js"; import { WatchdogTab } from "./components/watchdog-tab.js"; import { FileViewer } from "./components/file-viewer.js"; import { AppSidebar } from "./components/sidebar.js"; +import { UsageTab } from "./components/usage-tab.js"; +import { readUiSettings, writeUiSettings } from "./lib/ui-settings.js"; const html = htm.bind(h); const kDefaultUiTab = "general"; -const kUiSettingsStorageKey = "alphaclaw.uiSettings"; -const kLegacyUiSettingsStorageKey = "alphaclawUiSettings"; const kDefaultSidebarWidthPx = 220; const kSidebarMinWidthPx = 180; const kSidebarMaxWidthPx = 460; const kBrowseLastPathUiSettingKey = "browseLastPath"; +const kLastMenuRouteUiSettingKey = "lastMenuRoute"; const clampSidebarWidth = (value) => Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value)); -const readUiSettings = () => { - try { - const raw = - window.localStorage.getItem(kUiSettingsStorageKey) || - window.localStorage.getItem(kLegacyUiSettingsStorageKey); - if (!raw) return {}; - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - return {}; - } -}; - const getHashPath = () => { const hash = window.location.hash.replace(/^#/, ""); if (!hash) return `/${kDefaultUiTab}`; @@ -407,6 +395,18 @@ const App = () => { ? settings[kBrowseLastPathUiSettingKey] : ""; }); + const [lastMenuRoute, setLastMenuRoute] = useState(() => { + const settings = readUiSettings(); + const storedRoute = settings[kLastMenuRouteUiSettingKey]; + if ( + typeof storedRoute === "string" && + storedRoute.startsWith("/") && + !storedRoute.startsWith("/browse") + ) { + return storedRoute; + } + return `/${kDefaultUiTab}`; + }); const [isResizingSidebar, setIsResizingSidebar] = useState(false); const [browsePreviewPath, setBrowsePreviewPath] = useState(""); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -621,7 +621,7 @@ const App = () => { setSidebarTab(nextTab); if (nextTab === "menu" && location.startsWith("/browse")) { setBrowsePreviewPath(""); - setLocation("/general"); + setLocation(lastMenuRoute || `/${kDefaultUiTab}`); return; } if (nextTab === "browse" && !location.startsWith("/browse")) { @@ -645,10 +645,16 @@ const App = () => { const kNavSections = [ { - label: "Status", + label: "Setup", items: [ { id: "general", label: "General" }, + ], + }, + { + label: "Monitoring", + items: [ { id: "watchdog", label: "Watchdog" }, + { id: "usage", label: "Usage" }, ], }, { @@ -684,6 +690,8 @@ const App = () => { ? "providers" : location.startsWith("/watchdog") ? "watchdog" + : location.startsWith("/usage") + ? "usage" : location.startsWith("/envars") ? "envars" : location.startsWith("/webhooks") @@ -703,6 +711,14 @@ const App = () => { setBrowsePreviewPath(""); }, [location]); + useEffect(() => { + if (location.startsWith("/browse")) return; + if (location === "/telegram") return; + setLastMenuRoute((currentRoute) => + currentRoute === location ? currentRoute : location, + ); + }, [location]); + useEffect(() => { if (!isBrowseRoute) return; if (!selectedBrowsePath) return; @@ -715,10 +731,9 @@ const App = () => { const settings = readUiSettings(); settings.sidebarWidthPx = sidebarWidthPx; settings[kBrowseLastPathUiSettingKey] = lastBrowsePath; - try { - window.localStorage.setItem(kUiSettingsStorageKey, JSON.stringify(settings)); - } catch {} - }, [sidebarWidthPx, lastBrowsePath]); + settings[kLastMenuRouteUiSettingKey] = lastMenuRoute; + writeUiSettings(settings); + }, [sidebarWidthPx, lastBrowsePath, lastMenuRoute]); const resizeSidebarWithClientX = useCallback((clientX) => { const shellElement = appShellRef.current; @@ -767,7 +782,7 @@ const App = () => {
    <${GlobalRestartBanner} visible=${restartRequired} @@ -880,6 +895,27 @@ const App = () => { />
    + <${Route} path="/usage/:sessionId"> + ${(params) => html` +
    + <${UsageTab} + sessionId=${decodeURIComponent(params.sessionId || "")} + onSelectSession=${(id) => + setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} + onBackToSessions=${() => setLocation("/usage")} + /> +
    + `} + + <${Route} path="/usage"> +
    + <${UsageTab} + onSelectSession=${(id) => + setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} + onBackToSessions=${() => setLocation("/usage")} + /> +
    + <${Route} path="/envars">
    <${Envars} onRestartRequired=${setRestartRequired} /> diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index d995d58e..479fe3f5 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -23,6 +23,7 @@ import { } from "../lib/browse-draft-state.js"; import { ActionButton } from "./action-button.js"; import { LoadingSpinner } from "./loading-spinner.js"; +import { SegmentedControl } from "./segmented-control.js"; import { SaveFillIcon } from "./icons.js"; import { showToast } from "./toast.js"; @@ -431,20 +432,15 @@ export const FileViewer = ({ : null} ${isMarkdownFile && html` -
    - - -
    + <${SegmentedControl} + className="mr-2.5" + options=${[ + { label: "edit", value: "edit" }, + { label: "preview", value: "preview" }, + ]} + value=${viewMode} + onChange=${handleChangeViewMode} + /> `} <${ActionButton} onClick=${handleSave} diff --git a/lib/public/js/components/segmented-control.js b/lib/public/js/components/segmented-control.js new file mode 100644 index 00000000..18776398 --- /dev/null +++ b/lib/public/js/components/segmented-control.js @@ -0,0 +1,33 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; + +const html = htm.bind(h); + +/** + * Reusable segmented control (pill toggle). + * + * @param {Object} props + * @param {Array<{label:string, value:*}>} props.options + * @param {*} props.value Currently selected value. + * @param {Function} props.onChange Called with the new value on click. + * @param {string} [props.className] Extra classes on the wrapper. + */ +export const SegmentedControl = ({ + options = [], + value, + onChange = () => {}, + className = "", +}) => html` +
    + ${options.map( + (option) => html` + + `, + )} +
    +`; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index 3e9f1848..f2635b7c 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -5,37 +5,26 @@ import { HomeLineIcon, FolderLineIcon } from "./icons.js"; import { FileTree } from "./file-tree.js"; import { UpdateActionButton } from "./update-action-button.js"; import { SidebarGitPanel } from "./sidebar-git-panel.js"; +import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js"; const html = htm.bind(h); -const kUiSettingsStorageKey = "alphaclaw.uiSettings"; -const kLegacyUiSettingsStorageKey = "alphaclawUiSettings"; const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx"; const kBrowsePanelMinHeightPx = 120; const kBrowseBottomMinHeightPx = 120; const kBrowseResizerHeightPx = 6; const kDefaultBrowseBottomPanelHeightPx = 160; -const kLegacyBrowsePanelUiStorageKey = "alphaclawBrowsePanelHeightPx"; const readStoredBrowseBottomPanelHeight = () => { try { - const rawSettings = - window.localStorage.getItem(kUiSettingsStorageKey) || - window.localStorage.getItem(kLegacyUiSettingsStorageKey); - if (rawSettings) { - const parsedSettings = JSON.parse(rawSettings); - const fromSharedSettings = Number.parseInt( - String(parsedSettings?.[kBrowseBottomPanelUiSettingKey] || ""), - 10, - ); - if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) { - return fromSharedSettings; - } + const settings = readUiSettings(); + const fromSharedSettings = Number.parseInt( + String(settings?.[kBrowseBottomPanelUiSettingKey] || ""), + 10, + ); + if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) { + return fromSharedSettings; } - const legacyRawValue = window.localStorage.getItem(kLegacyBrowsePanelUiStorageKey); - const parsedValue = Number.parseInt(String(legacyRawValue || ""), 10); - return Number.isFinite(parsedValue) && parsedValue > 0 - ? parsedValue - : kDefaultBrowseBottomPanelHeightPx; + return kDefaultBrowseBottomPanelHeightPx; } catch { return kDefaultBrowseBottomPanelHeightPx; } @@ -71,18 +60,9 @@ export const AppSidebar = ({ const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false); useEffect(() => { - try { - const rawSettings = - window.localStorage.getItem(kUiSettingsStorageKey) || - window.localStorage.getItem(kLegacyUiSettingsStorageKey); - const parsedSettings = rawSettings ? JSON.parse(rawSettings) : {}; - const nextSettings = - parsedSettings && typeof parsedSettings === "object" - ? { ...parsedSettings } - : {}; - nextSettings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx; - window.localStorage.setItem(kUiSettingsStorageKey, JSON.stringify(nextSettings)); - } catch {} + const settings = readUiSettings(); + settings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx; + writeUiSettings(settings); }, [browseBottomPanelHeightPx]); const getClampedBrowseBottomPanelHeight = (value) => { diff --git a/lib/public/js/components/usage-tab.js b/lib/public/js/components/usage-tab.js new file mode 100644 index 00000000..e6a61e50 --- /dev/null +++ b/lib/public/js/components/usage-tab.js @@ -0,0 +1,528 @@ +import { h } from "https://esm.sh/preact"; +import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { + fetchUsageSummary, + fetchUsageSessions, + fetchUsageSessionDetail, +} from "../lib/api.js"; +import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js"; +import { PageHeader } from "./page-header.js"; +import { ActionButton } from "./action-button.js"; +import { SegmentedControl } from "./segmented-control.js"; + +const html = htm.bind(h); + +const kColorPalette = [ + "#7dd3fc", + "#22d3ee", + "#34d399", + "#fbbf24", + "#fb7185", + "#a78bfa", + "#f472b6", + "#60a5fa", + "#4ade80", + "#f97316", +]; + +const kTokenFormatter = new Intl.NumberFormat("en-US"); +const kMoneyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 3, +}); + +const formatTokens = (value) => kTokenFormatter.format(Number(value || 0)); +const formatUsd = (value) => kMoneyFormatter.format(Number(value || 0)); +const formatCountLabel = (value, singular, plural) => { + const count = Number(value || 0); + const label = count === 1 ? singular : plural; + return `${formatTokens(count)} ${label}`; +}; + +const formatDateTime = (value) => { + if (!value) return "n/a"; + try { + const d = new Date(Number(value)); + if (Number.isNaN(d.getTime())) return "n/a"; + const now = new Date(); + const isToday = + d.getFullYear() === now.getFullYear() + && d.getMonth() === now.getMonth() + && d.getDate() === now.getDate(); + if (isToday) { + return d.toLocaleTimeString(); + } + return d.toLocaleString(); + } catch { + return "n/a"; + } +}; + +const formatDuration = (value) => { + const ms = Number(value || 0); + if (!Number.isFinite(ms) || ms <= 0) return "0s"; + if (ms < 1000) return `${Math.round(ms)}ms`; + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes <= 0) return `${seconds}s`; + return `${minutes}m ${seconds}s`; +}; + +const kBadgeToneClass = { + cyan: "border-cyan-400/30 text-cyan-300 bg-cyan-400/10", + blue: "border-blue-400/30 text-blue-300 bg-blue-400/10", + purple: "border-purple-400/30 text-purple-300 bg-purple-400/10", + gray: "border-gray-400/30 text-gray-400 bg-gray-400/10", +}; + +const SessionBadges = ({ session }) => { + const labels = session?.labels; + if (!Array.isArray(labels) || labels.length === 0) { + const fallback = String(session?.sessionKey || session?.sessionId || ""); + return html`${fallback}`; + } + return html` + + ${labels.map( + (badge) => html` + + ${badge.label} + + `, + )} + + `; +}; + +const toChartColor = (key) => { + const raw = String(key || ""); + let hash = 0; + for (let index = 0; index < raw.length; index += 1) { + hash = ((hash << 5) - hash + raw.charCodeAt(index)) | 0; + } + return kColorPalette[Math.abs(hash) % kColorPalette.length]; +}; + +const kRangeOptions = [ + { label: "7d", value: 7 }, + { label: "30d", value: 30 }, + { label: "90d", value: 90 }, +]; +const kDefaultUsageDays = 30; +const kDefaultUsageMetric = "tokens"; +const kUsageDaysUiSettingKey = "usageDays"; +const kUsageMetricUiSettingKey = "usageMetric"; + +const SummaryCard = ({ title, tokens, cost }) => html` +
    +

    ${title}

    +
    ${formatTokens(tokens)} tokens
    +
    ${formatUsd(cost)}
    +
    +`; + +export const UsageTab = ({ sessionId = "" }) => { + const [days, setDays] = useState(() => { + const settings = readUiSettings(); + const parsedDays = Number.parseInt( + String(settings[kUsageDaysUiSettingKey] ?? ""), + 10, + ); + return kRangeOptions.some((option) => option.value === parsedDays) + ? parsedDays + : kDefaultUsageDays; + }); + const [metric, setMetric] = useState(() => { + const settings = readUiSettings(); + return settings[kUsageMetricUiSettingKey] === "cost" + ? "cost" + : kDefaultUsageMetric; + }); + const [summary, setSummary] = useState(null); + const [sessions, setSessions] = useState([]); + const [sessionDetailById, setSessionDetailById] = useState({}); + const [loadingSummary, setLoadingSummary] = useState(false); + const [loadingSessions, setLoadingSessions] = useState(false); + const [loadingDetailById, setLoadingDetailById] = useState({}); + const [expandedSessionIds, setExpandedSessionIds] = useState(() => + sessionId ? [String(sessionId)] : [], + ); + const [error, setError] = useState(""); + const overviewCanvasRef = useRef(null); + const overviewChartRef = useRef(null); + + const loadSummary = async () => { + setLoadingSummary(true); + setError(""); + try { + const data = await fetchUsageSummary(days); + setSummary(data.summary || null); + } catch (err) { + setError(err.message || "Could not load usage summary"); + } finally { + setLoadingSummary(false); + } + }; + + const loadSessions = async () => { + setLoadingSessions(true); + try { + const data = await fetchUsageSessions(100); + setSessions(Array.isArray(data.sessions) ? data.sessions : []); + } catch (err) { + setError(err.message || "Could not load sessions"); + } finally { + setLoadingSessions(false); + } + }; + + const loadSessionDetail = async (selectedSessionId) => { + const safeSessionId = String(selectedSessionId || "").trim(); + if (!safeSessionId) return; + setLoadingDetailById((currentValue) => ({ + ...currentValue, + [safeSessionId]: true, + })); + try { + const detailPayload = await fetchUsageSessionDetail(safeSessionId); + setSessionDetailById((currentValue) => ({ + ...currentValue, + [safeSessionId]: detailPayload.detail || null, + })); + } catch (err) { + setError(err.message || "Could not load session detail"); + } finally { + setLoadingDetailById((currentValue) => ({ + ...currentValue, + [safeSessionId]: false, + })); + } + }; + + useEffect(() => { + loadSummary(); + }, [days]); + + useEffect(() => { + const settings = readUiSettings(); + settings[kUsageDaysUiSettingKey] = days; + settings[kUsageMetricUiSettingKey] = metric; + writeUiSettings(settings); + }, [days, metric]); + + useEffect(() => { + loadSessions(); + }, []); + + useEffect(() => { + const safeSessionId = String(sessionId || "").trim(); + if (!safeSessionId) return; + setExpandedSessionIds((currentValue) => + currentValue.includes(safeSessionId) + ? currentValue + : [...currentValue, safeSessionId], + ); + if (!sessionDetailById[safeSessionId] && !loadingDetailById[safeSessionId]) { + loadSessionDetail(safeSessionId); + } + }, [sessionId]); + + const periodSummary = useMemo(() => { + const rows = Array.isArray(summary?.daily) ? summary.daily : []; + const now = new Date(); + const dayKey = now.toISOString().slice(0, 10); + const weekStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const monthStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const zero = { tokens: 0, cost: 0 }; + return rows.reduce( + (acc, row) => { + const tokens = Number(row.totalTokens || 0); + const cost = Number(row.totalCost || 0); + if (String(row.date) === dayKey) { + acc.today.tokens += tokens; + acc.today.cost += cost; + } + if (String(row.date) >= weekStart) { + acc.week.tokens += tokens; + acc.week.cost += cost; + } + if (String(row.date) >= monthStart) { + acc.month.tokens += tokens; + acc.month.cost += cost; + } + return acc; + }, + { + today: { ...zero }, + week: { ...zero }, + month: { ...zero }, + }, + ); + }, [summary]); + + const overviewDatasets = useMemo(() => { + const rows = Array.isArray(summary?.daily) ? summary.daily : []; + const allModels = new Set(); + for (const dayRow of rows) { + for (const modelRow of dayRow.models || []) { + allModels.add(String(modelRow.model || "unknown")); + } + } + const labels = rows.map((row) => String(row.date || "")); + const datasets = Array.from(allModels).map((model) => { + const values = rows.map((row) => { + const found = (row.models || []).find((m) => String(m.model || "") === model); + if (!found) return 0; + return metric === "cost" ? Number(found.totalCost || 0) : Number(found.totalTokens || 0); + }); + return { + label: model, + data: values, + backgroundColor: toChartColor(model), + }; + }); + return { labels, datasets }; + }, [summary, metric]); + + useEffect(() => { + const canvas = overviewCanvasRef.current; + const Chart = window.Chart; + if (!canvas || !Chart) return; + if (overviewChartRef.current) { + overviewChartRef.current.destroy(); + overviewChartRef.current = null; + } + overviewChartRef.current = new Chart(canvas, { + type: "bar", + data: overviewDatasets, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + scales: { + x: { stacked: true, ticks: { color: "rgba(156,163,175,1)" } }, + y: { + stacked: true, + ticks: { + color: "rgba(156,163,175,1)", + callback: (v) => (metric === "cost" ? `$${Number(v).toFixed(2)}` : formatTokens(v)), + }, + }, + }, + plugins: { + legend: { + labels: { color: "rgba(209,213,219,1)", boxWidth: 10, boxHeight: 10 }, + }, + tooltip: { + callbacks: { + label: (context) => { + const value = Number(context.parsed.y || 0); + return metric === "cost" + ? `${context.dataset.label}: ${formatUsd(value)}` + : `${context.dataset.label}: ${formatTokens(value)} tokens`; + }, + }, + }, + }, + }, + }); + return () => { + if (overviewChartRef.current) { + overviewChartRef.current.destroy(); + overviewChartRef.current = null; + } + }; + }, [overviewDatasets, metric]); + + const renderOverview = () => html` +
    +
    + <${SummaryCard} title="Today" tokens=${periodSummary.today.tokens} cost=${periodSummary.today.cost} /> + <${SummaryCard} title="Last 7 days" tokens=${periodSummary.week.tokens} cost=${periodSummary.week.cost} /> + <${SummaryCard} title="Last 30 days" tokens=${periodSummary.month.tokens} cost=${periodSummary.month.cost} /> +
    +
    +
    +

    Daily ${metric === "tokens" ? "tokens" : "cost"} by model

    +
    + <${SegmentedControl} + options=${kRangeOptions.map((o) => ({ label: o.label, value: o.value }))} + value=${days} + onChange=${setDays} + /> + <${SegmentedControl} + options=${[ + { label: "tokens", value: "tokens" }, + { label: "cost", value: "cost" }, + ]} + value=${metric} + onChange=${setMetric} + /> +
    +
    +
    + +
    +
    +
    + `; + + const renderSessionInlineDetail = (item) => { + const itemSessionId = String(item.sessionId || ""); + const isExpanded = expandedSessionIds.includes(itemSessionId); + if (!isExpanded) return null; + const detail = sessionDetailById[itemSessionId]; + const loadingDetail = !!loadingDetailById[itemSessionId]; + if (loadingDetail) { + return html` +
    +

    Loading session detail...

    +
    + `; + } + if (!detail) { + return html` +
    +

    Session detail not available.

    +
    + `; + } + return html` +
    +
    +

    Model breakdown

    + ${(detail.modelBreakdown || []).length === 0 + ? html`

    No model usage recorded.

    ` + : html` +
    + ${(detail.modelBreakdown || []).map( + (row) => html` +
    + ${row.model || "unknown"} + + ${formatTokens(row.totalTokens)} tok + ${formatUsd(row.totalCost)} + ${formatCountLabel(row.turnCount, "turn", "turns")} + +
    + `, + )} +
    + `} +
    +
    +

    Tool usage

    + ${(detail.toolUsage || []).length === 0 + ? html`

    No tool calls recorded.

    ` + : html` +
    + ${(detail.toolUsage || []).map( + (row) => html` +
    + ${row.toolName} + + ${formatCountLabel(row.callCount, "call", "calls")} + ${(Number(row.errorRate || 0) * 100).toFixed(1)}% err + ${formatDuration(row.avgDurationMs)} + +
    + `, + )} +
    + `} +
    +
    + `; + }; + + const renderSessions = () => html` +
    +

    Sessions

    +
    + ${sessions.length === 0 + ? html`

    + ${loadingSessions ? "Loading sessions..." : "No sessions recorded yet."} +

    ` + : sessions.map( + (item) => html` +
    { + const itemSessionId = String(item.sessionId || ""); + const isOpen = !!e.currentTarget?.open; + if (isOpen) { + setExpandedSessionIds((currentValue) => + currentValue.includes(itemSessionId) + ? currentValue + : [...currentValue, itemSessionId], + ); + if ( + !sessionDetailById[itemSessionId] + && !loadingDetailById[itemSessionId] + ) { + loadSessionDetail(itemSessionId); + } + return; + } + setExpandedSessionIds((currentValue) => + currentValue.filter((value) => value !== itemSessionId), + ); + }} + > + +
    + + + <${SessionBadges} session=${item} /> + + + ${formatTokens(item.totalTokens)} tok + ${formatUsd(item.totalCost)} + ${formatDateTime(item.lastActivityMs)} + +
    +
    + ${renderSessionInlineDetail(item)} +
    + `, + )} +
    +
    + `; + + return html` +
    + <${PageHeader} + title="Usage" + actions=${html` + <${ActionButton} + onClick=${loadSummary} + loading=${loadingSummary} + tone="secondary" + size="sm" + idleLabel="Refresh" + loadingMode="inline" + /> + `} + /> + ${error + ? html`
    ${error}
    ` + : null} + ${loadingSummary && !summary + ? html`
    Loading usage summary...
    ` + : renderOverview()} + ${renderSessions()} +
    + `; +}; diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index b34ebd0b..0288faf3 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -1,4 +1,4 @@ -const authFetch = async (url, opts = {}) => { +export const authFetch = async (url, opts = {}) => { const res = await fetch(url, opts); if (res.status === 401) { try { @@ -77,6 +77,30 @@ export async function fetchWatchdogStatus() { return parseJsonOrThrow(res, 'Could not load watchdog status'); } +export async function fetchUsageSummary(days = 30) { + const params = new URLSearchParams({ days: String(days) }); + const res = await authFetch(`/api/usage/summary?${params.toString()}`); + return parseJsonOrThrow(res, 'Could not load usage summary'); +} + +export async function fetchUsageSessions(limit = 50) { + const params = new URLSearchParams({ limit: String(limit) }); + const res = await authFetch(`/api/usage/sessions?${params.toString()}`); + return parseJsonOrThrow(res, 'Could not load usage sessions'); +} + +export async function fetchUsageSessionDetail(sessionId) { + const res = await authFetch(`/api/usage/sessions/${encodeURIComponent(String(sessionId || ''))}`); + return parseJsonOrThrow(res, 'Could not load usage session detail'); +} + +export async function fetchUsageSessionTimeSeries(sessionId, maxPoints = 100) { + const params = new URLSearchParams({ maxPoints: String(maxPoints) }); + const safeSessionId = encodeURIComponent(String(sessionId || '')); + const res = await authFetch(`/api/usage/sessions/${safeSessionId}/timeseries?${params.toString()}`); + return parseJsonOrThrow(res, 'Could not load usage time series'); +} + export async function fetchWatchdogEvents(limit = 20) { const res = await authFetch(`/api/watchdog/events?limit=${encodeURIComponent(String(limit))}`); return parseJsonOrThrow(res, 'Could not load watchdog events'); diff --git a/lib/public/js/lib/ui-settings.js b/lib/public/js/lib/ui-settings.js new file mode 100644 index 00000000..6004a862 --- /dev/null +++ b/lib/public/js/lib/ui-settings.js @@ -0,0 +1,38 @@ +export const kUiSettingsStorageKey = "alphaclaw.uiSettings"; + +const parseSettings = (rawValue) => { + if (!rawValue) return {}; + try { + const parsed = JSON.parse(rawValue); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +}; + +export const readUiSettings = () => { + try { + const rawValue = window.localStorage.getItem(kUiSettingsStorageKey); + return parseSettings(rawValue); + } catch { + return {}; + } +}; + +export const writeUiSettings = (nextSettings) => { + try { + window.localStorage.setItem( + kUiSettingsStorageKey, + JSON.stringify( + nextSettings && typeof nextSettings === "object" ? nextSettings : {}, + ), + ); + } catch {} +}; + +export const updateUiSettings = (updater) => { + const currentSettings = readUiSettings(); + const nextSettings = updater(currentSettings); + writeUiSettings(nextSettings); + return nextSettings; +}; diff --git a/lib/public/setup.html b/lib/public/setup.html index 7d296010..4e0674a9 100644 --- a/lib/public/setup.html +++ b/lib/public/setup.html @@ -10,6 +10,7 @@ + - - + + - - -
    - - + }, + }; + + + +
    + + diff --git a/lib/server/constants.js b/lib/server/constants.js index 82438e84..bf062ba0 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -293,6 +293,7 @@ const SETUP_API_PREFIXES = [ "/api/telegram", "/api/webhooks", "/api/watchdog", + "/api/usage", ]; module.exports = { diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js index 7cb9f118..a8d1a703 100644 --- a/lib/server/onboarding/openclaw.js +++ b/lib/server/onboarding/openclaw.js @@ -1,5 +1,19 @@ +const path = require("path"); const { buildSecretReplacements } = require("../helpers"); +const kUsageTrackerPluginId = "usage-tracker"; +const kLegacyUsageTrackerPluginIds = [ + "alphaclaw-usage-tracker", + "${GOG_KEYRING_PASSWORD}-usage-tracker", +]; +const kUsageTrackerPluginPath = path.resolve( + __dirname, + "..", + "..", + "plugin", + "usage-tracker", +); + const buildOnboardArgs = ({ varMap, selectedProvider, hasCodexOauth, workspaceDir }) => { const onboardArgs = [ "--non-interactive", @@ -116,6 +130,8 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); if (!cfg.channels) cfg.channels = {}; if (!cfg.plugins) cfg.plugins = {}; + if (!cfg.plugins.load) cfg.plugins.load = {}; + if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = []; if (!cfg.plugins.entries) cfg.plugins.entries = {}; if (!cfg.commands) cfg.commands = {}; if (!cfg.hooks) cfg.hooks = {}; @@ -149,6 +165,15 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { cfg.plugins.entries.discord = { enabled: true }; console.log("[onboard] Discord configured"); } + if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { + cfg.plugins.load.paths.push(kUsageTrackerPluginPath); + } + for (const legacyPluginId of kLegacyUsageTrackerPluginIds) { + if (legacyPluginId !== kUsageTrackerPluginId && cfg.plugins.entries[legacyPluginId]) { + delete cfg.plugins.entries[legacyPluginId]; + } + } + cfg.plugins.entries[kUsageTrackerPluginId] = { enabled: true }; let content = JSON.stringify(cfg, null, 2); const replacements = buildSecretReplacements(varMap, process.env); diff --git a/lib/server/routes/telegram.js b/lib/server/routes/telegram.js index a53bb479..6674e68a 100644 --- a/lib/server/routes/telegram.js +++ b/lib/server/routes/telegram.js @@ -19,7 +19,11 @@ const resolveGroupId = (req) => { const rawGroupId = body.groupId ?? body.chatId; return rawGroupId == null ? "" : String(rawGroupId).trim(); }; -const resolveAllowUserId = async ({ telegramApi, groupId, preferredUserId }) => { +const resolveAllowUserId = async ({ + telegramApi, + groupId, + preferredUserId, +}) => { const normalizedPreferred = String(preferredUserId || "").trim(); if (normalizedPreferred) return normalizedPreferred; const admins = await telegramApi.getChatAdministrators(groupId); @@ -43,7 +47,61 @@ const isMissingTopicError = (errorMessage) => { ].some((token) => message.includes(token)); }; -const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { +const registerTelegramRoutes = ({ + app, + telegramApi, + syncPromptFiles, + shellCmd, +}) => { + const repairGroupAllowFromIfMissing = async ({ + cfg, + groupId, + requireMention = false, + }) => { + const telegramConfig = cfg?.channels?.telegram || {}; + if ( + Array.isArray(telegramConfig.groupAllowFrom) && + telegramConfig.groupAllowFrom.length > 0 + ) { + return { repaired: false, resolvedUserId: "", syncWarning: null }; + } + const resolvedUserId = await resolveAllowUserId({ + telegramApi, + groupId, + preferredUserId: "", + }); + syncConfigForTelegram({ + fs, + openclawDir: OPENCLAW_DIR, + topicRegistry, + groupId, + requireMention, + resolvedUserId, + }); + const syncWarning = await runTelegramGitSync( + "repair-group-allow-from", + groupId, + ); + return { repaired: true, resolvedUserId, syncWarning }; + }; + + const runTelegramGitSync = async (action, target = "") => { + if (typeof shellCmd !== "function") return null; + const message = `telegram workspace: ${String(action || "").trim()} ${String( + target || "", + ) + .trim() + .replace(/"/g, "")}`.trim(); + try { + await shellCmd(`alphaclaw git-sync -m "${message}"`, { + timeout: 30000, + }); + return null; + } catch (err) { + return err?.message || "alphaclaw git-sync failed"; + } + }; + // Verify bot token app.get("/api/telegram/bot", async (req, res) => { try { @@ -57,7 +115,8 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { // Verify group: checks bot membership, admin rights, topics enabled app.post("/api/telegram/groups/verify", async (req, res) => { const groupId = resolveGroupId(req); - if (!groupId) return res.status(400).json({ ok: false, error: "groupId is required" }); + if (!groupId) + return res.status(400).json({ ok: false, error: "groupId is required" }); try { const chat = await telegramApi.getChat(groupId); @@ -69,7 +128,8 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { preferredUserId: "", }); - const isAdmin = member.status === "administrator" || member.status === "creator"; + const isAdmin = + member.status === "administrator" || member.status === "creator"; const isForum = !!chat.is_forum; res.json({ @@ -83,7 +143,7 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { bot: { status: member.status, isAdmin, - canManageTopics: isAdmin && (member.can_manage_topics !== false), + canManageTopics: isAdmin && member.can_manage_topics !== false, }, suggestedUserId: suggestedUserId || null, }); @@ -107,9 +167,13 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { const systemInstructions = String( body.systemInstructions ?? body.systemPrompt ?? "", ).trim(); - const iconColorValue = rawIconColor == null ? null : Number.parseInt(String(rawIconColor), 10); - const iconColor = Number.isFinite(iconColorValue) ? iconColorValue : undefined; - if (!name) return res.status(400).json({ ok: false, error: "name is required" }); + const iconColorValue = + rawIconColor == null ? null : Number.parseInt(String(rawIconColor), 10); + const iconColor = Number.isFinite(iconColorValue) + ? iconColorValue + : undefined; + if (!name) + return res.status(400).json({ ok: false, error: "name is required" }); try { const result = await telegramApi.createForumTopic(groupId, name, { @@ -130,7 +194,16 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { resolvedUserId: "", }); syncPromptFiles(); - res.json({ ok: true, topic: { threadId, name: result.name, iconColor: result.icon_color } }); + const syncWarning = await runTelegramGitSync("create-topic", result.name); + res.json({ + ok: true, + topic: { + threadId, + name: result.name, + iconColor: result.icon_color, + }, + syncWarning, + }); } catch (e) { res.json({ ok: false, error: e.message }); } @@ -142,7 +215,9 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { const body = req.body || {}; const topics = Array.isArray(body.topics) ? body.topics : []; if (!Array.isArray(topics) || topics.length === 0) { - return res.status(400).json({ ok: false, error: "topics array is required" }); + return res + .status(400) + .json({ ok: false, error: "topics array is required" }); } const results = []; @@ -156,7 +231,9 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { iconColor: t.iconColor || undefined, }); const threadId = result.message_thread_id; - const systemInstructions = String(t.systemInstructions ?? t.systemPrompt ?? "").trim(); + const systemInstructions = String( + t.systemInstructions ?? t.systemPrompt ?? "", + ).trim(); topicRegistry.addTopic(groupId, threadId, { name: result.name, iconColor: result.icon_color, @@ -176,64 +253,80 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { resolvedUserId: "", }); syncPromptFiles(); - res.json({ ok: true, results }); + const syncWarning = await runTelegramGitSync("bulk-create-topics", groupId); + res.json({ ok: true, results, syncWarning }); }); // Delete a topic - app.delete("/api/telegram/groups/:groupId/topics/:topicId", async (req, res) => { - const { groupId, topicId } = req.params; - try { - await telegramApi.deleteForumTopic(groupId, parseInt(topicId, 10)); - topicRegistry.removeTopic(groupId, topicId); - syncConfigForTelegram({ - fs, - openclawDir: OPENCLAW_DIR, - topicRegistry, - groupId, - requireMention: false, - resolvedUserId: "", - }); - syncPromptFiles(); - res.json({ ok: true }); - } catch (e) { - if (!isMissingTopicError(e?.message)) { - return res.json({ ok: false, error: e.message }); + app.delete( + "/api/telegram/groups/:groupId/topics/:topicId", + async (req, res) => { + const { groupId, topicId } = req.params; + try { + await telegramApi.deleteForumTopic(groupId, parseInt(topicId, 10)); + topicRegistry.removeTopic(groupId, topicId); + syncConfigForTelegram({ + fs, + openclawDir: OPENCLAW_DIR, + topicRegistry, + groupId, + requireMention: false, + resolvedUserId: "", + }); + syncPromptFiles(); + const syncWarning = await runTelegramGitSync("delete-topic", topicId); + res.json({ ok: true, syncWarning }); + } catch (e) { + if (!isMissingTopicError(e?.message)) { + return res.json({ ok: false, error: e.message }); + } + topicRegistry.removeTopic(groupId, topicId); + syncConfigForTelegram({ + fs, + openclawDir: OPENCLAW_DIR, + topicRegistry, + groupId, + requireMention: false, + resolvedUserId: "", + }); + syncPromptFiles(); + const syncWarning = await runTelegramGitSync( + "delete-stale-topic", + topicId, + ); + return res.json({ + ok: true, + removedFromRegistryOnly: true, + warning: + "Topic no longer exists in Telegram; removed stale registry entry.", + syncWarning, + }); } - topicRegistry.removeTopic(groupId, topicId); - syncConfigForTelegram({ - fs, - openclawDir: OPENCLAW_DIR, - topicRegistry, - groupId, - requireMention: false, - resolvedUserId: "", - }); - syncPromptFiles(); - return res.json({ - ok: true, - removedFromRegistryOnly: true, - warning: "Topic no longer exists in Telegram; removed stale registry entry.", - }); - } - }); + }, + ); // Rename a topic app.put("/api/telegram/groups/:groupId/topics/:topicId", async (req, res) => { const { groupId, topicId } = req.params; const body = req.body || {}; const name = String(body.name ?? "").trim(); - const hasSystemInstructions = Object.prototype.hasOwnProperty.call(body, "systemInstructions") - || Object.prototype.hasOwnProperty.call(body, "systemPrompt"); + const hasSystemInstructions = + Object.prototype.hasOwnProperty.call(body, "systemInstructions") || + Object.prototype.hasOwnProperty.call(body, "systemPrompt"); const systemInstructions = String( body.systemInstructions ?? body.systemPrompt ?? "", ).trim(); - if (!name) return res.status(400).json({ ok: false, error: "name is required" }); + if (!name) + return res.status(400).json({ ok: false, error: "name is required" }); try { const threadId = Number.parseInt(String(topicId), 10); if (!Number.isFinite(threadId)) { - return res.status(400).json({ ok: false, error: "topicId must be numeric" }); + return res + .status(400) + .json({ ok: false, error: "topicId must be numeric" }); } - const existingTopic = topicRegistry.getGroup(groupId)?.topics?.[String(threadId)] || {}; + const existingTopic = + topicRegistry.getGroup(groupId)?.topics?.[String(threadId)] || {}; const existingName = String(existingTopic.name || "").trim(); const shouldRename = !existingName || existingName !== name; if (shouldRename) { @@ -260,9 +353,15 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { resolvedUserId: "", }); syncPromptFiles(); + const syncWarning = await runTelegramGitSync("rename-topic", name); return res.json({ ok: true, - topic: { threadId, name, ...(hasSystemInstructions ? { systemInstructions } : {}) }, + topic: { + threadId, + name, + ...(hasSystemInstructions ? { systemInstructions } : {}), + }, + syncWarning, }); } catch (e) { return res.json({ ok: false, error: e.message }); @@ -296,8 +395,9 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { topicRegistry.setGroup(groupId, { name: groupName }); syncPromptFiles(); } + const syncWarning = await runTelegramGitSync("configure-group", groupId); - res.json({ ok: true, userId: resolvedUserId || null }); + res.json({ ok: true, userId: resolvedUserId || null, syncWarning }); } catch (e) { res.json({ ok: false, error: e.message }); } @@ -313,14 +413,24 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { try { const debugEnabled = isDebugEnabled(); const configPath = `${OPENCLAW_DIR}/openclaw.json`; - const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); - const telegramConfig = cfg.channels?.telegram || {}; + let cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + let telegramConfig = cfg.channels?.telegram || {}; const configuredGroups = telegramConfig.groups || {}; const groupIds = Object.keys(configuredGroups); if (groupIds.length === 0) { return res.json({ ok: true, configured: false, debugEnabled }); } const groupId = String(groupIds[0]); + const groupConfig = configuredGroups[groupId] || {}; + const repairResult = await repairGroupAllowFromIfMissing({ + cfg, + groupId, + requireMention: !!groupConfig.requireMention, + }); + if (repairResult.repaired) { + cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + telegramConfig = cfg.channels?.telegram || {}; + } const registryGroup = topicRegistry.getGroup(groupId); let groupName = registryGroup?.name || groupId; try { @@ -336,8 +446,12 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { debugEnabled, concurrency: { agentMaxConcurrent: cfg.agents?.defaults?.maxConcurrent ?? null, - subagentMaxConcurrent: cfg.agents?.defaults?.subagents?.maxConcurrent ?? null, + subagentMaxConcurrent: + cfg.agents?.defaults?.subagents?.maxConcurrent ?? null, }, + repairedGroupAllowFrom: !!repairResult.repaired, + repairedUserId: repairResult.resolvedUserId || null, + syncWarning: repairResult.syncWarning || null, }); } catch (e) { return res.json({ ok: false, error: e.message }); @@ -345,7 +459,7 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { }); // Reset Telegram workspace onboarding state - app.post("/api/telegram/workspace/reset", (req, res) => { + app.post("/api/telegram/workspace/reset", async (req, res) => { try { const configPath = `${OPENCLAW_DIR}/openclaw.json`; const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); @@ -366,7 +480,8 @@ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => { } syncPromptFiles(); - return res.json({ ok: true }); + const syncWarning = await runTelegramGitSync("reset-workspace", "telegram"); + return res.json({ ok: true, syncWarning }); } catch (e) { return res.json({ ok: false, error: e.message }); } From 1274635a0ed923bc796fead7817d2f577ed649ae Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 15:28:41 -0800 Subject: [PATCH 038/417] Add usage API and routes test coverage. Cover the new usage endpoints and frontend API helpers so request parsing, label enrichment, and session detail path encoding are validated by automated tests. --- tests/frontend/api.test.js | 33 +++++++++ tests/server/routes-usage.test.js | 119 ++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/server/routes-usage.test.js diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index e63133d8..98de0dd8 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -72,4 +72,37 @@ describe("frontend/api", () => { "Reserved env var", ); }); + + it("fetchUsageSummary calls usage summary endpoint", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, summary: { daily: [] } })); + const api = await loadApiModule(); + + const result = await api.fetchUsageSummary(90); + + expect(global.fetch).toHaveBeenCalledWith("/api/usage/summary?days=90", {}); + expect(result).toEqual({ ok: true, summary: { daily: [] } }); + }); + + it("fetchUsageSessions calls usage sessions endpoint", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, sessions: [] })); + const api = await loadApiModule(); + + const result = await api.fetchUsageSessions(100); + + expect(global.fetch).toHaveBeenCalledWith("/api/usage/sessions?limit=100", {}); + expect(result).toEqual({ ok: true, sessions: [] }); + }); + + it("fetchUsageSessionDetail encodes session id in path", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, detail: { sessionId: "x" } })); + const api = await loadApiModule(); + + const result = await api.fetchUsageSessionDetail("agent:main:telegram:group:-1:topic:2"); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/usage/sessions/agent%3Amain%3Atelegram%3Agroup%3A-1%3Atopic%3A2", + {}, + ); + expect(result).toEqual({ ok: true, detail: { sessionId: "x" } }); + }); }); diff --git a/tests/server/routes-usage.test.js b/tests/server/routes-usage.test.js new file mode 100644 index 00000000..1d0c9cb1 --- /dev/null +++ b/tests/server/routes-usage.test.js @@ -0,0 +1,119 @@ +const express = require("express"); +const request = require("supertest"); + +const topicRegistry = require("../../lib/server/topic-registry"); +const { registerUsageRoutes } = require("../../lib/server/routes/usage"); + +const createDeps = () => ({ + requireAuth: (req, res, next) => next(), + getDailySummary: vi.fn(() => ({ daily: [], totals: {} })), + getSessionsList: vi.fn(() => [ + { + sessionId: "agent:main:telegram:group:-1003832123427:topic:182", + sessionKey: "agent:main:telegram:group:-1003832123427:topic:182", + totalTokens: 1200, + totalCost: 0.012, + lastActivityMs: 1730000000000, + }, + { + sessionId: "agent:main:telegram:direct:1050628644", + sessionKey: "agent:main:telegram:direct:1050628644", + totalTokens: 800, + totalCost: 0.008, + lastActivityMs: 1730000001000, + }, + ]), + getSessionDetail: vi.fn(({ sessionId }) => + sessionId === "missing" + ? null + : ({ + sessionId, + sessionKey: sessionId, + modelBreakdown: [], + toolUsage: [], + })), + getSessionTimeSeries: vi.fn(() => ({ sessionId: "abc", points: [] })), +}); + +const createApp = (deps) => { + const app = express(); + app.use(express.json()); + registerUsageRoutes({ + app, + ...deps, + }); + return app; +}; + +describe("server/routes/usage", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("caches summary payloads by days", async () => { + const deps = createDeps(); + const app = createApp(deps); + + const firstResponse = await request(app).get("/api/usage/summary?days=30"); + const secondResponse = await request(app).get("/api/usage/summary?days=30"); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body.ok).toBe(true); + expect(firstResponse.body.cached).toBe(false); + expect(secondResponse.status).toBe(200); + expect(secondResponse.body.cached).toBe(true); + expect(deps.getDailySummary).toHaveBeenCalledTimes(1); + expect(deps.getDailySummary).toHaveBeenCalledWith({ days: 30 }); + }); + + it("returns sessions with resolved labels on GET /api/usage/sessions", async () => { + const deps = createDeps(); + vi.spyOn(topicRegistry, "getGroup").mockReturnValue({ + name: "Workspace Name", + topics: { + "182": { name: "Topic Name" }, + }, + }); + const app = createApp(deps); + + const response = await request(app).get("/api/usage/sessions?limit=25"); + + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + expect(deps.getSessionsList).toHaveBeenCalledWith({ limit: 25 }); + expect(response.body.sessions[0].labels).toEqual([ + { label: "Main", tone: "cyan" }, + { label: "Workspace Name", tone: "purple" }, + { label: "Topic Name", tone: "gray" }, + ]); + expect(response.body.sessions[1].labels).toEqual([ + { label: "Main", tone: "cyan" }, + { label: "Telegram Direct", tone: "blue" }, + ]); + }); + + it("returns 404 when session detail is missing", async () => { + const deps = createDeps(); + const app = createApp(deps); + + const response = await request(app).get("/api/usage/sessions/missing"); + + expect(response.status).toBe(404); + expect(response.body.ok).toBe(false); + expect(response.body.error).toBe("Session not found"); + }); + + it("parses maxPoints for session time series endpoint", async () => { + const deps = createDeps(); + const app = createApp(deps); + + const response = await request(app).get("/api/usage/sessions/abc/timeseries?maxPoints=200"); + + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + expect(deps.getSessionTimeSeries).toHaveBeenCalledWith({ + sessionId: "abc", + maxPoints: 200, + }); + }); +}); From 8992039980e414fcbf583c6a4b2f92fb9d4a8594 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 16:06:48 -0800 Subject: [PATCH 039/417] Split AGENTS guidance by scope and add project context. Move operational release/UI notice conventions to the repo root AGENTS guide while keeping the core prompts AGENTS file focused on coding safety and AlphaClaw architecture context for implementation decisions. --- AGENTS.md | 101 +++++++++++++++++++++++ lib/setup/core-prompts/AGENTS.md | 134 ++++++++----------------------- 2 files changed, 134 insertions(+), 101 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b333c607 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +### Release Flow (Beta -> Production) + +Use this release flow when promoting tested beta builds to production: + +1. Ensure `main` is clean and synced, and tests pass. +2. Publish beta iterations as needed: + - `npm version prerelease --preid=beta` + - `git push && git push --tags` + - `npm publish --tag beta` +3. For beta template testing, pin exact beta in template `package.json` (for example `0.3.2-beta.4`) to avoid Docker layer cache reusing old installs. +4. When ready for production, publish a stable release version (for example `0.3.2`): + - `npm version 0.3.2` + - `git push && git push --tags` + - `npm publish` (publishes to `latest`) +5. Return templates to production channel: + - `@chrysb/alphaclaw: "latest"` +6. Optionally keep beta branch/tag flows active for next release cycle. + +### Telegram Notice Format (AlphaClaw) + +Use this format for any Telegram notices sent from AlphaClaw services (watchdog, system alerts, repair notices): + +1. Header line (Markdown): `🐺 *AlphaClaw Watchdog*` +2. Headline line (simple, no `Status:` prefix): + - `🔴 Crash loop detected` + - `🔴 Crash loop detected, auto-repairing...` + - `🟡 Auto-repair started, awaiting health check` + - `🟢 Auto-repair complete, gateway healthy` + - `🟢 Gateway healthy again` + - `🔴 Auto-repair failed` +3. Append a markdown link to the headline when URL is available: + - `... - [View logs](/#/watchdog)` +4. Optional context lines like `Trigger: ...`, `Attempt count: ...` +5. For values with underscores or special characters (for example `crash_loop`), wrap the value in backticks: + - `Trigger: \`crash_loop\`` +6. Do not use HTML tags (``, `
    `) for Telegram watchdog notices. + +### UI Conventions + +Use these conventions for all UI work under `lib/public/js` and `lib/public/css`. + +#### Component structure + +- Use arrow-function components and helpers. +- Prefer shared components over one-off markup when a pattern already exists. +- Keep constants in `kName` format (e.g. `kUiTabs`, `kGroupOrder`, `kNamePattern`). +- Keep component-level helpers near the top of the file, before the main export. + +#### Rendering and composition + +- Use the `htm` + `preact` pattern: + - `const html = htm.bind(h);` + - return `html\`...\`` +- Prefer early return for hidden states (e.g. `if (!visible) return null;`). +- Use `` for tab/page headers that need a title and right-side actions. +- Use card shells consistently: `bg-surface border border-border rounded-xl`. + +#### Buttons + +- Primary actions: `ac-btn-cyan` +- Secondary actions: `ac-btn-secondary` +- Positive/success actions: `ac-btn-green` +- Ghost/text actions: `ac-btn-ghost` (use for low-emphasis actions like "Disconnect" or "Add provider") +- Destructive inline actions: `ac-btn-danger` +- Use consistent disabled treatment: `opacity-50 cursor-not-allowed`. +- Keep action sizing consistent (`text-xs px-3 py-1.5 rounded-lg` for compact controls unless there is a clear reason otherwise). +- For `` actions, use `ac-btn-cyan` (primary) or `ac-btn-secondary` (secondary) by default; avoid ghost/text-only styling for main header actions. +- Prefer shared action components when available (`ActionButton`, `UpdateActionButton`, `ConfirmDialog`) before custom button logic. +- In setup/onboarding auth flows (e.g. Codex OAuth), prefer `` over raw `
  • `; diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index 9b9653e5..5fa276a6 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -24,7 +24,7 @@ import { import { ActionButton } from "./action-button.js"; import { LoadingSpinner } from "./loading-spinner.js"; import { SegmentedControl } from "./segmented-control.js"; -import { SaveFillIcon } from "./icons.js"; +import { LockLineIcon, SaveFillIcon } from "./icons.js"; import { showToast } from "./toast.js"; const html = htm.bind(h); @@ -32,6 +32,12 @@ const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode"; const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode"; const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath"; const kProtectedBrowsePaths = new Set(["openclaw.json", "devices/paired.json"]); +const kLockedBrowsePaths = new Set([ + "hooks/bootstrap/agents.md", + "hooks/bootstrap/tools.md", + "hourly-git-sync", + "hourly-git-sync.sh", +]); const kLoadingIndicatorDelayMs = 1000; const kFileRefreshIntervalMs = 5000; @@ -49,6 +55,20 @@ const normalizePolicyPath = (inputPath) => .trim() .toLowerCase(); +const matchesPolicyPath = (policyPathSet, normalizedPath) => { + const safeNormalizedPath = String(normalizedPath || "").trim(); + if (!safeNormalizedPath) return false; + for (const policyPath of policyPathSet) { + if ( + safeNormalizedPath === policyPath || + safeNormalizedPath.endsWith(`/${policyPath}`) + ) { + return true; + } + } + return false; +}; + const readStoredFileViewerMode = () => { try { const storedMode = String( @@ -68,35 +88,35 @@ const clampSelectionIndex = (value, maxValue) => { return Math.max(0, Math.min(maxValue, numericValue)); }; -const readStoredEditorSelection = (filePath) => { - const safePath = String(filePath || "").trim(); - if (!safePath) return null; +const readEditorSelectionStorageMap = () => { try { const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey); - if (!rawStorageValue) return null; + if (!rawStorageValue) return {}; const parsedStorageValue = JSON.parse(rawStorageValue); - if (!parsedStorageValue || typeof parsedStorageValue !== "object") return null; - const selection = parsedStorageValue[safePath]; - if (!selection || typeof selection !== "object") return null; - return { - start: selection.start, - end: selection.end, - }; + if (!parsedStorageValue || typeof parsedStorageValue !== "object") return {}; + return parsedStorageValue; } catch { - return null; + return {}; } }; +const readStoredEditorSelection = (filePath) => { + const safePath = String(filePath || "").trim(); + if (!safePath) return null; + const storageMap = readEditorSelectionStorageMap(); + const selection = storageMap[safePath]; + if (!selection || typeof selection !== "object") return null; + return { + start: selection.start, + end: selection.end, + }; +}; + const writeStoredEditorSelection = (filePath, selection) => { const safePath = String(filePath || "").trim(); if (!safePath || !selection || typeof selection !== "object") return; try { - const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey); - const parsedStorageValue = rawStorageValue ? JSON.parse(rawStorageValue) : {}; - const nextStorageValue = - parsedStorageValue && typeof parsedStorageValue === "object" - ? parsedStorageValue - : {}; + const nextStorageValue = readEditorSelectionStorageMap(); nextStorageValue[safePath] = { start: selection.start, end: selection.end, @@ -148,10 +168,14 @@ export const FileViewer = ({ const hasSelectedPath = normalizedPath.length > 0; const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly; const isDirty = canEditFile && content !== initialContent; + const isLockedFile = canEditFile && matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath); const isProtectedFile = - canEditFile && kProtectedBrowsePaths.has(normalizedPolicyPath); + canEditFile && + !isLockedFile && + matchesPolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); + const isEditBlocked = isLockedFile || isProtectedLocked; const syntaxKind = useMemo( () => getFileSyntaxKind(normalizedPath), [normalizedPath], @@ -371,26 +395,68 @@ export const FileViewer = ({ ]); useEffect(() => { - if (!canEditFile || loading || !hasSelectedPath) return; - if (loadedFilePathRef.current !== normalizedPath) return; - if (restoredSelectionPathRef.current === normalizedPath) return; - const textareaElement = editorTextareaRef.current; - if (!textareaElement) return; + if (!canEditFile || loading || !hasSelectedPath) return () => {}; + if (loadedFilePathRef.current !== normalizedPath) return () => {}; + if (restoredSelectionPathRef.current === normalizedPath) return () => {}; + if (viewMode !== "edit") return () => {}; const storedSelection = readStoredEditorSelection(normalizedPath); - restoredSelectionPathRef.current = normalizedPath; - if (!storedSelection) return; - const maxIndex = String(content || "").length; - const start = clampSelectionIndex(storedSelection.start, maxIndex); - const end = clampSelectionIndex(storedSelection.end, maxIndex); - textareaElement.setSelectionRange(start, Math.max(start, end)); - }, [canEditFile, loading, hasSelectedPath, normalizedPath, content]); + if (!storedSelection) { + restoredSelectionPathRef.current = normalizedPath; + return () => {}; + } + let frameId = 0; + let attempts = 0; + const restoreSelection = () => { + const textareaElement = editorTextareaRef.current; + if (!textareaElement) { + attempts += 1; + if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection); + return; + } + const maxIndex = String(content || "").length; + const start = clampSelectionIndex(storedSelection.start, maxIndex); + const end = clampSelectionIndex(storedSelection.end, maxIndex); + textareaElement.focus(); + textareaElement.setSelectionRange(start, Math.max(start, end)); + window.requestAnimationFrame(() => { + const nextTextareaElement = editorTextareaRef.current; + if (!nextTextareaElement) return; + const safeContent = String(content || ""); + const safeStart = clampSelectionIndex(start, safeContent.length); + const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1; + const computedStyle = window.getComputedStyle(nextTextareaElement); + const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || ""); + const lineHeight = + Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 + ? parsedLineHeight + : 20; + const nextScrollTop = Math.max( + 0, + lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4, + ); + nextTextareaElement.scrollTop = nextScrollTop; + if (editorLineNumbersRef.current) { + editorLineNumbersRef.current.scrollTop = nextScrollTop; + } + if (editorHighlightRef.current) { + editorHighlightRef.current.scrollTop = nextScrollTop; + } + viewScrollRatioRef.current = getScrollRatio(nextTextareaElement); + }); + restoredSelectionPathRef.current = normalizedPath; + }; + frameId = window.requestAnimationFrame(restoreSelection); + return () => { + if (frameId) window.cancelAnimationFrame(frameId); + }; + }, [canEditFile, loading, hasSelectedPath, normalizedPath, content, viewMode]); const handleSave = async () => { - if (!canEditFile || saving || !isDirty || isProtectedLocked) return; + if (!canEditFile || saving || !isDirty || isEditBlocked) return; setSaving(true); setError(""); try { - const data = await saveFileContent(normalizedPath, content); + await saveFileContent(normalizedPath, content); setInitialContent(content); setExternalChangeNoticeShown(false); clearStoredFileDraft(normalizedPath); @@ -402,11 +468,7 @@ export const FileViewer = ({ detail: { path: normalizedPath }, }), ); - if (data.synced === false) { - showToast("Saved, but git sync failed", "error"); - } else { - showToast("Saved and synced", "success"); - } + showToast("Saved", "success"); } catch (saveError) { const message = saveError.message || "Could not save file"; setError(message); @@ -426,7 +488,7 @@ export const FileViewer = ({ }; const handleContentInput = (event) => { - if (isProtectedLocked || isPreviewOnly) return; + if (isEditBlocked || isPreviewOnly) return; const nextContent = event.target.value; setContent(nextContent); if (hasSelectedPath && canEditFile) { @@ -585,7 +647,7 @@ export const FileViewer = ({ `} <${ActionButton} onClick=${handleSave} - disabled=${loading || !isDirty || !canEditFile || isProtectedLocked} + disabled=${loading || !isDirty || !canEditFile || isEditBlocked} loading=${saving} tone=${isDirty ? "primary" : "secondary"} size="sm" @@ -596,6 +658,16 @@ export const FileViewer = ({ className="file-viewer-save-action" />
    + ${isLockedFile + ? html` +
    + <${LockLineIcon} className="file-viewer-protected-banner-icon" /> +
    + This file is managed by Alpha Claw and cannot be edited. +
    +
    + ` + : null} ${isProtectedFile ? html`
    @@ -752,6 +824,8 @@ ${formattedValue} html` /> `; + +export const LockLineIcon = ({ className = "" }) => html` + +`; diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js index 1cf3a5e9..eb36fe48 100644 --- a/lib/public/js/components/sidebar-git-panel.js +++ b/lib/public/js/components/sidebar-git-panel.js @@ -1,9 +1,11 @@ import { h } from "https://esm.sh/preact"; import { useEffect, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; -import { fetchBrowseGitSummary } from "../lib/api.js"; +import { fetchBrowseGitSummary, syncBrowseChanges } from "../lib/api.js"; +import { ActionButton } from "./action-button.js"; import { GitBranchLineIcon, GithubFillIcon } from "./icons.js"; import { LoadingSpinner } from "./loading-spinner.js"; +import { showToast } from "./toast.js"; const html = htm.bind(h); const kRefreshMs = 10000; @@ -25,8 +27,41 @@ const getRepoName = (summary) => { return segment || "repo"; }; -export const SidebarGitPanel = () => { +const getChangedFilePresentation = (changedFile) => { + const statusKind = String(changedFile?.statusKind || "M").toUpperCase(); + if (statusKind === "U") { + return { + statusLabel: "U", + statusClass: "is-untracked", + rowClass: "is-clickable", + canOpen: true, + }; + } + if (statusKind === "D") { + return { + statusLabel: "D", + statusClass: "is-deleted", + rowClass: "", + canOpen: false, + }; + } + return { + statusLabel: "M", + statusClass: "is-modified", + rowClass: "is-clickable", + canOpen: true, + }; +}; + +const formatDelta = (value, prefix) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue < 0) return ""; + return `${prefix}${numericValue}`; +}; + +export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); const [error, setError] = useState(""); const [summary, setSummary] = useState(null); @@ -84,6 +119,27 @@ export const SidebarGitPanel = () => { `; } + const hasUnsyncedChanges = (summary.changedFiles || []).length > 0; + const handleSyncChanges = async () => { + if (!hasUnsyncedChanges || syncing) return; + try { + setSyncing(true); + const syncResult = await syncBrowseChanges("sync changes"); + if (syncResult?.committed) { + showToast(syncResult.message || "Changes committed", "success"); + } else { + showToast(syncResult?.message || "No changes to sync", "info"); + } + const nextSummary = await fetchBrowseGitSummary(); + setSummary(nextSummary); + setError(""); + } catch (syncError) { + showToast(syncError.message || "Could not sync changes", "error"); + } finally { + setSyncing(false); + } + }; + return html`
    `; }; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index f2635b7c..d4b13ca0 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -214,7 +214,7 @@ export const AppSidebar = ({ ref=${browseBottomPanelRef} style=${{ height: `${browseBottomPanelHeightPx}px` }} > - <${SidebarGitPanel} /> + <${SidebarGitPanel} onSelectFile=${onSelectBrowseFile} /> ${acHasUpdate && acLatest && !acDismissed ? html` <${UpdateActionButton} diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 0288faf3..d91e5067 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -393,3 +393,12 @@ export const fetchBrowseGitSummary = async () => { const res = await authFetch('/api/browse/git-summary'); return parseJsonOrThrow(res, 'Could not load git summary'); }; + +export const syncBrowseChanges = async (message = "") => { + const res = await authFetch('/api/browse/git-sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: String(message || "") }), + }); + return parseJsonOrThrow(res, 'Could not sync changes'); +}; diff --git a/lib/server/routes/browse.js b/lib/server/routes/browse.js index 746835d0..2ead1945 100644 --- a/lib/server/routes/browse.js +++ b/lib/server/routes/browse.js @@ -9,6 +9,26 @@ const kIgnoredDirectoryNames = new Set([ "dist", "build", ]); +const kLockedBrowsePaths = new Set([ + "hooks/bootstrap/agents.md", + "hooks/bootstrap/tools.md", + "hourly-git-sync", + "hourly-git-sync.sh", +]); + +const matchesPolicyPath = (policyPathSet, normalizedPath) => { + const safeNormalizedPath = String(normalizedPath || "").trim(); + if (!safeNormalizedPath) return false; + for (const policyPath of policyPathSet) { + if ( + safeNormalizedPath === policyPath || + safeNormalizedPath.endsWith(`/${policyPath}`) + ) { + return true; + } + } + return false; +}; const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const kRootResolved = path.resolve(kRootDir); @@ -25,6 +45,14 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { return rawPath.replace(/\\/g, "/").replace(/^\/+/, ""); }; + const normalizePolicyPath = (inputPath) => + String(inputPath || "") + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/^\/+/, "") + .trim() + .toLowerCase(); + const resolveSafePath = (inputPath) => { const relativePath = normalizeRelativePath(inputPath); const absolutePath = path.resolve(kRootResolved, relativePath); @@ -88,28 +116,6 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { return { type: "folder", name: nodeName, path: nodePath, children }; }; - const runGitSync = (message, relativeFilePath) => - new Promise((resolve) => { - const syncArgs = ["git-sync", "-m", message]; - if (relativeFilePath) { - syncArgs.push("--file", String(relativeFilePath)); - } - execFile( - "alphaclaw", - syncArgs, - { timeout: 20000, cwd: kRootResolved }, - (error) => { - if (error) { - return resolve({ - ok: false, - error: error.message || "alphaclaw git-sync failed", - }); - } - return resolve({ ok: true }); - }, - ); - }); - const runGitCommand = (args) => new Promise((resolve) => { execFile( @@ -138,6 +144,16 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .trim(); }; + const normalizeChangedPath = (rawPath) => { + const value = String(rawPath || "").trim(); + if (!value) return ""; + if (value.includes(" -> ")) { + const segments = value.split(" -> "); + return String(segments[segments.length - 1] || "").trim(); + } + return value; + }; + app.get("/api/browse/tree", (req, res) => { const depthValue = Number.parseInt(String(req.query.depth || ""), 10); const depth = Number.isFinite(depthValue) && depthValue > 0 ? depthValue : kDefaultTreeDepth; @@ -200,12 +216,48 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .filter(Boolean); const branchLine = statusLines.find((line) => line.startsWith("##")) || ""; const branch = branchLine.replace(/^##\s*/, "").split("...")[0] || "unknown"; + const diffNumstatResult = await runGitCommand(["diff", "--numstat", "HEAD"]); + const diffStatsByPath = new Map(); + if (diffNumstatResult.ok) { + diffNumstatResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + const [addedRaw = "", deletedRaw = "", rawPath = ""] = line.split("\t"); + const normalizedPath = normalizeChangedPath(rawPath); + if (!normalizedPath) return; + const addedLines = Number.parseInt(addedRaw, 10); + const deletedLines = Number.parseInt(deletedRaw, 10); + diffStatsByPath.set(normalizedPath, { + addedLines: Number.isFinite(addedLines) ? addedLines : null, + deletedLines: Number.isFinite(deletedLines) ? deletedLines : null, + }); + }); + } const changedFiles = statusLines .filter((line) => !line.startsWith("##")) - .map((line) => ({ - status: line.slice(0, 2).trim() || "M", - path: line.slice(3).trim(), - })); + .map((line) => { + const rawStatus = line.slice(0, 2); + const pathValue = normalizeChangedPath(line.slice(3)); + const stats = diffStatsByPath.get(pathValue) || { + addedLines: null, + deletedLines: null, + }; + const statusKind = + rawStatus === "??" || rawStatus.includes("A") + ? "U" + : rawStatus.includes("D") + ? "D" + : "M"; + return { + status: rawStatus.trim() || "M", + statusKind, + path: pathValue, + addedLines: stats.addedLines, + deletedLines: stats.deletedLines, + }; + }); let repoSlug = envRepoSlug; if (!repoSlug) { @@ -248,7 +300,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { branch, isDirty: changedFiles.length > 0, changedFilesCount: changedFiles.length, - changedFiles: changedFiles.slice(0, 8), + changedFiles: changedFiles.slice(0, 16), commits, }); } catch (error) { @@ -259,12 +311,83 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } }); + app.post("/api/browse/git-sync", async (req, res) => { + try { + const commitMessageRaw = String(req.body?.message || "").trim(); + const commitMessage = commitMessageRaw || "sync changes"; + const statusResult = await runGitCommand(["status", "--porcelain"]); + if (!statusResult.ok) { + if (/not a git repository/i.test(statusResult.error || "")) { + return res + .status(400) + .json({ ok: false, error: "No git repo at this root" }); + } + return res.status(500).json({ + ok: false, + error: statusResult.error || "Could not read git status", + }); + } + const hasChanges = statusResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean).length > 0; + if (!hasChanges) { + return res.json({ + ok: true, + committed: false, + message: "No changes to sync", + }); + } + const addResult = await runGitCommand(["add", "-A"]); + if (!addResult.ok) { + return res.status(500).json({ + ok: false, + error: addResult.error || "Could not stage changes", + }); + } + const commitResult = await runGitCommand(["commit", "-m", commitMessage]); + if (!commitResult.ok) { + if (/nothing to commit/i.test(commitResult.error || "")) { + return res.json({ + ok: true, + committed: false, + message: "No changes to sync", + }); + } + return res.status(500).json({ + ok: false, + error: commitResult.error || "Could not commit changes", + }); + } + const shortHashResult = await runGitCommand(["rev-parse", "--short", "HEAD"]); + const shortHash = shortHashResult.ok ? String(shortHashResult.stdout || "").trim() : ""; + return res.json({ + ok: true, + committed: true, + shortHash, + message: `Committed ${shortHash || "changes"}`, + }); + } catch (error) { + return res.status(500).json({ + ok: false, + error: error.message || "Could not sync changes", + }); + } + }); + app.put("/api/browse/write", async (req, res) => { const { path: targetPath, content } = req.body || {}; const resolvedPath = resolveSafePath(targetPath); if (!resolvedPath.ok) { return res.status(400).json({ ok: false, error: resolvedPath.error }); } + const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath); + if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) { + return res.status(403).json({ + ok: false, + error: "This file is managed by Alpha Claw and cannot be edited.", + }); + } if (typeof content !== "string") { return res.status(400).json({ ok: false, error: "content must be a string" }); } @@ -275,16 +398,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { return res.status(400).json({ ok: false, error: "Path is not a file" }); } fs.writeFileSync(resolvedPath.absolutePath, content, "utf8"); - const fileName = path.basename(resolvedPath.absolutePath); - const syncResult = await runGitSync( - `Edit ${fileName} via UI`, - resolvedPath.relativePath, - ); return res.json({ ok: true, path: resolvedPath.relativePath, - synced: syncResult.ok, - syncError: syncResult.ok ? undefined : syncResult.error, }); } catch (error) { return res.status(500).json({ ok: false, error: error.message || "Could not save file" }); diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 98de0dd8..91405fb0 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -105,4 +105,18 @@ describe("frontend/api", () => { ); expect(result).toEqual({ ok: true, detail: { sessionId: "x" } }); }); + + it("syncBrowseChanges posts commit message", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, committed: true })); + const api = await loadApiModule(); + + const result = await api.syncBrowseChanges("sync changes"); + + expect(global.fetch).toHaveBeenCalledWith("/api/browse/git-sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "sync changes" }), + }); + expect(result).toEqual({ ok: true, committed: true }); + }); }); diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index 132b5adc..a72e6913 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -86,10 +86,68 @@ describe("server/routes/browse", () => { expect(res.status).toBe(200); expect(res.body.ok).toBe(true); expect(res.body.path).toBe("openclaw.json"); - expect(typeof res.body.synced).toBe("boolean"); expect(fs.readFileSync(filePath, "utf8")).toBe('{"after":true}\n'); }); + it("rejects writes to locked bootstrap files", async () => { + const rootDir = createTestRoot(); + const lockedPath = path.join(rootDir, "hooks", "bootstrap", "AGENTS.md"); + fs.mkdirSync(path.dirname(lockedPath), { recursive: true }); + fs.writeFileSync(lockedPath, "before\n", "utf8"); + const app = createApp(rootDir); + + const res = await request(app).put("/api/browse/write").send({ + path: "hooks/bootstrap/AGENTS.md", + content: "after\n", + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + ok: false, + error: "This file is managed by Alpha Claw and cannot be edited.", + }); + expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); + }); + + it("rejects writes to locked bootstrap files with workspace prefix", async () => { + const rootDir = createTestRoot(); + const lockedPath = path.join(rootDir, "workspace", "hooks", "bootstrap", "AGENTS.md"); + fs.mkdirSync(path.dirname(lockedPath), { recursive: true }); + fs.writeFileSync(lockedPath, "before\n", "utf8"); + const app = createApp(rootDir); + + const res = await request(app).put("/api/browse/write").send({ + path: "workspace/hooks/bootstrap/AGENTS.md", + content: "after\n", + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + ok: false, + error: "This file is managed by Alpha Claw and cannot be edited.", + }); + expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); + }); + + it("rejects writes to locked hourly git sync file", async () => { + const rootDir = createTestRoot(); + const lockedPath = path.join(rootDir, "hourly-git-sync.sh"); + fs.writeFileSync(lockedPath, "before\n", "utf8"); + const app = createApp(rootDir); + + const res = await request(app).put("/api/browse/write").send({ + path: "hourly-git-sync.sh", + content: "after\n", + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + ok: false, + error: "This file is managed by Alpha Claw and cannot be edited.", + }); + expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); + }); + it("returns non-repo git summary outside git repositories", async () => { const rootDir = createTestRoot(); const app = createApp(rootDir); @@ -105,4 +163,19 @@ describe("server/routes/browse", () => { }), ); }); + + it("rejects git sync outside git repositories", async () => { + const rootDir = createTestRoot(); + const app = createApp(rootDir); + + const res = await request(app).post("/api/browse/git-sync").send({ + message: "sync changes", + }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + ok: false, + error: "No git repo at this root", + }); + }); }); From ce8677051d34d16b0d99172ee3a242c9dcaebbc9 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 20:08:33 -0800 Subject: [PATCH 047/417] Move managed operational files into .openclaw/.alphaclaw. This adds startup/onboarding migration for internal files, updates cron and pairing marker consumers to the new paths, and aligns browse locking/tree visibility with the relocated managed directory. --- bin/alphaclaw.js | 10 +- lib/public/css/explorer.css | 2 - lib/public/js/components/file-tree.js | 4 +- lib/public/js/components/file-viewer.js | 4 +- lib/server.js | 7 ++ lib/server/internal-files-migration.js | 93 +++++++++++++++++++ lib/server/onboarding/cron.js | 10 +- lib/server/onboarding/index.js | 7 ++ lib/server/routes/browse.js | 5 +- lib/server/routes/pairings.js | 10 +- lib/server/routes/system.js | 6 +- tests/server/internal-files-migration.test.js | 77 +++++++++++++++ tests/server/routes-browse.test.js | 12 ++- tests/server/routes-onboarding.test.js | 6 +- tests/server/routes-pairings.test.js | 2 +- tests/server/routes-system.test.js | 2 +- 16 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 lib/server/internal-files-migration.js create mode 100644 tests/server/internal-files-migration.test.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 52528368..a84cd440 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -10,6 +10,9 @@ const { validateGitSyncFilePath, } = require("../lib/cli/git-sync"); const { buildSecretReplacements } = require("../lib/server/helpers"); +const { + migrateManagedInternalFiles, +} = require("../lib/server/internal-files-migration"); const kUsageTrackerPluginPath = path.resolve( __dirname, @@ -143,6 +146,12 @@ if (portFlag) { const openclawDir = path.join(rootDir, ".openclaw"); fs.mkdirSync(openclawDir, { recursive: true }); +const { + hourlyGitSyncPath, +} = migrateManagedInternalFiles({ + fs, + openclawDir, +}); console.log(`[alphaclaw] Root directory: ${rootDir}`); // Check for pending update marker (written by the update endpoint before restart). @@ -542,7 +551,6 @@ if (!fs.existsSync(gogConfigFile)) { // 8. Install/reconcile system cron entry // --------------------------------------------------------------------------- -const hourlyGitSyncPath = path.join(openclawDir, "hourly-git-sync.sh"); const packagedHourlyGitSyncPath = path.join(setupDir, "hourly-git-sync.sh"); try { diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index e237f44f..469c7d46 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -341,8 +341,6 @@ .file-viewer-protected-banner.is-locked { background: rgba(220, 38, 38, 0.16); - border-top: 1px solid rgba(248, 113, 113, 0.42); - border-bottom: 1px solid rgba(248, 113, 113, 0.42); } .file-viewer-protected-banner-icon { diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index 4505b135..181d91cc 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -36,8 +36,8 @@ const kLegacyCollapsedFoldersStorageKey = "alphaclawBrowseCollapsedFolders"; const kLockedBrowsePaths = new Set([ "hooks/bootstrap/agents.md", "hooks/bootstrap/tools.md", - "hourly-git-sync", - "hourly-git-sync.sh", + ".alphaclaw/hourly-git-sync.sh", + ".alphaclaw/.cli-device-auto-approved", ]); const normalizePolicyPath = (inputPath) => diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index 5fa276a6..30d62dec 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -35,8 +35,8 @@ const kProtectedBrowsePaths = new Set(["openclaw.json", "devices/paired.json"]); const kLockedBrowsePaths = new Set([ "hooks/bootstrap/agents.md", "hooks/bootstrap/tools.md", - "hourly-git-sync", - "hourly-git-sync.sh", + ".alphaclaw/hourly-git-sync.sh", + ".alphaclaw/.cli-device-auto-approved", ]); const kLoadingIndicatorDelayMs = 1000; const kFileRefreshIntervalMs = 5000; diff --git a/lib/server.js b/lib/server.js index 4a93991a..99d02b44 100644 --- a/lib/server.js +++ b/lib/server.js @@ -76,6 +76,9 @@ const { installControlUiSkill, syncBootstrapPromptFiles, } = require("./server/onboarding/workspace"); +const { + migrateManagedInternalFiles, +} = require("./server/internal-files-migration"); const { createTelegramApi } = require("./server/telegram-api"); const { createDiscordApi } = require("./server/discord-api"); const { createWatchdogNotifier } = require("./server/watchdog-notify"); @@ -100,6 +103,10 @@ const { PORT, GATEWAY_URL, kTrustProxyHops, SETUP_API_PREFIXES } = constants; startEnvWatcher(); attachGatewaySignalHandlers(); +migrateManagedInternalFiles({ + fs, + openclawDir: constants.OPENCLAW_DIR, +}); const app = express(); app.set("trust proxy", kTrustProxyHops); diff --git a/lib/server/internal-files-migration.js b/lib/server/internal-files-migration.js new file mode 100644 index 00000000..b48f0f03 --- /dev/null +++ b/lib/server/internal-files-migration.js @@ -0,0 +1,93 @@ +const path = require("path"); + +const kInternalDirName = ".alphaclaw"; +const kHourlyGitSyncFileName = "hourly-git-sync.sh"; +const kCliDeviceAutoApprovedFileName = ".cli-device-auto-approved"; + +const buildManagedPaths = ({ openclawDir, pathModule = path }) => { + const internalDir = pathModule.join(openclawDir, kInternalDirName); + return { + internalDir, + hourlyGitSyncPath: pathModule.join(internalDir, kHourlyGitSyncFileName), + cliDeviceAutoApprovedPath: pathModule.join( + internalDir, + kCliDeviceAutoApprovedFileName, + ), + legacyHourlyGitSyncPath: pathModule.join(openclawDir, kHourlyGitSyncFileName), + legacyCliDeviceAutoApprovedPath: pathModule.join( + openclawDir, + kCliDeviceAutoApprovedFileName, + ), + }; +}; + +const moveFile = ({ fs, sourcePath, targetPath, mode }) => { + try { + fs.renameSync(sourcePath, targetPath); + return true; + } catch { + fs.copyFileSync(sourcePath, targetPath); + if (Number.isFinite(mode)) { + fs.chmodSync(targetPath, mode); + } + fs.rmSync(sourcePath, { force: true }); + return true; + } +}; + +const migrateManagedInternalFiles = ({ + fs, + openclawDir, + pathModule = path, + logger = console, +}) => { + const managedPaths = buildManagedPaths({ openclawDir, pathModule }); + fs.mkdirSync(managedPaths.internalDir, { recursive: true }); + + const migrateOne = ({ sourcePaths, targetPath }) => { + const existingSourcePath = sourcePaths.find((sourcePath) => fs.existsSync(sourcePath)); + if (fs.existsSync(targetPath)) { + sourcePaths.forEach((sourcePath) => { + if (sourcePath !== targetPath && fs.existsSync(sourcePath)) { + fs.rmSync(sourcePath, { force: true }); + } + }); + return; + } + if (!existingSourcePath) return; + const sourceStats = fs.statSync(existingSourcePath); + moveFile({ + fs, + sourcePath: existingSourcePath, + targetPath, + mode: sourceStats.mode, + }); + sourcePaths.forEach((sourcePath) => { + if (sourcePath !== existingSourcePath && sourcePath !== targetPath && fs.existsSync(sourcePath)) { + fs.rmSync(sourcePath, { force: true }); + } + }); + }; + + try { + migrateOne({ + sourcePaths: [managedPaths.legacyHourlyGitSyncPath], + targetPath: managedPaths.hourlyGitSyncPath, + }); + migrateOne({ + sourcePaths: [managedPaths.legacyCliDeviceAutoApprovedPath], + targetPath: managedPaths.cliDeviceAutoApprovedPath, + }); + } catch (error) { + logger.error?.( + `[alphaclaw] Failed to migrate internal managed files: ${error.message || String(error)}`, + ); + } + + return managedPaths; +}; + +module.exports = { + buildManagedPaths, + migrateManagedInternalFiles, +}; diff --git a/lib/server/onboarding/cron.js b/lib/server/onboarding/cron.js index 6833f195..235c3d98 100644 --- a/lib/server/onboarding/cron.js +++ b/lib/server/onboarding/cron.js @@ -1,5 +1,6 @@ const path = require("path"); const { kSetupDir } = require("../constants"); +const { buildManagedPaths } = require("../internal-files-migration"); const kHourlyGitSyncTemplatePath = path.join(kSetupDir, "hourly-git-sync.sh"); const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync"; @@ -17,9 +18,10 @@ const buildSystemCronFile = ({ schedule, scriptPath }) => const installHourlyGitSyncScript = ({ fs, openclawDir }) => { try { - const scriptPath = `${openclawDir}/hourly-git-sync.sh`; + const { internalDir, hourlyGitSyncPath } = buildManagedPaths({ openclawDir }); const hourlyGitSyncScript = fs.readFileSync(kHourlyGitSyncTemplatePath, "utf8"); - fs.writeFileSync(scriptPath, hourlyGitSyncScript, { mode: 0o755 }); + fs.mkdirSync(internalDir, { recursive: true }); + fs.writeFileSync(hourlyGitSyncPath, hourlyGitSyncScript, { mode: 0o755 }); console.log("[onboard] Installed deterministic hourly git sync script"); } catch (e) { console.error("[onboard] Hourly git sync script install error:", e.message); @@ -28,7 +30,7 @@ const installHourlyGitSyncScript = ({ fs, openclawDir }) => { const installHourlyGitSyncCron = async ({ fs, openclawDir }) => { try { - const scriptPath = `${openclawDir}/hourly-git-sync.sh`; + const { hourlyGitSyncPath } = buildManagedPaths({ openclawDir }); const configDir = `${openclawDir}/${kSystemCronConfigDir}`; const configPath = `${configDir}/${kSystemCronConfigFile}`; const config = { enabled: true, schedule: kDefaultSystemCronSchedule }; @@ -37,7 +39,7 @@ const installHourlyGitSyncCron = async ({ fs, openclawDir }) => { const cronContent = buildSystemCronFile({ schedule: config.schedule, - scriptPath, + scriptPath: hourlyGitSyncPath, }); fs.writeFileSync(kSystemCronPath, cronContent, { mode: 0o644 }); console.log(`[onboard] Installed system cron job at ${kSystemCronPath} (${configPath})`); diff --git a/lib/server/onboarding/index.js b/lib/server/onboarding/index.js index cd74a41a..4bc32425 100644 --- a/lib/server/onboarding/index.js +++ b/lib/server/onboarding/index.js @@ -17,6 +17,9 @@ const { installHourlyGitSyncScript, installHourlyGitSyncCron, } = require("./cron"); +const { + migrateManagedInternalFiles, +} = require("../internal-files-migration"); const createOnboardingService = ({ fs, @@ -89,6 +92,10 @@ const createOnboardingService = ({ fs.mkdirSync(OPENCLAW_DIR, { recursive: true }); fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); + migrateManagedInternalFiles({ + fs, + openclawDir: OPENCLAW_DIR, + }); syncBootstrapPromptFiles({ fs, workspaceDir: WORKSPACE_DIR, diff --git a/lib/server/routes/browse.js b/lib/server/routes/browse.js index 2ead1945..0f5317b1 100644 --- a/lib/server/routes/browse.js +++ b/lib/server/routes/browse.js @@ -4,6 +4,7 @@ const { execFile } = require("child_process"); const kDefaultTreeDepth = 10; const kIgnoredDirectoryNames = new Set([ ".git", + ".alphaclaw", "node_modules", ".cache", "dist", @@ -12,8 +13,8 @@ const kIgnoredDirectoryNames = new Set([ const kLockedBrowsePaths = new Set([ "hooks/bootstrap/agents.md", "hooks/bootstrap/tools.md", - "hourly-git-sync", - "hourly-git-sync.sh", + ".alphaclaw/hourly-git-sync.sh", + ".alphaclaw/.cli-device-auto-approved", ]); const matchesPolicyPath = (policyPathSet, normalizedPath) => { diff --git a/lib/server/routes/pairings.js b/lib/server/routes/pairings.js index 327ebb8b..35c1d589 100644 --- a/lib/server/routes/pairings.js +++ b/lib/server/routes/pairings.js @@ -1,15 +1,21 @@ const fs = require("fs"); const { OPENCLAW_DIR } = require("../constants"); +const { buildManagedPaths } = require("../internal-files-migration"); const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openclawDir = OPENCLAW_DIR }) => { let pairingCache = { pending: [], ts: 0 }; const PAIRING_CACHE_TTL = 10000; - const kCliAutoApproveMarkerPath = `${openclawDir}/.cli-device-auto-approved`; + const { + cliDeviceAutoApprovedPath: kCliAutoApproveMarkerPath, + internalDir: kManagedFilesDir, + } = buildManagedPaths({ + openclawDir, + }); const hasCliAutoApproveMarker = () => fsModule.existsSync(kCliAutoApproveMarkerPath); const writeCliAutoApproveMarker = () => { - fsModule.mkdirSync(openclawDir, { recursive: true }); + fsModule.mkdirSync(kManagedFilesDir, { recursive: true }); fsModule.writeFileSync( kCliAutoApproveMarkerPath, JSON.stringify({ approvedAt: new Date().toISOString() }, null, 2), diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index 4529df64..28e7ced0 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -1,3 +1,5 @@ +const { buildManagedPaths } = require("../internal-files-migration"); + const registerSystemRoutes = ({ app, fs, @@ -35,7 +37,9 @@ const registerSystemRoutes = ({ kSystemVars.has(key) || kEnvVarsReservedForUserInput.has(key); const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync"; const kSystemCronConfigPath = `${OPENCLAW_DIR}/cron/system-sync.json`; - const kSystemCronScriptPath = `${OPENCLAW_DIR}/hourly-git-sync.sh`; + const { hourlyGitSyncPath: kSystemCronScriptPath } = buildManagedPaths({ + openclawDir: OPENCLAW_DIR, + }); const kDefaultSystemCronSchedule = "0 * * * *"; const isValidCronSchedule = (value) => typeof value === "string" && /^(\S+\s+){4}\S+$/.test(value.trim()); diff --git a/tests/server/internal-files-migration.test.js b/tests/server/internal-files-migration.test.js new file mode 100644 index 00000000..ad81763e --- /dev/null +++ b/tests/server/internal-files-migration.test.js @@ -0,0 +1,77 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + buildManagedPaths, + migrateManagedInternalFiles, +} = require("../../lib/server/internal-files-migration"); + +const createTempOpenclawDir = () => + fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-managed-files-test-")); + +describe("server/internal-files-migration", () => { + it("moves legacy managed files into .alphaclaw", () => { + const openclawDir = createTempOpenclawDir(); + const legacyScriptPath = path.join(openclawDir, "hourly-git-sync.sh"); + const legacyMarkerPath = path.join(openclawDir, ".cli-device-auto-approved"); + fs.writeFileSync(legacyScriptPath, "echo legacy\n", { mode: 0o755 }); + fs.writeFileSync(legacyMarkerPath, '{"approvedAt":"x"}\n', "utf8"); + + const managedPaths = migrateManagedInternalFiles({ + fs, + openclawDir, + logger: { error: vi.fn() }, + }); + + expect(fs.existsSync(legacyScriptPath)).toBe(false); + expect(fs.existsSync(legacyMarkerPath)).toBe(false); + expect(fs.existsSync(managedPaths.hourlyGitSyncPath)).toBe(true); + expect(fs.existsSync(managedPaths.cliDeviceAutoApprovedPath)).toBe(true); + expect(fs.readFileSync(managedPaths.hourlyGitSyncPath, "utf8")).toContain("legacy"); + }); + + it("keeps new paths as source of truth when both old and new exist", () => { + const openclawDir = createTempOpenclawDir(); + const managedPaths = buildManagedPaths({ openclawDir }); + fs.mkdirSync(managedPaths.internalDir, { recursive: true }); + fs.writeFileSync(managedPaths.hourlyGitSyncPath, "echo new\n", "utf8"); + fs.writeFileSync(managedPaths.cliDeviceAutoApprovedPath, '{"approvedAt":"new"}\n', "utf8"); + fs.writeFileSync(managedPaths.legacyHourlyGitSyncPath, "echo old\n", "utf8"); + fs.writeFileSync(managedPaths.legacyCliDeviceAutoApprovedPath, '{"approvedAt":"old"}\n', "utf8"); + + migrateManagedInternalFiles({ + fs, + openclawDir, + logger: { error: vi.fn() }, + }); + + expect(fs.existsSync(managedPaths.legacyHourlyGitSyncPath)).toBe(false); + expect(fs.existsSync(managedPaths.legacyCliDeviceAutoApprovedPath)).toBe(false); + expect(fs.readFileSync(managedPaths.hourlyGitSyncPath, "utf8")).toBe("echo new\n"); + expect(fs.readFileSync(managedPaths.cliDeviceAutoApprovedPath, "utf8")).toBe( + '{"approvedAt":"new"}\n', + ); + }); + + it("is idempotent across repeated runs", () => { + const openclawDir = createTempOpenclawDir(); + fs.writeFileSync(path.join(openclawDir, "hourly-git-sync.sh"), "echo script\n", "utf8"); + + migrateManagedInternalFiles({ + fs, + openclawDir, + logger: { error: vi.fn() }, + }); + migrateManagedInternalFiles({ + fs, + openclawDir, + logger: { error: vi.fn() }, + }); + + const managedPaths = buildManagedPaths({ openclawDir }); + expect(fs.existsSync(managedPaths.hourlyGitSyncPath)).toBe(true); + expect(fs.existsSync(managedPaths.legacyHourlyGitSyncPath)).toBe(false); + expect(fs.existsSync(managedPaths.internalDir)).toBe(true); + }); +}); diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index a72e6913..9cc93048 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -19,8 +19,10 @@ describe("server/routes/browse", () => { it("returns browse tree rooted at configured directory", async () => { const rootDir = createTestRoot(); fs.mkdirSync(path.join(rootDir, "devices"), { recursive: true }); + fs.mkdirSync(path.join(rootDir, ".alphaclaw"), { recursive: true }); fs.writeFileSync(path.join(rootDir, "openclaw.json"), '{"ok":true}\n', "utf8"); fs.writeFileSync(path.join(rootDir, "devices", "paired.json"), "[]\n", "utf8"); + fs.writeFileSync(path.join(rootDir, ".alphaclaw", "hourly-git-sync.sh"), "#!/bin/bash\n", "utf8"); const app = createApp(rootDir); const res = await request(app).get("/api/browse/tree"); @@ -40,6 +42,9 @@ describe("server/routes/browse", () => { expect.objectContaining({ type: "file", path: "openclaw.json", name: "openclaw.json" }), ]), ); + expect( + (res.body.root.children || []).some((entry) => entry?.name === ".alphaclaw"), + ).toBe(false); }); it("rejects path traversal on read", async () => { @@ -129,14 +134,15 @@ describe("server/routes/browse", () => { expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); }); - it("rejects writes to locked hourly git sync file", async () => { + it("rejects writes to locked managed files under .alphaclaw", async () => { const rootDir = createTestRoot(); - const lockedPath = path.join(rootDir, "hourly-git-sync.sh"); + const lockedPath = path.join(rootDir, ".alphaclaw", "hourly-git-sync.sh"); + fs.mkdirSync(path.dirname(lockedPath), { recursive: true }); fs.writeFileSync(lockedPath, "before\n", "utf8"); const app = createApp(rootDir); const res = await request(app).put("/api/browse/write").send({ - path: "hourly-git-sync.sh", + path: ".alphaclaw/hourly-git-sync.sh", content: "after\n", }); diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index 124e5db0..7b377e50 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -207,14 +207,16 @@ describe("server/routes/onboarding", () => { expect(toolsWriteCall[1]).toContain("https://example.com"); expect(deps.fs.writeFileSync).toHaveBeenCalledWith( - "/tmp/openclaw/hourly-git-sync.sh", + "/tmp/openclaw/.alphaclaw/hourly-git-sync.sh", expect.stringContaining("Auto-commit hourly sync"), expect.objectContaining({ mode: 0o755 }), ); expect(deps.fs.writeFileSync).toHaveBeenCalledWith( "/etc/cron.d/openclaw-hourly-sync", - expect.stringContaining('0 * * * * root bash "/tmp/openclaw/hourly-git-sync.sh"'), + expect.stringContaining( + '0 * * * * root bash "/tmp/openclaw/.alphaclaw/hourly-git-sync.sh"', + ), expect.objectContaining({ mode: 0o644 }), ); diff --git a/tests/server/routes-pairings.test.js b/tests/server/routes-pairings.test.js index ada97ca1..34e39c96 100644 --- a/tests/server/routes-pairings.test.js +++ b/tests/server/routes-pairings.test.js @@ -59,7 +59,7 @@ describe("server/routes/pairings", () => { expect(res.body.pending).toEqual([]); expect(clawCmd).toHaveBeenCalledWith("devices approve req-cli-1", { quiet: true }); expect(fsModule.writeFileSync).toHaveBeenCalledWith( - "/tmp/openclaw/.cli-device-auto-approved", + "/tmp/openclaw/.alphaclaw/.cli-device-auto-approved", expect.stringContaining("approvedAt"), ); }); diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index 8d25d8b8..7b35029e 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -285,7 +285,7 @@ describe("server/routes/system", () => { ); expect(deps.fs.writeFileSync).toHaveBeenCalledWith( "/etc/cron.d/openclaw-hourly-sync", - expect.stringContaining('*/15 * * * * root bash "/tmp/openclaw/hourly-git-sync.sh"'), + expect.stringContaining('*/15 * * * * root bash "/tmp/openclaw/.alphaclaw/hourly-git-sync.sh"'), expect.objectContaining({ mode: 0o644 }), ); expect(res.body.ok).toBe(true); From 0067c49a0619c5bdf4bbd71599cad249d8327a93 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 20:52:18 -0800 Subject: [PATCH 048/417] Improve browse diff flow and sync commit context. Add inline git diff viewing with URL-based diff state, keyboard save support, and smarter sync commit messages while tightening config secret replacement so only exact JSON values are scrubbed. --- bin/alphaclaw.js | 15 +- lib/public/css/explorer.css | 51 ++ lib/public/js/app.js | 60 ++- lib/public/js/components/file-viewer.js | 499 ++++++++++++------ lib/public/js/components/sidebar-git-panel.js | 25 +- lib/public/js/lib/api.js | 6 + lib/server/helpers.js | 23 +- lib/server/onboarding/openclaw.js | 7 +- lib/server/routes/browse.js | 88 +++ tests/frontend/api.test.js | 13 + tests/server/routes-browse.test.js | 13 + 11 files changed, 610 insertions(+), 190 deletions(-) diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index a84cd440..e8be3a18 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -146,9 +146,7 @@ if (portFlag) { const openclawDir = path.join(rootDir, ".openclaw"); fs.mkdirSync(openclawDir, { recursive: true }); -const { - hourlyGitSyncPath, -} = migrateManagedInternalFiles({ +const { hourlyGitSyncPath } = migrateManagedInternalFiles({ fs, openclawDir, }); @@ -830,7 +828,16 @@ if (fs.existsSync(configPath)) { const replacements = buildSecretReplacements(process.env); for (const [secret, envRef] of replacements) { if (secret) { - content = content.split(secret).join(envRef); + // Only replace the secret if it is an exact match for a JSON string value + // This ensures we do not replace substrings inside other strings + const secretJson = JSON.stringify(secret); + content = content.replace( + new RegExp( + secretJson.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"), + "g", + ), + JSON.stringify(envRef), + ); } } fs.writeFileSync(configPath, content); diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 469c7d46..d3ed5305 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -367,6 +367,15 @@ letter-spacing: 0.01em; } +.file-viewer-diff-banner { + background: rgba(59, 130, 246, 0.12); +} + +.file-viewer-diff-banner .file-viewer-protected-banner-text, +.file-viewer-diff-banner { + color: #bfdbfe; +} + .file-viewer-tabbar-spacer { flex: 1; } @@ -582,6 +591,48 @@ align-items: stretch; } +.file-viewer-diff-shell { + width: 100%; + min-height: 0; + height: 100%; + overflow: auto; + padding: 8px 0; +} + +.file-viewer-diff-pre { + margin: 0; + padding: 0 12px 12px; + font-family: inherit; + font-size: 12px; + line-height: 1.45; + color: var(--text-muted); +} + +.file-viewer-diff-line { + white-space: pre-wrap; + word-break: break-word; + padding: 1px 8px; + border-radius: 4px; +} + +.file-viewer-diff-line.is-added { + color: #86efac; + background: rgba(34, 197, 94, 0.1); +} + +.file-viewer-diff-line.is-removed { + color: #fca5a5; + background: rgba(239, 68, 68, 0.1); +} + +.file-viewer-diff-line.is-hunk { + color: #93c5fd; +} + +.file-viewer-diff-line.is-header { + color: var(--text-dim); +} + .file-viewer-editor-line-num-col { width: 56px; flex-shrink: 0; diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 463844b1..71921d8a 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -591,21 +591,24 @@ const App = () => { `; } - const buildBrowseRoute = (relativePath) => { + const buildBrowseRoute = (relativePath, options = {}) => { + const view = String(options?.view || "edit"); const encodedPath = String(relativePath || "") .split("/") .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join("/"); - return encodedPath ? `/browse/${encodedPath}` : "/browse"; + const baseRoute = encodedPath ? `/browse/${encodedPath}` : "/browse"; + if (view === "diff" && encodedPath) return `${baseRoute}?view=diff`; + return baseRoute; }; const navigateToSubScreen = (screen) => { setLocation(`/${screen}`); setMobileSidebarOpen(false); }; - const navigateToBrowseFile = (relativePath) => { + const navigateToBrowseFile = (relativePath, options = {}) => { setBrowsePreviewPath(""); - setLocation(buildBrowseRoute(relativePath)); + setLocation(buildBrowseRoute(relativePath, options)); setMobileSidebarOpen(false); }; const handleSidebarLogout = async () => { @@ -668,8 +671,13 @@ const App = () => { ]; const isBrowseRoute = location.startsWith("/browse"); + const browseRoutePath = isBrowseRoute ? String(location || "").split("?")[0] : ""; + const browseRouteQuery = + isBrowseRoute && String(location || "").includes("?") + ? String(location || "").split("?").slice(1).join("?") + : ""; const selectedBrowsePath = isBrowseRoute - ? location + ? browseRoutePath .replace(/^\/browse\/?/, "") .split("/") .filter(Boolean) @@ -682,6 +690,12 @@ const App = () => { }) .join("/") : ""; + const activeBrowsePath = browsePreviewPath || selectedBrowsePath; + const browseViewerMode = + !browsePreviewPath && + new URLSearchParams(browseRouteQuery).get("view") === "diff" + ? "diff" + : "edit"; const selectedNavId = isBrowseRoute ? "browse" : location === "/telegram" @@ -727,6 +741,28 @@ const App = () => { ); }, [isBrowseRoute, selectedBrowsePath]); + useEffect(() => { + const handleBrowseGitSynced = () => { + if (!isBrowseRoute || browseViewerMode !== "diff") return; + const activePath = String(selectedBrowsePath || "").trim(); + if (!activePath) return; + setLocation(buildBrowseRoute(activePath, { view: "edit" })); + }; + window.addEventListener("alphaclaw:browse-git-synced", handleBrowseGitSynced); + return () => { + window.removeEventListener( + "alphaclaw:browse-git-synced", + handleBrowseGitSynced, + ); + }; + }, [ + isBrowseRoute, + browseViewerMode, + selectedBrowsePath, + setLocation, + buildBrowseRoute, + ]); + useEffect(() => { const settings = readUiSettings(); settings.sidebarWidthPx = sidebarWidthPx; @@ -848,11 +884,23 @@ const App = () => { ${isBrowseRoute ? html` <${FileViewer} - filePath=${browsePreviewPath || selectedBrowsePath} + filePath=${activeBrowsePath} isPreviewOnly=${Boolean( browsePreviewPath && browsePreviewPath !== selectedBrowsePath, )} + browseView=${browseViewerMode} + onRequestEdit=${(targetPath) => { + const normalizedTargetPath = String(targetPath || ""); + if ( + normalizedTargetPath && + normalizedTargetPath !== selectedBrowsePath + ) { + navigateToBrowseFile(normalizedTargetPath, { view: "edit" }); + return; + } + setLocation(buildBrowseRoute(selectedBrowsePath, { view: "edit" })); + }} /> ` : html` diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index 30d62dec..f0c88c4e 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -8,7 +8,11 @@ import { } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { marked } from "https://esm.sh/marked"; -import { fetchFileContent, saveFileContent } from "../lib/api.js"; +import { + fetchBrowseFileDiff, + fetchFileContent, + saveFileContent, +} from "../lib/api.js"; import { formatFrontmatterValue, getFileSyntaxKind, @@ -90,10 +94,13 @@ const clampSelectionIndex = (value, maxValue) => { const readEditorSelectionStorageMap = () => { try { - const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey); + const rawStorageValue = window.localStorage.getItem( + kEditorSelectionStorageKey, + ); if (!rawStorageValue) return {}; const parsedStorageValue = JSON.parse(rawStorageValue); - if (!parsedStorageValue || typeof parsedStorageValue !== "object") return {}; + if (!parsedStorageValue || typeof parsedStorageValue !== "object") + return {}; return parsedStorageValue; } catch { return {}; @@ -128,10 +135,11 @@ const writeStoredEditorSelection = (filePath, selection) => { } catch {} }; - export const FileViewer = ({ filePath = "", isPreviewOnly = false, + browseView = "edit", + onRequestEdit = () => {}, }) => { const normalizedPath = String(filePath || "").trim(); const normalizedPolicyPath = normalizePolicyPath(normalizedPath); @@ -141,11 +149,15 @@ export const FileViewer = ({ const [loading, setLoading] = useState(false); const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false); + const [diffLoading, setDiffLoading] = useState(false); + const [diffError, setDiffError] = useState(""); + const [diffContent, setDiffContent] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [isFolderPath, setIsFolderPath] = useState(false); const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); - const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false); + const [externalChangeNoticeShown, setExternalChangeNoticeShown] = + useState(false); const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState( () => new Set(), ); @@ -167,8 +179,10 @@ export const FileViewer = ({ ); const hasSelectedPath = normalizedPath.length > 0; const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly; + const isDiffView = String(browseView || "edit") === "diff"; const isDirty = canEditFile && content !== initialContent; - const isLockedFile = canEditFile && matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath); + const isLockedFile = + canEditFile && matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath); const isProtectedFile = canEditFile && !isLockedFile && @@ -339,7 +353,9 @@ export const FileViewer = ({ dispatchEvent: (event) => window.dispatchEvent(event), }); setExternalChangeNoticeShown(false); - window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-tree-refresh"), + ); return; } if (!externalChangeNoticeShown) { @@ -355,7 +371,10 @@ export const FileViewer = ({ fileRefreshInFlightRef.current = false; } }; - const intervalId = window.setInterval(refreshFromDisk, kFileRefreshIntervalMs); + const intervalId = window.setInterval( + refreshFromDisk, + kFileRefreshIntervalMs, + ); return () => { window.clearInterval(intervalId); }; @@ -371,6 +390,36 @@ export const FileViewer = ({ externalChangeNoticeShown, ]); + useEffect(() => { + let active = true; + if (!hasSelectedPath || !isDiffView || isPreviewOnly) { + setDiffLoading(false); + setDiffError(""); + setDiffContent(""); + return () => { + active = false; + }; + } + const loadDiff = async () => { + setDiffLoading(true); + setDiffError(""); + try { + const data = await fetchBrowseFileDiff(normalizedPath); + if (!active) return; + setDiffContent(String(data?.content || "")); + } catch (nextError) { + if (!active) return; + setDiffError(nextError.message || "Could not load diff"); + } finally { + if (active) setDiffLoading(false); + } + }; + loadDiff(); + return () => { + active = false; + }; + }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]); + useEffect(() => { if (loadedFilePathRef.current !== normalizedPath) return; if (!canEditFile || !hasSelectedPath || loading) return; @@ -410,7 +459,8 @@ export const FileViewer = ({ const textareaElement = editorTextareaRef.current; if (!textareaElement) { attempts += 1; - if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection); + if (attempts < 6) + frameId = window.requestAnimationFrame(restoreSelection); return; } const maxIndex = String(content || "").length; @@ -423,9 +473,12 @@ export const FileViewer = ({ if (!nextTextareaElement) return; const safeContent = String(content || ""); const safeStart = clampSelectionIndex(start, safeContent.length); - const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1; + const lineIndex = + safeContent.slice(0, safeStart).split("\n").length - 1; const computedStyle = window.getComputedStyle(nextTextareaElement); - const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || ""); + const parsedLineHeight = Number.parseFloat( + computedStyle.lineHeight || "", + ); const lineHeight = Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight @@ -449,9 +502,16 @@ export const FileViewer = ({ return () => { if (frameId) window.cancelAnimationFrame(frameId); }; - }, [canEditFile, loading, hasSelectedPath, normalizedPath, content, viewMode]); + }, [ + canEditFile, + loading, + hasSelectedPath, + normalizedPath, + content, + viewMode, + ]); - const handleSave = async () => { + const handleSave = useCallback(async () => { if (!canEditFile || saving || !isDirty || isEditBlocked) return; setSaving(true); setError(""); @@ -476,7 +536,31 @@ export const FileViewer = ({ } finally { setSaving(false); } - }; + }, [ + canEditFile, + saving, + isDirty, + isEditBlocked, + normalizedPath, + content, + initialContent, + ]); + + useEffect(() => { + const handleKeyDown = (event) => { + const isSaveShortcut = + (event.metaKey || event.ctrlKey) && + !event.shiftKey && + !event.altKey && + String(event.key || "").toLowerCase() === "s"; + if (!isSaveShortcut) return; + if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return; + event.preventDefault(); + void handleSave(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]); const handleEditProtectedFile = () => { if (!normalizedPolicyPath) return; @@ -633,7 +717,8 @@ export const FileViewer = ({ ${isPreviewOnly ? html`
    Preview
    ` : null} - ${isMarkdownFile && + ${!isDiffView && + isMarkdownFile && html` <${SegmentedControl} className="mr-2.5" @@ -645,20 +730,39 @@ export const FileViewer = ({ onChange=${handleChangeViewMode} /> `} - <${ActionButton} - onClick=${handleSave} - disabled=${loading || !isDirty || !canEditFile || isEditBlocked} - loading=${saving} - tone=${isDirty ? "primary" : "secondary"} - size="sm" - idleLabel="Save" - loadingLabel="Saving..." - idleIcon=${SaveFillIcon} - idleIconClassName="file-viewer-save-icon" - className="file-viewer-save-action" - /> + ${!isDiffView + ? html` + <${ActionButton} + onClick=${handleSave} + disabled=${loading || !isDirty || !canEditFile || isEditBlocked} + loading=${saving} + tone=${isDirty ? "primary" : "secondary"} + size="sm" + idleLabel="Save" + loadingLabel="Saving..." + idleIcon=${SaveFillIcon} + idleIconClassName="file-viewer-save-icon" + className="file-viewer-save-action" + /> + ` + : null}
    - ${isLockedFile + ${isDiffView + ? html` +
    +
    + Viewing unsynced changes +
    + <${ActionButton} + onClick=${() => onRequestEdit(normalizedPath)} + tone="secondary" + size="sm" + idleLabel="View file" + /> +
    + ` + : null} + ${!isDiffView && isLockedFile ? html`
    <${LockLineIcon} className="file-viewer-protected-banner-icon" /> @@ -668,7 +772,7 @@ export const FileViewer = ({
    ` : null} - ${isProtectedFile + ${!isDiffView && isProtectedFile ? html`
    @@ -755,141 +859,215 @@ ${formattedValue} ` - : html` - ${isMarkdownFile - ? html` -
    -
    + : isDiffView + ? html` +
    + ${diffLoading + ? html` +
    + <${LoadingSpinner} className="h-4 w-4" /> +
    + ` + : diffError + ? html` +
    + ${diffError} +
    + ` + : html` +
    +${(diffContent || "").split("\n").map((line, lineIndex) => {
    +                                const lineClass =
    +                                  line.startsWith("+") &&
    +                                  !line.startsWith("+++")
    +                                    ? "is-added"
    +                                    : line.startsWith("-") &&
    +                                        !line.startsWith("---")
    +                                      ? "is-removed"
    +                                      : line.startsWith("@@")
    +                                        ? "is-hunk"
    +                                        : line.startsWith("diff ") ||
    +                                            line.startsWith("index ") ||
    +                                            line.startsWith("--- ") ||
    +                                            line.startsWith("+++ ")
    +                                          ? "is-header"
    +                                          : "";
    +                                return html`
    +                                  
    + ${line || " "} +
    + `; + })} +
    + `} +
    + ` + : html` + ${isMarkdownFile + ? html`
    +
    - ${editorLineNumbers.map( - (lineNumber) => html` -
    { - editorLineNumberRowRefs.current[ - lineNumber - 1 - ] = element; - }} - > - ${lineNumber} -
    - `, - )} -
    -
    - ${highlightedEditorLines.map( - (line) => html` + ${editorLineNumbers.map( + (lineNumber) => html`
    { - editorHighlightLineRefs.current[ - line.lineNumber - 1 + editorLineNumberRowRefs.current[ + lineNumber - 1 ] = element; }} > - + ${lineNumber}
    `, )}
    - -
    -
    - ` - : html` -
    -
    - ${editorLineNumbers.map( - (lineNumber) => html` -
    { - editorLineNumberRowRefs.current[ - lineNumber - 1 - ] = element; - }} - > - ${lineNumber} -
    - `, - )} +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[ + line.lineNumber - 1 + ] = element; + }} + > + +
    + `, + )} +
    + +
    - ${shouldUseHighlightedEditor - ? html` -
    + ` + : html` +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html`
    { + editorLineNumberRowRefs.current[ + lineNumber - 1 + ] = element; + }} > - ${highlightedEditorLines.map( - (line) => html` -
    { - editorHighlightLineRefs.current[ - line.lineNumber - 1 - ] = element; - }} - > - + `, + )} +
    + ${shouldUseHighlightedEditor + ? html` +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[ + line.lineNumber - 1 + ] = element; }} - > -
    - `, - )} + > + +
    + `, + )} +
    +
    + ` + : html` -
    - ` - : html` - - `} -
    - `} - `} + `} +
    + `} + `}
    `; }; diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js index eb36fe48..fc9aa22e 100644 --- a/lib/public/js/components/sidebar-git-panel.js +++ b/lib/public/js/components/sidebar-git-panel.js @@ -9,6 +9,7 @@ import { showToast } from "./toast.js"; const html = htm.bind(h); const kRefreshMs = 10000; +const kSyncCommitFileNameLimit = 4; const formatCommitTime = (unixSeconds) => { if (!unixSeconds) return ""; @@ -59,6 +60,24 @@ const formatDelta = (value, prefix) => { return `${prefix}${numericValue}`; }; +const buildSyncCommitMessage = (changedFiles) => { + const filePaths = Array.isArray(changedFiles) + ? changedFiles + .map((file) => String(file?.path || "").trim()) + .filter(Boolean) + : []; + const totalCount = filePaths.length; + if (totalCount <= 0) return "sync changes"; + + const fileNames = filePaths.map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath); + const uniqueFileNames = Array.from(new Set(fileNames)); + const shownFileNames = uniqueFileNames.slice(0, kSyncCommitFileNameLimit); + const remainingCount = Math.max(0, uniqueFileNames.length - shownFileNames.length); + const noun = totalCount === 1 ? "file" : "files"; + const suffix = remainingCount > 0 ? ` +${remainingCount} more` : ""; + return `Edited ${totalCount} ${noun} - ${shownFileNames.join(", ")}${suffix}`; +}; + export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); @@ -124,8 +143,10 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { if (!hasUnsyncedChanges || syncing) return; try { setSyncing(true); - const syncResult = await syncBrowseChanges("sync changes"); + const commitMessage = buildSyncCommitMessage(summary?.changedFiles || []); + const syncResult = await syncBrowseChanges(commitMessage); if (syncResult?.committed) { + window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced")); showToast(syncResult.message || "Changes committed", "success"); } else { showToast(syncResult?.message || "No changes to sync", "info"); @@ -188,7 +209,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { title=${changedPath} onclick=${() => { if (!presentation.canOpen || !changedPath) return; - onSelectFile(changedPath); + onSelectFile(changedPath, { view: "diff" }); }} > ${changedPath} diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index d91e5067..62308759 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -394,6 +394,12 @@ export const fetchBrowseGitSummary = async () => { return parseJsonOrThrow(res, 'Could not load git summary'); }; +export const fetchBrowseFileDiff = async (filePath) => { + const params = new URLSearchParams({ path: String(filePath || "") }); + const res = await authFetch(`/api/browse/git-diff?${params.toString()}`); + return parseJsonOrThrow(res, 'Could not load file diff'); +}; + export const syncBrowseChanges = async (message = "") => { const res = await authFetch('/api/browse/git-sync', { method: 'POST', diff --git a/lib/server/helpers.js b/lib/server/helpers.js index e6a533ea..1cd6f919 100644 --- a/lib/server/helpers.js +++ b/lib/server/helpers.js @@ -8,7 +8,11 @@ const { const normalizeOpenclawVersion = (rawVersion) => { if (!rawVersion) return null; - return String(rawVersion).trim().replace(/^openclaw\s*/i, "") || null; + return ( + String(rawVersion) + .trim() + .replace(/^openclaw\s*/i, "") || null + ); }; const compareVersionParts = (a, b) => { @@ -62,9 +66,14 @@ const getCodexAccountId = (accessToken) => { const normalizeIp = (ip) => String(ip || "").replace(/^::ffff:/, ""); const isTruthyEnvFlag = (value) => - ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase()); + ["1", "true", "yes", "on"].includes( + String(value || "") + .trim() + .toLowerCase(), + ); const isDebugEnabled = () => - isTruthyEnvFlag(process.env.ALPHACLAW_DEBUG) || isTruthyEnvFlag(process.env.DEBUG); + isTruthyEnvFlag(process.env.ALPHACLAW_DEBUG) || + isTruthyEnvFlag(process.env.DEBUG); const getClientKey = (req) => normalizeIp( @@ -165,9 +174,13 @@ const readGoogleCredentials = () => { try { const c = JSON.parse(fs.readFileSync(GOG_CREDENTIALS_PATH, "utf8")); return { - clientId: c.web?.client_id || c.installed?.client_id || c.client_id || null, + clientId: + c.web?.client_id || c.installed?.client_id || c.client_id || null, clientSecret: - c.web?.client_secret || c.installed?.client_secret || c.client_secret || null, + c.web?.client_secret || + c.installed?.client_secret || + c.client_secret || + null, }; } catch { return { clientId: null, clientSecret: null }; diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js index 070c5885..3867a530 100644 --- a/lib/server/onboarding/openclaw.js +++ b/lib/server/onboarding/openclaw.js @@ -9,7 +9,12 @@ const kUsageTrackerPluginPath = path.resolve( "usage-tracker", ); -const buildOnboardArgs = ({ varMap, selectedProvider, hasCodexOauth, workspaceDir }) => { +const buildOnboardArgs = ({ + varMap, + selectedProvider, + hasCodexOauth, + workspaceDir, +}) => { const onboardArgs = [ "--non-interactive", "--accept-risk", diff --git a/lib/server/routes/browse.js b/lib/server/routes/browse.js index 0f5317b1..031f599e 100644 --- a/lib/server/routes/browse.js +++ b/lib/server/routes/browse.js @@ -135,6 +135,34 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { ); }); + const runGitCommandWithExitCode = (args) => + new Promise((resolve) => { + execFile( + "git", + args, + { timeout: 10000, cwd: kRootResolved }, + (error, stdout, stderr) => { + const safeStdout = String(stdout || ""); + const safeStderr = String(stderr || ""); + if (!error) { + return resolve({ + ok: true, + stdout: safeStdout, + stderr: safeStderr, + exitCode: 0, + }); + } + return resolve({ + ok: false, + stdout: safeStdout, + stderr: safeStderr, + exitCode: Number.isInteger(error.code) ? error.code : 1, + error: String(error.message || "git command failed"), + }); + }, + ); + }); + const parseGithubRepoSlug = (value) => { const raw = String(value || "").trim(); if (!raw) return ""; @@ -312,6 +340,66 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } }); + app.get("/api/browse/git-diff", async (req, res) => { + const resolvedPath = resolveSafePath(req.query.path); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + const relativePath = String(resolvedPath.relativePath || "").trim(); + if (!relativePath) { + return res.status(400).json({ ok: false, error: "path is required" }); + } + + try { + const statusResult = await runGitCommandWithExitCode([ + "status", + "--porcelain", + "--", + relativePath, + ]); + if (!statusResult.ok && /not a git repository/i.test(statusResult.stderr || "")) { + return res.status(400).json({ ok: false, error: "No git repo at this root" }); + } + const statusLines = statusResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const isUntracked = statusLines.some((line) => line.startsWith("??")); + + const diffResult = isUntracked + ? await runGitCommandWithExitCode([ + "diff", + "--no-index", + "--", + "/dev/null", + resolvedPath.absolutePath, + ]) + : await runGitCommandWithExitCode(["diff", "HEAD", "--", relativePath]); + + const untrackedAllowedFailure = + isUntracked && diffResult.exitCode === 1 && diffResult.stdout; + if (!diffResult.ok && !untrackedAllowedFailure) { + return res.status(500).json({ + ok: false, + error: diffResult.stderr || diffResult.error || "Could not load file diff", + }); + } + + const content = String(diffResult.stdout || "") + .replaceAll(resolvedPath.absolutePath, relativePath) + .trimEnd(); + return res.json({ + ok: true, + path: relativePath, + content, + }); + } catch (error) { + return res + .status(500) + .json({ ok: false, error: error.message || "Could not load file diff" }); + } + }); + app.post("/api/browse/git-sync", async (req, res) => { try { const commitMessageRaw = String(req.body?.message || "").trim(); diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 91405fb0..ccb75d98 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -119,4 +119,17 @@ describe("frontend/api", () => { }); expect(result).toEqual({ ok: true, committed: true }); }); + + it("fetchBrowseFileDiff calls git diff endpoint with encoded path", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, content: "diff --git" })); + const api = await loadApiModule(); + + const result = await api.fetchBrowseFileDiff("workspace/hooks/bootstrap/AGENTS.md"); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/browse/git-diff?path=workspace%2Fhooks%2Fbootstrap%2FAGENTS.md", + {}, + ); + expect(result).toEqual({ ok: true, content: "diff --git" }); + }); }); diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index 9cc93048..eb7f4c60 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -60,6 +60,19 @@ describe("server/routes/browse", () => { expect(res.body.error).toContain("Path must stay within"); }); + it("rejects path traversal on git diff", async () => { + const rootDir = createTestRoot(); + const app = createApp(rootDir); + + const res = await request(app) + .get("/api/browse/git-diff") + .query({ path: "../outside.txt" }); + + expect(res.status).toBe(400); + expect(res.body.ok).toBe(false); + expect(res.body.error).toContain("Path must stay within"); + }); + it("rejects likely binary files on read", async () => { const rootDir = createTestRoot(); const binaryFilePath = path.join(rootDir, "image.bin"); From f446d88a5397ad4ba3290020c2bbfe860b1c5200 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 21:19:52 -0800 Subject: [PATCH 049/417] Polish watchdog and browse sidebar UX for clearer, steadier status feedback. This keeps critical actions and uptime grouped, makes resource bars visually consistent, centers file-tree loading feedback, and prevents a brief sidebar tab flicker on browse reload. --- lib/public/css/explorer.css | 14 +++++++++++ lib/public/js/app.js | 4 +++- lib/public/js/components/file-tree.js | 9 ++++++- lib/public/js/components/gateway.js | 30 ++++++++++-------------- lib/public/js/components/watchdog-tab.js | 2 -- 5 files changed, 37 insertions(+), 22 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index d3ed5305..388e1daa 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -105,6 +105,11 @@ padding: 6px 0 8px; } +.file-tree-wrap-loading { + min-height: 100%; + display: flex; +} + .file-tree-search { padding: 0 8px 6px; } @@ -303,6 +308,15 @@ color: var(--text-muted); } +.file-tree-state-loading { + width: 100%; + flex: 1 1 auto; + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + .file-tree-state-error { color: #f87171; } diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 71921d8a..7ffa7da3 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -383,7 +383,9 @@ const App = () => { const [acDismissed, setAcDismissed] = useState(false); const [authEnabled, setAuthEnabled] = useState(false); const [menuOpen, setMenuOpen] = useState(false); - const [sidebarTab, setSidebarTab] = useState("menu"); + const [sidebarTab, setSidebarTab] = useState(() => + location.startsWith("/browse") ? "browse" : "menu", + ); const [sidebarWidthPx, setSidebarWidthPx] = useState(() => { const settings = readUiSettings(); if (!Number.isFinite(settings.sidebarWidthPx)) return kDefaultSidebarWidthPx; diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index 181d91cc..62634ee8 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -25,6 +25,7 @@ import { HashtagIcon, LockLineIcon, } from "./icons.js"; +import { LoadingSpinner } from "./loading-spinner.js"; const html = htm.bind(h); const kTreeIndentPx = 9; @@ -498,7 +499,13 @@ export const FileTree = ({ }; if (loading) { - return html`
    Loading files...
    `; + return html` +
    +
    + <${LoadingSpinner} className="h-5 w-5 text-gray-400" /> +
    +
    + `; } if (error) { return html`
    diff --git a/lib/public/js/components/gateway.js b/lib/public/js/components/gateway.js index 97f77e09..eda70a2b 100644 --- a/lib/public/js/components/gateway.js +++ b/lib/public/js/components/gateway.js @@ -160,7 +160,7 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { ${error}
    `}
    - - ${!restarting && isRunning - ? html` -
    - Uptime: ${formatDuration(liveUptimeMs)} -
    - ` - : null}
    ${onOpenWatchdog ? html` diff --git a/lib/public/js/components/watchdog-tab.js b/lib/public/js/components/watchdog-tab.js index 06582540..98c84859 100644 --- a/lib/public/js/components/watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab.js @@ -28,8 +28,6 @@ const formatBytes = (bytes) => { const barColor = (percent) => { if (percent == null) return "bg-gray-600"; - if (percent >= 90) return "bg-red-500"; - if (percent >= 70) return "bg-yellow-400"; return "bg-cyan-400"; }; From 26d3aa3e262dbadaa8b0710f9cd7fbc18b1aa821 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 21:23:37 -0800 Subject: [PATCH 050/417] 0.3.5-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b0d9a84..91e195c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.4", + "version": "0.3.5-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.4", + "version": "0.3.5-beta.0", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index b2d30d1b..7c10a811 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.4", + "version": "0.3.5-beta.0", "publishConfig": { "access": "public" }, From 3b149057dc93178b8a4ce725422629bbcd7420c1 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 22:29:17 -0800 Subject: [PATCH 051/417] Prevent file viewer hangs when switching from large JSON to Markdown. Clear stale editor content immediately on file path changes and guard render-time parsing/highlighting so Markdown views never process the previous file's large payload. --- lib/public/js/components/file-viewer.js | 80 +++++++++++++++++++++---- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js index f0c88c4e..57e9672d 100644 --- a/lib/public/js/components/file-viewer.js +++ b/lib/public/js/components/file-viewer.js @@ -145,6 +145,8 @@ export const FileViewer = ({ const normalizedPolicyPath = normalizePolicyPath(normalizedPath); const [content, setContent] = useState(""); const [initialContent, setInitialContent] = useState(""); + const [fileKind, setFileKind] = useState("text"); + const [imageDataUrl, setImageDataUrl] = useState(""); const [viewMode, setViewMode] = useState(readStoredFileViewerMode); const [loading, setLoading] = useState(false); const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = @@ -177,10 +179,15 @@ export const FileViewer = ({ () => parsePathSegments(normalizedPath), [normalizedPath], ); + const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath; + const renderContent = isCurrentFileLoaded ? content : ""; + const renderInitialContent = isCurrentFileLoaded ? initialContent : ""; const hasSelectedPath = normalizedPath.length > 0; - const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly; + const isImageFile = fileKind === "image"; + const canEditFile = + hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile; const isDiffView = String(browseView || "edit") === "diff"; - const isDirty = canEditFile && content !== initialContent; + const isDirty = canEditFile && renderContent !== renderInitialContent; const isLockedFile = canEditFile && matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath); const isProtectedFile = @@ -199,21 +206,21 @@ export const FileViewer = ({ const parsedFrontmatter = useMemo( () => isMarkdownFile - ? parseFrontmatter(content) - : { entries: [], body: content }, - [content, isMarkdownFile], + ? parseFrontmatter(renderContent) + : { entries: [], body: renderContent }, + [renderContent, isMarkdownFile], ); const highlightedEditorLines = useMemo( () => shouldUseHighlightedEditor - ? highlightEditorLines(content, syntaxKind) + ? highlightEditorLines(renderContent, syntaxKind) : [], - [content, shouldUseHighlightedEditor, syntaxKind], + [renderContent, shouldUseHighlightedEditor, syntaxKind], ); const editorLineNumbers = useMemo(() => { - const lineCount = String(content || "").split("\n").length; + const lineCount = String(renderContent || "").split("\n").length; return Array.from({ length: lineCount }, (_, index) => index + 1); - }, [content]); + }, [renderContent]); const syncEditorLineNumberHeights = useCallback(() => { if (!shouldUseHighlightedEditor || viewMode !== "edit") return; @@ -279,6 +286,8 @@ export const FileViewer = ({ if (!hasSelectedPath) { setContent(""); setInitialContent(""); + setFileKind("text"); + setImageDataUrl(""); setError(""); setIsFolderPath(false); viewScrollRatioRef.current = 0; @@ -287,6 +296,16 @@ export const FileViewer = ({ active = false; }; } + // Clear previous file state immediately so large content from the last + // file is never rendered/parses under the next file's syntax mode. + setContent(""); + setInitialContent(""); + setImageDataUrl(""); + setFileKind("text"); + setError(""); + setIsFolderPath(false); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; const loadFile = async () => { setLoading(true); @@ -295,6 +314,19 @@ export const FileViewer = ({ try { const data = await fetchFileContent(normalizedPath); if (!active) return; + const nextFileKind = data?.kind === "image" ? "image" : "text"; + setFileKind(nextFileKind); + if (nextFileKind === "image") { + setImageDataUrl(String(data?.imageDataUrl || "")); + setContent(""); + setInitialContent(""); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return; + } + setImageDataUrl(""); const nextContent = data.content || ""; const draftContent = readStoredFileDraft(normalizedPath); setContent(draftContent || nextContent); @@ -310,6 +342,8 @@ export const FileViewer = ({ restoredSelectionPathRef.current = ""; } catch (loadError) { if (!active) return; + setFileKind("text"); + setImageDataUrl(""); const message = loadError.message || "Could not load file"; if (/path is not a file/i.test(message)) { setContent(""); @@ -731,7 +765,8 @@ export const FileViewer = ({ /> `} ${!isDiffView - ? html` + ? !isImageFile + ? html` <${ActionButton} onClick=${handleSave} disabled=${loading || !isDirty || !canEditFile || isEditBlocked} @@ -745,6 +780,7 @@ export const FileViewer = ({ className="file-viewer-save-action" /> ` + : null : null}
    ${isDiffView @@ -859,6 +895,24 @@ ${formattedValue} ` + : isImageFile + ? html` +
    + ${imageDataUrl + ? html` + ${pathSegments[pathSegments.length + ` + : html` +
    + Could not render image preview. +
    + `} +
    + ` : isDiffView ? html`
    @@ -973,7 +1027,7 @@ ${(diffContent || "").split("\n").map((line, lineIndex) => { -
    -
    - ` - : html` -
    -
    - ${editorLineNumbers.map( - (lineNumber) => html` + > + ${lineNumber} +
    + `, + )} +
    +
    { - editorLineNumberRowRefs.current[ - lineNumber - 1 - ] = element; - }} + class="file-viewer-editor-highlight" + ref=${editorHighlightRef} > - ${lineNumber} -
    - `, - )} -
    - ${shouldUseHighlightedEditor - ? html` -
    -
    - ${highlightedEditorLines.map( - (line) => html` -
    { - editorHighlightLineRefs.current[ - line.lineNumber - 1 - ] = element; + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[ + line.lineNumber - 1 + ] = element; + }} + > + - -
    - `, - )} -
    - + > +
    + `, + )}
    - ` - : html` - `} -
    - `} - `} +
    +
    + ` + : html` +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html` +
    { + editorLineNumberRowRefs.current[ + lineNumber - 1 + ] = element; + }} + > + ${lineNumber} +
    + `, + )} +
    + ${shouldUseHighlightedEditor + ? html` +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[ + line.lineNumber - 1 + ] = element; + }} + > + +
    + `, + )} +
    + +
    + ` + : html` + + `} +
    + `} + `}
    `; }; diff --git a/lib/public/js/lib/browse-file-policies.js b/lib/public/js/lib/browse-file-policies.js new file mode 100644 index 00000000..d479f916 --- /dev/null +++ b/lib/public/js/lib/browse-file-policies.js @@ -0,0 +1,34 @@ +export const kProtectedBrowsePaths = new Set([ + "openclaw.json", + "devices/paired.json", +]); + +export const kLockedBrowsePaths = new Set([ + "hooks/bootstrap/agents.md", + "hooks/bootstrap/tools.md", + "skills/control-ui/skill.md", + ".alphaclaw/hourly-git-sync.sh", + ".alphaclaw/.cli-device-auto-approved", +]); + +export const normalizeBrowsePolicyPath = (inputPath) => + String(inputPath || "") + .replaceAll("\\", "/") + .replace(/^\.\/+/, "") + .replace(/^\/+/, "") + .trim() + .toLowerCase(); + +export const matchesBrowsePolicyPath = (policyPathSet, normalizedPath) => { + const safeNormalizedPath = String(normalizedPath || "").trim(); + if (!safeNormalizedPath) return false; + for (const policyPath of policyPathSet) { + if ( + safeNormalizedPath === policyPath || + safeNormalizedPath.endsWith(`/${policyPath}`) + ) { + return true; + } + } + return false; +}; diff --git a/lib/server/constants.js b/lib/server/constants.js index bf062ba0..23cef4e0 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -274,6 +274,13 @@ const kChannelDefs = { telegram: { envKey: "TELEGRAM_BOT_TOKEN" }, discord: { envKey: "DISCORD_BOT_TOKEN" }, }; +const kLockedBrowsePaths = new Set([ + "hooks/bootstrap/agents.md", + "hooks/bootstrap/tools.md", + "skills/control-ui/skill.md", + ".alphaclaw/hourly-git-sync.sh", + ".alphaclaw/.cli-device-auto-approved", +]); const SETUP_API_PREFIXES = [ "/api/status", @@ -343,6 +350,7 @@ module.exports = { kSystemVars, kKnownVars, kKnownKeys, + kLockedBrowsePaths, SCOPE_MAP, REVERSE_SCOPE_MAP, BASE_SCOPES, diff --git a/lib/server/routes/browse.js b/lib/server/routes/browse.js index f85d52bd..737c2f25 100644 --- a/lib/server/routes/browse.js +++ b/lib/server/routes/browse.js @@ -1,5 +1,6 @@ const path = require("path"); const { execFile } = require("child_process"); +const { kLockedBrowsePaths } = require("../constants"); const kDefaultTreeDepth = 10; const kIgnoredDirectoryNames = new Set([ @@ -10,12 +11,6 @@ const kIgnoredDirectoryNames = new Set([ "dist", "build", ]); -const kLockedBrowsePaths = new Set([ - "hooks/bootstrap/agents.md", - "hooks/bootstrap/tools.md", - ".alphaclaw/hourly-git-sync.sh", - ".alphaclaw/.cli-device-auto-approved", -]); const kImageMimeTypeByExtension = new Map([ [".png", "image/png"], [".jpg", "image/jpeg"], @@ -109,12 +104,16 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { }; const getImageMimeType = (targetPath) => { - const extension = String(path.extname(targetPath || "") || "").toLowerCase(); + const extension = String( + path.extname(targetPath || "") || "", + ).toLowerCase(); return kImageMimeTypeByExtension.get(extension) || ""; }; const getAudioMimeType = (targetPath) => { - const extension = String(path.extname(targetPath || "") || "").toLowerCase(); + const extension = String( + path.extname(targetPath || "") || "", + ).toLowerCase(); return kAudioMimeTypeByExtension.get(extension) || ""; }; @@ -139,7 +138,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } return entry.isDirectory() || entry.isFile(); }) - .map((entry) => buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1)) + .map((entry) => + buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1), + ) .sort((leftNode, rightNode) => { if (leftNode.type !== rightNode.type) { return leftNode.type === "folder" ? -1 : 1; @@ -160,7 +161,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { if (error) { return resolve({ ok: false, - error: String(stderr || stdout || error.message || "git command failed").trim(), + error: String( + stderr || stdout || error.message || "git command failed", + ).trim(), }); } return resolve({ ok: true, stdout: String(stdout || "") }); @@ -272,14 +275,20 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { app.get("/api/browse/tree", (req, res) => { const depthValue = Number.parseInt(String(req.query.depth || ""), 10); - const depth = Number.isFinite(depthValue) && depthValue > 0 ? depthValue : kDefaultTreeDepth; + const depth = + Number.isFinite(depthValue) && depthValue > 0 + ? depthValue + : kDefaultTreeDepth; try { const tree = buildTreeNode(kRootResolved, depth); return res.json({ ok: true, root: tree }); } catch (error) { return res .status(500) - .json({ ok: false, error: error.message || "Could not build file tree" }); + .json({ + ok: false, + error: error.message || "Could not build file tree", + }); } }); @@ -333,14 +342,22 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { content, }); } catch (error) { - return res.status(500).json({ ok: false, error: error.message || "Could not read file" }); + return res + .status(500) + .json({ ok: false, error: error.message || "Could not read file" }); } }); app.get("/api/browse/git-summary", async (req, res) => { try { - const envRepoSlug = parseGithubRepoSlug(process.env.GITHUB_WORKSPACE_REPO || ""); - const statusResult = await runGitCommand(["status", "--porcelain", "--branch"]); + const envRepoSlug = parseGithubRepoSlug( + process.env.GITHUB_WORKSPACE_REPO || "", + ); + const statusResult = await runGitCommand([ + "status", + "--porcelain", + "--branch", + ]); if (!statusResult.ok) { if (/not a git repository/i.test(statusResult.error || "")) { return res.json({ @@ -359,10 +376,15 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .split("\n") .map((line) => line.trimEnd()) .filter(Boolean); - const branchLine = statusLines.find((line) => line.startsWith("##")) || ""; + const branchLine = + statusLines.find((line) => line.startsWith("##")) || ""; const branchTracking = parseBranchTracking(branchLine); const branch = branchTracking.branch; - const diffNumstatResult = await runGitCommand(["diff", "--numstat", "HEAD"]); + const diffNumstatResult = await runGitCommand([ + "diff", + "--numstat", + "HEAD", + ]); const diffStatsByPath = new Map(); if (diffNumstatResult.ok) { diffNumstatResult.stdout @@ -370,7 +392,8 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .map((line) => line.trim()) .filter(Boolean) .forEach((line) => { - const [addedRaw = "", deletedRaw = "", rawPath = ""] = line.split("\t"); + const [addedRaw = "", deletedRaw = "", rawPath = ""] = + line.split("\t"); const normalizedPath = normalizeChangedPath(rawPath); if (!normalizedPath) return; const addedLines = Number.parseInt(addedRaw, 10); @@ -407,7 +430,11 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { let repoSlug = envRepoSlug; if (!repoSlug) { - const remoteResult = await runGitCommand(["remote", "get-url", "origin"]); + const remoteResult = await runGitCommand([ + "remote", + "get-url", + "origin", + ]); if (remoteResult.ok) { repoSlug = parseGithubRepoSlug(remoteResult.stdout || ""); } @@ -426,7 +453,8 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .map((line) => line.trim()) .filter(Boolean) .map((line) => { - const [hash = "", shortHash = "", message = "", unixTs = "0"] = line.split("\t"); + const [hash = "", shortHash = "", message = "", unixTs = "0"] = + line.split("\t"); return { hash, shortHash, @@ -480,8 +508,13 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { "--", relativePath, ]); - if (!statusResult.ok && /not a git repository/i.test(statusResult.stderr || "")) { - return res.status(400).json({ ok: false, error: "No git repo at this root" }); + if ( + !statusResult.ok && + /not a git repository/i.test(statusResult.stderr || "") + ) { + return res + .status(400) + .json({ ok: false, error: "No git repo at this root" }); } const statusLines = statusResult.stdout .split("\n") @@ -504,7 +537,8 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { if (!diffResult.ok && !untrackedAllowedFailure) { return res.status(500).json({ ok: false, - error: diffResult.stderr || diffResult.error || "Could not load file diff", + error: + diffResult.stderr || diffResult.error || "Could not load file diff", }); } @@ -519,7 +553,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } catch (error) { return res .status(500) - .json({ ok: false, error: error.message || "Could not load file diff" }); + .json({ + ok: false, + error: error.message || "Could not load file diff", + }); } }); @@ -527,7 +564,11 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { try { const commitMessageRaw = String(req.body?.message || "").trim(); const commitMessage = commitMessageRaw || "sync changes"; - const statusResult = await runGitCommand(["status", "--porcelain", "--branch"]); + const statusResult = await runGitCommand([ + "status", + "--porcelain", + "--branch", + ]); if (!statusResult.ok) { if (/not a git repository/i.test(statusResult.error || "")) { return res @@ -543,17 +584,20 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .split("\n") .map((line) => line.trimEnd()) .filter(Boolean); - const branchLine = statusLines.find((line) => line.startsWith("##")) || ""; + const branchLine = + statusLines.find((line) => line.startsWith("##")) || ""; const branchTracking = parseBranchTracking(branchLine); - const hasChanges = statusLines - .filter((line) => !line.startsWith("##")) - .map((line) => line.trim()) - .filter(Boolean).length > 0; + const hasChanges = + statusLines + .filter((line) => !line.startsWith("##")) + .map((line) => line.trim()) + .filter(Boolean).length > 0; let committed = false; let pushed = false; let shortHash = ""; if (!hasChanges) { - const hasAheadCommits = branchTracking.hasUpstream && branchTracking.aheadCount > 0; + const hasAheadCommits = + branchTracking.hasUpstream && branchTracking.aheadCount > 0; if (!hasAheadCommits) { return res.json({ ok: true, @@ -571,7 +615,11 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { error: addResult.error || "Could not stage changes", }); } - const commitResult = await runGitCommand(["commit", "-m", commitMessage]); + const commitResult = await runGitCommand([ + "commit", + "-m", + commitMessage, + ]); if (!commitResult.ok) { if (/nothing to commit/i.test(commitResult.error || "")) { return res.json({ @@ -587,14 +635,22 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { }); } committed = true; - const shortHashResult = await runGitCommand(["rev-parse", "--short", "HEAD"]); - shortHash = shortHashResult.ok ? String(shortHashResult.stdout || "").trim() : ""; + const shortHashResult = await runGitCommand([ + "rev-parse", + "--short", + "HEAD", + ]); + shortHash = shortHashResult.ok + ? String(shortHashResult.stdout || "").trim() + : ""; } const shouldPush = branchTracking.hasUpstream ? branchTracking.aheadCount > 0 || committed : committed; if (shouldPush) { - const pushArgs = branchTracking.hasUpstream ? ["push"] : ["push", "-u", "origin", "HEAD"]; + const pushArgs = branchTracking.hasUpstream + ? ["push"] + : ["push", "-u", "origin", "HEAD"]; const pushResult = await runGitCommand(pushArgs); if (pushResult.ok) { pushed = true; @@ -643,11 +699,13 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) { return res.status(403).json({ ok: false, - error: "This file is managed by Alpha Claw and cannot be edited.", + error: "This file is managed by AlphaClaw and cannot be edited.", }); } if (typeof content !== "string") { - return res.status(400).json({ ok: false, error: "content must be a string" }); + return res + .status(400) + .json({ ok: false, error: "content must be a string" }); } try { @@ -661,7 +719,9 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { path: resolvedPath.relativePath, }); } catch (error) { - return res.status(500).json({ ok: false, error: error.message || "Could not save file" }); + return res + .status(500) + .json({ ok: false, error: error.message || "Could not save file" }); } }); }; diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index d9ae5014..54f43ff3 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -6,7 +6,8 @@ const request = require("supertest"); const { registerBrowseRoutes } = require("../../lib/server/routes/browse"); -const createTestRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-browse-test-")); +const createTestRoot = () => + fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-browse-test-")); const createApp = (kRootDir) => { const app = express(); @@ -20,9 +21,21 @@ describe("server/routes/browse", () => { const rootDir = createTestRoot(); fs.mkdirSync(path.join(rootDir, "devices"), { recursive: true }); fs.mkdirSync(path.join(rootDir, ".alphaclaw"), { recursive: true }); - fs.writeFileSync(path.join(rootDir, "openclaw.json"), '{"ok":true}\n', "utf8"); - fs.writeFileSync(path.join(rootDir, "devices", "paired.json"), "[]\n", "utf8"); - fs.writeFileSync(path.join(rootDir, ".alphaclaw", "hourly-git-sync.sh"), "#!/bin/bash\n", "utf8"); + fs.writeFileSync( + path.join(rootDir, "openclaw.json"), + '{"ok":true}\n', + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "devices", "paired.json"), + "[]\n", + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, ".alphaclaw", "hourly-git-sync.sh"), + "#!/bin/bash\n", + "utf8", + ); const app = createApp(rootDir); const res = await request(app).get("/api/browse/tree"); @@ -38,12 +51,22 @@ describe("server/routes/browse", () => { ); expect(res.body.root.children).toEqual( expect.arrayContaining([ - expect.objectContaining({ type: "folder", path: "devices", name: "devices" }), - expect.objectContaining({ type: "file", path: "openclaw.json", name: "openclaw.json" }), + expect.objectContaining({ + type: "folder", + path: "devices", + name: "devices", + }), + expect.objectContaining({ + type: "file", + path: "openclaw.json", + name: "openclaw.json", + }), ]), ); expect( - (res.body.root.children || []).some((entry) => entry?.name === ".alphaclaw"), + (res.body.root.children || []).some( + (entry) => entry?.name === ".alphaclaw", + ), ).toBe(false); }); @@ -104,7 +127,9 @@ describe("server/routes/browse", () => { expect(res.body.ok).toBe(true); expect(res.body.kind).toBe("audio"); expect(res.body.mimeType).toBe("audio/mpeg"); - expect(String(res.body.audioDataUrl || "")).toContain("data:audio/mpeg;base64,"); + expect(String(res.body.audioDataUrl || "")).toContain( + "data:audio/mpeg;base64,", + ); expect(res.body.content).toBe(""); }); @@ -140,14 +165,20 @@ describe("server/routes/browse", () => { expect(res.status).toBe(403); expect(res.body).toEqual({ ok: false, - error: "This file is managed by Alpha Claw and cannot be edited.", + error: "This file is managed by AlphaClaw and cannot be edited.", }); expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); }); it("rejects writes to locked bootstrap files with workspace prefix", async () => { const rootDir = createTestRoot(); - const lockedPath = path.join(rootDir, "workspace", "hooks", "bootstrap", "AGENTS.md"); + const lockedPath = path.join( + rootDir, + "workspace", + "hooks", + "bootstrap", + "AGENTS.md", + ); fs.mkdirSync(path.dirname(lockedPath), { recursive: true }); fs.writeFileSync(lockedPath, "before\n", "utf8"); const app = createApp(rootDir); @@ -160,7 +191,7 @@ describe("server/routes/browse", () => { expect(res.status).toBe(403); expect(res.body).toEqual({ ok: false, - error: "This file is managed by Alpha Claw and cannot be edited.", + error: "This file is managed by AlphaClaw and cannot be edited.", }); expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); }); @@ -180,7 +211,7 @@ describe("server/routes/browse", () => { expect(res.status).toBe(403); expect(res.body).toEqual({ ok: false, - error: "This file is managed by Alpha Claw and cannot be edited.", + error: "This file is managed by AlphaClaw and cannot be edited.", }); expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); }); From 458a8d60fce4c60f645b8bcb3ef37e2e9175a06a Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 23:26:55 -0800 Subject: [PATCH 056/417] Add interactive SQLite table browsing and fix proxy wildcard route parsing. Introduce a read-only SQLite table endpoint with paged row viewing in the file browser, polish the SQLite/table list UI, add the Telegram workspace row label, and update proxy route patterns to avoid path-to-regexp wildcard startup errors. --- lib/public/css/explorer.css | 144 ++++++++++++++ lib/public/js/components/channels.js | 1 + lib/public/js/components/file-viewer.js | 252 +++++++++++++++++++++++- lib/public/js/lib/api.js | 16 ++ lib/server/routes/browse.js | 194 ++++++++++++++++++ lib/server/routes/proxy.js | 16 +- tests/server/routes-browse.test.js | 75 +++++++ 7 files changed, 691 insertions(+), 7 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 7ce8ad62..8db56dfa 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -970,6 +970,150 @@ width: min(640px, 100%); } +.file-viewer-sqlite-shell { + flex: 1 1 auto; + min-height: 0; + height: 100%; + overflow: auto; + padding: 14px 16px 22px; +} + +.file-viewer-sqlite-header { + font-size: 12px; + color: var(--text-dim); + margin-bottom: 10px; +} + +.file-viewer-sqlite-footer { + margin-top: 10px; + text-align: center; + font-size: 12px; + color: var(--text-dim); +} + +.file-viewer-sqlite-list { + flex: 0 0 240px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.file-viewer-sqlite-layout { + display: flex; + gap: 12px; + align-items: stretch; + min-height: 0; +} + +.file-viewer-sqlite-card { + width: 100%; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 8px; + padding: 7px 10px; + background: rgba(255, 255, 255, 0.02); + font: inherit; +} + +.file-viewer-sqlite-card:hover { + background: rgba(255, 255, 255, 0.04); +} + +.file-viewer-sqlite-card.is-active { + border-color: rgba(99, 235, 255, 0.45); + background: rgba(99, 235, 255, 0.08); +} + +.file-viewer-sqlite-title { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; + color: var(--text); + margin-bottom: 0; +} + +.file-viewer-sqlite-type { + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-dim); +} + +.file-viewer-sqlite-table-shell { + flex: 1; + min-width: 0; + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + background: rgba(255, 255, 255, 0.015); + display: flex; + flex-direction: column; + min-height: 0; +} + +.file-viewer-sqlite-table-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.file-viewer-sqlite-table-name { + font-size: 12px; + color: var(--text); + font-weight: 600; +} + +.file-viewer-sqlite-table-nav { + display: inline-flex; + gap: 6px; +} + +.file-viewer-sqlite-table-meta { + margin-top: 4px; + margin-bottom: 8px; + font-size: 11px; + color: var(--text-dim); +} + +.file-viewer-sqlite-table-wrap { + min-height: 0; + overflow: auto; +} + +.file-viewer-sqlite-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.file-viewer-sqlite-table th, +.file-viewer-sqlite-table td { + border: 1px solid var(--border); + padding: 6px 8px; + font-size: 11px; + color: var(--text-muted); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-viewer-sqlite-table th { + color: var(--text); + background: rgba(255, 255, 255, 0.04); +} + +.file-viewer-sqlite-table-empty { + color: var(--text-dim); +} + .file-viewer-state-error { color: #f87171; } diff --git a/lib/public/js/components/channels.js b/lib/public/js/components/channels.js index d7bfe8bd..303a4f0a 100644 --- a/lib/public/js/components/channels.js +++ b/lib/public/js/components/channels.js @@ -43,6 +43,7 @@ export function Channels({ channels, onSwitchTab, onNavigate }) { : null} ${channelMeta.label} ${isClickable && html` + Workspace String(inputPath || "") @@ -125,6 +127,12 @@ export const FileViewer = ({ const [fileKind, setFileKind] = useState("text"); const [imageDataUrl, setImageDataUrl] = useState(""); const [audioDataUrl, setAudioDataUrl] = useState(""); + const [sqliteSummary, setSqliteSummary] = useState(null); + const [sqliteSelectedTable, setSqliteSelectedTable] = useState(""); + const [sqliteTableOffset, setSqliteTableOffset] = useState(0); + const [sqliteTableLoading, setSqliteTableLoading] = useState(false); + const [sqliteTableError, setSqliteTableError] = useState(""); + const [sqliteTableData, setSqliteTableData] = useState(null); const [viewMode, setViewMode] = useState(readStoredFileViewerMode); const [loading, setLoading] = useState(false); const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = @@ -163,12 +171,14 @@ export const FileViewer = ({ const hasSelectedPath = normalizedPath.length > 0; const isImageFile = fileKind === "image"; const isAudioFile = fileKind === "audio"; + const isSqliteFile = fileKind === "sqlite"; const canEditFile = hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && - !isAudioFile; + !isAudioFile && + !isSqliteFile; const isDiffView = String(browseView || "edit") === "diff"; const isDirty = canEditFile && renderContent !== renderInitialContent; const isLockedFile = @@ -273,6 +283,12 @@ export const FileViewer = ({ setFileKind("text"); setImageDataUrl(""); setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); setError(""); setIsFolderPath(false); viewScrollRatioRef.current = 0; @@ -287,6 +303,12 @@ export const FileViewer = ({ setInitialContent(""); setImageDataUrl(""); setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); setFileKind("text"); setError(""); setIsFolderPath(false); @@ -305,11 +327,19 @@ export const FileViewer = ({ ? "image" : data?.kind === "audio" ? "audio" + : data?.kind === "sqlite" + ? "sqlite" : "text"; setFileKind(nextFileKind); if (nextFileKind === "image") { setImageDataUrl(String(data?.imageDataUrl || "")); setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); setContent(""); setInitialContent(""); setExternalChangeNoticeShown(false); @@ -321,6 +351,35 @@ export const FileViewer = ({ if (nextFileKind === "audio") { setAudioDataUrl(String(data?.audioDataUrl || "")); setImageDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setContent(""); + setInitialContent(""); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return; + } + if (nextFileKind === "sqlite") { + const nextSqliteSummary = data?.sqliteSummary || null; + const nextObjects = nextSqliteSummary?.objects || []; + const defaultTable = + nextObjects.find((entry) => entry?.type === "table")?.name || + nextObjects[0]?.name || + ""; + setSqliteSummary(nextSqliteSummary); + setSqliteSelectedTable(defaultTable); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setImageDataUrl(""); + setAudioDataUrl(""); setContent(""); setInitialContent(""); setExternalChangeNoticeShown(false); @@ -331,6 +390,12 @@ export const FileViewer = ({ } setImageDataUrl(""); setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); const nextContent = data.content || ""; const draftContent = readStoredFileDraft(normalizedPath); setContent(draftContent || nextContent); @@ -349,6 +414,12 @@ export const FileViewer = ({ setFileKind("text"); setImageDataUrl(""); setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); const message = loadError.message || "Could not load file"; if (/path is not a file/i.test(message)) { setContent(""); @@ -370,6 +441,39 @@ export const FileViewer = ({ }; }, [hasSelectedPath, normalizedPath]); + useEffect(() => { + if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) { + setSqliteTableData(null); + setSqliteTableError(""); + setSqliteTableLoading(false); + return () => {}; + } + let active = true; + const loadSqliteTable = async () => { + setSqliteTableLoading(true); + setSqliteTableError(""); + try { + const tableData = await fetchBrowseSqliteTable({ + filePath: normalizedPath, + table: sqliteSelectedTable, + limit: kSqlitePageSize, + offset: sqliteTableOffset, + }); + if (!active) return; + setSqliteTableData(tableData); + } catch (nextError) { + if (!active) return; + setSqliteTableError(nextError.message || "Could not load sqlite table"); + } finally { + if (active) setSqliteTableLoading(false); + } + }; + loadSqliteTable(); + return () => { + active = false; + }; + }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]); + useEffect(() => { if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {}; const refreshFromDisk = async () => { @@ -713,6 +817,17 @@ export const FileViewer = ({ }); }; + const sqliteRows = Array.isArray(sqliteTableData?.rows) ? sqliteTableData.rows : []; + const sqliteColumns = + Array.isArray(sqliteTableData?.columns) && sqliteTableData.columns.length + ? sqliteTableData.columns + : (sqliteSummary?.objects || []).find( + (entry) => entry?.name === sqliteSelectedTable, + )?.columns || []; + const sqliteTotalRows = Number(sqliteTableData?.totalRows || 0); + const sqliteCanGoPrev = sqliteTableOffset > 0; + const sqliteCanGoNext = sqliteTableOffset + kSqlitePageSize < sqliteTotalRows; + if (!hasSelectedPath) { return html`
    @@ -771,7 +886,7 @@ export const FileViewer = ({ /> `} ${!isDiffView - ? !isImageFile && !isAudioFile + ? !isImageFile && !isAudioFile && !isSqliteFile ? html` <${ActionButton} onClick=${handleSave} @@ -944,6 +1059,139 @@ ${formattedValue} ` + : isSqliteFile + ? html` +
    + ${sqliteSummary?.objects?.length + ? html` +
    +
    + ${sqliteSummary.objects.map((entry) => html` + + `)} +
    +
    + ${sqliteSelectedTable + ? html` +
    + ${sqliteSelectedTable} +
    + + +
    +
    +
    + ${sqliteTotalRows + ? `${Math.min(sqliteTableOffset + 1, sqliteTotalRows)}-${Math.min(sqliteTableOffset + kSqlitePageSize, sqliteTotalRows)} of ${sqliteTotalRows} rows` + : "No rows"} +
    + ${sqliteTableLoading + ? html` +
    + <${LoadingSpinner} className="h-4 w-4" /> +
    + ` + : sqliteTableError + ? html` +
    + ${sqliteTableError} +
    + ` + : html` +
    + + + + ${sqliteColumns.map((column) => html` + + `)} + + + + ${sqliteRows.length + ? sqliteRows.map((row, rowIndex) => html` + + ${sqliteColumns.map((column) => { + const cellValue = row?.[column.name]; + const displayValue = + cellValue === null + ? "NULL" + : typeof cellValue === "object" + ? JSON.stringify(cellValue) + : String(cellValue ?? ""); + return html` + + `; + })} + + `) + : html` + + + + `} + +
    ${column.name}
    + ${displayValue} +
    + No rows +
    +
    + `} + ` + : html` +
    + Select a table to view rows. +
    + `} +
    +
    + + ` + : html` +
    + SQLite database loaded, but no tables/views were found. +
    + `} +
    + ` : isDiffView ? html`
    diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 62308759..7ce5312b 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -400,6 +400,22 @@ export const fetchBrowseFileDiff = async (filePath) => { return parseJsonOrThrow(res, 'Could not load file diff'); }; +export const fetchBrowseSqliteTable = async ({ + filePath, + table, + limit = 50, + offset = 0, +}) => { + const params = new URLSearchParams({ + path: String(filePath || ""), + table: String(table || ""), + limit: String(limit), + offset: String(offset), + }); + const res = await authFetch(`/api/browse/sqlite-table?${params.toString()}`); + return parseJsonOrThrow(res, "Could not load sqlite table data"); +}; + export const syncBrowseChanges = async (message = "") => { const res = await authFetch('/api/browse/git-sync', { method: 'POST', diff --git a/lib/server/routes/browse.js b/lib/server/routes/browse.js index 737c2f25..7fbfd5d8 100644 --- a/lib/server/routes/browse.js +++ b/lib/server/routes/browse.js @@ -34,6 +34,15 @@ const kAudioMimeTypeByExtension = new Map([ [".opus", "audio/opus"], [".weba", "audio/webm"], ]); +const kSqliteFileExtensions = new Set([ + ".sqlite", + ".sqlite3", + ".db", + ".db3", + ".sdb", + ".sqlitedb", +]); +const kSqliteTablePageSize = 50; const matchesPolicyPath = (policyPathSet, normalizedPath) => { const safeNormalizedPath = String(normalizedPath || "").trim(); @@ -117,6 +126,146 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { return kAudioMimeTypeByExtension.get(extension) || ""; }; + const isSqliteFilePath = (targetPath) => { + const extension = String( + path.extname(targetPath || "") || "", + ).toLowerCase(); + return kSqliteFileExtensions.has(extension); + }; + + const quoteSqliteIdentifier = (value) => + `"${String(value || "").replaceAll('"', '""')}"`; + + const readSqliteSummary = (targetPath) => { + let DatabaseSync = null; + try { + ({ DatabaseSync } = require("node:sqlite")); + } catch { + throw new Error("SQLite preview is unavailable on this Node runtime"); + } + const database = new DatabaseSync(targetPath, { readOnly: true }); + try { + const allObjects = database + .prepare( + ` + SELECT name, type + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + `, + ) + .all(); + const maxObjects = 12; + const objects = allObjects.slice(0, maxObjects).map((entry) => { + const objectName = String(entry?.name || "").trim(); + const objectType = String(entry?.type || "table").trim() || "table"; + if (!objectName) return null; + const quotedName = quoteSqliteIdentifier(objectName); + const columns = database + .prepare(`PRAGMA table_info(${quotedName})`) + .all() + .map((column) => ({ + name: String(column?.name || "").trim(), + type: String(column?.type || "").trim(), + notNull: Number(column?.notnull || 0) === 1, + isPrimaryKey: Number(column?.pk || 0) > 0, + })); + let sampleRows = []; + if (objectType === "table") { + try { + sampleRows = database + .prepare(`SELECT * FROM ${quotedName} LIMIT 5`) + .all(); + } catch { + sampleRows = []; + } + } + return { + name: objectName, + type: objectType, + columns, + sampleRows, + }; + }); + return { + totalObjects: allObjects.length, + truncated: allObjects.length > maxObjects, + objects: objects.filter(Boolean), + }; + } finally { + database.close(); + } + }; + + const clampSqlitePageValue = (value, fallbackValue, maxValue) => { + const parsedValue = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsedValue)) return fallbackValue; + return Math.max(0, Math.min(maxValue, parsedValue)); + }; + + const readSqliteTableData = (targetPath, tableName, limit, offset) => { + const safeTableName = String(tableName || "").trim(); + if (!safeTableName) { + return { ok: false, error: "table is required" }; + } + let DatabaseSync = null; + try { + ({ DatabaseSync } = require("node:sqlite")); + } catch { + return { ok: false, error: "SQLite preview is unavailable on this Node runtime" }; + } + const database = new DatabaseSync(targetPath, { readOnly: true }); + try { + const quotedTableName = quoteSqliteIdentifier(safeTableName); + const tableExists = database + .prepare( + ` + SELECT 1 + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name = ? + LIMIT 1 + `, + ) + .get(safeTableName); + if (!tableExists) { + return { ok: false, error: "table not found" }; + } + const columns = database + .prepare(`PRAGMA table_info(${quotedTableName})`) + .all() + .map((column) => ({ + name: String(column?.name || "").trim(), + type: String(column?.type || "").trim(), + notNull: Number(column?.notnull || 0) === 1, + isPrimaryKey: Number(column?.pk || 0) > 0, + })); + const totalRowsResult = database + .prepare(`SELECT COUNT(*) AS count FROM ${quotedTableName}`) + .get(); + const totalRows = Number(totalRowsResult?.count || 0); + const safeLimit = clampSqlitePageValue(limit, kSqliteTablePageSize, 200) || kSqliteTablePageSize; + const safeOffset = clampSqlitePageValue(offset, 0, Number.MAX_SAFE_INTEGER); + const rows = database + .prepare(`SELECT * FROM ${quotedTableName} LIMIT ? OFFSET ?`) + .all(safeLimit, safeOffset); + return { + ok: true, + table: safeTableName, + columns, + rows, + limit: safeLimit, + offset: safeOffset, + totalRows, + }; + } catch (error) { + return { ok: false, error: error.message || "Could not read sqlite table" }; + } finally { + database.close(); + } + }; + const buildTreeNode = (absolutePath, depthRemaining) => { const stats = fs.statSync(absolutePath); const nodeName = path.basename(absolutePath); @@ -303,6 +452,16 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { if (!stats.isFile()) { return res.status(400).json({ ok: false, error: "Path is not a file" }); } + if (isSqliteFilePath(resolvedPath.absolutePath)) { + const sqliteSummary = readSqliteSummary(resolvedPath.absolutePath); + return res.json({ + ok: true, + path: resolvedPath.relativePath, + kind: "sqlite", + sqliteSummary, + content: "", + }); + } const audioMimeType = getAudioMimeType(resolvedPath.absolutePath); if (audioMimeType) { const audioBytes = fs.readFileSync(resolvedPath.absolutePath); @@ -491,6 +650,41 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } }); + app.get("/api/browse/sqlite-table", (req, res) => { + const resolvedPath = resolveSafePath(req.query.path); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + if (!isSqliteFilePath(resolvedPath.absolutePath)) { + return res.status(400).json({ ok: false, error: "Path is not a sqlite file" }); + } + const tableName = String(req.query.table || "").trim(); + const limit = req.query.limit; + const offset = req.query.offset; + const sqliteResult = readSqliteTableData( + resolvedPath.absolutePath, + tableName, + limit, + offset, + ); + if (!sqliteResult.ok) { + return res.status(400).json({ + ok: false, + error: sqliteResult.error || "Could not read sqlite table", + }); + } + return res.json({ + ok: true, + path: resolvedPath.relativePath, + table: sqliteResult.table, + columns: sqliteResult.columns, + rows: sqliteResult.rows, + limit: sqliteResult.limit, + offset: sqliteResult.offset, + totalRows: sqliteResult.totalRows, + }); + }); + app.get("/api/browse/git-diff", async (req, res) => { const resolvedPath = resolveSafePath(req.query.path); if (!resolvedPath.ok) { diff --git a/lib/server/routes/proxy.js b/lib/server/routes/proxy.js index e367daed..f0061b9a 100644 --- a/lib/server/routes/proxy.js +++ b/lib/server/routes/proxy.js @@ -5,20 +5,26 @@ const registerProxyRoutes = ({ requireAuth, webhookMiddleware, }) => { + const kOpenClawPathPattern = /^\/openclaw\/.+/; + const kAssetsPathPattern = /^\/assets\/.+/; + const kHooksPathPattern = /^\/hooks\/.+/; + const kWebhookPathPattern = /^\/webhook\/.+/; + const kApiPathPattern = /^\/api\/.+/; + app.all("/openclaw", requireAuth, (req, res) => { req.url = "/"; proxy.web(req, res); }); - app.all("/openclaw/*", requireAuth, (req, res) => { + app.all(kOpenClawPathPattern, requireAuth, (req, res) => { req.url = req.url.replace(/^\/openclaw/, ""); proxy.web(req, res); }); - app.all("/assets/*", requireAuth, (req, res) => proxy.web(req, res)); + app.all(kAssetsPathPattern, requireAuth, (req, res) => proxy.web(req, res)); - app.all("/hooks/*", webhookMiddleware); - app.all("/webhook/*", webhookMiddleware); + app.all(kHooksPathPattern, webhookMiddleware); + app.all(kWebhookPathPattern, webhookMiddleware); - app.all("/api/*", (req, res) => { + app.all(kApiPathPattern, (req, res) => { if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return; proxy.web(req, res); }); diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index 54f43ff3..75c98541 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -133,6 +133,81 @@ describe("server/routes/browse", () => { expect(res.body.content).toBe(""); }); + it("returns sqlite schema previews for sqlite files", async () => { + let DatabaseSync = null; + try { + ({ DatabaseSync } = require("node:sqlite")); + } catch { + // Runtime does not support node:sqlite. + return; + } + const rootDir = createTestRoot(); + const dbPath = path.join(rootDir, "test.sqlite"); + const database = new DatabaseSync(dbPath); + database.exec( + ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + INSERT INTO users (name) VALUES ('Ada'); + `, + ); + database.close(); + const app = createApp(rootDir); + + const res = await request(app) + .get("/api/browse/read") + .query({ path: "test.sqlite" }); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.kind).toBe("sqlite"); + expect(res.body.sqliteSummary).toBeTruthy(); + expect(Array.isArray(res.body.sqliteSummary.objects)).toBe(true); + expect( + res.body.sqliteSummary.objects.some((entry) => entry?.name === "users"), + ).toBe(true); + expect(res.body.content).toBe(""); + }); + + it("returns sqlite table rows for selected table", async () => { + let DatabaseSync = null; + try { + ({ DatabaseSync } = require("node:sqlite")); + } catch { + return; + } + const rootDir = createTestRoot(); + const dbPath = path.join(rootDir, "rows.sqlite"); + const database = new DatabaseSync(dbPath); + database.exec( + ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + INSERT INTO users (name) VALUES ('Ada'), ('Grace'); + `, + ); + database.close(); + const app = createApp(rootDir); + + const res = await request(app) + .get("/api/browse/sqlite-table") + .query({ path: "rows.sqlite", table: "users", limit: "1", offset: "1" }); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.table).toBe("users"); + expect(Array.isArray(res.body.columns)).toBe(true); + expect(Array.isArray(res.body.rows)).toBe(true); + expect(res.body.rows.length).toBe(1); + expect(res.body.totalRows).toBe(2); + expect(res.body.limit).toBe(1); + expect(res.body.offset).toBe(1); + }); + it("writes file content and returns write result", async () => { const rootDir = createTestRoot(); const filePath = path.join(rootDir, "openclaw.json"); From 81e5ae4113f74704913f55877a8766547bd77972 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 23:38:32 -0800 Subject: [PATCH 057/417] Refactor browse routes and file viewer into modular directories. This keeps behavior unchanged while making the largest files easier to maintain and extend with focused helpers, hooks, and subcomponents. --- lib/public/js/app.js | 2 +- lib/public/js/components/file-viewer.js | 1432 ----------------- .../js/components/file-viewer/constants.js | 6 + lib/public/js/components/file-viewer/index.js | 837 ++++++++++ .../js/components/file-viewer/scroll-sync.js | 95 ++ .../components/file-viewer/sqlite-viewer.js | 167 ++ .../js/components/file-viewer/storage.js | 58 + .../components/file-viewer/use-file-loader.js | 302 ++++ lib/public/js/components/file-viewer/utils.js | 11 + lib/server/routes/browse/constants.js | 51 + lib/server/routes/browse/file-helpers.js | 43 + lib/server/routes/browse/git.js | 131 ++ .../routes/{browse.js => browse/index.js} | 577 ++----- lib/server/routes/browse/path-utils.js | 53 + lib/server/routes/browse/sqlite.js | 140 ++ 15 files changed, 2008 insertions(+), 1897 deletions(-) delete mode 100644 lib/public/js/components/file-viewer.js create mode 100644 lib/public/js/components/file-viewer/constants.js create mode 100644 lib/public/js/components/file-viewer/index.js create mode 100644 lib/public/js/components/file-viewer/scroll-sync.js create mode 100644 lib/public/js/components/file-viewer/sqlite-viewer.js create mode 100644 lib/public/js/components/file-viewer/storage.js create mode 100644 lib/public/js/components/file-viewer/use-file-loader.js create mode 100644 lib/public/js/components/file-viewer/utils.js create mode 100644 lib/server/routes/browse/constants.js create mode 100644 lib/server/routes/browse/file-helpers.js create mode 100644 lib/server/routes/browse/git.js rename lib/server/routes/{browse.js => browse/index.js} (50%) create mode 100644 lib/server/routes/browse/path-utils.js create mode 100644 lib/server/routes/browse/sqlite.js diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 7ffa7da3..c061cea5 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -40,7 +40,7 @@ import { UpdateActionButton } from "./components/update-action-button.js"; import { GlobalRestartBanner } from "./components/global-restart-banner.js"; import { LoadingSpinner } from "./components/loading-spinner.js"; import { WatchdogTab } from "./components/watchdog-tab.js"; -import { FileViewer } from "./components/file-viewer.js"; +import { FileViewer } from "./components/file-viewer/index.js"; import { AppSidebar } from "./components/sidebar.js"; import { UsageTab } from "./components/usage-tab.js"; import { readUiSettings, writeUiSettings } from "./lib/ui-settings.js"; diff --git a/lib/public/js/components/file-viewer.js b/lib/public/js/components/file-viewer.js deleted file mode 100644 index 499160e2..00000000 --- a/lib/public/js/components/file-viewer.js +++ /dev/null @@ -1,1432 +0,0 @@ -import { h } from "https://esm.sh/preact"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; -import { marked } from "https://esm.sh/marked"; -import { - fetchBrowseFileDiff, - fetchBrowseSqliteTable, - fetchFileContent, - saveFileContent, -} from "../lib/api.js"; -import { - formatFrontmatterValue, - getFileSyntaxKind, - highlightEditorLines, - parseFrontmatter, -} from "../lib/syntax-highlighters/index.js"; -import { - clearStoredFileDraft, - readStoredFileDraft, - updateDraftIndex, - writeStoredFileDraft, -} from "../lib/browse-draft-state.js"; -import { - kLockedBrowsePaths, - kProtectedBrowsePaths, - matchesBrowsePolicyPath, - normalizeBrowsePolicyPath, -} from "../lib/browse-file-policies.js"; -import { ActionButton } from "./action-button.js"; -import { LoadingSpinner } from "./loading-spinner.js"; -import { SegmentedControl } from "./segmented-control.js"; -import { LockLineIcon, SaveFillIcon } from "./icons.js"; -import { showToast } from "./toast.js"; - -const html = htm.bind(h); -const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode"; -const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode"; -const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath"; -const kLoadingIndicatorDelayMs = 1000; -const kFileRefreshIntervalMs = 5000; -const kSqlitePageSize = 50; - -const parsePathSegments = (inputPath) => - String(inputPath || "") - .split("/") - .map((part) => part.trim()) - .filter(Boolean); - -const readStoredFileViewerMode = () => { - try { - const storedMode = String( - window.localStorage.getItem(kFileViewerModeStorageKey) || - window.localStorage.getItem(kLegacyFileViewerModeStorageKey) || - "", - ).trim(); - return storedMode === "preview" ? "preview" : "edit"; - } catch { - return "edit"; - } -}; - -const clampSelectionIndex = (value, maxValue) => { - const numericValue = Number.parseInt(String(value ?? ""), 10); - if (!Number.isFinite(numericValue)) return 0; - return Math.max(0, Math.min(maxValue, numericValue)); -}; - -const readEditorSelectionStorageMap = () => { - try { - const rawStorageValue = window.localStorage.getItem( - kEditorSelectionStorageKey, - ); - if (!rawStorageValue) return {}; - const parsedStorageValue = JSON.parse(rawStorageValue); - if (!parsedStorageValue || typeof parsedStorageValue !== "object") - return {}; - return parsedStorageValue; - } catch { - return {}; - } -}; - -const readStoredEditorSelection = (filePath) => { - const safePath = String(filePath || "").trim(); - if (!safePath) return null; - const storageMap = readEditorSelectionStorageMap(); - const selection = storageMap[safePath]; - if (!selection || typeof selection !== "object") return null; - return { - start: selection.start, - end: selection.end, - }; -}; - -const writeStoredEditorSelection = (filePath, selection) => { - const safePath = String(filePath || "").trim(); - if (!safePath || !selection || typeof selection !== "object") return; - try { - const nextStorageValue = readEditorSelectionStorageMap(); - nextStorageValue[safePath] = { - start: selection.start, - end: selection.end, - }; - window.localStorage.setItem( - kEditorSelectionStorageKey, - JSON.stringify(nextStorageValue), - ); - } catch {} -}; - -export const FileViewer = ({ - filePath = "", - isPreviewOnly = false, - browseView = "edit", - onRequestEdit = () => {}, -}) => { - const normalizedPath = String(filePath || "").trim(); - const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath); - const [content, setContent] = useState(""); - const [initialContent, setInitialContent] = useState(""); - const [fileKind, setFileKind] = useState("text"); - const [imageDataUrl, setImageDataUrl] = useState(""); - const [audioDataUrl, setAudioDataUrl] = useState(""); - const [sqliteSummary, setSqliteSummary] = useState(null); - const [sqliteSelectedTable, setSqliteSelectedTable] = useState(""); - const [sqliteTableOffset, setSqliteTableOffset] = useState(0); - const [sqliteTableLoading, setSqliteTableLoading] = useState(false); - const [sqliteTableError, setSqliteTableError] = useState(""); - const [sqliteTableData, setSqliteTableData] = useState(null); - const [viewMode, setViewMode] = useState(readStoredFileViewerMode); - const [loading, setLoading] = useState(false); - const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = - useState(false); - const [diffLoading, setDiffLoading] = useState(false); - const [diffError, setDiffError] = useState(""); - const [diffContent, setDiffContent] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); - const [isFolderPath, setIsFolderPath] = useState(false); - const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); - const [externalChangeNoticeShown, setExternalChangeNoticeShown] = - useState(false); - const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState( - () => new Set(), - ); - const editorLineNumbersRef = useRef(null); - const editorHighlightRef = useRef(null); - const editorTextareaRef = useRef(null); - const previewRef = useRef(null); - const viewScrollRatioRef = useRef(0); - const isSyncingScrollRef = useRef(false); - const loadedFilePathRef = useRef(""); - const restoredSelectionPathRef = useRef(""); - const fileRefreshInFlightRef = useRef(false); - const editorLineNumberRowRefs = useRef([]); - const editorHighlightLineRefs = useRef([]); - - const pathSegments = useMemo( - () => parsePathSegments(normalizedPath), - [normalizedPath], - ); - const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath; - const renderContent = isCurrentFileLoaded ? content : ""; - const renderInitialContent = isCurrentFileLoaded ? initialContent : ""; - const hasSelectedPath = normalizedPath.length > 0; - const isImageFile = fileKind === "image"; - const isAudioFile = fileKind === "audio"; - const isSqliteFile = fileKind === "sqlite"; - const canEditFile = - hasSelectedPath && - !isFolderPath && - !isPreviewOnly && - !isImageFile && - !isAudioFile && - !isSqliteFile; - const isDiffView = String(browseView || "edit") === "diff"; - const isDirty = canEditFile && renderContent !== renderInitialContent; - const isLockedFile = - canEditFile && - matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath); - const isProtectedFile = - canEditFile && - !isLockedFile && - matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); - const isProtectedLocked = - isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); - const isEditBlocked = isLockedFile || isProtectedLocked; - const syntaxKind = useMemo( - () => getFileSyntaxKind(normalizedPath), - [normalizedPath], - ); - const isMarkdownFile = syntaxKind === "markdown"; - const shouldUseHighlightedEditor = syntaxKind !== "plain"; - const parsedFrontmatter = useMemo( - () => - isMarkdownFile - ? parseFrontmatter(renderContent) - : { entries: [], body: renderContent }, - [renderContent, isMarkdownFile], - ); - const highlightedEditorLines = useMemo( - () => - shouldUseHighlightedEditor - ? highlightEditorLines(renderContent, syntaxKind) - : [], - [renderContent, shouldUseHighlightedEditor, syntaxKind], - ); - const editorLineNumbers = useMemo(() => { - const lineCount = String(renderContent || "").split("\n").length; - return Array.from({ length: lineCount }, (_, index) => index + 1); - }, [renderContent]); - - const syncEditorLineNumberHeights = useCallback(() => { - if (!shouldUseHighlightedEditor || viewMode !== "edit") return; - const numberRows = editorLineNumberRowRefs.current; - const highlightRows = editorHighlightLineRefs.current; - const rowCount = Math.min(numberRows.length, highlightRows.length); - for (let index = 0; index < rowCount; index += 1) { - const numberRow = numberRows[index]; - const highlightRow = highlightRows[index]; - if (!numberRow || !highlightRow) continue; - numberRow.style.height = `${highlightRow.offsetHeight}px`; - } - }, [shouldUseHighlightedEditor, viewMode]); - - useEffect(() => { - syncEditorLineNumberHeights(); - }, [content, syncEditorLineNumberHeights]); - - useEffect(() => { - if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {}; - const onResize = () => syncEditorLineNumberHeights(); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]); - const previewHtml = useMemo( - () => - isMarkdownFile - ? marked.parse(parsedFrontmatter.body || "", { - gfm: true, - breaks: true, - }) - : "", - [parsedFrontmatter.body, isMarkdownFile], - ); - - useEffect(() => { - if (!isMarkdownFile && viewMode !== "edit") { - setViewMode("edit"); - } - }, [isMarkdownFile, viewMode]); - - useEffect(() => { - try { - window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); - } catch {} - }, [viewMode]); - - useEffect(() => { - if (!loading) { - setShowDelayedLoadingSpinner(false); - return () => {}; - } - const timer = window.setTimeout(() => { - setShowDelayedLoadingSpinner(true); - }, kLoadingIndicatorDelayMs); - return () => window.clearTimeout(timer); - }, [loading]); - - useEffect(() => { - let active = true; - loadedFilePathRef.current = ""; - restoredSelectionPathRef.current = ""; - if (!hasSelectedPath) { - setContent(""); - setInitialContent(""); - setFileKind("text"); - setImageDataUrl(""); - setAudioDataUrl(""); - setSqliteSummary(null); - setSqliteSelectedTable(""); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - setError(""); - setIsFolderPath(false); - viewScrollRatioRef.current = 0; - loadedFilePathRef.current = ""; - return () => { - active = false; - }; - } - // Clear previous file state immediately so large content from the last - // file is never rendered/parses under the next file's syntax mode. - setContent(""); - setInitialContent(""); - setImageDataUrl(""); - setAudioDataUrl(""); - setSqliteSummary(null); - setSqliteSelectedTable(""); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - setFileKind("text"); - setError(""); - setIsFolderPath(false); - setExternalChangeNoticeShown(false); - viewScrollRatioRef.current = 0; - - const loadFile = async () => { - setLoading(true); - setError(""); - setIsFolderPath(false); - try { - const data = await fetchFileContent(normalizedPath); - if (!active) return; - const nextFileKind = - data?.kind === "image" - ? "image" - : data?.kind === "audio" - ? "audio" - : data?.kind === "sqlite" - ? "sqlite" - : "text"; - setFileKind(nextFileKind); - if (nextFileKind === "image") { - setImageDataUrl(String(data?.imageDataUrl || "")); - setAudioDataUrl(""); - setSqliteSummary(null); - setSqliteSelectedTable(""); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - setContent(""); - setInitialContent(""); - setExternalChangeNoticeShown(false); - viewScrollRatioRef.current = 0; - loadedFilePathRef.current = normalizedPath; - restoredSelectionPathRef.current = ""; - return; - } - if (nextFileKind === "audio") { - setAudioDataUrl(String(data?.audioDataUrl || "")); - setImageDataUrl(""); - setSqliteSummary(null); - setSqliteSelectedTable(""); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - setContent(""); - setInitialContent(""); - setExternalChangeNoticeShown(false); - viewScrollRatioRef.current = 0; - loadedFilePathRef.current = normalizedPath; - restoredSelectionPathRef.current = ""; - return; - } - if (nextFileKind === "sqlite") { - const nextSqliteSummary = data?.sqliteSummary || null; - const nextObjects = nextSqliteSummary?.objects || []; - const defaultTable = - nextObjects.find((entry) => entry?.type === "table")?.name || - nextObjects[0]?.name || - ""; - setSqliteSummary(nextSqliteSummary); - setSqliteSelectedTable(defaultTable); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - setImageDataUrl(""); - setAudioDataUrl(""); - setContent(""); - setInitialContent(""); - setExternalChangeNoticeShown(false); - viewScrollRatioRef.current = 0; - loadedFilePathRef.current = normalizedPath; - restoredSelectionPathRef.current = ""; - return; - } - setImageDataUrl(""); - setAudioDataUrl(""); - setSqliteSummary(null); - setSqliteSelectedTable(""); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - const nextContent = data.content || ""; - const draftContent = readStoredFileDraft(normalizedPath); - setContent(draftContent || nextContent); - updateDraftIndex( - normalizedPath, - Boolean(draftContent && draftContent !== nextContent), - { dispatchEvent: (event) => window.dispatchEvent(event) }, - ); - setInitialContent(nextContent); - setExternalChangeNoticeShown(false); - viewScrollRatioRef.current = 0; - loadedFilePathRef.current = normalizedPath; - restoredSelectionPathRef.current = ""; - } catch (loadError) { - if (!active) return; - setFileKind("text"); - setImageDataUrl(""); - setAudioDataUrl(""); - setSqliteSummary(null); - setSqliteSelectedTable(""); - setSqliteTableOffset(0); - setSqliteTableLoading(false); - setSqliteTableError(""); - setSqliteTableData(null); - const message = loadError.message || "Could not load file"; - if (/path is not a file/i.test(message)) { - setContent(""); - setInitialContent(""); - setIsFolderPath(true); - setError(""); - loadedFilePathRef.current = normalizedPath; - restoredSelectionPathRef.current = ""; - return; - } - setError(message); - } finally { - if (active) setLoading(false); - } - }; - loadFile(); - return () => { - active = false; - }; - }, [hasSelectedPath, normalizedPath]); - - useEffect(() => { - if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) { - setSqliteTableData(null); - setSqliteTableError(""); - setSqliteTableLoading(false); - return () => {}; - } - let active = true; - const loadSqliteTable = async () => { - setSqliteTableLoading(true); - setSqliteTableError(""); - try { - const tableData = await fetchBrowseSqliteTable({ - filePath: normalizedPath, - table: sqliteSelectedTable, - limit: kSqlitePageSize, - offset: sqliteTableOffset, - }); - if (!active) return; - setSqliteTableData(tableData); - } catch (nextError) { - if (!active) return; - setSqliteTableError(nextError.message || "Could not load sqlite table"); - } finally { - if (active) setSqliteTableLoading(false); - } - }; - loadSqliteTable(); - return () => { - active = false; - }; - }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]); - - useEffect(() => { - if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {}; - const refreshFromDisk = async () => { - if (loading || saving) return; - if (fileRefreshInFlightRef.current) return; - fileRefreshInFlightRef.current = true; - try { - const data = await fetchFileContent(normalizedPath); - const diskContent = data.content || ""; - if (diskContent === initialContent) { - setExternalChangeNoticeShown(false); - return; - } - // Auto-refresh only when editor has no unsaved work. - if (!isDirty) { - setContent(diskContent); - setInitialContent(diskContent); - clearStoredFileDraft(normalizedPath); - updateDraftIndex(normalizedPath, false, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - setExternalChangeNoticeShown(false); - window.dispatchEvent( - new CustomEvent("alphaclaw:browse-tree-refresh"), - ); - return; - } - if (!externalChangeNoticeShown) { - showToast( - "This file changed on disk. Save to overwrite or reload by re-opening.", - "error", - ); - setExternalChangeNoticeShown(true); - } - } catch { - // Ignore transient refresh errors to avoid interrupting editing. - } finally { - fileRefreshInFlightRef.current = false; - } - }; - const intervalId = window.setInterval( - refreshFromDisk, - kFileRefreshIntervalMs, - ); - return () => { - window.clearInterval(intervalId); - }; - }, [ - hasSelectedPath, - isFolderPath, - canEditFile, - loading, - saving, - normalizedPath, - initialContent, - isDirty, - externalChangeNoticeShown, - ]); - - useEffect(() => { - let active = true; - if (!hasSelectedPath || !isDiffView || isPreviewOnly) { - setDiffLoading(false); - setDiffError(""); - setDiffContent(""); - return () => { - active = false; - }; - } - const loadDiff = async () => { - setDiffLoading(true); - setDiffError(""); - try { - const data = await fetchBrowseFileDiff(normalizedPath); - if (!active) return; - setDiffContent(String(data?.content || "")); - } catch (nextError) { - if (!active) return; - setDiffError(nextError.message || "Could not load diff"); - } finally { - if (active) setDiffLoading(false); - } - }; - loadDiff(); - return () => { - active = false; - }; - }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]); - - useEffect(() => { - if (loadedFilePathRef.current !== normalizedPath) return; - if (!canEditFile || !hasSelectedPath || loading) return; - if (content === initialContent) { - clearStoredFileDraft(normalizedPath); - updateDraftIndex(normalizedPath, false, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - return; - } - writeStoredFileDraft(normalizedPath, content); - updateDraftIndex(normalizedPath, true, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - }, [ - canEditFile, - hasSelectedPath, - loading, - content, - initialContent, - normalizedPath, - ]); - - useEffect(() => { - if (!canEditFile || loading || !hasSelectedPath) return () => {}; - if (loadedFilePathRef.current !== normalizedPath) return () => {}; - if (restoredSelectionPathRef.current === normalizedPath) return () => {}; - if (viewMode !== "edit") return () => {}; - const storedSelection = readStoredEditorSelection(normalizedPath); - if (!storedSelection) { - restoredSelectionPathRef.current = normalizedPath; - return () => {}; - } - let frameId = 0; - let attempts = 0; - const restoreSelection = () => { - const textareaElement = editorTextareaRef.current; - if (!textareaElement) { - attempts += 1; - if (attempts < 6) - frameId = window.requestAnimationFrame(restoreSelection); - return; - } - const maxIndex = String(content || "").length; - const start = clampSelectionIndex(storedSelection.start, maxIndex); - const end = clampSelectionIndex(storedSelection.end, maxIndex); - textareaElement.focus(); - textareaElement.setSelectionRange(start, Math.max(start, end)); - window.requestAnimationFrame(() => { - const nextTextareaElement = editorTextareaRef.current; - if (!nextTextareaElement) return; - const safeContent = String(content || ""); - const safeStart = clampSelectionIndex(start, safeContent.length); - const lineIndex = - safeContent.slice(0, safeStart).split("\n").length - 1; - const computedStyle = window.getComputedStyle(nextTextareaElement); - const parsedLineHeight = Number.parseFloat( - computedStyle.lineHeight || "", - ); - const lineHeight = - Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 - ? parsedLineHeight - : 20; - const nextScrollTop = Math.max( - 0, - lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4, - ); - nextTextareaElement.scrollTop = nextScrollTop; - if (editorLineNumbersRef.current) { - editorLineNumbersRef.current.scrollTop = nextScrollTop; - } - if (editorHighlightRef.current) { - editorHighlightRef.current.scrollTop = nextScrollTop; - } - viewScrollRatioRef.current = getScrollRatio(nextTextareaElement); - }); - restoredSelectionPathRef.current = normalizedPath; - }; - frameId = window.requestAnimationFrame(restoreSelection); - return () => { - if (frameId) window.cancelAnimationFrame(frameId); - }; - }, [ - canEditFile, - loading, - hasSelectedPath, - normalizedPath, - content, - viewMode, - ]); - - const handleSave = useCallback(async () => { - if (!canEditFile || saving || !isDirty || isEditBlocked) return; - setSaving(true); - setError(""); - try { - await saveFileContent(normalizedPath, content); - setInitialContent(content); - setExternalChangeNoticeShown(false); - clearStoredFileDraft(normalizedPath); - updateDraftIndex(normalizedPath, false, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - window.dispatchEvent( - new CustomEvent("alphaclaw:browse-file-saved", { - detail: { path: normalizedPath }, - }), - ); - showToast("Saved", "success"); - } catch (saveError) { - const message = saveError.message || "Could not save file"; - setError(message); - showToast(message, "error"); - } finally { - setSaving(false); - } - }, [ - canEditFile, - saving, - isDirty, - isEditBlocked, - normalizedPath, - content, - initialContent, - ]); - - useEffect(() => { - const handleKeyDown = (event) => { - const isSaveShortcut = - (event.metaKey || event.ctrlKey) && - !event.shiftKey && - !event.altKey && - String(event.key || "").toLowerCase() === "s"; - if (!isSaveShortcut) return; - if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") - return; - event.preventDefault(); - void handleSave(); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]); - - const handleEditProtectedFile = () => { - if (!normalizedPolicyPath) return; - setProtectedEditBypassPaths((previousPaths) => { - const nextPaths = new Set(previousPaths); - nextPaths.add(normalizedPolicyPath); - return nextPaths; - }); - }; - - const handleContentInput = (event) => { - if (isEditBlocked || isPreviewOnly) return; - const nextContent = event.target.value; - setContent(nextContent); - if (hasSelectedPath && canEditFile) { - writeStoredEditorSelection(normalizedPath, { - start: event.target.selectionStart, - end: event.target.selectionEnd, - }); - } - if (hasSelectedPath && canEditFile) { - writeStoredFileDraft(normalizedPath, nextContent); - updateDraftIndex(normalizedPath, nextContent !== initialContent, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - } - }; - - const handleEditorSelectionChange = () => { - if (!hasSelectedPath || !canEditFile || loading) return; - const textareaElement = editorTextareaRef.current; - if (!textareaElement) return; - writeStoredEditorSelection(normalizedPath, { - start: textareaElement.selectionStart, - end: textareaElement.selectionEnd, - }); - }; - - const getScrollRatio = (element) => { - if (!element) return 0; - const maxScrollTop = element.scrollHeight - element.clientHeight; - if (maxScrollTop <= 0) return 0; - return element.scrollTop / maxScrollTop; - }; - - const setScrollByRatio = (element, ratio) => { - if (!element) return; - const maxScrollTop = element.scrollHeight - element.clientHeight; - if (maxScrollTop <= 0) { - element.scrollTop = 0; - return; - } - const clampedRatio = Math.max(0, Math.min(1, ratio)); - element.scrollTop = maxScrollTop * clampedRatio; - }; - - const handleEditorScroll = (event) => { - if (isSyncingScrollRef.current) return; - const nextScrollTop = event.currentTarget.scrollTop; - const nextRatio = getScrollRatio(event.currentTarget); - viewScrollRatioRef.current = nextRatio; - if (!editorLineNumbersRef.current) return; - editorLineNumbersRef.current.scrollTop = nextScrollTop; - if (editorHighlightRef.current) { - editorHighlightRef.current.scrollTop = nextScrollTop; - editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft; - } - if (previewRef.current) { - isSyncingScrollRef.current = true; - setScrollByRatio(previewRef.current, nextRatio); - window.requestAnimationFrame(() => { - isSyncingScrollRef.current = false; - }); - } - }; - - const handlePreviewScroll = (event) => { - if (isSyncingScrollRef.current) return; - const nextRatio = getScrollRatio(event.currentTarget); - viewScrollRatioRef.current = nextRatio; - isSyncingScrollRef.current = true; - setScrollByRatio(editorTextareaRef.current, nextRatio); - setScrollByRatio(editorLineNumbersRef.current, nextRatio); - setScrollByRatio(editorHighlightRef.current, nextRatio); - window.requestAnimationFrame(() => { - isSyncingScrollRef.current = false; - }); - }; - - const handleChangeViewMode = (nextMode) => { - if (nextMode === viewMode) return; - const nextRatio = - viewMode === "preview" - ? getScrollRatio(previewRef.current) - : getScrollRatio(editorTextareaRef.current); - viewScrollRatioRef.current = nextRatio; - setViewMode(nextMode); - window.requestAnimationFrame(() => { - isSyncingScrollRef.current = true; - if (nextMode === "preview") { - setScrollByRatio(previewRef.current, nextRatio); - } else { - setScrollByRatio(editorTextareaRef.current, nextRatio); - setScrollByRatio(editorLineNumbersRef.current, nextRatio); - setScrollByRatio(editorHighlightRef.current, nextRatio); - } - window.requestAnimationFrame(() => { - isSyncingScrollRef.current = false; - }); - }); - }; - - const sqliteRows = Array.isArray(sqliteTableData?.rows) ? sqliteTableData.rows : []; - const sqliteColumns = - Array.isArray(sqliteTableData?.columns) && sqliteTableData.columns.length - ? sqliteTableData.columns - : (sqliteSummary?.objects || []).find( - (entry) => entry?.name === sqliteSelectedTable, - )?.columns || []; - const sqliteTotalRows = Number(sqliteTableData?.totalRows || 0); - const sqliteCanGoPrev = sqliteTableOffset > 0; - const sqliteCanGoNext = sqliteTableOffset + kSqlitePageSize < sqliteTotalRows; - - if (!hasSelectedPath) { - return html` -
    -
    [ ]
    -
    - Browse and edit files
    Syncs to git -
    -
    - `; - } - - return html` -
    -
    -
    - f - - ${pathSegments.map( - (segment, index) => html` - - - ${segment} - - ${index < pathSegments.length - 1 && - html`>`} - - `, - )} - - ${isDirty - ? html`` - : null} -
    -
    - ${isPreviewOnly - ? html`
    Preview
    ` - : null} - ${!isDiffView && - isMarkdownFile && - html` - <${SegmentedControl} - className="mr-2.5" - options=${[ - { label: "edit", value: "edit" }, - { label: "preview", value: "preview" }, - ]} - value=${viewMode} - onChange=${handleChangeViewMode} - /> - `} - ${!isDiffView - ? !isImageFile && !isAudioFile && !isSqliteFile - ? html` - <${ActionButton} - onClick=${handleSave} - disabled=${loading || - !isDirty || - !canEditFile || - isEditBlocked} - loading=${saving} - tone=${isDirty ? "primary" : "secondary"} - size="sm" - idleLabel="Save" - loadingLabel="Saving..." - idleIcon=${SaveFillIcon} - idleIconClassName="file-viewer-save-icon" - className="file-viewer-save-action" - /> - ` - : null - : null} -
    - ${isDiffView - ? html` -
    -
    - Viewing unsynced changes -
    - <${ActionButton} - onClick=${() => onRequestEdit(normalizedPath)} - tone="secondary" - size="sm" - idleLabel="View file" - /> -
    - ` - : null} - ${!isDiffView && isLockedFile - ? html` -
    - <${LockLineIcon} className="file-viewer-protected-banner-icon" /> -
    - This file is managed by AlphaClaw and cannot be edited. -
    -
    - ` - : null} - ${!isDiffView && isProtectedFile - ? html` -
    -
    - Protected file. Changes may break workspace behavior. -
    - ${isProtectedLocked - ? html` - <${ActionButton} - onClick=${handleEditProtectedFile} - tone="warning" - size="sm" - idleLabel="Edit anyway" - /> - ` - : null} -
    - ` - : null} - ${isMarkdownFile && parsedFrontmatter.entries.length > 0 - ? html` -
    - - ${!frontmatterCollapsed - ? html` -
    - ${parsedFrontmatter.entries.map((entry) => { - const formattedValue = formatFrontmatterValue( - entry.rawValue, - ); - const isMultilineValue = formattedValue.includes("\n"); - return html` -
    -
    ${entry.key}
    - ${isMultilineValue - ? html` -
    -${formattedValue}
    - ` - : html`
    - ${formattedValue} -
    `} -
    - `; - })} -
    - ` - : null} -
    - ` - : null} - ${loading - ? html` -
    - ${showDelayedLoadingSpinner - ? html`<${LoadingSpinner} className="h-4 w-4" />` - : null} -
    - ` - : error - ? html`
    - ${error} -
    ` - : isFolderPath - ? html` -
    - Folder selected. Choose a file from this folder in the tree. -
    - ` - : isImageFile - ? html` -
    - ${imageDataUrl - ? html` - ${pathSegments[pathSegments.length - ` - : html` -
    - Could not render image preview. -
    - `} -
    - ` - : isAudioFile - ? html` -
    - ${audioDataUrl - ? html` - - ` - : html` -
    - Could not render audio preview. -
    - `} -
    - ` - : isSqliteFile - ? html` -
    - ${sqliteSummary?.objects?.length - ? html` -
    -
    - ${sqliteSummary.objects.map((entry) => html` - - `)} -
    -
    - ${sqliteSelectedTable - ? html` -
    - ${sqliteSelectedTable} -
    - - -
    -
    -
    - ${sqliteTotalRows - ? `${Math.min(sqliteTableOffset + 1, sqliteTotalRows)}-${Math.min(sqliteTableOffset + kSqlitePageSize, sqliteTotalRows)} of ${sqliteTotalRows} rows` - : "No rows"} -
    - ${sqliteTableLoading - ? html` -
    - <${LoadingSpinner} className="h-4 w-4" /> -
    - ` - : sqliteTableError - ? html` -
    - ${sqliteTableError} -
    - ` - : html` -
    - - - - ${sqliteColumns.map((column) => html` - - `)} - - - - ${sqliteRows.length - ? sqliteRows.map((row, rowIndex) => html` - - ${sqliteColumns.map((column) => { - const cellValue = row?.[column.name]; - const displayValue = - cellValue === null - ? "NULL" - : typeof cellValue === "object" - ? JSON.stringify(cellValue) - : String(cellValue ?? ""); - return html` - - `; - })} - - `) - : html` - - - - `} - -
    ${column.name}
    - ${displayValue} -
    - No rows -
    -
    - `} - ` - : html` -
    - Select a table to view rows. -
    - `} -
    -
    - - ` - : html` -
    - SQLite database loaded, but no tables/views were found. -
    - `} -
    - ` - : isDiffView - ? html` -
    - ${diffLoading - ? html` -
    - <${LoadingSpinner} className="h-4 w-4" /> -
    - ` - : diffError - ? html` -
    - ${diffError} -
    - ` - : html` -
    -${(diffContent || "").split("\n").map((line, lineIndex) => {
    -                                    const lineClass =
    -                                      line.startsWith("+") &&
    -                                      !line.startsWith("+++")
    -                                        ? "is-added"
    -                                        : line.startsWith("-") &&
    -                                            !line.startsWith("---")
    -                                          ? "is-removed"
    -                                          : line.startsWith("@@")
    -                                            ? "is-hunk"
    -                                            : line.startsWith("diff ") ||
    -                                                line.startsWith("index ") ||
    -                                                line.startsWith("--- ") ||
    -                                                line.startsWith("+++ ")
    -                                              ? "is-header"
    -                                              : "";
    -                                    return html`
    -                                      
    - ${line || " "} -
    - `; - })} -
    - `} -
    - ` - : html` - ${isMarkdownFile - ? html` -
    -
    -
    - ${editorLineNumbers.map( - (lineNumber) => html` -
    { - editorLineNumberRowRefs.current[ - lineNumber - 1 - ] = element; - }} - > - ${lineNumber} -
    - `, - )} -
    -
    -
    - ${highlightedEditorLines.map( - (line) => html` -
    { - editorHighlightLineRefs.current[ - line.lineNumber - 1 - ] = element; - }} - > - -
    - `, - )} -
    - -
    -
    - ` - : html` -
    -
    - ${editorLineNumbers.map( - (lineNumber) => html` -
    { - editorLineNumberRowRefs.current[ - lineNumber - 1 - ] = element; - }} - > - ${lineNumber} -
    - `, - )} -
    - ${shouldUseHighlightedEditor - ? html` -
    -
    - ${highlightedEditorLines.map( - (line) => html` -
    { - editorHighlightLineRefs.current[ - line.lineNumber - 1 - ] = element; - }} - > - -
    - `, - )} -
    - -
    - ` - : html` - - `} -
    - `} - `} -
    - `; -}; diff --git a/lib/public/js/components/file-viewer/constants.js b/lib/public/js/components/file-viewer/constants.js new file mode 100644 index 00000000..96cbcb65 --- /dev/null +++ b/lib/public/js/components/file-viewer/constants.js @@ -0,0 +1,6 @@ +export const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode"; +export const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode"; +export const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath"; +export const kLoadingIndicatorDelayMs = 1000; +export const kFileRefreshIntervalMs = 5000; +export const kSqlitePageSize = 50; diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js new file mode 100644 index 00000000..b9b1b4e2 --- /dev/null +++ b/lib/public/js/components/file-viewer/index.js @@ -0,0 +1,837 @@ +import { h } from "https://esm.sh/preact"; +import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { marked } from "https://esm.sh/marked"; +import { fetchBrowseFileDiff, saveFileContent } from "../../lib/api.js"; +import { + formatFrontmatterValue, + getFileSyntaxKind, + highlightEditorLines, + parseFrontmatter, +} from "../../lib/syntax-highlighters/index.js"; +import { + clearStoredFileDraft, + updateDraftIndex, + writeStoredFileDraft, +} from "../../lib/browse-draft-state.js"; +import { + kLockedBrowsePaths, + kProtectedBrowsePaths, + matchesBrowsePolicyPath, + normalizeBrowsePolicyPath, +} from "../../lib/browse-file-policies.js"; +import { ActionButton } from "../action-button.js"; +import { LoadingSpinner } from "../loading-spinner.js"; +import { SegmentedControl } from "../segmented-control.js"; +import { LockLineIcon, SaveFillIcon } from "../icons.js"; +import { showToast } from "../toast.js"; +import { + kFileViewerModeStorageKey, + kLoadingIndicatorDelayMs, + kSqlitePageSize, +} from "./constants.js"; +import { + readStoredEditorSelection, + readStoredFileViewerMode, + writeStoredEditorSelection, +} from "./storage.js"; +import { clampSelectionIndex, parsePathSegments } from "./utils.js"; +import { getScrollRatio, useScrollSync } from "./scroll-sync.js"; +import { useFileLoader } from "./use-file-loader.js"; +import { SqliteViewer } from "./sqlite-viewer.js"; + +const html = htm.bind(h); + +export const FileViewer = ({ + filePath = "", + isPreviewOnly = false, + browseView = "edit", + onRequestEdit = () => {}, +}) => { + const normalizedPath = String(filePath || "").trim(); + const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath); + const [content, setContent] = useState(""); + const [initialContent, setInitialContent] = useState(""); + const [fileKind, setFileKind] = useState("text"); + const [imageDataUrl, setImageDataUrl] = useState(""); + const [audioDataUrl, setAudioDataUrl] = useState(""); + const [sqliteSummary, setSqliteSummary] = useState(null); + const [sqliteSelectedTable, setSqliteSelectedTable] = useState(""); + const [sqliteTableOffset, setSqliteTableOffset] = useState(0); + const [sqliteTableLoading, setSqliteTableLoading] = useState(false); + const [sqliteTableError, setSqliteTableError] = useState(""); + const [sqliteTableData, setSqliteTableData] = useState(null); + const [viewMode, setViewMode] = useState(readStoredFileViewerMode); + const [loading, setLoading] = useState(false); + const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false); + const [diffLoading, setDiffLoading] = useState(false); + const [diffError, setDiffError] = useState(""); + const [diffContent, setDiffContent] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [isFolderPath, setIsFolderPath] = useState(false); + const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); + const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false); + const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set()); + const editorLineNumbersRef = useRef(null); + const editorHighlightRef = useRef(null); + const editorTextareaRef = useRef(null); + const previewRef = useRef(null); + const editorLineNumberRowRefs = useRef([]); + const editorHighlightLineRefs = useRef([]); + + const hasSelectedPath = normalizedPath.length > 0; + const isImageFile = fileKind === "image"; + const isAudioFile = fileKind === "audio"; + const isSqliteFile = fileKind === "sqlite"; + const canEditFile = + hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile; + const isDiffView = String(browseView || "edit") === "diff"; + + const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } = + useScrollSync({ + viewMode, + setViewMode, + previewRef, + editorTextareaRef, + editorLineNumbersRef, + editorHighlightRef, + }); + + const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({ + hasSelectedPath, + normalizedPath, + isSqliteFile, + sqliteSelectedTable, + sqliteTableOffset, + canEditFile, + isFolderPath, + loading, + saving, + initialContent, + isDirty: canEditFile && content !== initialContent, + setLoading, + setContent, + setInitialContent, + setFileKind, + setImageDataUrl, + setAudioDataUrl, + setSqliteSummary, + setSqliteSelectedTable, + setSqliteTableOffset, + setSqliteTableLoading, + setSqliteTableError, + setSqliteTableData, + setError, + setIsFolderPath, + setExternalChangeNoticeShown, + externalChangeNoticeShown, + viewScrollRatioRef, + }); + + const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]); + const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath; + const renderContent = isCurrentFileLoaded ? content : ""; + const renderInitialContent = isCurrentFileLoaded ? initialContent : ""; + const isDirty = canEditFile && renderContent !== renderInitialContent; + const isLockedFile = + canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath); + const isProtectedFile = + canEditFile && + !isLockedFile && + matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); + const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); + const isEditBlocked = isLockedFile || isProtectedLocked; + const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]); + const isMarkdownFile = syntaxKind === "markdown"; + const shouldUseHighlightedEditor = syntaxKind !== "plain"; + const parsedFrontmatter = useMemo( + () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }), + [renderContent, isMarkdownFile], + ); + const highlightedEditorLines = useMemo( + () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []), + [renderContent, shouldUseHighlightedEditor, syntaxKind], + ); + const editorLineNumbers = useMemo(() => { + const lineCount = String(renderContent || "").split("\n").length; + return Array.from({ length: lineCount }, (_, index) => index + 1); + }, [renderContent]); + + const syncEditorLineNumberHeights = useCallback(() => { + if (!shouldUseHighlightedEditor || viewMode !== "edit") return; + const numberRows = editorLineNumberRowRefs.current; + const highlightRows = editorHighlightLineRefs.current; + const rowCount = Math.min(numberRows.length, highlightRows.length); + for (let index = 0; index < rowCount; index += 1) { + const numberRow = numberRows[index]; + const highlightRow = highlightRows[index]; + if (!numberRow || !highlightRow) continue; + numberRow.style.height = `${highlightRow.offsetHeight}px`; + } + }, [shouldUseHighlightedEditor, viewMode]); + + useEffect(() => { + syncEditorLineNumberHeights(); + }, [content, syncEditorLineNumberHeights]); + + useEffect(() => { + if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {}; + const onResize = () => syncEditorLineNumberHeights(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]); + + const previewHtml = useMemo( + () => + isMarkdownFile + ? marked.parse(parsedFrontmatter.body || "", { + gfm: true, + breaks: true, + }) + : "", + [parsedFrontmatter.body, isMarkdownFile], + ); + + useEffect(() => { + if (!isMarkdownFile && viewMode !== "edit") { + setViewMode("edit"); + } + }, [isMarkdownFile, viewMode]); + + useEffect(() => { + try { + window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); + } catch {} + }, [viewMode]); + + useEffect(() => { + if (!loading) { + setShowDelayedLoadingSpinner(false); + return () => {}; + } + const timer = window.setTimeout(() => { + setShowDelayedLoadingSpinner(true); + }, kLoadingIndicatorDelayMs); + return () => window.clearTimeout(timer); + }, [loading]); + + useEffect(() => { + let active = true; + if (!hasSelectedPath || !isDiffView || isPreviewOnly) { + setDiffLoading(false); + setDiffError(""); + setDiffContent(""); + return () => { + active = false; + }; + } + const loadDiff = async () => { + setDiffLoading(true); + setDiffError(""); + try { + const data = await fetchBrowseFileDiff(normalizedPath); + if (!active) return; + setDiffContent(String(data?.content || "")); + } catch (nextError) { + if (!active) return; + setDiffError(nextError.message || "Could not load diff"); + } finally { + if (active) setDiffLoading(false); + } + }; + loadDiff(); + return () => { + active = false; + }; + }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]); + + useEffect(() => { + if (loadedFilePathRef.current !== normalizedPath) return; + if (!canEditFile || !hasSelectedPath || loading) return; + if (content === initialContent) { + clearStoredFileDraft(normalizedPath); + updateDraftIndex(normalizedPath, false, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + return; + } + writeStoredFileDraft(normalizedPath, content); + updateDraftIndex(normalizedPath, true, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + }, [canEditFile, hasSelectedPath, loading, content, initialContent, normalizedPath]); + + useEffect(() => { + if (!canEditFile || loading || !hasSelectedPath) return () => {}; + if (loadedFilePathRef.current !== normalizedPath) return () => {}; + if (restoredSelectionPathRef.current === normalizedPath) return () => {}; + if (viewMode !== "edit") return () => {}; + const storedSelection = readStoredEditorSelection(normalizedPath); + if (!storedSelection) { + restoredSelectionPathRef.current = normalizedPath; + return () => {}; + } + let frameId = 0; + let attempts = 0; + const restoreSelection = () => { + const textareaElement = editorTextareaRef.current; + if (!textareaElement) { + attempts += 1; + if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection); + return; + } + const maxIndex = String(content || "").length; + const start = clampSelectionIndex(storedSelection.start, maxIndex); + const end = clampSelectionIndex(storedSelection.end, maxIndex); + textareaElement.focus(); + textareaElement.setSelectionRange(start, Math.max(start, end)); + window.requestAnimationFrame(() => { + const nextTextareaElement = editorTextareaRef.current; + if (!nextTextareaElement) return; + const safeContent = String(content || ""); + const safeStart = clampSelectionIndex(start, safeContent.length); + const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1; + const computedStyle = window.getComputedStyle(nextTextareaElement); + const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || ""); + const lineHeight = + Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20; + const nextScrollTop = Math.max( + 0, + lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4, + ); + nextTextareaElement.scrollTop = nextScrollTop; + if (editorLineNumbersRef.current) { + editorLineNumbersRef.current.scrollTop = nextScrollTop; + } + if (editorHighlightRef.current) { + editorHighlightRef.current.scrollTop = nextScrollTop; + } + viewScrollRatioRef.current = getScrollRatio(nextTextareaElement); + }); + restoredSelectionPathRef.current = normalizedPath; + }; + frameId = window.requestAnimationFrame(restoreSelection); + return () => { + if (frameId) window.cancelAnimationFrame(frameId); + }; + }, [canEditFile, loading, hasSelectedPath, normalizedPath, content, viewMode]); + + const handleSave = useCallback(async () => { + if (!canEditFile || saving || !isDirty || isEditBlocked) return; + setSaving(true); + setError(""); + try { + await saveFileContent(normalizedPath, content); + setInitialContent(content); + setExternalChangeNoticeShown(false); + clearStoredFileDraft(normalizedPath); + updateDraftIndex(normalizedPath, false, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-saved", { + detail: { path: normalizedPath }, + }), + ); + showToast("Saved", "success"); + } catch (saveError) { + const message = saveError.message || "Could not save file"; + setError(message); + showToast(message, "error"); + } finally { + setSaving(false); + } + }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content, initialContent]); + + useEffect(() => { + const handleKeyDown = (event) => { + const isSaveShortcut = + (event.metaKey || event.ctrlKey) && + !event.shiftKey && + !event.altKey && + String(event.key || "").toLowerCase() === "s"; + if (!isSaveShortcut) return; + if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return; + event.preventDefault(); + void handleSave(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]); + + const handleEditProtectedFile = () => { + if (!normalizedPolicyPath) return; + setProtectedEditBypassPaths((previousPaths) => { + const nextPaths = new Set(previousPaths); + nextPaths.add(normalizedPolicyPath); + return nextPaths; + }); + }; + + const handleContentInput = (event) => { + if (isEditBlocked || isPreviewOnly) return; + const nextContent = event.target.value; + setContent(nextContent); + if (hasSelectedPath && canEditFile) { + writeStoredEditorSelection(normalizedPath, { + start: event.target.selectionStart, + end: event.target.selectionEnd, + }); + } + if (hasSelectedPath && canEditFile) { + writeStoredFileDraft(normalizedPath, nextContent); + updateDraftIndex(normalizedPath, nextContent !== initialContent, { + dispatchEvent: (dispatchEvent) => window.dispatchEvent(dispatchEvent), + }); + } + }; + + const handleEditorSelectionChange = () => { + if (!hasSelectedPath || !canEditFile || loading) return; + const textareaElement = editorTextareaRef.current; + if (!textareaElement) return; + writeStoredEditorSelection(normalizedPath, { + start: textareaElement.selectionStart, + end: textareaElement.selectionEnd, + }); + }; + + if (!hasSelectedPath) { + return html` +
    +
    [ ]
    +
    Browse and edit files
    Syncs to git
    +
    + `; + } + + return html` +
    +
    +
    + f + + ${pathSegments.map( + (segment, index) => html` + + + ${segment} + + ${index < pathSegments.length - 1 && + html`>`} + + `, + )} + + ${isDirty + ? html`` + : null} +
    +
    + ${isPreviewOnly ? html`
    Preview
    ` : null} + ${!isDiffView && + isMarkdownFile && + html` + <${SegmentedControl} + className="mr-2.5" + options=${[ + { label: "edit", value: "edit" }, + { label: "preview", value: "preview" }, + ]} + value=${viewMode} + onChange=${handleChangeViewMode} + /> + `} + ${!isDiffView + ? !isImageFile && !isAudioFile && !isSqliteFile + ? html` + <${ActionButton} + onClick=${handleSave} + disabled=${loading || !isDirty || !canEditFile || isEditBlocked} + loading=${saving} + tone=${isDirty ? "primary" : "secondary"} + size="sm" + idleLabel="Save" + loadingLabel="Saving..." + idleIcon=${SaveFillIcon} + idleIconClassName="file-viewer-save-icon" + className="file-viewer-save-action" + /> + ` + : null + : null} +
    + ${isDiffView + ? html` +
    +
    Viewing unsynced changes
    + <${ActionButton} + onClick=${() => onRequestEdit(normalizedPath)} + tone="secondary" + size="sm" + idleLabel="View file" + /> +
    + ` + : null} + ${!isDiffView && isLockedFile + ? html` +
    + <${LockLineIcon} className="file-viewer-protected-banner-icon" /> +
    + This file is managed by AlphaClaw and cannot be edited. +
    +
    + ` + : null} + ${!isDiffView && isProtectedFile + ? html` +
    +
    + Protected file. Changes may break workspace behavior. +
    + ${isProtectedLocked + ? html` + <${ActionButton} + onClick=${handleEditProtectedFile} + tone="warning" + size="sm" + idleLabel="Edit anyway" + /> + ` + : null} +
    + ` + : null} + ${isMarkdownFile && parsedFrontmatter.entries.length > 0 + ? html` +
    + + ${!frontmatterCollapsed + ? html` +
    + ${parsedFrontmatter.entries.map((entry) => { + const formattedValue = formatFrontmatterValue(entry.rawValue); + const isMultilineValue = formattedValue.includes("\n"); + return html` +
    +
    ${entry.key}
    + ${isMultilineValue + ? html` +
    +${formattedValue}
    + ` + : html`
    ${formattedValue}
    `} +
    + `; + })} +
    + ` + : null} +
    + ` + : null} + ${loading + ? html` +
    + ${showDelayedLoadingSpinner + ? html`<${LoadingSpinner} className="h-4 w-4" />` + : null} +
    + ` + : error + ? html`
    ${error}
    ` + : isFolderPath + ? html` +
    + Folder selected. Choose a file from this folder in the tree. +
    + ` + : isImageFile + ? html` +
    + ${imageDataUrl + ? html` + ${pathSegments[pathSegments.length + ` + : html` +
    Could not render image preview.
    + `} +
    + ` + : isAudioFile + ? html` +
    + ${audioDataUrl + ? html` + + ` + : html` +
    Could not render audio preview.
    + `} +
    + ` + : isSqliteFile + ? html` + <${SqliteViewer} + sqliteSummary=${sqliteSummary} + sqliteSelectedTable=${sqliteSelectedTable} + setSqliteSelectedTable=${setSqliteSelectedTable} + sqliteTableOffset=${sqliteTableOffset} + setSqliteTableOffset=${setSqliteTableOffset} + sqliteTableLoading=${sqliteTableLoading} + sqliteTableError=${sqliteTableError} + sqliteTableData=${sqliteTableData} + kSqlitePageSize=${kSqlitePageSize} + /> + ` + : isDiffView + ? html` +
    + ${diffLoading + ? html` +
    + <${LoadingSpinner} className="h-4 w-4" /> +
    + ` + : diffError + ? html` +
    + ${diffError} +
    + ` + : html` +
    +${(diffContent || "").split("\n").map((line, lineIndex) => {
    +                                    const lineClass =
    +                                      line.startsWith("+") && !line.startsWith("+++")
    +                                        ? "is-added"
    +                                        : line.startsWith("-") && !line.startsWith("---")
    +                                          ? "is-removed"
    +                                          : line.startsWith("@@")
    +                                            ? "is-hunk"
    +                                            : line.startsWith("diff ") ||
    +                                                line.startsWith("index ") ||
    +                                                line.startsWith("--- ") ||
    +                                                line.startsWith("+++ ")
    +                                              ? "is-header"
    +                                              : "";
    +                                    return html`
    +                                      
    + ${line || " "} +
    + `; + })} +
    + `} +
    + ` + : html` + ${isMarkdownFile + ? html` +
    +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html` +
    { + editorLineNumberRowRefs.current[lineNumber - 1] = element; + }} + > + ${lineNumber} +
    + `, + )} +
    +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[line.lineNumber - 1] = + element; + }} + > + +
    + `, + )} +
    + +
    +
    + ` + : html` +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html` +
    { + editorLineNumberRowRefs.current[lineNumber - 1] = element; + }} + > + ${lineNumber} +
    + `, + )} +
    + ${shouldUseHighlightedEditor + ? html` +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[ + line.lineNumber - 1 + ] = element; + }} + > + +
    + `, + )} +
    + +
    + ` + : html` + + `} +
    + `} + `} +
    + `; +}; diff --git a/lib/public/js/components/file-viewer/scroll-sync.js b/lib/public/js/components/file-viewer/scroll-sync.js new file mode 100644 index 00000000..6097a029 --- /dev/null +++ b/lib/public/js/components/file-viewer/scroll-sync.js @@ -0,0 +1,95 @@ +import { useRef } from "https://esm.sh/preact/hooks"; + +export const getScrollRatio = (element) => { + if (!element) return 0; + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) return 0; + return element.scrollTop / maxScrollTop; +}; + +export const setScrollByRatio = (element, ratio) => { + if (!element) return; + const maxScrollTop = element.scrollHeight - element.clientHeight; + if (maxScrollTop <= 0) { + element.scrollTop = 0; + return; + } + const clampedRatio = Math.max(0, Math.min(1, ratio)); + element.scrollTop = maxScrollTop * clampedRatio; +}; + +export const useScrollSync = ({ + viewMode, + setViewMode, + previewRef, + editorTextareaRef, + editorLineNumbersRef, + editorHighlightRef, +}) => { + const viewScrollRatioRef = useRef(0); + const isSyncingScrollRef = useRef(false); + + const handleEditorScroll = (event) => { + if (isSyncingScrollRef.current) return; + const nextScrollTop = event.currentTarget.scrollTop; + const nextRatio = getScrollRatio(event.currentTarget); + viewScrollRatioRef.current = nextRatio; + if (!editorLineNumbersRef.current) return; + editorLineNumbersRef.current.scrollTop = nextScrollTop; + if (editorHighlightRef.current) { + editorHighlightRef.current.scrollTop = nextScrollTop; + editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft; + } + if (previewRef.current) { + isSyncingScrollRef.current = true; + setScrollByRatio(previewRef.current, nextRatio); + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = false; + }); + } + }; + + const handlePreviewScroll = (event) => { + if (isSyncingScrollRef.current) return; + const nextRatio = getScrollRatio(event.currentTarget); + viewScrollRatioRef.current = nextRatio; + isSyncingScrollRef.current = true; + setScrollByRatio(editorTextareaRef.current, nextRatio); + setScrollByRatio(editorLineNumbersRef.current, nextRatio); + setScrollByRatio(editorHighlightRef.current, nextRatio); + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = false; + }); + }; + + const handleChangeViewMode = (nextMode) => { + if (nextMode === viewMode) return; + const nextRatio = + viewMode === "preview" + ? getScrollRatio(previewRef.current) + : getScrollRatio(editorTextareaRef.current); + viewScrollRatioRef.current = nextRatio; + setViewMode(nextMode); + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = true; + if (nextMode === "preview") { + setScrollByRatio(previewRef.current, nextRatio); + } else { + setScrollByRatio(editorTextareaRef.current, nextRatio); + setScrollByRatio(editorLineNumbersRef.current, nextRatio); + setScrollByRatio(editorHighlightRef.current, nextRatio); + } + window.requestAnimationFrame(() => { + isSyncingScrollRef.current = false; + }); + }); + }; + + return { + viewScrollRatioRef, + isSyncingScrollRef, + handleEditorScroll, + handlePreviewScroll, + handleChangeViewMode, + }; +}; diff --git a/lib/public/js/components/file-viewer/sqlite-viewer.js b/lib/public/js/components/file-viewer/sqlite-viewer.js new file mode 100644 index 00000000..df91876a --- /dev/null +++ b/lib/public/js/components/file-viewer/sqlite-viewer.js @@ -0,0 +1,167 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { LoadingSpinner } from "../loading-spinner.js"; + +const html = htm.bind(h); + +export const SqliteViewer = ({ + sqliteSummary, + sqliteSelectedTable, + setSqliteSelectedTable, + sqliteTableOffset, + setSqliteTableOffset, + sqliteTableLoading, + sqliteTableError, + sqliteTableData, + kSqlitePageSize, +}) => { + const sqliteRows = Array.isArray(sqliteTableData?.rows) ? sqliteTableData.rows : []; + const sqliteColumns = + Array.isArray(sqliteTableData?.columns) && sqliteTableData.columns.length + ? sqliteTableData.columns + : (sqliteSummary?.objects || []).find( + (entry) => entry?.name === sqliteSelectedTable, + )?.columns || []; + const sqliteTotalRows = Number(sqliteTableData?.totalRows || 0); + const sqliteCanGoPrev = sqliteTableOffset > 0; + const sqliteCanGoNext = sqliteTableOffset + kSqlitePageSize < sqliteTotalRows; + + return html` +
    + ${sqliteSummary?.objects?.length + ? html` +
    +
    + ${sqliteSummary.objects.map( + (entry) => html` + + `, + )} +
    +
    + ${sqliteSelectedTable + ? html` +
    + + ${sqliteSelectedTable} + +
    + + +
    +
    +
    + ${sqliteTotalRows + ? `${Math.min(sqliteTableOffset + 1, sqliteTotalRows)}-${Math.min(sqliteTableOffset + kSqlitePageSize, sqliteTotalRows)} of ${sqliteTotalRows} rows` + : "No rows"} +
    + ${sqliteTableLoading + ? html` +
    + <${LoadingSpinner} className="h-4 w-4" /> +
    + ` + : sqliteTableError + ? html` +
    + ${sqliteTableError} +
    + ` + : html` +
    + + + + ${sqliteColumns.map( + (column) => html``, + )} + + + + ${sqliteRows.length + ? sqliteRows.map( + (row, rowIndex) => html` + + ${sqliteColumns.map((column) => { + const cellValue = row?.[column.name]; + const displayValue = + cellValue === null + ? "NULL" + : typeof cellValue === "object" + ? JSON.stringify(cellValue) + : String(cellValue ?? ""); + return html` + + `; + })} + + `, + ) + : html` + + + + `} + +
    ${column.name}
    + ${displayValue} +
    + + No rows + +
    +
    + `} + ` + : html` +
    Select a table to view rows.
    + `} +
    +
    + + ` + : html` +
    + SQLite database loaded, but no tables/views were found. +
    + `} +
    + `; +}; diff --git a/lib/public/js/components/file-viewer/storage.js b/lib/public/js/components/file-viewer/storage.js new file mode 100644 index 00000000..da32b0fb --- /dev/null +++ b/lib/public/js/components/file-viewer/storage.js @@ -0,0 +1,58 @@ +import { + kEditorSelectionStorageKey, + kFileViewerModeStorageKey, + kLegacyFileViewerModeStorageKey, +} from "./constants.js"; + +export const readStoredFileViewerMode = () => { + try { + const storedMode = String( + window.localStorage.getItem(kFileViewerModeStorageKey) || + window.localStorage.getItem(kLegacyFileViewerModeStorageKey) || + "", + ).trim(); + return storedMode === "preview" ? "preview" : "edit"; + } catch { + return "edit"; + } +}; + +export const readEditorSelectionStorageMap = () => { + try { + const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey); + if (!rawStorageValue) return {}; + const parsedStorageValue = JSON.parse(rawStorageValue); + if (!parsedStorageValue || typeof parsedStorageValue !== "object") return {}; + return parsedStorageValue; + } catch { + return {}; + } +}; + +export const readStoredEditorSelection = (filePath) => { + const safePath = String(filePath || "").trim(); + if (!safePath) return null; + const storageMap = readEditorSelectionStorageMap(); + const selection = storageMap[safePath]; + if (!selection || typeof selection !== "object") return null; + return { + start: selection.start, + end: selection.end, + }; +}; + +export const writeStoredEditorSelection = (filePath, selection) => { + const safePath = String(filePath || "").trim(); + if (!safePath || !selection || typeof selection !== "object") return; + try { + const nextStorageValue = readEditorSelectionStorageMap(); + nextStorageValue[safePath] = { + start: selection.start, + end: selection.end, + }; + window.localStorage.setItem( + kEditorSelectionStorageKey, + JSON.stringify(nextStorageValue), + ); + } catch {} +}; diff --git a/lib/public/js/components/file-viewer/use-file-loader.js b/lib/public/js/components/file-viewer/use-file-loader.js new file mode 100644 index 00000000..fa72c88a --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-loader.js @@ -0,0 +1,302 @@ +import { useEffect, useRef } from "https://esm.sh/preact/hooks"; +import { fetchBrowseSqliteTable, fetchFileContent } from "../../lib/api.js"; +import { + clearStoredFileDraft, + readStoredFileDraft, + updateDraftIndex, +} from "../../lib/browse-draft-state.js"; +import { showToast } from "../toast.js"; +import { kFileRefreshIntervalMs, kSqlitePageSize } from "./constants.js"; + +export const useFileLoader = ({ + hasSelectedPath, + normalizedPath, + isSqliteFile, + sqliteSelectedTable, + sqliteTableOffset, + canEditFile, + isFolderPath, + loading, + saving, + initialContent, + isDirty, + setContent, + setInitialContent, + setFileKind, + setImageDataUrl, + setAudioDataUrl, + setSqliteSummary, + setSqliteSelectedTable, + setSqliteTableOffset, + setSqliteTableLoading, + setSqliteTableError, + setSqliteTableData, + setError, + setIsFolderPath, + setExternalChangeNoticeShown, + externalChangeNoticeShown, + viewScrollRatioRef, + setLoading, +}) => { + const loadedFilePathRef = useRef(""); + const restoredSelectionPathRef = useRef(""); + const fileRefreshInFlightRef = useRef(false); + + useEffect(() => { + let active = true; + loadedFilePathRef.current = ""; + restoredSelectionPathRef.current = ""; + if (!hasSelectedPath) { + setContent(""); + setInitialContent(""); + setFileKind("text"); + setImageDataUrl(""); + setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setError(""); + setIsFolderPath(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = ""; + return () => { + active = false; + }; + } + // Clear previous file state immediately so large content from the last + // file is never rendered/parses under the next file's syntax mode. + setContent(""); + setInitialContent(""); + setImageDataUrl(""); + setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setFileKind("text"); + setError(""); + setIsFolderPath(false); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + + const loadFile = async () => { + setLoading(true); + setError(""); + setIsFolderPath(false); + try { + const data = await fetchFileContent(normalizedPath); + if (!active) return; + const nextFileKind = + data?.kind === "image" + ? "image" + : data?.kind === "audio" + ? "audio" + : data?.kind === "sqlite" + ? "sqlite" + : "text"; + setFileKind(nextFileKind); + if (nextFileKind === "image") { + setImageDataUrl(String(data?.imageDataUrl || "")); + setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setContent(""); + setInitialContent(""); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return; + } + if (nextFileKind === "audio") { + setAudioDataUrl(String(data?.audioDataUrl || "")); + setImageDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setContent(""); + setInitialContent(""); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return; + } + if (nextFileKind === "sqlite") { + const nextSqliteSummary = data?.sqliteSummary || null; + const nextObjects = nextSqliteSummary?.objects || []; + const defaultTable = + nextObjects.find((entry) => entry?.type === "table")?.name || + nextObjects[0]?.name || + ""; + setSqliteSummary(nextSqliteSummary); + setSqliteSelectedTable(defaultTable); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + setImageDataUrl(""); + setAudioDataUrl(""); + setContent(""); + setInitialContent(""); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return; + } + setImageDataUrl(""); + setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + const nextContent = data.content || ""; + const draftContent = readStoredFileDraft(normalizedPath); + setContent(draftContent || nextContent); + updateDraftIndex(normalizedPath, Boolean(draftContent && draftContent !== nextContent), { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + setInitialContent(nextContent); + setExternalChangeNoticeShown(false); + viewScrollRatioRef.current = 0; + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + } catch (loadError) { + if (!active) return; + setFileKind("text"); + setImageDataUrl(""); + setAudioDataUrl(""); + setSqliteSummary(null); + setSqliteSelectedTable(""); + setSqliteTableOffset(0); + setSqliteTableLoading(false); + setSqliteTableError(""); + setSqliteTableData(null); + const message = loadError.message || "Could not load file"; + if (/path is not a file/i.test(message)) { + setContent(""); + setInitialContent(""); + setIsFolderPath(true); + setError(""); + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return; + } + setError(message); + } finally { + if (active) setLoading(false); + } + }; + loadFile(); + return () => { + active = false; + }; + }, [hasSelectedPath, normalizedPath]); + + useEffect(() => { + if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) { + setSqliteTableData(null); + setSqliteTableError(""); + setSqliteTableLoading(false); + return () => {}; + } + let active = true; + const loadSqliteTable = async () => { + setSqliteTableLoading(true); + setSqliteTableError(""); + try { + const tableData = await fetchBrowseSqliteTable({ + filePath: normalizedPath, + table: sqliteSelectedTable, + limit: kSqlitePageSize, + offset: sqliteTableOffset, + }); + if (!active) return; + setSqliteTableData(tableData); + } catch (nextError) { + if (!active) return; + setSqliteTableError(nextError.message || "Could not load sqlite table"); + } finally { + if (active) setSqliteTableLoading(false); + } + }; + loadSqliteTable(); + return () => { + active = false; + }; + }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]); + + useEffect(() => { + if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {}; + const refreshFromDisk = async () => { + if (loading || saving) return; + if (fileRefreshInFlightRef.current) return; + fileRefreshInFlightRef.current = true; + try { + const data = await fetchFileContent(normalizedPath); + const diskContent = data.content || ""; + if (diskContent === initialContent) { + setExternalChangeNoticeShown(false); + return; + } + // Auto-refresh only when editor has no unsaved work. + if (!isDirty) { + setContent(diskContent); + setInitialContent(diskContent); + clearStoredFileDraft(normalizedPath); + updateDraftIndex(normalizedPath, false, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + setExternalChangeNoticeShown(false); + window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + return; + } + if (!externalChangeNoticeShown) { + showToast( + "This file changed on disk. Save to overwrite or reload by re-opening.", + "error", + ); + setExternalChangeNoticeShown(true); + } + } catch { + // Ignore transient refresh errors to avoid interrupting editing. + } finally { + fileRefreshInFlightRef.current = false; + } + }; + const intervalId = window.setInterval(refreshFromDisk, kFileRefreshIntervalMs); + return () => { + window.clearInterval(intervalId); + }; + }, [ + hasSelectedPath, + isFolderPath, + canEditFile, + loading, + saving, + normalizedPath, + initialContent, + isDirty, + externalChangeNoticeShown, + ]); + + return { + loadedFilePathRef, + restoredSelectionPathRef, + }; +}; diff --git a/lib/public/js/components/file-viewer/utils.js b/lib/public/js/components/file-viewer/utils.js new file mode 100644 index 00000000..15ea5cab --- /dev/null +++ b/lib/public/js/components/file-viewer/utils.js @@ -0,0 +1,11 @@ +export const parsePathSegments = (inputPath) => + String(inputPath || "") + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + +export const clampSelectionIndex = (value, maxValue) => { + const numericValue = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(numericValue)) return 0; + return Math.max(0, Math.min(maxValue, numericValue)); +}; diff --git a/lib/server/routes/browse/constants.js b/lib/server/routes/browse/constants.js new file mode 100644 index 00000000..c7989b6b --- /dev/null +++ b/lib/server/routes/browse/constants.js @@ -0,0 +1,51 @@ +const kDefaultTreeDepth = 10; +const kIgnoredDirectoryNames = new Set([ + ".git", + ".alphaclaw", + "node_modules", + ".cache", + "dist", + "build", +]); +const kImageMimeTypeByExtension = new Map([ + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".gif", "image/gif"], + [".webp", "image/webp"], + [".svg", "image/svg+xml"], + [".bmp", "image/bmp"], + [".ico", "image/x-icon"], + [".avif", "image/avif"], +]); +const kCommitHistoryLimit = 12; +const kAudioMimeTypeByExtension = new Map([ + [".mp3", "audio/mpeg"], + [".wav", "audio/wav"], + [".ogg", "audio/ogg"], + [".oga", "audio/ogg"], + [".m4a", "audio/mp4"], + [".aac", "audio/aac"], + [".flac", "audio/flac"], + [".opus", "audio/opus"], + [".weba", "audio/webm"], +]); +const kSqliteFileExtensions = new Set([ + ".sqlite", + ".sqlite3", + ".db", + ".db3", + ".sdb", + ".sqlitedb", +]); +const kSqliteTablePageSize = 50; + +module.exports = { + kDefaultTreeDepth, + kIgnoredDirectoryNames, + kImageMimeTypeByExtension, + kCommitHistoryLimit, + kAudioMimeTypeByExtension, + kSqliteFileExtensions, + kSqliteTablePageSize, +}; diff --git a/lib/server/routes/browse/file-helpers.js b/lib/server/routes/browse/file-helpers.js new file mode 100644 index 00000000..77f740c6 --- /dev/null +++ b/lib/server/routes/browse/file-helpers.js @@ -0,0 +1,43 @@ +const path = require("path"); +const { + kImageMimeTypeByExtension, + kAudioMimeTypeByExtension, + kSqliteFileExtensions, +} = require("./constants"); + +const isLikelyBinaryFile = (fs, targetPath) => { + let fileHandle = null; + try { + fileHandle = fs.openSync(targetPath, "r"); + const sample = Buffer.alloc(512); + const bytesRead = fs.readSync(fileHandle, sample, 0, sample.length, 0); + for (let index = 0; index < bytesRead; index += 1) { + if (sample[index] === 0) return true; + } + return false; + } finally { + if (fileHandle !== null) fs.closeSync(fileHandle); + } +}; + +const getImageMimeType = (targetPath) => { + const extension = String(path.extname(targetPath || "") || "").toLowerCase(); + return kImageMimeTypeByExtension.get(extension) || ""; +}; + +const getAudioMimeType = (targetPath) => { + const extension = String(path.extname(targetPath || "") || "").toLowerCase(); + return kAudioMimeTypeByExtension.get(extension) || ""; +}; + +const isSqliteFilePath = (targetPath) => { + const extension = String(path.extname(targetPath || "") || "").toLowerCase(); + return kSqliteFileExtensions.has(extension); +}; + +module.exports = { + isLikelyBinaryFile, + getImageMimeType, + getAudioMimeType, + isSqliteFilePath, +}; diff --git a/lib/server/routes/browse/git.js b/lib/server/routes/browse/git.js new file mode 100644 index 00000000..d8c9a6d7 --- /dev/null +++ b/lib/server/routes/browse/git.js @@ -0,0 +1,131 @@ +const { execFile } = require("child_process"); + +const runGitCommand = (args, kRootResolved) => + new Promise((resolve) => { + execFile( + "git", + args, + { timeout: 10000, cwd: kRootResolved }, + (error, stdout, stderr) => { + if (error) { + return resolve({ + ok: false, + error: String( + stderr || stdout || error.message || "git command failed", + ).trim(), + }); + } + return resolve({ ok: true, stdout: String(stdout || "") }); + }, + ); + }); + +const runGitCommandWithExitCode = (args, kRootResolved) => + new Promise((resolve) => { + execFile( + "git", + args, + { timeout: 10000, cwd: kRootResolved }, + (error, stdout, stderr) => { + const safeStdout = String(stdout || ""); + const safeStderr = String(stderr || ""); + if (!error) { + return resolve({ + ok: true, + stdout: safeStdout, + stderr: safeStderr, + exitCode: 0, + }); + } + return resolve({ + ok: false, + stdout: safeStdout, + stderr: safeStderr, + exitCode: Number.isInteger(error.code) ? error.code : 1, + error: String(error.message || "git command failed"), + }); + }, + ); + }); + +const parseGithubRepoSlug = (value) => { + const raw = String(value || "").trim(); + if (!raw) return ""; + return raw + .replace(/^git@github\.com:/i, "") + .replace(/^https:\/\/github\.com\//i, "") + .replace(/\.git$/i, "") + .trim(); +}; + +const normalizeChangedPath = (rawPath) => { + const value = String(rawPath || "").trim(); + if (!value) return ""; + if (value.includes(" -> ")) { + const segments = value.split(" -> "); + return String(segments[segments.length - 1] || "").trim(); + } + return value; +}; + +const parseBranchTracking = (branchLine) => { + const safeBranchLine = String(branchLine || "").trim(); + const withoutPrefix = safeBranchLine.replace(/^##\s*/, ""); + const [localBranchRaw = "", trackingRaw = ""] = withoutPrefix.split("..."); + const localBranch = localBranchRaw || "unknown"; + const trackingSegment = String(trackingRaw || "").trim(); + const upstreamBranch = trackingSegment.split(" [")[0]?.trim() || ""; + const hasUpstream = upstreamBranch.length > 0; + const countsMatch = trackingSegment.match(/\[([^\]]+)\]/); + const countsRaw = countsMatch?.[1] || ""; + const countParts = countsRaw + .split(",") + .map((part) => String(part || "").trim()) + .filter(Boolean); + let aheadCount = 0; + let behindCount = 0; + let upstreamGone = false; + countParts.forEach((part) => { + const aheadMatch = part.match(/^ahead\s+(\d+)$/i); + if (aheadMatch?.[1]) { + aheadCount = Number.parseInt(aheadMatch[1], 10) || 0; + return; + } + const behindMatch = part.match(/^behind\s+(\d+)$/i); + if (behindMatch?.[1]) { + behindCount = Number.parseInt(behindMatch[1], 10) || 0; + return; + } + if (part.toLowerCase() === "gone") { + upstreamGone = true; + } + }); + const syncState = !hasUpstream + ? "no-upstream" + : upstreamGone + ? "upstream-gone" + : aheadCount > 0 && behindCount > 0 + ? "diverged" + : aheadCount > 0 + ? "ahead" + : behindCount > 0 + ? "behind" + : "up-to-date"; + return { + branch: localBranch, + upstreamBranch, + hasUpstream, + upstreamGone, + aheadCount, + behindCount, + syncState, + }; +}; + +module.exports = { + runGitCommand, + runGitCommandWithExitCode, + parseGithubRepoSlug, + normalizeChangedPath, + parseBranchTracking, +}; diff --git a/lib/server/routes/browse.js b/lib/server/routes/browse/index.js similarity index 50% rename from lib/server/routes/browse.js rename to lib/server/routes/browse/index.js index 7fbfd5d8..e461ec19 100644 --- a/lib/server/routes/browse.js +++ b/lib/server/routes/browse/index.js @@ -1,62 +1,30 @@ const path = require("path"); -const { execFile } = require("child_process"); -const { kLockedBrowsePaths } = require("../constants"); - -const kDefaultTreeDepth = 10; -const kIgnoredDirectoryNames = new Set([ - ".git", - ".alphaclaw", - "node_modules", - ".cache", - "dist", - "build", -]); -const kImageMimeTypeByExtension = new Map([ - [".png", "image/png"], - [".jpg", "image/jpeg"], - [".jpeg", "image/jpeg"], - [".gif", "image/gif"], - [".webp", "image/webp"], - [".svg", "image/svg+xml"], - [".bmp", "image/bmp"], - [".ico", "image/x-icon"], - [".avif", "image/avif"], -]); -const kCommitHistoryLimit = 12; -const kAudioMimeTypeByExtension = new Map([ - [".mp3", "audio/mpeg"], - [".wav", "audio/wav"], - [".ogg", "audio/ogg"], - [".oga", "audio/ogg"], - [".m4a", "audio/mp4"], - [".aac", "audio/aac"], - [".flac", "audio/flac"], - [".opus", "audio/opus"], - [".weba", "audio/webm"], -]); -const kSqliteFileExtensions = new Set([ - ".sqlite", - ".sqlite3", - ".db", - ".db3", - ".sdb", - ".sqlitedb", -]); -const kSqliteTablePageSize = 50; - -const matchesPolicyPath = (policyPathSet, normalizedPath) => { - const safeNormalizedPath = String(normalizedPath || "").trim(); - if (!safeNormalizedPath) return false; - for (const policyPath of policyPathSet) { - if ( - safeNormalizedPath === policyPath || - safeNormalizedPath.endsWith(`/${policyPath}`) - ) { - return true; - } - } - return false; -}; +const { kLockedBrowsePaths } = require("../../constants"); +const { + kDefaultTreeDepth, + kIgnoredDirectoryNames, + kCommitHistoryLimit, +} = require("./constants"); +const { + normalizePolicyPath, + resolveSafePath, + toRelativePath, + matchesPolicyPath, +} = require("./path-utils"); +const { + isLikelyBinaryFile, + getImageMimeType, + getAudioMimeType, + isSqliteFilePath, +} = require("./file-helpers"); +const { readSqliteSummary, readSqliteTableData } = require("./sqlite"); +const { + runGitCommand, + runGitCommandWithExitCode, + parseGithubRepoSlug, + normalizeChangedPath, + parseBranchTracking, +} = require("./git"); const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const kRootResolved = path.resolve(kRootDir); @@ -67,209 +35,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { fs.mkdirSync(kRootResolved, { recursive: true }); } - const normalizeRelativePath = (inputPath) => { - const rawPath = String(inputPath || "").trim(); - if (!rawPath) return ""; - return rawPath.replace(/\\/g, "/").replace(/^\/+/, ""); - }; - - const normalizePolicyPath = (inputPath) => - String(inputPath || "") - .replace(/\\/g, "/") - .replace(/^\.\/+/, "") - .replace(/^\/+/, "") - .trim() - .toLowerCase(); - - const resolveSafePath = (inputPath) => { - const relativePath = normalizeRelativePath(inputPath); - const absolutePath = path.resolve(kRootResolved, relativePath); - const isInsideRoot = - absolutePath === kRootResolved || absolutePath.startsWith(kRootWithSep); - if (!isInsideRoot) { - return { ok: false, error: `Path must stay within ${kRootDisplayName}` }; - } - return { ok: true, relativePath, absolutePath }; - }; - - const isLikelyBinaryFile = (targetPath) => { - let fileHandle = null; - try { - fileHandle = fs.openSync(targetPath, "r"); - const sample = Buffer.alloc(512); - const bytesRead = fs.readSync(fileHandle, sample, 0, sample.length, 0); - for (let index = 0; index < bytesRead; index += 1) { - if (sample[index] === 0) return true; - } - return false; - } finally { - if (fileHandle !== null) fs.closeSync(fileHandle); - } - }; - - const toRelativePath = (absolutePath) => { - const relative = path.relative(kRootResolved, absolutePath); - return relative === "" ? "" : relative.split(path.sep).join("/"); - }; - - const getImageMimeType = (targetPath) => { - const extension = String( - path.extname(targetPath || "") || "", - ).toLowerCase(); - return kImageMimeTypeByExtension.get(extension) || ""; - }; - - const getAudioMimeType = (targetPath) => { - const extension = String( - path.extname(targetPath || "") || "", - ).toLowerCase(); - return kAudioMimeTypeByExtension.get(extension) || ""; - }; - - const isSqliteFilePath = (targetPath) => { - const extension = String( - path.extname(targetPath || "") || "", - ).toLowerCase(); - return kSqliteFileExtensions.has(extension); - }; - - const quoteSqliteIdentifier = (value) => - `"${String(value || "").replaceAll('"', '""')}"`; - - const readSqliteSummary = (targetPath) => { - let DatabaseSync = null; - try { - ({ DatabaseSync } = require("node:sqlite")); - } catch { - throw new Error("SQLite preview is unavailable on this Node runtime"); - } - const database = new DatabaseSync(targetPath, { readOnly: true }); - try { - const allObjects = database - .prepare( - ` - SELECT name, type - FROM sqlite_master - WHERE type IN ('table', 'view') - AND name NOT LIKE 'sqlite_%' - ORDER BY type, name - `, - ) - .all(); - const maxObjects = 12; - const objects = allObjects.slice(0, maxObjects).map((entry) => { - const objectName = String(entry?.name || "").trim(); - const objectType = String(entry?.type || "table").trim() || "table"; - if (!objectName) return null; - const quotedName = quoteSqliteIdentifier(objectName); - const columns = database - .prepare(`PRAGMA table_info(${quotedName})`) - .all() - .map((column) => ({ - name: String(column?.name || "").trim(), - type: String(column?.type || "").trim(), - notNull: Number(column?.notnull || 0) === 1, - isPrimaryKey: Number(column?.pk || 0) > 0, - })); - let sampleRows = []; - if (objectType === "table") { - try { - sampleRows = database - .prepare(`SELECT * FROM ${quotedName} LIMIT 5`) - .all(); - } catch { - sampleRows = []; - } - } - return { - name: objectName, - type: objectType, - columns, - sampleRows, - }; - }); - return { - totalObjects: allObjects.length, - truncated: allObjects.length > maxObjects, - objects: objects.filter(Boolean), - }; - } finally { - database.close(); - } - }; - - const clampSqlitePageValue = (value, fallbackValue, maxValue) => { - const parsedValue = Number.parseInt(String(value ?? ""), 10); - if (!Number.isFinite(parsedValue)) return fallbackValue; - return Math.max(0, Math.min(maxValue, parsedValue)); - }; - - const readSqliteTableData = (targetPath, tableName, limit, offset) => { - const safeTableName = String(tableName || "").trim(); - if (!safeTableName) { - return { ok: false, error: "table is required" }; - } - let DatabaseSync = null; - try { - ({ DatabaseSync } = require("node:sqlite")); - } catch { - return { ok: false, error: "SQLite preview is unavailable on this Node runtime" }; - } - const database = new DatabaseSync(targetPath, { readOnly: true }); - try { - const quotedTableName = quoteSqliteIdentifier(safeTableName); - const tableExists = database - .prepare( - ` - SELECT 1 - FROM sqlite_master - WHERE type IN ('table', 'view') - AND name = ? - LIMIT 1 - `, - ) - .get(safeTableName); - if (!tableExists) { - return { ok: false, error: "table not found" }; - } - const columns = database - .prepare(`PRAGMA table_info(${quotedTableName})`) - .all() - .map((column) => ({ - name: String(column?.name || "").trim(), - type: String(column?.type || "").trim(), - notNull: Number(column?.notnull || 0) === 1, - isPrimaryKey: Number(column?.pk || 0) > 0, - })); - const totalRowsResult = database - .prepare(`SELECT COUNT(*) AS count FROM ${quotedTableName}`) - .get(); - const totalRows = Number(totalRowsResult?.count || 0); - const safeLimit = clampSqlitePageValue(limit, kSqliteTablePageSize, 200) || kSqliteTablePageSize; - const safeOffset = clampSqlitePageValue(offset, 0, Number.MAX_SAFE_INTEGER); - const rows = database - .prepare(`SELECT * FROM ${quotedTableName} LIMIT ? OFFSET ?`) - .all(safeLimit, safeOffset); - return { - ok: true, - table: safeTableName, - columns, - rows, - limit: safeLimit, - offset: safeOffset, - totalRows, - }; - } catch (error) { - return { ok: false, error: error.message || "Could not read sqlite table" }; - } finally { - database.close(); - } - }; - const buildTreeNode = (absolutePath, depthRemaining) => { const stats = fs.statSync(absolutePath); const nodeName = path.basename(absolutePath); - const nodePath = toRelativePath(absolutePath); + const nodePath = toRelativePath(absolutePath, kRootResolved); if (!stats.isDirectory()) { return { type: "file", name: nodeName, path: nodePath }; @@ -300,128 +69,6 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { return { type: "folder", name: nodeName, path: nodePath, children }; }; - const runGitCommand = (args) => - new Promise((resolve) => { - execFile( - "git", - args, - { timeout: 10000, cwd: kRootResolved }, - (error, stdout, stderr) => { - if (error) { - return resolve({ - ok: false, - error: String( - stderr || stdout || error.message || "git command failed", - ).trim(), - }); - } - return resolve({ ok: true, stdout: String(stdout || "") }); - }, - ); - }); - - const runGitCommandWithExitCode = (args) => - new Promise((resolve) => { - execFile( - "git", - args, - { timeout: 10000, cwd: kRootResolved }, - (error, stdout, stderr) => { - const safeStdout = String(stdout || ""); - const safeStderr = String(stderr || ""); - if (!error) { - return resolve({ - ok: true, - stdout: safeStdout, - stderr: safeStderr, - exitCode: 0, - }); - } - return resolve({ - ok: false, - stdout: safeStdout, - stderr: safeStderr, - exitCode: Number.isInteger(error.code) ? error.code : 1, - error: String(error.message || "git command failed"), - }); - }, - ); - }); - - const parseGithubRepoSlug = (value) => { - const raw = String(value || "").trim(); - if (!raw) return ""; - return raw - .replace(/^git@github\.com:/i, "") - .replace(/^https:\/\/github\.com\//i, "") - .replace(/\.git$/i, "") - .trim(); - }; - - const normalizeChangedPath = (rawPath) => { - const value = String(rawPath || "").trim(); - if (!value) return ""; - if (value.includes(" -> ")) { - const segments = value.split(" -> "); - return String(segments[segments.length - 1] || "").trim(); - } - return value; - }; - - const parseBranchTracking = (branchLine) => { - const safeBranchLine = String(branchLine || "").trim(); - const withoutPrefix = safeBranchLine.replace(/^##\s*/, ""); - const [localBranchRaw = "", trackingRaw = ""] = withoutPrefix.split("..."); - const localBranch = localBranchRaw || "unknown"; - const trackingSegment = String(trackingRaw || "").trim(); - const upstreamBranch = trackingSegment.split(" [")[0]?.trim() || ""; - const hasUpstream = upstreamBranch.length > 0; - const countsMatch = trackingSegment.match(/\[([^\]]+)\]/); - const countsRaw = countsMatch?.[1] || ""; - const countParts = countsRaw - .split(",") - .map((part) => String(part || "").trim()) - .filter(Boolean); - let aheadCount = 0; - let behindCount = 0; - let upstreamGone = false; - countParts.forEach((part) => { - const aheadMatch = part.match(/^ahead\s+(\d+)$/i); - if (aheadMatch?.[1]) { - aheadCount = Number.parseInt(aheadMatch[1], 10) || 0; - return; - } - const behindMatch = part.match(/^behind\s+(\d+)$/i); - if (behindMatch?.[1]) { - behindCount = Number.parseInt(behindMatch[1], 10) || 0; - return; - } - if (part.toLowerCase() === "gone") { - upstreamGone = true; - } - }); - const syncState = !hasUpstream - ? "no-upstream" - : upstreamGone - ? "upstream-gone" - : aheadCount > 0 && behindCount > 0 - ? "diverged" - : aheadCount > 0 - ? "ahead" - : behindCount > 0 - ? "behind" - : "up-to-date"; - return { - branch: localBranch, - upstreamBranch, - hasUpstream, - upstreamGone, - aheadCount, - behindCount, - syncState, - }; - }; - app.get("/api/browse/tree", (req, res) => { const depthValue = Number.parseInt(String(req.query.depth || ""), 10); const depth = @@ -432,17 +79,20 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const tree = buildTreeNode(kRootResolved, depth); return res.json({ ok: true, root: tree }); } catch (error) { - return res - .status(500) - .json({ - ok: false, - error: error.message || "Could not build file tree", - }); + return res.status(500).json({ + ok: false, + error: error.message || "Could not build file tree", + }); } }); app.get("/api/browse/read", (req, res) => { - const resolvedPath = resolveSafePath(req.query.path); + const resolvedPath = resolveSafePath( + req.query.path, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); if (!resolvedPath.ok) { return res.status(400).json({ ok: false, error: resolvedPath.error }); } @@ -475,7 +125,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { content: "", }); } - if (isLikelyBinaryFile(resolvedPath.absolutePath)) { + if (isLikelyBinaryFile(fs, resolvedPath.absolutePath)) { const imageMimeType = getImageMimeType(resolvedPath.absolutePath); if (!imageMimeType) { return res @@ -512,11 +162,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const envRepoSlug = parseGithubRepoSlug( process.env.GITHUB_WORKSPACE_REPO || "", ); - const statusResult = await runGitCommand([ - "status", - "--porcelain", - "--branch", - ]); + const statusResult = await runGitCommand( + ["status", "--porcelain", "--branch"], + kRootResolved, + ); if (!statusResult.ok) { if (/not a git repository/i.test(statusResult.error || "")) { return res.json({ @@ -535,15 +184,13 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .split("\n") .map((line) => line.trimEnd()) .filter(Boolean); - const branchLine = - statusLines.find((line) => line.startsWith("##")) || ""; + const branchLine = statusLines.find((line) => line.startsWith("##")) || ""; const branchTracking = parseBranchTracking(branchLine); const branch = branchTracking.branch; - const diffNumstatResult = await runGitCommand([ - "diff", - "--numstat", - "HEAD", - ]); + const diffNumstatResult = await runGitCommand( + ["diff", "--numstat", "HEAD"], + kRootResolved, + ); const diffStatsByPath = new Map(); if (diffNumstatResult.ok) { diffNumstatResult.stdout @@ -589,23 +236,25 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { let repoSlug = envRepoSlug; if (!repoSlug) { - const remoteResult = await runGitCommand([ - "remote", - "get-url", - "origin", - ]); + const remoteResult = await runGitCommand( + ["remote", "get-url", "origin"], + kRootResolved, + ); if (remoteResult.ok) { repoSlug = parseGithubRepoSlug(remoteResult.stdout || ""); } } const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : ""; - const logResult = await runGitCommand([ - "log", - "--pretty=format:%H%x09%h%x09%s%x09%ct", - "-n", - String(kCommitHistoryLimit), - ]); + const logResult = await runGitCommand( + [ + "log", + "--pretty=format:%H%x09%h%x09%s%x09%ct", + "-n", + String(kCommitHistoryLimit), + ], + kRootResolved, + ); const commits = logResult.ok ? logResult.stdout .split("\n") @@ -651,7 +300,12 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { }); app.get("/api/browse/sqlite-table", (req, res) => { - const resolvedPath = resolveSafePath(req.query.path); + const resolvedPath = resolveSafePath( + req.query.path, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); if (!resolvedPath.ok) { return res.status(400).json({ ok: false, error: resolvedPath.error }); } @@ -686,7 +340,12 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { }); app.get("/api/browse/git-diff", async (req, res) => { - const resolvedPath = resolveSafePath(req.query.path); + const resolvedPath = resolveSafePath( + req.query.path, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); if (!resolvedPath.ok) { return res.status(400).json({ ok: false, error: resolvedPath.error }); } @@ -696,19 +355,15 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } try { - const statusResult = await runGitCommandWithExitCode([ - "status", - "--porcelain", - "--", - relativePath, - ]); + const statusResult = await runGitCommandWithExitCode( + ["status", "--porcelain", "--", relativePath], + kRootResolved, + ); if ( !statusResult.ok && /not a git repository/i.test(statusResult.stderr || "") ) { - return res - .status(400) - .json({ ok: false, error: "No git repo at this root" }); + return res.status(400).json({ ok: false, error: "No git repo at this root" }); } const statusLines = statusResult.stdout .split("\n") @@ -717,22 +372,21 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const isUntracked = statusLines.some((line) => line.startsWith("??")); const diffResult = isUntracked - ? await runGitCommandWithExitCode([ - "diff", - "--no-index", - "--", - "/dev/null", - resolvedPath.absolutePath, - ]) - : await runGitCommandWithExitCode(["diff", "HEAD", "--", relativePath]); + ? await runGitCommandWithExitCode( + ["diff", "--no-index", "--", "/dev/null", resolvedPath.absolutePath], + kRootResolved, + ) + : await runGitCommandWithExitCode( + ["diff", "HEAD", "--", relativePath], + kRootResolved, + ); const untrackedAllowedFailure = isUntracked && diffResult.exitCode === 1 && diffResult.stdout; if (!diffResult.ok && !untrackedAllowedFailure) { return res.status(500).json({ ok: false, - error: - diffResult.stderr || diffResult.error || "Could not load file diff", + error: diffResult.stderr || diffResult.error || "Could not load file diff", }); } @@ -745,12 +399,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { content, }); } catch (error) { - return res - .status(500) - .json({ - ok: false, - error: error.message || "Could not load file diff", - }); + return res.status(500).json({ + ok: false, + error: error.message || "Could not load file diff", + }); } }); @@ -758,16 +410,13 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { try { const commitMessageRaw = String(req.body?.message || "").trim(); const commitMessage = commitMessageRaw || "sync changes"; - const statusResult = await runGitCommand([ - "status", - "--porcelain", - "--branch", - ]); + const statusResult = await runGitCommand( + ["status", "--porcelain", "--branch"], + kRootResolved, + ); if (!statusResult.ok) { if (/not a git repository/i.test(statusResult.error || "")) { - return res - .status(400) - .json({ ok: false, error: "No git repo at this root" }); + return res.status(400).json({ ok: false, error: "No git repo at this root" }); } return res.status(500).json({ ok: false, @@ -778,8 +427,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .split("\n") .map((line) => line.trimEnd()) .filter(Boolean); - const branchLine = - statusLines.find((line) => line.startsWith("##")) || ""; + const branchLine = statusLines.find((line) => line.startsWith("##")) || ""; const branchTracking = parseBranchTracking(branchLine); const hasChanges = statusLines @@ -802,18 +450,17 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } } if (hasChanges) { - const addResult = await runGitCommand(["add", "-A"]); + const addResult = await runGitCommand(["add", "-A"], kRootResolved); if (!addResult.ok) { return res.status(500).json({ ok: false, error: addResult.error || "Could not stage changes", }); } - const commitResult = await runGitCommand([ - "commit", - "-m", - commitMessage, - ]); + const commitResult = await runGitCommand( + ["commit", "-m", commitMessage], + kRootResolved, + ); if (!commitResult.ok) { if (/nothing to commit/i.test(commitResult.error || "")) { return res.json({ @@ -829,11 +476,10 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { }); } committed = true; - const shortHashResult = await runGitCommand([ - "rev-parse", - "--short", - "HEAD", - ]); + const shortHashResult = await runGitCommand( + ["rev-parse", "--short", "HEAD"], + kRootResolved, + ); shortHash = shortHashResult.ok ? String(shortHashResult.stdout || "").trim() : ""; @@ -845,7 +491,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const pushArgs = branchTracking.hasUpstream ? ["push"] : ["push", "-u", "origin", "HEAD"]; - const pushResult = await runGitCommand(pushArgs); + const pushResult = await runGitCommand(pushArgs, kRootResolved); if (pushResult.ok) { pushed = true; } else { @@ -885,7 +531,12 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { app.put("/api/browse/write", async (req, res) => { const { path: targetPath, content } = req.body || {}; - const resolvedPath = resolveSafePath(targetPath); + const resolvedPath = resolveSafePath( + targetPath, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); if (!resolvedPath.ok) { return res.status(400).json({ ok: false, error: resolvedPath.error }); } @@ -897,9 +548,7 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { }); } if (typeof content !== "string") { - return res - .status(400) - .json({ ok: false, error: "content must be a string" }); + return res.status(400).json({ ok: false, error: "content must be a string" }); } try { diff --git a/lib/server/routes/browse/path-utils.js b/lib/server/routes/browse/path-utils.js new file mode 100644 index 00000000..d59d28c2 --- /dev/null +++ b/lib/server/routes/browse/path-utils.js @@ -0,0 +1,53 @@ +const path = require("path"); + +const normalizeRelativePath = (inputPath) => { + const rawPath = String(inputPath || "").trim(); + if (!rawPath) return ""; + return rawPath.replace(/\\/g, "/").replace(/^\/+/, ""); +}; + +const normalizePolicyPath = (inputPath) => + String(inputPath || "") + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/^\/+/, "") + .trim() + .toLowerCase(); + +const resolveSafePath = (inputPath, kRootResolved, kRootWithSep, kRootDisplayName) => { + const relativePath = normalizeRelativePath(inputPath); + const absolutePath = path.resolve(kRootResolved, relativePath); + const isInsideRoot = + absolutePath === kRootResolved || absolutePath.startsWith(kRootWithSep); + if (!isInsideRoot) { + return { ok: false, error: `Path must stay within ${kRootDisplayName}` }; + } + return { ok: true, relativePath, absolutePath }; +}; + +const toRelativePath = (absolutePath, kRootResolved) => { + const relative = path.relative(kRootResolved, absolutePath); + return relative === "" ? "" : relative.split(path.sep).join("/"); +}; + +const matchesPolicyPath = (policyPathSet, normalizedPath) => { + const safeNormalizedPath = String(normalizedPath || "").trim(); + if (!safeNormalizedPath) return false; + for (const policyPath of policyPathSet) { + if ( + safeNormalizedPath === policyPath || + safeNormalizedPath.endsWith(`/${policyPath}`) + ) { + return true; + } + } + return false; +}; + +module.exports = { + normalizeRelativePath, + normalizePolicyPath, + resolveSafePath, + toRelativePath, + matchesPolicyPath, +}; diff --git a/lib/server/routes/browse/sqlite.js b/lib/server/routes/browse/sqlite.js new file mode 100644 index 00000000..e845bf8b --- /dev/null +++ b/lib/server/routes/browse/sqlite.js @@ -0,0 +1,140 @@ +const { kSqliteTablePageSize } = require("./constants"); + +const quoteSqliteIdentifier = (value) => + `"${String(value || "").replaceAll('"', '""')}"`; + +const readSqliteSummary = (targetPath) => { + let DatabaseSync = null; + try { + ({ DatabaseSync } = require("node:sqlite")); + } catch { + throw new Error("SQLite preview is unavailable on this Node runtime"); + } + const database = new DatabaseSync(targetPath, { readOnly: true }); + try { + const allObjects = database + .prepare( + ` + SELECT name, type + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + `, + ) + .all(); + const maxObjects = 12; + const objects = allObjects.slice(0, maxObjects).map((entry) => { + const objectName = String(entry?.name || "").trim(); + const objectType = String(entry?.type || "table").trim() || "table"; + if (!objectName) return null; + const quotedName = quoteSqliteIdentifier(objectName); + const columns = database + .prepare(`PRAGMA table_info(${quotedName})`) + .all() + .map((column) => ({ + name: String(column?.name || "").trim(), + type: String(column?.type || "").trim(), + notNull: Number(column?.notnull || 0) === 1, + isPrimaryKey: Number(column?.pk || 0) > 0, + })); + let sampleRows = []; + if (objectType === "table") { + try { + sampleRows = database.prepare(`SELECT * FROM ${quotedName} LIMIT 5`).all(); + } catch { + sampleRows = []; + } + } + return { + name: objectName, + type: objectType, + columns, + sampleRows, + }; + }); + return { + totalObjects: allObjects.length, + truncated: allObjects.length > maxObjects, + objects: objects.filter(Boolean), + }; + } finally { + database.close(); + } +}; + +const clampSqlitePageValue = (value, fallbackValue, maxValue) => { + const parsedValue = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsedValue)) return fallbackValue; + return Math.max(0, Math.min(maxValue, parsedValue)); +}; + +const readSqliteTableData = (targetPath, tableName, limit, offset) => { + const safeTableName = String(tableName || "").trim(); + if (!safeTableName) { + return { ok: false, error: "table is required" }; + } + let DatabaseSync = null; + try { + ({ DatabaseSync } = require("node:sqlite")); + } catch { + return { ok: false, error: "SQLite preview is unavailable on this Node runtime" }; + } + const database = new DatabaseSync(targetPath, { readOnly: true }); + try { + const quotedTableName = quoteSqliteIdentifier(safeTableName); + const tableExists = database + .prepare( + ` + SELECT 1 + FROM sqlite_master + WHERE type IN ('table', 'view') + AND name = ? + LIMIT 1 + `, + ) + .get(safeTableName); + if (!tableExists) { + return { ok: false, error: "table not found" }; + } + const columns = database + .prepare(`PRAGMA table_info(${quotedTableName})`) + .all() + .map((column) => ({ + name: String(column?.name || "").trim(), + type: String(column?.type || "").trim(), + notNull: Number(column?.notnull || 0) === 1, + isPrimaryKey: Number(column?.pk || 0) > 0, + })); + const totalRowsResult = database + .prepare(`SELECT COUNT(*) AS count FROM ${quotedTableName}`) + .get(); + const totalRows = Number(totalRowsResult?.count || 0); + const safeLimit = + clampSqlitePageValue(limit, kSqliteTablePageSize, 200) || kSqliteTablePageSize; + const safeOffset = clampSqlitePageValue(offset, 0, Number.MAX_SAFE_INTEGER); + const rows = database + .prepare(`SELECT * FROM ${quotedTableName} LIMIT ? OFFSET ?`) + .all(safeLimit, safeOffset); + return { + ok: true, + table: safeTableName, + columns, + rows, + limit: safeLimit, + offset: safeOffset, + totalRows, + }; + } catch (error) { + return { ok: false, error: error.message || "Could not read sqlite table" }; + } finally { + database.close(); + } +}; + +module.exports = { + quoteSqliteIdentifier, + readSqliteSummary, + clampSqlitePageValue, + readSqliteTableData, +}; From 796ca1a369769103ca43227382d3b9535e39383b Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 23:43:08 -0800 Subject: [PATCH 058/417] Hoist file viewer UI sections into focused subcomponents. This reduces index.js surface area and keeps rendering behavior unchanged while making markdown, diff, media, and status sections easier to evolve. --- .../js/components/file-viewer/diff-viewer.js | 46 ++ .../components/file-viewer/editor-surface.js | 120 +++++ .../file-viewer/frontmatter-panel.js | 56 +++ lib/public/js/components/file-viewer/index.js | 471 +++--------------- .../file-viewer/markdown-split-view.js | 51 ++ .../components/file-viewer/media-preview.js | 44 ++ .../components/file-viewer/status-banners.js | 59 +++ .../js/components/file-viewer/toolbar.js | 77 +++ 8 files changed, 536 insertions(+), 388 deletions(-) create mode 100644 lib/public/js/components/file-viewer/diff-viewer.js create mode 100644 lib/public/js/components/file-viewer/editor-surface.js create mode 100644 lib/public/js/components/file-viewer/frontmatter-panel.js create mode 100644 lib/public/js/components/file-viewer/markdown-split-view.js create mode 100644 lib/public/js/components/file-viewer/media-preview.js create mode 100644 lib/public/js/components/file-viewer/status-banners.js create mode 100644 lib/public/js/components/file-viewer/toolbar.js diff --git a/lib/public/js/components/file-viewer/diff-viewer.js b/lib/public/js/components/file-viewer/diff-viewer.js new file mode 100644 index 00000000..c06b6d56 --- /dev/null +++ b/lib/public/js/components/file-viewer/diff-viewer.js @@ -0,0 +1,46 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { LoadingSpinner } from "../loading-spinner.js"; + +const html = htm.bind(h); + +const getDiffLineClass = (line) => { + if (line.startsWith("+") && !line.startsWith("+++")) return "is-added"; + if (line.startsWith("-") && !line.startsWith("---")) return "is-removed"; + if (line.startsWith("@@")) return "is-hunk"; + if ( + line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") + ) { + return "is-header"; + } + return ""; +}; + +export const DiffViewer = ({ diffLoading, diffError, diffContent }) => html` +
    + ${diffLoading + ? html` +
    + <${LoadingSpinner} className="h-4 w-4" /> +
    + ` + : diffError + ? html`
    ${diffError}
    ` + : html` +
    +${(diffContent || "").split("\n").map((line, lineIndex) => html`
    +                
    + ${line || " "} +
    + `)} +
    + `} +
    +`; diff --git a/lib/public/js/components/file-viewer/editor-surface.js b/lib/public/js/components/file-viewer/editor-surface.js new file mode 100644 index 00000000..e0f2ccdc --- /dev/null +++ b/lib/public/js/components/file-viewer/editor-surface.js @@ -0,0 +1,120 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; + +const html = htm.bind(h); + +const EditorTextarea = ({ + overlay = false, + editorTextareaRef, + renderContent, + handleContentInput, + handleEditorScroll, + handleEditorSelectionChange, + isEditBlocked, + isPreviewOnly, +}) => html` + +`; + +export const EditorSurface = ({ + editorShellClassName = "file-viewer-editor-shell", + editorShellAriaHidden, + editorLineNumbers, + editorLineNumbersRef, + editorLineNumberRowRefs, + shouldUseHighlightedEditor, + highlightedEditorLines, + editorHighlightRef, + editorHighlightLineRefs, + editorTextareaRef, + renderContent, + handleContentInput, + handleEditorScroll, + handleEditorSelectionChange, + isEditBlocked, + isPreviewOnly, +}) => html` +
    +
    + ${editorLineNumbers.map( + (lineNumber) => html` +
    { + editorLineNumberRowRefs.current[lineNumber - 1] = element; + }} + > + ${lineNumber} +
    + `, + )} +
    + ${shouldUseHighlightedEditor + ? html` +
    +
    + ${highlightedEditorLines.map( + (line) => html` +
    { + editorHighlightLineRefs.current[line.lineNumber - 1] = element; + }} + > + +
    + `, + )} +
    + <${EditorTextarea} + overlay=${true} + editorTextareaRef=${editorTextareaRef} + renderContent=${renderContent} + handleContentInput=${handleContentInput} + handleEditorScroll=${handleEditorScroll} + handleEditorSelectionChange=${handleEditorSelectionChange} + isEditBlocked=${isEditBlocked} + isPreviewOnly=${isPreviewOnly} + /> +
    + ` + : html` + <${EditorTextarea} + overlay=${false} + editorTextareaRef=${editorTextareaRef} + renderContent=${renderContent} + handleContentInput=${handleContentInput} + handleEditorScroll=${handleEditorScroll} + handleEditorSelectionChange=${handleEditorSelectionChange} + isEditBlocked=${isEditBlocked} + isPreviewOnly=${isPreviewOnly} + /> + `} +
    +`; diff --git a/lib/public/js/components/file-viewer/frontmatter-panel.js b/lib/public/js/components/file-viewer/frontmatter-panel.js new file mode 100644 index 00000000..a465892b --- /dev/null +++ b/lib/public/js/components/file-viewer/frontmatter-panel.js @@ -0,0 +1,56 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { formatFrontmatterValue } from "../../lib/syntax-highlighters/index.js"; + +const html = htm.bind(h); + +export const FrontmatterPanel = ({ + isMarkdownFile, + parsedFrontmatter, + frontmatterCollapsed, + setFrontmatterCollapsed, +}) => { + if (!isMarkdownFile || parsedFrontmatter.entries.length <= 0) return null; + + return html` +
    + + ${!frontmatterCollapsed + ? html` +
    + ${parsedFrontmatter.entries.map((entry) => { + const formattedValue = formatFrontmatterValue(entry.rawValue); + const isMultilineValue = formattedValue.includes("\n"); + return html` +
    +
    ${entry.key}
    + ${isMultilineValue + ? html` +
    +${formattedValue}
    + ` + : html`
    ${formattedValue}
    `} +
    + `; + })} +
    + ` + : null} +
    + `; +}; diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js index b9b1b4e2..97d5ed8e 100644 --- a/lib/public/js/components/file-viewer/index.js +++ b/lib/public/js/components/file-viewer/index.js @@ -4,7 +4,6 @@ import htm from "https://esm.sh/htm"; import { marked } from "https://esm.sh/marked"; import { fetchBrowseFileDiff, saveFileContent } from "../../lib/api.js"; import { - formatFrontmatterValue, getFileSyntaxKind, highlightEditorLines, parseFrontmatter, @@ -20,10 +19,7 @@ import { matchesBrowsePolicyPath, normalizeBrowsePolicyPath, } from "../../lib/browse-file-policies.js"; -import { ActionButton } from "../action-button.js"; import { LoadingSpinner } from "../loading-spinner.js"; -import { SegmentedControl } from "../segmented-control.js"; -import { LockLineIcon, SaveFillIcon } from "../icons.js"; import { showToast } from "../toast.js"; import { kFileViewerModeStorageKey, @@ -39,6 +35,13 @@ import { clampSelectionIndex, parsePathSegments } from "./utils.js"; import { getScrollRatio, useScrollSync } from "./scroll-sync.js"; import { useFileLoader } from "./use-file-loader.js"; import { SqliteViewer } from "./sqlite-viewer.js"; +import { FileViewerToolbar } from "./toolbar.js"; +import { FileViewerStatusBanners } from "./status-banners.js"; +import { FrontmatterPanel } from "./frontmatter-panel.js"; +import { DiffViewer } from "./diff-viewer.js"; +import { MediaPreview } from "./media-preview.js"; +import { EditorSurface } from "./editor-surface.js"; +import { MarkdownSplitView } from "./markdown-split-view.js"; const html = htm.bind(h); @@ -408,145 +411,38 @@ export const FileViewer = ({ return html`
    -
    -
    - f - - ${pathSegments.map( - (segment, index) => html` - - - ${segment} - - ${index < pathSegments.length - 1 && - html`>`} - - `, - )} - - ${isDirty - ? html`` - : null} -
    -
    - ${isPreviewOnly ? html`
    Preview
    ` : null} - ${!isDiffView && - isMarkdownFile && - html` - <${SegmentedControl} - className="mr-2.5" - options=${[ - { label: "edit", value: "edit" }, - { label: "preview", value: "preview" }, - ]} - value=${viewMode} - onChange=${handleChangeViewMode} - /> - `} - ${!isDiffView - ? !isImageFile && !isAudioFile && !isSqliteFile - ? html` - <${ActionButton} - onClick=${handleSave} - disabled=${loading || !isDirty || !canEditFile || isEditBlocked} - loading=${saving} - tone=${isDirty ? "primary" : "secondary"} - size="sm" - idleLabel="Save" - loadingLabel="Saving..." - idleIcon=${SaveFillIcon} - idleIconClassName="file-viewer-save-icon" - className="file-viewer-save-action" - /> - ` - : null - : null} -
    - ${isDiffView - ? html` -
    -
    Viewing unsynced changes
    - <${ActionButton} - onClick=${() => onRequestEdit(normalizedPath)} - tone="secondary" - size="sm" - idleLabel="View file" - /> -
    - ` - : null} - ${!isDiffView && isLockedFile - ? html` -
    - <${LockLineIcon} className="file-viewer-protected-banner-icon" /> -
    - This file is managed by AlphaClaw and cannot be edited. -
    -
    - ` - : null} - ${!isDiffView && isProtectedFile - ? html` -
    -
    - Protected file. Changes may break workspace behavior. -
    - ${isProtectedLocked - ? html` - <${ActionButton} - onClick=${handleEditProtectedFile} - tone="warning" - size="sm" - idleLabel="Edit anyway" - /> - ` - : null} -
    - ` - : null} - ${isMarkdownFile && parsedFrontmatter.entries.length > 0 - ? html` -
    - - ${!frontmatterCollapsed - ? html` -
    - ${parsedFrontmatter.entries.map((entry) => { - const formattedValue = formatFrontmatterValue(entry.rawValue); - const isMultilineValue = formattedValue.includes("\n"); - return html` -
    -
    ${entry.key}
    - ${isMultilineValue - ? html` -
    -${formattedValue}
    - ` - : html`
    ${formattedValue}
    `} -
    - `; - })} -
    - ` - : null} -
    - ` - : null} + <${FileViewerToolbar} + pathSegments=${pathSegments} + isDirty=${isDirty} + isPreviewOnly=${isPreviewOnly} + isDiffView=${isDiffView} + isMarkdownFile=${isMarkdownFile} + viewMode=${viewMode} + handleChangeViewMode=${handleChangeViewMode} + handleSave=${handleSave} + loading=${loading} + canEditFile=${canEditFile} + isEditBlocked=${isEditBlocked} + isImageFile=${isImageFile} + isAudioFile=${isAudioFile} + isSqliteFile=${isSqliteFile} + saving=${saving} + /> + <${FileViewerStatusBanners} + isDiffView=${isDiffView} + onRequestEdit=${onRequestEdit} + normalizedPath=${normalizedPath} + isLockedFile=${isLockedFile} + isProtectedFile=${isProtectedFile} + isProtectedLocked=${isProtectedLocked} + handleEditProtectedFile=${handleEditProtectedFile} + /> + <${FrontmatterPanel} + isMarkdownFile=${isMarkdownFile} + parsedFrontmatter=${parsedFrontmatter} + frontmatterCollapsed=${frontmatterCollapsed} + setFrontmatterCollapsed=${setFrontmatterCollapsed} + /> ${loading ? html`
    @@ -563,41 +459,16 @@ ${formattedValue} ` - : isImageFile + : isImageFile || isAudioFile ? html` -
    - ${imageDataUrl - ? html` - ${pathSegments[pathSegments.length - ` - : html` -
    Could not render image preview.
    - `} -
    + <${MediaPreview} + isImageFile=${isImageFile} + imageDataUrl=${imageDataUrl} + pathSegments=${pathSegments} + isAudioFile=${isAudioFile} + audioDataUrl=${audioDataUrl} + /> ` - : isAudioFile - ? html` -
    - ${audioDataUrl - ? html` - - ` - : html` -
    Could not render audio preview.
    - `} -
    - ` : isSqliteFile ? html` <${SqliteViewer} @@ -613,223 +484,47 @@ ${formattedValue} ` : isDiffView - ? html` -
    - ${diffLoading - ? html` -
    - <${LoadingSpinner} className="h-4 w-4" /> -
    - ` - : diffError - ? html` -
    - ${diffError} -
    - ` - : html` -
    -${(diffContent || "").split("\n").map((line, lineIndex) => {
    -                                    const lineClass =
    -                                      line.startsWith("+") && !line.startsWith("+++")
    -                                        ? "is-added"
    -                                        : line.startsWith("-") && !line.startsWith("---")
    -                                          ? "is-removed"
    -                                          : line.startsWith("@@")
    -                                            ? "is-hunk"
    -                                            : line.startsWith("diff ") ||
    -                                                line.startsWith("index ") ||
    -                                                line.startsWith("--- ") ||
    -                                                line.startsWith("+++ ")
    -                                              ? "is-header"
    -                                              : "";
    -                                    return html`
    -                                      
    - ${line || " "} -
    - `; - })} -
    - `} -
    - ` + ? html`<${DiffViewer} diffLoading=${diffLoading} diffError=${diffError} diffContent=${diffContent} />` : html` ${isMarkdownFile ? html` -
    -
    -
    - ${editorLineNumbers.map( - (lineNumber) => html` -
    { - editorLineNumberRowRefs.current[lineNumber - 1] = element; - }} - > - ${lineNumber} -
    - `, - )} -
    -
    -
    - ${highlightedEditorLines.map( - (line) => html` -
    { - editorHighlightLineRefs.current[line.lineNumber - 1] = - element; - }} - > - -
    - `, - )} -
    - -
    -
    + <${MarkdownSplitView} + viewMode=${viewMode} + previewRef=${previewRef} + handlePreviewScroll=${handlePreviewScroll} + previewHtml=${previewHtml} + editorLineNumbers=${editorLineNumbers} + editorLineNumbersRef=${editorLineNumbersRef} + editorLineNumberRowRefs=${editorLineNumberRowRefs} + highlightedEditorLines=${highlightedEditorLines} + editorHighlightRef=${editorHighlightRef} + editorHighlightLineRefs=${editorHighlightLineRefs} + editorTextareaRef=${editorTextareaRef} + renderContent=${renderContent} + handleContentInput=${handleContentInput} + handleEditorScroll=${handleEditorScroll} + handleEditorSelectionChange=${handleEditorSelectionChange} + isEditBlocked=${isEditBlocked} + isPreviewOnly=${isPreviewOnly} + /> ` : html` -
    -
    - ${editorLineNumbers.map( - (lineNumber) => html` -
    { - editorLineNumberRowRefs.current[lineNumber - 1] = element; - }} - > - ${lineNumber} -
    - `, - )} -
    - ${shouldUseHighlightedEditor - ? html` -
    -
    - ${highlightedEditorLines.map( - (line) => html` -
    { - editorHighlightLineRefs.current[ - line.lineNumber - 1 - ] = element; - }} - > - -
    - `, - )} -
    - -
    - ` - : html` - - `} -
    + <${EditorSurface} + editorLineNumbers=${editorLineNumbers} + editorLineNumbersRef=${editorLineNumbersRef} + editorLineNumberRowRefs=${editorLineNumberRowRefs} + shouldUseHighlightedEditor=${shouldUseHighlightedEditor} + highlightedEditorLines=${highlightedEditorLines} + editorHighlightRef=${editorHighlightRef} + editorHighlightLineRefs=${editorHighlightLineRefs} + editorTextareaRef=${editorTextareaRef} + renderContent=${renderContent} + handleContentInput=${handleContentInput} + handleEditorScroll=${handleEditorScroll} + handleEditorSelectionChange=${handleEditorSelectionChange} + isEditBlocked=${isEditBlocked} + isPreviewOnly=${isPreviewOnly} + /> `} `}
    diff --git a/lib/public/js/components/file-viewer/markdown-split-view.js b/lib/public/js/components/file-viewer/markdown-split-view.js new file mode 100644 index 00000000..e53b5a46 --- /dev/null +++ b/lib/public/js/components/file-viewer/markdown-split-view.js @@ -0,0 +1,51 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { EditorSurface } from "./editor-surface.js"; + +const html = htm.bind(h); + +export const MarkdownSplitView = ({ + viewMode, + previewRef, + handlePreviewScroll, + previewHtml, + editorLineNumbers, + editorLineNumbersRef, + editorLineNumberRowRefs, + highlightedEditorLines, + editorHighlightRef, + editorHighlightLineRefs, + editorTextareaRef, + renderContent, + handleContentInput, + handleEditorScroll, + handleEditorSelectionChange, + isEditBlocked, + isPreviewOnly, +}) => html` +
    + <${EditorSurface} + editorShellClassName=${`file-viewer-editor-shell ${viewMode === "edit" ? "" : "file-viewer-pane-hidden"}`} + editorShellAriaHidden=${viewMode === "edit" ? "false" : "true"} + editorLineNumbers=${editorLineNumbers} + editorLineNumbersRef=${editorLineNumbersRef} + editorLineNumberRowRefs=${editorLineNumberRowRefs} + shouldUseHighlightedEditor=${true} + highlightedEditorLines=${highlightedEditorLines} + editorHighlightRef=${editorHighlightRef} + editorHighlightLineRefs=${editorHighlightLineRefs} + editorTextareaRef=${editorTextareaRef} + renderContent=${renderContent} + handleContentInput=${handleContentInput} + handleEditorScroll=${handleEditorScroll} + handleEditorSelectionChange=${handleEditorSelectionChange} + isEditBlocked=${isEditBlocked} + isPreviewOnly=${isPreviewOnly} + /> +`; diff --git a/lib/public/js/components/file-viewer/media-preview.js b/lib/public/js/components/file-viewer/media-preview.js new file mode 100644 index 00000000..57719a98 --- /dev/null +++ b/lib/public/js/components/file-viewer/media-preview.js @@ -0,0 +1,44 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; + +const html = htm.bind(h); + +export const MediaPreview = ({ + isImageFile, + imageDataUrl, + pathSegments, + isAudioFile, + audioDataUrl, +}) => { + if (isImageFile) { + return html` +
    + ${imageDataUrl + ? html` + ${pathSegments[pathSegments.length + ` + : html`
    Could not render image preview.
    `} +
    + `; + } + + if (isAudioFile) { + return html` +
    + ${audioDataUrl + ? html` + + ` + : html`
    Could not render audio preview.
    `} +
    + `; + } + + return null; +}; diff --git a/lib/public/js/components/file-viewer/status-banners.js b/lib/public/js/components/file-viewer/status-banners.js new file mode 100644 index 00000000..ee80ba83 --- /dev/null +++ b/lib/public/js/components/file-viewer/status-banners.js @@ -0,0 +1,59 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { ActionButton } from "../action-button.js"; +import { LockLineIcon } from "../icons.js"; + +const html = htm.bind(h); + +export const FileViewerStatusBanners = ({ + isDiffView, + onRequestEdit, + normalizedPath, + isLockedFile, + isProtectedFile, + isProtectedLocked, + handleEditProtectedFile, +}) => html` + ${isDiffView + ? html` +
    +
    Viewing unsynced changes
    + <${ActionButton} + onClick=${() => onRequestEdit(normalizedPath)} + tone="secondary" + size="sm" + idleLabel="View file" + /> +
    + ` + : null} + ${!isDiffView && isLockedFile + ? html` +
    + <${LockLineIcon} className="file-viewer-protected-banner-icon" /> +
    + This file is managed by AlphaClaw and cannot be edited. +
    +
    + ` + : null} + ${!isDiffView && isProtectedFile + ? html` +
    +
    + Protected file. Changes may break workspace behavior. +
    + ${isProtectedLocked + ? html` + <${ActionButton} + onClick=${handleEditProtectedFile} + tone="warning" + size="sm" + idleLabel="Edit anyway" + /> + ` + : null} +
    + ` + : null} +`; diff --git a/lib/public/js/components/file-viewer/toolbar.js b/lib/public/js/components/file-viewer/toolbar.js new file mode 100644 index 00000000..83f1aacc --- /dev/null +++ b/lib/public/js/components/file-viewer/toolbar.js @@ -0,0 +1,77 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { ActionButton } from "../action-button.js"; +import { SegmentedControl } from "../segmented-control.js"; +import { SaveFillIcon } from "../icons.js"; + +const html = htm.bind(h); + +export const FileViewerToolbar = ({ + pathSegments, + isDirty, + isPreviewOnly, + isDiffView, + isMarkdownFile, + viewMode, + handleChangeViewMode, + handleSave, + loading, + canEditFile, + isEditBlocked, + isImageFile, + isAudioFile, + isSqliteFile, + saving, +}) => html` +
    +
    + f + + ${pathSegments.map( + (segment, index) => html` + + + ${segment} + + ${index < pathSegments.length - 1 && html`>`} + + `, + )} + + ${isDirty ? html`` : null} +
    +
    + ${isPreviewOnly ? html`
    Preview
    ` : null} + ${!isDiffView && + isMarkdownFile && + html` + <${SegmentedControl} + className="mr-2.5" + options=${[ + { label: "edit", value: "edit" }, + { label: "preview", value: "preview" }, + ]} + value=${viewMode} + onChange=${handleChangeViewMode} + /> + `} + ${!isDiffView + ? !isImageFile && !isAudioFile && !isSqliteFile + ? html` + <${ActionButton} + onClick=${handleSave} + disabled=${loading || !isDirty || !canEditFile || isEditBlocked} + loading=${saving} + tone=${isDirty ? "primary" : "secondary"} + size="sm" + idleLabel="Save" + loadingLabel="Saving..." + idleIcon=${SaveFillIcon} + idleIconClassName="file-viewer-save-icon" + className="file-viewer-save-action" + /> + ` + : null + : null} +
    +`; From d3124e038e403968155dd5f31799048681b75cd7 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 2 Mar 2026 23:55:17 -0800 Subject: [PATCH 059/417] Refactor file viewer into a hook-driven controller pattern. This moves file viewer business logic and handlers into a dedicated useFileViewer flow, keeps index.js presentational, adds focus-on-unlock behavior for protected edits, and documents the pattern in AGENTS.md. --- AGENTS.md | 3 + lib/public/js/components/file-viewer/index.js | 576 ++++-------------- .../use-editor-selection-restore.js | 87 +++ .../components/file-viewer/use-file-diff.js | 49 ++ .../file-viewer/use-file-viewer-draft-sync.js | 32 + .../file-viewer/use-file-viewer-hotkeys.js | 25 + .../components/file-viewer/use-file-viewer.js | 379 ++++++++++++ 7 files changed, 679 insertions(+), 472 deletions(-) create mode 100644 lib/public/js/components/file-viewer/use-editor-selection-restore.js create mode 100644 lib/public/js/components/file-viewer/use-file-diff.js create mode 100644 lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js create mode 100644 lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js create mode 100644 lib/public/js/components/file-viewer/use-file-viewer.js diff --git a/AGENTS.md b/AGENTS.md index 5277bbda..cf92ada4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,9 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css` - Prefer shared components over one-off markup when a pattern already exists. - Keep constants in `kName` format (e.g. `kUiTabs`, `kGroupOrder`, `kNamePattern`). - Keep component-level helpers near the top of the file, before the main export. +- Avoid monolithic component files. Prefer breaking large features into focused child components and hooks. +- Preferred pattern for non-trivial UI features: a feature folder with `index.js` for rendering/composition and `hook.js` (or `use-*.js`) for business logic/state/effects/handlers. +- Treat `index.js` as a presentational shell whenever possible: keep business logic in hooks and pass derived state/actions down as props. #### Rendering and composition diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js index 97d5ed8e..ec6d45b2 100644 --- a/lib/public/js/components/file-viewer/index.js +++ b/lib/public/js/components/file-viewer/index.js @@ -1,39 +1,6 @@ import { h } from "https://esm.sh/preact"; -import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; -import { marked } from "https://esm.sh/marked"; -import { fetchBrowseFileDiff, saveFileContent } from "../../lib/api.js"; -import { - getFileSyntaxKind, - highlightEditorLines, - parseFrontmatter, -} from "../../lib/syntax-highlighters/index.js"; -import { - clearStoredFileDraft, - updateDraftIndex, - writeStoredFileDraft, -} from "../../lib/browse-draft-state.js"; -import { - kLockedBrowsePaths, - kProtectedBrowsePaths, - matchesBrowsePolicyPath, - normalizeBrowsePolicyPath, -} from "../../lib/browse-file-policies.js"; import { LoadingSpinner } from "../loading-spinner.js"; -import { showToast } from "../toast.js"; -import { - kFileViewerModeStorageKey, - kLoadingIndicatorDelayMs, - kSqlitePageSize, -} from "./constants.js"; -import { - readStoredEditorSelection, - readStoredFileViewerMode, - writeStoredEditorSelection, -} from "./storage.js"; -import { clampSelectionIndex, parsePathSegments } from "./utils.js"; -import { getScrollRatio, useScrollSync } from "./scroll-sync.js"; -import { useFileLoader } from "./use-file-loader.js"; import { SqliteViewer } from "./sqlite-viewer.js"; import { FileViewerToolbar } from "./toolbar.js"; import { FileViewerStatusBanners } from "./status-banners.js"; @@ -42,6 +9,8 @@ import { DiffViewer } from "./diff-viewer.js"; import { MediaPreview } from "./media-preview.js"; import { EditorSurface } from "./editor-surface.js"; import { MarkdownSplitView } from "./markdown-split-view.js"; +import { kSqlitePageSize } from "./constants.js"; +import { useFileViewer } from "./use-file-viewer.js"; const html = htm.bind(h); @@ -51,356 +20,13 @@ export const FileViewer = ({ browseView = "edit", onRequestEdit = () => {}, }) => { - const normalizedPath = String(filePath || "").trim(); - const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath); - const [content, setContent] = useState(""); - const [initialContent, setInitialContent] = useState(""); - const [fileKind, setFileKind] = useState("text"); - const [imageDataUrl, setImageDataUrl] = useState(""); - const [audioDataUrl, setAudioDataUrl] = useState(""); - const [sqliteSummary, setSqliteSummary] = useState(null); - const [sqliteSelectedTable, setSqliteSelectedTable] = useState(""); - const [sqliteTableOffset, setSqliteTableOffset] = useState(0); - const [sqliteTableLoading, setSqliteTableLoading] = useState(false); - const [sqliteTableError, setSqliteTableError] = useState(""); - const [sqliteTableData, setSqliteTableData] = useState(null); - const [viewMode, setViewMode] = useState(readStoredFileViewerMode); - const [loading, setLoading] = useState(false); - const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false); - const [diffLoading, setDiffLoading] = useState(false); - const [diffError, setDiffError] = useState(""); - const [diffContent, setDiffContent] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(""); - const [isFolderPath, setIsFolderPath] = useState(false); - const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); - const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false); - const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set()); - const editorLineNumbersRef = useRef(null); - const editorHighlightRef = useRef(null); - const editorTextareaRef = useRef(null); - const previewRef = useRef(null); - const editorLineNumberRowRefs = useRef([]); - const editorHighlightLineRefs = useRef([]); - - const hasSelectedPath = normalizedPath.length > 0; - const isImageFile = fileKind === "image"; - const isAudioFile = fileKind === "audio"; - const isSqliteFile = fileKind === "sqlite"; - const canEditFile = - hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile; - const isDiffView = String(browseView || "edit") === "diff"; - - const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } = - useScrollSync({ - viewMode, - setViewMode, - previewRef, - editorTextareaRef, - editorLineNumbersRef, - editorHighlightRef, - }); - - const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({ - hasSelectedPath, - normalizedPath, - isSqliteFile, - sqliteSelectedTable, - sqliteTableOffset, - canEditFile, - isFolderPath, - loading, - saving, - initialContent, - isDirty: canEditFile && content !== initialContent, - setLoading, - setContent, - setInitialContent, - setFileKind, - setImageDataUrl, - setAudioDataUrl, - setSqliteSummary, - setSqliteSelectedTable, - setSqliteTableOffset, - setSqliteTableLoading, - setSqliteTableError, - setSqliteTableData, - setError, - setIsFolderPath, - setExternalChangeNoticeShown, - externalChangeNoticeShown, - viewScrollRatioRef, + const { state, derived, refs, actions, context } = useFileViewer({ + filePath, + isPreviewOnly, + browseView, }); - const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]); - const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath; - const renderContent = isCurrentFileLoaded ? content : ""; - const renderInitialContent = isCurrentFileLoaded ? initialContent : ""; - const isDirty = canEditFile && renderContent !== renderInitialContent; - const isLockedFile = - canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath); - const isProtectedFile = - canEditFile && - !isLockedFile && - matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); - const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); - const isEditBlocked = isLockedFile || isProtectedLocked; - const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]); - const isMarkdownFile = syntaxKind === "markdown"; - const shouldUseHighlightedEditor = syntaxKind !== "plain"; - const parsedFrontmatter = useMemo( - () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }), - [renderContent, isMarkdownFile], - ); - const highlightedEditorLines = useMemo( - () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []), - [renderContent, shouldUseHighlightedEditor, syntaxKind], - ); - const editorLineNumbers = useMemo(() => { - const lineCount = String(renderContent || "").split("\n").length; - return Array.from({ length: lineCount }, (_, index) => index + 1); - }, [renderContent]); - - const syncEditorLineNumberHeights = useCallback(() => { - if (!shouldUseHighlightedEditor || viewMode !== "edit") return; - const numberRows = editorLineNumberRowRefs.current; - const highlightRows = editorHighlightLineRefs.current; - const rowCount = Math.min(numberRows.length, highlightRows.length); - for (let index = 0; index < rowCount; index += 1) { - const numberRow = numberRows[index]; - const highlightRow = highlightRows[index]; - if (!numberRow || !highlightRow) continue; - numberRow.style.height = `${highlightRow.offsetHeight}px`; - } - }, [shouldUseHighlightedEditor, viewMode]); - - useEffect(() => { - syncEditorLineNumberHeights(); - }, [content, syncEditorLineNumberHeights]); - - useEffect(() => { - if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {}; - const onResize = () => syncEditorLineNumberHeights(); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]); - - const previewHtml = useMemo( - () => - isMarkdownFile - ? marked.parse(parsedFrontmatter.body || "", { - gfm: true, - breaks: true, - }) - : "", - [parsedFrontmatter.body, isMarkdownFile], - ); - - useEffect(() => { - if (!isMarkdownFile && viewMode !== "edit") { - setViewMode("edit"); - } - }, [isMarkdownFile, viewMode]); - - useEffect(() => { - try { - window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); - } catch {} - }, [viewMode]); - - useEffect(() => { - if (!loading) { - setShowDelayedLoadingSpinner(false); - return () => {}; - } - const timer = window.setTimeout(() => { - setShowDelayedLoadingSpinner(true); - }, kLoadingIndicatorDelayMs); - return () => window.clearTimeout(timer); - }, [loading]); - - useEffect(() => { - let active = true; - if (!hasSelectedPath || !isDiffView || isPreviewOnly) { - setDiffLoading(false); - setDiffError(""); - setDiffContent(""); - return () => { - active = false; - }; - } - const loadDiff = async () => { - setDiffLoading(true); - setDiffError(""); - try { - const data = await fetchBrowseFileDiff(normalizedPath); - if (!active) return; - setDiffContent(String(data?.content || "")); - } catch (nextError) { - if (!active) return; - setDiffError(nextError.message || "Could not load diff"); - } finally { - if (active) setDiffLoading(false); - } - }; - loadDiff(); - return () => { - active = false; - }; - }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]); - - useEffect(() => { - if (loadedFilePathRef.current !== normalizedPath) return; - if (!canEditFile || !hasSelectedPath || loading) return; - if (content === initialContent) { - clearStoredFileDraft(normalizedPath); - updateDraftIndex(normalizedPath, false, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - return; - } - writeStoredFileDraft(normalizedPath, content); - updateDraftIndex(normalizedPath, true, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - }, [canEditFile, hasSelectedPath, loading, content, initialContent, normalizedPath]); - - useEffect(() => { - if (!canEditFile || loading || !hasSelectedPath) return () => {}; - if (loadedFilePathRef.current !== normalizedPath) return () => {}; - if (restoredSelectionPathRef.current === normalizedPath) return () => {}; - if (viewMode !== "edit") return () => {}; - const storedSelection = readStoredEditorSelection(normalizedPath); - if (!storedSelection) { - restoredSelectionPathRef.current = normalizedPath; - return () => {}; - } - let frameId = 0; - let attempts = 0; - const restoreSelection = () => { - const textareaElement = editorTextareaRef.current; - if (!textareaElement) { - attempts += 1; - if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection); - return; - } - const maxIndex = String(content || "").length; - const start = clampSelectionIndex(storedSelection.start, maxIndex); - const end = clampSelectionIndex(storedSelection.end, maxIndex); - textareaElement.focus(); - textareaElement.setSelectionRange(start, Math.max(start, end)); - window.requestAnimationFrame(() => { - const nextTextareaElement = editorTextareaRef.current; - if (!nextTextareaElement) return; - const safeContent = String(content || ""); - const safeStart = clampSelectionIndex(start, safeContent.length); - const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1; - const computedStyle = window.getComputedStyle(nextTextareaElement); - const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || ""); - const lineHeight = - Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20; - const nextScrollTop = Math.max( - 0, - lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4, - ); - nextTextareaElement.scrollTop = nextScrollTop; - if (editorLineNumbersRef.current) { - editorLineNumbersRef.current.scrollTop = nextScrollTop; - } - if (editorHighlightRef.current) { - editorHighlightRef.current.scrollTop = nextScrollTop; - } - viewScrollRatioRef.current = getScrollRatio(nextTextareaElement); - }); - restoredSelectionPathRef.current = normalizedPath; - }; - frameId = window.requestAnimationFrame(restoreSelection); - return () => { - if (frameId) window.cancelAnimationFrame(frameId); - }; - }, [canEditFile, loading, hasSelectedPath, normalizedPath, content, viewMode]); - - const handleSave = useCallback(async () => { - if (!canEditFile || saving || !isDirty || isEditBlocked) return; - setSaving(true); - setError(""); - try { - await saveFileContent(normalizedPath, content); - setInitialContent(content); - setExternalChangeNoticeShown(false); - clearStoredFileDraft(normalizedPath); - updateDraftIndex(normalizedPath, false, { - dispatchEvent: (event) => window.dispatchEvent(event), - }); - window.dispatchEvent( - new CustomEvent("alphaclaw:browse-file-saved", { - detail: { path: normalizedPath }, - }), - ); - showToast("Saved", "success"); - } catch (saveError) { - const message = saveError.message || "Could not save file"; - setError(message); - showToast(message, "error"); - } finally { - setSaving(false); - } - }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content, initialContent]); - - useEffect(() => { - const handleKeyDown = (event) => { - const isSaveShortcut = - (event.metaKey || event.ctrlKey) && - !event.shiftKey && - !event.altKey && - String(event.key || "").toLowerCase() === "s"; - if (!isSaveShortcut) return; - if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return; - event.preventDefault(); - void handleSave(); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]); - - const handleEditProtectedFile = () => { - if (!normalizedPolicyPath) return; - setProtectedEditBypassPaths((previousPaths) => { - const nextPaths = new Set(previousPaths); - nextPaths.add(normalizedPolicyPath); - return nextPaths; - }); - }; - - const handleContentInput = (event) => { - if (isEditBlocked || isPreviewOnly) return; - const nextContent = event.target.value; - setContent(nextContent); - if (hasSelectedPath && canEditFile) { - writeStoredEditorSelection(normalizedPath, { - start: event.target.selectionStart, - end: event.target.selectionEnd, - }); - } - if (hasSelectedPath && canEditFile) { - writeStoredFileDraft(normalizedPath, nextContent); - updateDraftIndex(normalizedPath, nextContent !== initialContent, { - dispatchEvent: (dispatchEvent) => window.dispatchEvent(dispatchEvent), - }); - } - }; - - const handleEditorSelectionChange = () => { - if (!hasSelectedPath || !canEditFile || loading) return; - const textareaElement = editorTextareaRef.current; - if (!textareaElement) return; - writeStoredEditorSelection(normalizedPath, { - start: textareaElement.selectionStart, - end: textareaElement.selectionEnd, - }); - }; - - if (!hasSelectedPath) { + if (!state.hasSelectedPath) { return html`
    [ ]
    @@ -412,121 +38,127 @@ export const FileViewer = ({ return html`
    <${FileViewerToolbar} - pathSegments=${pathSegments} - isDirty=${isDirty} - isPreviewOnly=${isPreviewOnly} - isDiffView=${isDiffView} - isMarkdownFile=${isMarkdownFile} - viewMode=${viewMode} - handleChangeViewMode=${handleChangeViewMode} - handleSave=${handleSave} - loading=${loading} - canEditFile=${canEditFile} - isEditBlocked=${isEditBlocked} - isImageFile=${isImageFile} - isAudioFile=${isAudioFile} - isSqliteFile=${isSqliteFile} - saving=${saving} + pathSegments=${derived.pathSegments} + isDirty=${derived.isDirty} + isPreviewOnly=${state.isPreviewOnly} + isDiffView=${state.isDiffView} + isMarkdownFile=${state.isMarkdownFile} + viewMode=${state.viewMode} + handleChangeViewMode=${actions.handleChangeViewMode} + handleSave=${actions.handleSave} + loading=${state.loading} + canEditFile=${derived.canEditFile} + isEditBlocked=${derived.isEditBlocked} + isImageFile=${state.isImageFile} + isAudioFile=${state.isAudioFile} + isSqliteFile=${state.isSqliteFile} + saving=${state.saving} /> <${FileViewerStatusBanners} - isDiffView=${isDiffView} + isDiffView=${state.isDiffView} onRequestEdit=${onRequestEdit} - normalizedPath=${normalizedPath} - isLockedFile=${isLockedFile} - isProtectedFile=${isProtectedFile} - isProtectedLocked=${isProtectedLocked} - handleEditProtectedFile=${handleEditProtectedFile} + normalizedPath=${context.normalizedPath} + isLockedFile=${derived.isLockedFile} + isProtectedFile=${derived.isProtectedFile} + isProtectedLocked=${derived.isProtectedLocked} + handleEditProtectedFile=${actions.handleEditProtectedFile} /> <${FrontmatterPanel} - isMarkdownFile=${isMarkdownFile} - parsedFrontmatter=${parsedFrontmatter} - frontmatterCollapsed=${frontmatterCollapsed} - setFrontmatterCollapsed=${setFrontmatterCollapsed} + isMarkdownFile=${state.isMarkdownFile} + parsedFrontmatter=${derived.parsedFrontmatter} + frontmatterCollapsed=${state.frontmatterCollapsed} + setFrontmatterCollapsed=${actions.setFrontmatterCollapsed} /> - ${loading + ${state.loading ? html`
    - ${showDelayedLoadingSpinner + ${state.showDelayedLoadingSpinner ? html`<${LoadingSpinner} className="h-4 w-4" />` : null}
    ` - : error - ? html`
    ${error}
    ` - : isFolderPath + : state.error + ? html`
    ${state.error}
    ` + : state.isFolderPath ? html`
    Folder selected. Choose a file from this folder in the tree.
    ` - : isImageFile || isAudioFile + : state.isImageFile || state.isAudioFile ? html` <${MediaPreview} - isImageFile=${isImageFile} - imageDataUrl=${imageDataUrl} - pathSegments=${pathSegments} - isAudioFile=${isAudioFile} - audioDataUrl=${audioDataUrl} + isImageFile=${state.isImageFile} + imageDataUrl=${state.imageDataUrl} + pathSegments=${derived.pathSegments} + isAudioFile=${state.isAudioFile} + audioDataUrl=${state.audioDataUrl} /> ` - : isSqliteFile + : state.isSqliteFile + ? html` + <${SqliteViewer} + sqliteSummary=${state.sqliteSummary} + sqliteSelectedTable=${state.sqliteSelectedTable} + setSqliteSelectedTable=${actions.setSqliteSelectedTable} + sqliteTableOffset=${state.sqliteTableOffset} + setSqliteTableOffset=${actions.setSqliteTableOffset} + sqliteTableLoading=${state.sqliteTableLoading} + sqliteTableError=${state.sqliteTableError} + sqliteTableData=${state.sqliteTableData} + kSqlitePageSize=${kSqlitePageSize} + /> + ` + : state.isDiffView ? html` - <${SqliteViewer} - sqliteSummary=${sqliteSummary} - sqliteSelectedTable=${sqliteSelectedTable} - setSqliteSelectedTable=${setSqliteSelectedTable} - sqliteTableOffset=${sqliteTableOffset} - setSqliteTableOffset=${setSqliteTableOffset} - sqliteTableLoading=${sqliteTableLoading} - sqliteTableError=${sqliteTableError} - sqliteTableData=${sqliteTableData} - kSqlitePageSize=${kSqlitePageSize} + <${DiffViewer} + diffLoading=${state.diffLoading} + diffError=${state.diffError} + diffContent=${state.diffContent} /> ` - : isDiffView - ? html`<${DiffViewer} diffLoading=${diffLoading} diffError=${diffError} diffContent=${diffContent} />` - : html` - ${isMarkdownFile - ? html` - <${MarkdownSplitView} - viewMode=${viewMode} - previewRef=${previewRef} - handlePreviewScroll=${handlePreviewScroll} - previewHtml=${previewHtml} - editorLineNumbers=${editorLineNumbers} - editorLineNumbersRef=${editorLineNumbersRef} - editorLineNumberRowRefs=${editorLineNumberRowRefs} - highlightedEditorLines=${highlightedEditorLines} - editorHighlightRef=${editorHighlightRef} - editorHighlightLineRefs=${editorHighlightLineRefs} - editorTextareaRef=${editorTextareaRef} - renderContent=${renderContent} - handleContentInput=${handleContentInput} - handleEditorScroll=${handleEditorScroll} - handleEditorSelectionChange=${handleEditorSelectionChange} - isEditBlocked=${isEditBlocked} - isPreviewOnly=${isPreviewOnly} - /> - ` - : html` - <${EditorSurface} - editorLineNumbers=${editorLineNumbers} - editorLineNumbersRef=${editorLineNumbersRef} - editorLineNumberRowRefs=${editorLineNumberRowRefs} - shouldUseHighlightedEditor=${shouldUseHighlightedEditor} - highlightedEditorLines=${highlightedEditorLines} - editorHighlightRef=${editorHighlightRef} - editorHighlightLineRefs=${editorHighlightLineRefs} - editorTextareaRef=${editorTextareaRef} - renderContent=${renderContent} - handleContentInput=${handleContentInput} - handleEditorScroll=${handleEditorScroll} - handleEditorSelectionChange=${handleEditorSelectionChange} - isEditBlocked=${isEditBlocked} - isPreviewOnly=${isPreviewOnly} - /> - `} - `} + : html` + ${state.isMarkdownFile + ? html` + <${MarkdownSplitView} + viewMode=${state.viewMode} + previewRef=${refs.previewRef} + handlePreviewScroll=${actions.handlePreviewScroll} + previewHtml=${state.previewHtml} + editorLineNumbers=${derived.editorLineNumbers} + editorLineNumbersRef=${refs.editorLineNumbersRef} + editorLineNumberRowRefs=${refs.editorLineNumberRowRefs} + highlightedEditorLines=${derived.highlightedEditorLines} + editorHighlightRef=${refs.editorHighlightRef} + editorHighlightLineRefs=${refs.editorHighlightLineRefs} + editorTextareaRef=${refs.editorTextareaRef} + renderContent=${state.renderContent} + handleContentInput=${actions.handleContentInput} + handleEditorScroll=${actions.handleEditorScroll} + handleEditorSelectionChange=${actions.handleEditorSelectionChange} + isEditBlocked=${derived.isEditBlocked} + isPreviewOnly=${state.isPreviewOnly} + /> + ` + : html` + <${EditorSurface} + editorLineNumbers=${derived.editorLineNumbers} + editorLineNumbersRef=${refs.editorLineNumbersRef} + editorLineNumberRowRefs=${refs.editorLineNumberRowRefs} + shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor} + highlightedEditorLines=${derived.highlightedEditorLines} + editorHighlightRef=${refs.editorHighlightRef} + editorHighlightLineRefs=${refs.editorHighlightLineRefs} + editorTextareaRef=${refs.editorTextareaRef} + renderContent=${state.renderContent} + handleContentInput=${actions.handleContentInput} + handleEditorScroll=${actions.handleEditorScroll} + handleEditorSelectionChange=${actions.handleEditorSelectionChange} + isEditBlocked=${derived.isEditBlocked} + isPreviewOnly=${state.isPreviewOnly} + /> + `} + `}
    `; }; diff --git a/lib/public/js/components/file-viewer/use-editor-selection-restore.js b/lib/public/js/components/file-viewer/use-editor-selection-restore.js new file mode 100644 index 00000000..59c19452 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-editor-selection-restore.js @@ -0,0 +1,87 @@ +import { useEffect } from "https://esm.sh/preact/hooks"; +import { readStoredEditorSelection } from "./storage.js"; +import { clampSelectionIndex } from "./utils.js"; +import { getScrollRatio } from "./scroll-sync.js"; + +export const useEditorSelectionRestore = ({ + canEditFile, + loading, + hasSelectedPath, + normalizedPath, + loadedFilePathRef, + restoredSelectionPathRef, + viewMode, + content, + editorTextareaRef, + editorLineNumbersRef, + editorHighlightRef, + viewScrollRatioRef, +}) => { + useEffect(() => { + if (!canEditFile || loading || !hasSelectedPath) return () => {}; + if (loadedFilePathRef.current !== normalizedPath) return () => {}; + if (restoredSelectionPathRef.current === normalizedPath) return () => {}; + if (viewMode !== "edit") return () => {}; + const storedSelection = readStoredEditorSelection(normalizedPath); + if (!storedSelection) { + restoredSelectionPathRef.current = normalizedPath; + return () => {}; + } + let frameId = 0; + let attempts = 0; + const restoreSelection = () => { + const textareaElement = editorTextareaRef.current; + if (!textareaElement) { + attempts += 1; + if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection); + return; + } + const maxIndex = String(content || "").length; + const start = clampSelectionIndex(storedSelection.start, maxIndex); + const end = clampSelectionIndex(storedSelection.end, maxIndex); + textareaElement.focus(); + textareaElement.setSelectionRange(start, Math.max(start, end)); + window.requestAnimationFrame(() => { + const nextTextareaElement = editorTextareaRef.current; + if (!nextTextareaElement) return; + const safeContent = String(content || ""); + const safeStart = clampSelectionIndex(start, safeContent.length); + const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1; + const computedStyle = window.getComputedStyle(nextTextareaElement); + const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || ""); + const lineHeight = + Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20; + const nextScrollTop = Math.max( + 0, + lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4, + ); + nextTextareaElement.scrollTop = nextScrollTop; + if (editorLineNumbersRef.current) { + editorLineNumbersRef.current.scrollTop = nextScrollTop; + } + if (editorHighlightRef.current) { + editorHighlightRef.current.scrollTop = nextScrollTop; + } + viewScrollRatioRef.current = getScrollRatio(nextTextareaElement); + }); + restoredSelectionPathRef.current = normalizedPath; + }; + frameId = window.requestAnimationFrame(restoreSelection); + return () => { + if (frameId) window.cancelAnimationFrame(frameId); + }; + }, [ + canEditFile, + loading, + hasSelectedPath, + normalizedPath, + content, + viewMode, + loadedFilePathRef, + restoredSelectionPathRef, + editorTextareaRef, + editorLineNumbersRef, + editorHighlightRef, + viewScrollRatioRef, + ]); +}; diff --git a/lib/public/js/components/file-viewer/use-file-diff.js b/lib/public/js/components/file-viewer/use-file-diff.js new file mode 100644 index 00000000..24effe6d --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-diff.js @@ -0,0 +1,49 @@ +import { useEffect, useState } from "https://esm.sh/preact/hooks"; +import { fetchBrowseFileDiff } from "../../lib/api.js"; + +export const useFileDiff = ({ + hasSelectedPath, + isDiffView, + isPreviewOnly, + normalizedPath, +}) => { + const [diffLoading, setDiffLoading] = useState(false); + const [diffError, setDiffError] = useState(""); + const [diffContent, setDiffContent] = useState(""); + + useEffect(() => { + let active = true; + if (!hasSelectedPath || !isDiffView || isPreviewOnly) { + setDiffLoading(false); + setDiffError(""); + setDiffContent(""); + return () => { + active = false; + }; + } + const loadDiff = async () => { + setDiffLoading(true); + setDiffError(""); + try { + const data = await fetchBrowseFileDiff(normalizedPath); + if (!active) return; + setDiffContent(String(data?.content || "")); + } catch (nextError) { + if (!active) return; + setDiffError(nextError.message || "Could not load diff"); + } finally { + if (active) setDiffLoading(false); + } + }; + loadDiff(); + return () => { + active = false; + }; + }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]); + + return { + diffLoading, + diffError, + diffContent, + }; +}; diff --git a/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js b/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js new file mode 100644 index 00000000..18e6dd86 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js @@ -0,0 +1,32 @@ +import { useEffect } from "https://esm.sh/preact/hooks"; +import { + clearStoredFileDraft, + updateDraftIndex, + writeStoredFileDraft, +} from "../../lib/browse-draft-state.js"; + +export const useFileViewerDraftSync = ({ + loadedFilePathRef, + normalizedPath, + canEditFile, + hasSelectedPath, + loading, + content, + initialContent, +}) => { + useEffect(() => { + if (loadedFilePathRef.current !== normalizedPath) return; + if (!canEditFile || !hasSelectedPath || loading) return; + if (content === initialContent) { + clearStoredFileDraft(normalizedPath); + updateDraftIndex(normalizedPath, false, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + return; + } + writeStoredFileDraft(normalizedPath, content); + updateDraftIndex(normalizedPath, true, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + }, [canEditFile, hasSelectedPath, loading, content, initialContent, normalizedPath]); +}; diff --git a/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js b/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js new file mode 100644 index 00000000..b40d66af --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js @@ -0,0 +1,25 @@ +import { useEffect } from "https://esm.sh/preact/hooks"; + +export const useFileViewerHotkeys = ({ + canEditFile, + isPreviewOnly, + isDiffView, + viewMode, + handleSave, +}) => { + useEffect(() => { + const handleKeyDown = (event) => { + const isSaveShortcut = + (event.metaKey || event.ctrlKey) && + !event.shiftKey && + !event.altKey && + String(event.key || "").toLowerCase() === "s"; + if (!isSaveShortcut) return; + if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== "edit") return; + event.preventDefault(); + void handleSave(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]); +}; diff --git a/lib/public/js/components/file-viewer/use-file-viewer.js b/lib/public/js/components/file-viewer/use-file-viewer.js new file mode 100644 index 00000000..62362bb1 --- /dev/null +++ b/lib/public/js/components/file-viewer/use-file-viewer.js @@ -0,0 +1,379 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks"; +import { marked } from "https://esm.sh/marked"; +import { saveFileContent } from "../../lib/api.js"; +import { + getFileSyntaxKind, + highlightEditorLines, + parseFrontmatter, +} from "../../lib/syntax-highlighters/index.js"; +import { + clearStoredFileDraft, + updateDraftIndex, + writeStoredFileDraft, +} from "../../lib/browse-draft-state.js"; +import { + kLockedBrowsePaths, + kProtectedBrowsePaths, + matchesBrowsePolicyPath, + normalizeBrowsePolicyPath, +} from "../../lib/browse-file-policies.js"; +import { showToast } from "../toast.js"; +import { kFileViewerModeStorageKey, kLoadingIndicatorDelayMs } from "./constants.js"; +import { readStoredFileViewerMode, writeStoredEditorSelection } from "./storage.js"; +import { parsePathSegments } from "./utils.js"; +import { useScrollSync } from "./scroll-sync.js"; +import { useFileLoader } from "./use-file-loader.js"; +import { useFileDiff } from "./use-file-diff.js"; +import { useFileViewerDraftSync } from "./use-file-viewer-draft-sync.js"; +import { useFileViewerHotkeys } from "./use-file-viewer-hotkeys.js"; +import { useEditorSelectionRestore } from "./use-editor-selection-restore.js"; + +export const useFileViewer = ({ + filePath = "", + isPreviewOnly = false, + browseView = "edit", +}) => { + const normalizedPath = String(filePath || "").trim(); + const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath); + const [content, setContent] = useState(""); + const [initialContent, setInitialContent] = useState(""); + const [fileKind, setFileKind] = useState("text"); + const [imageDataUrl, setImageDataUrl] = useState(""); + const [audioDataUrl, setAudioDataUrl] = useState(""); + const [sqliteSummary, setSqliteSummary] = useState(null); + const [sqliteSelectedTable, setSqliteSelectedTable] = useState(""); + const [sqliteTableOffset, setSqliteTableOffset] = useState(0); + const [sqliteTableLoading, setSqliteTableLoading] = useState(false); + const [sqliteTableError, setSqliteTableError] = useState(""); + const [sqliteTableData, setSqliteTableData] = useState(null); + const [viewMode, setViewMode] = useState(readStoredFileViewerMode); + const [loading, setLoading] = useState(false); + const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [isFolderPath, setIsFolderPath] = useState(false); + const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); + const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false); + const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set()); + const editorLineNumbersRef = useRef(null); + const editorHighlightRef = useRef(null); + const editorTextareaRef = useRef(null); + const previewRef = useRef(null); + const editorLineNumberRowRefs = useRef([]); + const editorHighlightLineRefs = useRef([]); + + const hasSelectedPath = normalizedPath.length > 0; + const isImageFile = fileKind === "image"; + const isAudioFile = fileKind === "audio"; + const isSqliteFile = fileKind === "sqlite"; + const canEditFile = + hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile; + const isDiffView = String(browseView || "edit") === "diff"; + + const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } = + useScrollSync({ + viewMode, + setViewMode, + previewRef, + editorTextareaRef, + editorLineNumbersRef, + editorHighlightRef, + }); + + const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({ + hasSelectedPath, + normalizedPath, + isSqliteFile, + sqliteSelectedTable, + sqliteTableOffset, + canEditFile, + isFolderPath, + loading, + saving, + initialContent, + isDirty: canEditFile && content !== initialContent, + setLoading, + setContent, + setInitialContent, + setFileKind, + setImageDataUrl, + setAudioDataUrl, + setSqliteSummary, + setSqliteSelectedTable, + setSqliteTableOffset, + setSqliteTableLoading, + setSqliteTableError, + setSqliteTableData, + setError, + setIsFolderPath, + setExternalChangeNoticeShown, + externalChangeNoticeShown, + viewScrollRatioRef, + }); + + const { diffLoading, diffError, diffContent } = useFileDiff({ + hasSelectedPath, + isDiffView, + isPreviewOnly, + normalizedPath, + }); + + const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]); + const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath; + const renderContent = isCurrentFileLoaded ? content : ""; + const renderInitialContent = isCurrentFileLoaded ? initialContent : ""; + const isDirty = canEditFile && renderContent !== renderInitialContent; + const isLockedFile = + canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath); + const isProtectedFile = + canEditFile && + !isLockedFile && + matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); + const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); + const isEditBlocked = isLockedFile || isProtectedLocked; + const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]); + const isMarkdownFile = syntaxKind === "markdown"; + const shouldUseHighlightedEditor = syntaxKind !== "plain"; + const parsedFrontmatter = useMemo( + () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }), + [renderContent, isMarkdownFile], + ); + const highlightedEditorLines = useMemo( + () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []), + [renderContent, shouldUseHighlightedEditor, syntaxKind], + ); + const editorLineNumbers = useMemo(() => { + const lineCount = String(renderContent || "").split("\n").length; + return Array.from({ length: lineCount }, (_, index) => index + 1); + }, [renderContent]); + const previewHtml = useMemo( + () => + isMarkdownFile + ? marked.parse(parsedFrontmatter.body || "", { + gfm: true, + breaks: true, + }) + : "", + [parsedFrontmatter.body, isMarkdownFile], + ); + + const syncEditorLineNumberHeights = useCallback(() => { + if (!shouldUseHighlightedEditor || viewMode !== "edit") return; + const numberRows = editorLineNumberRowRefs.current; + const highlightRows = editorHighlightLineRefs.current; + const rowCount = Math.min(numberRows.length, highlightRows.length); + for (let index = 0; index < rowCount; index += 1) { + const numberRow = numberRows[index]; + const highlightRow = highlightRows[index]; + if (!numberRow || !highlightRow) continue; + numberRow.style.height = `${highlightRow.offsetHeight}px`; + } + }, [shouldUseHighlightedEditor, viewMode]); + + useEffect(() => { + syncEditorLineNumberHeights(); + }, [content, syncEditorLineNumberHeights]); + + useEffect(() => { + if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {}; + const onResize = () => syncEditorLineNumberHeights(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]); + + useEffect(() => { + if (!isMarkdownFile && viewMode !== "edit") { + setViewMode("edit"); + } + }, [isMarkdownFile, viewMode]); + + useEffect(() => { + try { + window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); + } catch {} + }, [viewMode]); + + useEffect(() => { + if (!loading) { + setShowDelayedLoadingSpinner(false); + return () => {}; + } + const timer = window.setTimeout(() => { + setShowDelayedLoadingSpinner(true); + }, kLoadingIndicatorDelayMs); + return () => window.clearTimeout(timer); + }, [loading]); + + useFileViewerDraftSync({ + loadedFilePathRef, + normalizedPath, + canEditFile, + hasSelectedPath, + loading, + content, + initialContent, + }); + + useEditorSelectionRestore({ + canEditFile, + loading, + hasSelectedPath, + normalizedPath, + loadedFilePathRef, + restoredSelectionPathRef, + viewMode, + content, + editorTextareaRef, + editorLineNumbersRef, + editorHighlightRef, + viewScrollRatioRef, + }); + + const handleSave = useCallback(async () => { + if (!canEditFile || saving || !isDirty || isEditBlocked) return; + setSaving(true); + setError(""); + try { + await saveFileContent(normalizedPath, content); + setInitialContent(content); + setExternalChangeNoticeShown(false); + clearStoredFileDraft(normalizedPath); + updateDraftIndex(normalizedPath, false, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-saved", { + detail: { path: normalizedPath }, + }), + ); + showToast("Saved", "success"); + } catch (saveError) { + const message = saveError.message || "Could not save file"; + setError(message); + showToast(message, "error"); + } finally { + setSaving(false); + } + }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content]); + + useFileViewerHotkeys({ + canEditFile, + isPreviewOnly, + isDiffView, + viewMode, + handleSave, + }); + + const handleEditProtectedFile = () => { + if (!normalizedPolicyPath) return; + setProtectedEditBypassPaths((previousPaths) => { + const nextPaths = new Set(previousPaths); + nextPaths.add(normalizedPolicyPath); + return nextPaths; + }); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + const textareaElement = editorTextareaRef.current; + if (!textareaElement) return; + if (textareaElement.disabled || textareaElement.readOnly) return; + textareaElement.focus(); + }); + }); + }; + + const handleContentInput = (event) => { + if (isEditBlocked || isPreviewOnly) return; + const nextContent = event.target.value; + setContent(nextContent); + if (hasSelectedPath && canEditFile) { + writeStoredEditorSelection(normalizedPath, { + start: event.target.selectionStart, + end: event.target.selectionEnd, + }); + } + if (hasSelectedPath && canEditFile) { + writeStoredFileDraft(normalizedPath, nextContent); + updateDraftIndex(normalizedPath, nextContent !== initialContent, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + } + }; + + const handleEditorSelectionChange = () => { + if (!hasSelectedPath || !canEditFile || loading) return; + const textareaElement = editorTextareaRef.current; + if (!textareaElement) return; + writeStoredEditorSelection(normalizedPath, { + start: textareaElement.selectionStart, + end: textareaElement.selectionEnd, + }); + }; + + return { + state: { + hasSelectedPath, + isPreviewOnly, + loading, + saving, + showDelayedLoadingSpinner, + error, + isFolderPath, + isImageFile, + imageDataUrl, + isAudioFile, + audioDataUrl, + isSqliteFile, + sqliteSummary, + sqliteSelectedTable, + sqliteTableOffset, + sqliteTableLoading, + sqliteTableError, + sqliteTableData, + isDiffView, + diffLoading, + diffError, + diffContent, + isMarkdownFile, + frontmatterCollapsed, + previewHtml, + viewMode, + renderContent, + }, + derived: { + pathSegments, + isDirty, + canEditFile, + isEditBlocked, + isLockedFile, + isProtectedFile, + isProtectedLocked, + shouldUseHighlightedEditor, + parsedFrontmatter, + highlightedEditorLines, + editorLineNumbers, + }, + refs: { + previewRef, + editorLineNumbersRef, + editorLineNumberRowRefs, + editorHighlightRef, + editorHighlightLineRefs, + editorTextareaRef, + }, + actions: { + setFrontmatterCollapsed, + setSqliteSelectedTable, + setSqliteTableOffset, + handleChangeViewMode, + handleSave, + handleEditProtectedFile, + handleContentInput, + handleEditorScroll, + handlePreviewScroll, + handleEditorSelectionChange, + }, + context: { + normalizedPath, + }, + }; +}; From 831cb5d25a2011f3fcca927d3de1e9065f1d540c Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 00:05:01 -0800 Subject: [PATCH 060/417] Fix OpenClaw update state sync across setup tabs. Hoist update progress to shared app state and reconcile version status after completion so Watchdog and General show consistent button and version state without requiring a tab remount. --- AGENTS.md | 2 +- lib/public/js/app.js | 44 +++++++++ lib/public/js/components/gateway.js | 113 +++++++++++++++++------ lib/public/js/components/watchdog-tab.js | 6 ++ 4 files changed, 134 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf92ada4..e004e722 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css` - Keep constants in `kName` format (e.g. `kUiTabs`, `kGroupOrder`, `kNamePattern`). - Keep component-level helpers near the top of the file, before the main export. - Avoid monolithic component files. Prefer breaking large features into focused child components and hooks. -- Preferred pattern for non-trivial UI features: a feature folder with `index.js` for rendering/composition and `hook.js` (or `use-*.js`) for business logic/state/effects/handlers. +- Preferred pattern for non-trivial UI features: a feature folder with `index.js` for rendering/composition and `use-*.js` for business logic/state/effects/handlers. - Treat `index.js` as a presentational shell whenever possible: keep business logic in hooks and pass derived state/actions down as props. #### Rendering and composition diff --git a/lib/public/js/app.js b/lib/public/js/app.js index c061cea5..95b7e0d8 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -21,6 +21,7 @@ import { restartGateway, fetchWatchdogStatus, triggerWatchdogRepair, + updateOpenclaw, } from "./lib/api.js"; import { usePolling } from "./hooks/usePolling.js"; import { Gateway } from "./components/gateway.js"; @@ -101,6 +102,9 @@ const GeneralTab = ({ restartingGateway, onRestartGateway, restartSignal = 0, + openclawUpdateInProgress = false, + onOpenclawVersionActionComplete = () => {}, + onOpenclawUpdate, }) => { const [googleKey, setGoogleKey] = useState(0); const [dashboardLoading, setDashboardLoading] = useState(false); @@ -263,6 +267,9 @@ const GeneralTab = ({ onOpenWatchdog=${() => onSwitchTab("watchdog")} onRepair=${handleWatchdogRepair} repairing=${repairingWatchdog} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} + onOpenclawUpdate=${onOpenclawUpdate} /> <${Channels} channels=${channels} onSwitchTab=${onSwitchTab} onNavigate=${onNavigate} /> <${Pairings} @@ -417,6 +424,7 @@ const App = () => { const [restartingGateway, setRestartingGateway] = useState(false); const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0); const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000); + const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false); const menuRef = useRef(null); const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, { enabled: onboarded === true, @@ -548,6 +556,36 @@ const App = () => { } }, [restartingGateway, refreshRestartStatus, refreshSharedStatuses]); + const handleOpenclawUpdate = useCallback(async () => { + if (openclawUpdateInProgress) { + return { ok: false, error: "OpenClaw update already in progress" }; + } + setOpenclawUpdateInProgress(true); + try { + const data = await updateOpenclaw(); + return data; + } finally { + setOpenclawUpdateInProgress(false); + refreshSharedStatuses(); + setTimeout(refreshSharedStatuses, 1200); + setTimeout(refreshSharedStatuses, 3500); + setTimeout(refreshRestartStatus, 1200); + } + }, [ + openclawUpdateInProgress, + refreshRestartStatus, + refreshSharedStatuses, + ]); + + const handleOpenclawVersionActionComplete = useCallback( + ({ type }) => { + if (type !== "update") return; + refreshSharedStatuses(); + setTimeout(refreshSharedStatuses, 1200); + }, + [refreshSharedStatuses], + ); + const handleAcUpdate = async () => { if (acUpdating) return; setAcUpdating(true); @@ -924,6 +962,9 @@ const App = () => { restartingGateway=${restartingGateway} onRestartGateway=${handleGatewayRestart} restartSignal=${gatewayRestartSignal} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} + onOpenclawUpdate=${handleOpenclawUpdate} />
    @@ -942,6 +983,9 @@ const App = () => { restartingGateway=${restartingGateway} onRestartGateway=${handleGatewayRestart} restartSignal=${gatewayRestartSignal} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} + onOpenclawUpdate=${handleOpenclawUpdate} />
    diff --git a/lib/public/js/components/gateway.js b/lib/public/js/components/gateway.js index eda70a2b..6bb3fda2 100644 --- a/lib/public/js/components/gateway.js +++ b/lib/public/js/components/gateway.js @@ -24,7 +24,14 @@ const formatDuration = (ms) => { return `${seconds}s`; }; -function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { +const VersionRow = ({ + label, + currentVersion, + fetchVersion, + applyUpdate, + updateInProgress = false, + onActionComplete = () => {}, +}) => { const [checking, setChecking] = useState(false); const [version, setVersion] = useState(currentVersion || null); const [latestVersion, setLatestVersion] = useState(null); @@ -51,6 +58,10 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { })(); const effectiveHasUpdate = simulateUpdate || hasUpdate; const effectiveLatestVersion = simulatedVersion || latestVersion; + const isUpdateActionActive = updateInProgress || effectiveHasUpdate; + const updateIdleLabel = effectiveLatestVersion + ? `Update to ${effectiveLatestVersion}` + : "Update"; const changelogUrl = "https://github.com/openclaw/openclaw/tags"; const showMobileUpdateRow = effectiveHasUpdate && effectiveLatestVersion; @@ -60,9 +71,9 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { useEffect(() => { let active = true; - const load = async () => { + const load = async (refresh = false) => { try { - const data = await fetchVersion(false); + const data = await fetchVersion(refresh); if (!active) return; setVersion(data.currentVersion || currentVersion || null); setLatestVersion(data.latestVersion || null); @@ -73,11 +84,30 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { setError(err.message || "Could not check updates"); } }; - load(); + load(false); return () => { active = false; }; - }, []); + }, [currentVersion, fetchVersion]); + + useEffect(() => { + if (updateInProgress) return () => {}; + let active = true; + const timeoutId = setTimeout(async () => { + try { + const data = await fetchVersion(true); + if (!active) return; + setVersion(data.currentVersion || currentVersion || null); + setLatestVersion(data.latestVersion || null); + setHasUpdate(!!data.hasUpdate); + setError(data.ok ? "" : data.error || ""); + } catch {} + }, 1200); + return () => { + active = false; + clearTimeout(timeoutId); + }; + }, [updateInProgress, currentVersion, fetchVersion]); useEffect(() => { if (!effectiveHasUpdate || !effectiveLatestVersion) { @@ -88,16 +118,18 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { }, [effectiveHasUpdate, effectiveLatestVersion]); const runAction = async () => { - if (checking) return; + const isUpdateAction = !!effectiveHasUpdate; + const busy = isUpdateActionActive ? checking || updateInProgress : checking; + if (busy) return; setChecking(true); setError(""); try { - const data = effectiveHasUpdate ? await applyUpdate() : await fetchVersion(true); + const data = isUpdateAction ? await applyUpdate() : await fetchVersion(true); setVersion(data.currentVersion || version); setLatestVersion(data.latestVersion || null); setHasUpdate(!!data.hasUpdate); setError(data.ok ? "" : data.error || ""); - if (effectiveHasUpdate) { + if (isUpdateAction) { if (!data.ok) { showToast(data.error || `${label} update failed`, "error"); } else if (data.updated || data.restarting) { @@ -118,21 +150,33 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { } else { showToast(`${label} is up to date`, "success"); } + await onActionComplete({ + type: isUpdateAction ? "update" : "check", + ok: !!data?.ok, + result: data, + }); } catch (err) { setError( err.message || - (effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates"), + (isUpdateAction ? `Could not update ${label}` : "Could not check updates"), ); showToast( - effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates", + isUpdateAction ? `Could not update ${label}` : "Could not check updates", "error", ); + await onActionComplete({ + type: isUpdateAction ? "update" : "check", + ok: false, + error: err, + }); + } finally { + setChecking(false); } - setChecking(false); }; const handleAction = () => { - if (checking) return; + const busy = isUpdateActionActive ? checking || updateInProgress : checking; + if (busy) return; if (effectiveHasUpdate && effectiveLatestVersion && !hasViewedChangelog) { setConfirmWithoutChangelogOpen(true); return; @@ -145,6 +189,10 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { runAction(); }; + const updateButtonLoading = isUpdateActionActive + ? checking || updateInProgress + : checking; + return html`
    @@ -175,24 +223,24 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { ? html` <${UpdateActionButton} onClick=${handleAction} - loading=${checking} - warning=${effectiveHasUpdate} - idleLabel=${effectiveHasUpdate - ? `Update to ${effectiveLatestVersion || "latest"}` + loading=${updateButtonLoading} + warning=${isUpdateActionActive} + idleLabel=${isUpdateActionActive + ? updateIdleLabel : "Check updates"} - loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."} + loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."} className="hidden md:inline-flex" /> ` : html` <${UpdateActionButton} onClick=${handleAction} - loading=${checking} - warning=${effectiveHasUpdate} - idleLabel=${effectiveHasUpdate - ? `Update to ${effectiveLatestVersion || "latest"}` + loading=${updateButtonLoading} + warning=${isUpdateActionActive} + idleLabel=${isUpdateActionActive + ? updateIdleLabel : "Check updates"} - loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."} + loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."} /> `}
    @@ -209,9 +257,9 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { > <${UpdateActionButton} onClick=${handleAction} - loading=${checking} - warning=${effectiveHasUpdate} - idleLabel=${`Update to ${effectiveLatestVersion || "latest"}`} + loading=${updateButtonLoading} + warning=${isUpdateActionActive} + idleLabel=${updateIdleLabel} loadingLabel="Updating..." className="flex-1 h-9 px-3" /> @@ -228,9 +276,9 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) { onConfirm=${handleConfirmWithoutChangelog} /> `; -} +}; -export function Gateway({ +export const Gateway = ({ status, openclawVersion, restarting = false, @@ -239,7 +287,10 @@ export function Gateway({ onOpenWatchdog, onRepair, repairing = false, -}) { + openclawUpdateInProgress = false, + onOpenclawVersionActionComplete = () => {}, + onOpenclawUpdate = updateOpenclaw, +}) => { const [nowMs, setNowMs] = useState(() => Date.now()); const isRunning = status === "running" && !restarting; const dotClass = isRunning @@ -357,8 +408,10 @@ export function Gateway({ label="OpenClaw" currentVersion=${openclawVersion} fetchVersion=${fetchOpenclawVersion} - applyUpdate=${updateOpenclaw} + applyUpdate=${onOpenclawUpdate} + updateInProgress=${openclawUpdateInProgress} + onActionComplete=${onOpenclawVersionActionComplete} />
    `; -} +}; diff --git a/lib/public/js/components/watchdog-tab.js b/lib/public/js/components/watchdog-tab.js index 98c84859..2b9f1521 100644 --- a/lib/public/js/components/watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab.js @@ -137,6 +137,9 @@ export const WatchdogTab = ({ restartingGateway = false, onRestartGateway, restartSignal = 0, + openclawUpdateInProgress = false, + onOpenclawVersionActionComplete = () => {}, + onOpenclawUpdate, }) => { const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000); const resourcesPoll = usePolling(() => fetchWatchdogResources(), 5000); @@ -300,6 +303,9 @@ export const WatchdogTab = ({ watchdogStatus=${currentWatchdogStatus} onRepair=${onRepair} repairing=${isRepairInProgress} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} + onOpenclawUpdate=${onOpenclawUpdate} /> ${(() => { From d04ad1253f2a0e44f952fb18ab46191ca8e2a2db Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 00:05:42 -0800 Subject: [PATCH 061/417] 0.3.5-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91e195c2..654cfb45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.5-beta.0", + "version": "0.3.5-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.3.5-beta.0", + "version": "0.3.5-beta.1", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 7c10a811..a9e4e165 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.3.5-beta.0", + "version": "0.3.5-beta.1", "publishConfig": { "access": "public" }, From b6d4ec39b96c41d89fd3bad45d89302a46849d5d Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 07:03:00 -0800 Subject: [PATCH 062/417] Improve browse git panel folder handling and restore cron git-sync execution. Support selecting and highlighting directories from git changes while preserving preview context, hide frontmatter in diff mode, and make hourly git-sync resolve alphaclaw from fallback install paths so cron runs reliably. --- lib/public/css/explorer.css | 9 +++ lib/public/js/app.js | 19 ++++++- lib/public/js/components/file-tree.js | 55 ++++++++++++++++--- lib/public/js/components/file-viewer/index.js | 16 ++++-- lib/public/js/components/sidebar-git-panel.js | 45 +++++++++++---- lib/setup/hourly-git-sync.sh | 29 +++++++++- 6 files changed, 145 insertions(+), 28 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 8db56dfa..51ca0209 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -216,6 +216,15 @@ background: var(--bg-hover); } +.tree-folder.active { + background: var(--bg-active); + color: var(--accent); +} + +.tree-folder.active .arrow { + color: var(--accent); +} + .arrow { font-size: 10px; transition: transform 0.15s; diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 95b7e0d8..b20150e0 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -52,6 +52,7 @@ const kSidebarMinWidthPx = 180; const kSidebarMaxWidthPx = 460; const kBrowseLastPathUiSettingKey = "browseLastPath"; const kLastMenuRouteUiSettingKey = "lastMenuRoute"; +const normalizeBrowsePath = (value) => String(value || "").replace(/^\/+|\/+$/g, ""); const clampSidebarWidth = (value) => Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value)); @@ -647,8 +648,22 @@ const App = () => { setMobileSidebarOpen(false); }; const navigateToBrowseFile = (relativePath, options = {}) => { - setBrowsePreviewPath(""); - setLocation(buildBrowseRoute(relativePath, options)); + const normalizedTargetPath = normalizeBrowsePath(relativePath); + const selectingDirectory = + !!options.directory || String(relativePath || "").trim().endsWith("/"); + const shouldPreservePreview = selectingDirectory && !!options.preservePreview; + setBrowsePreviewPath((currentPreviewPath) => { + if (!shouldPreservePreview) return ""; + const activePath = normalizeBrowsePath( + currentPreviewPath || selectedBrowsePath || "", + ); + if (!activePath || activePath === normalizedTargetPath) return ""; + return activePath; + }); + const routeOptions = selectingDirectory + ? { ...options, view: "edit" } + : options; + setLocation(buildBrowseRoute(normalizedTargetPath, routeOptions)); setMobileSidebarOpen(false); }; const handleSidebarLogout = async () => { diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index fd021c93..7f9eb9ae 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -173,7 +173,8 @@ const TreeNode = ({ node, depth = 0, expandedPaths, - onToggleFolder, + onSetFolderExpanded, + onSelectFolder, onSelectFile, selectedPath = "", draftPaths, @@ -218,11 +219,21 @@ const TreeNode = ({ const folderPath = node.path || ""; const isCollapsed = isSearchActive ? false : !expandedPaths.has(folderPath); + const isFolderActive = selectedPath === folderPath; return html`
  • onToggleFolder(folderPath)} + class=${`tree-folder ${isCollapsed ? "collapsed" : ""} ${isFolderActive ? "active" : ""}`.trim()} + onclick=${() => { + if (!folderPath) return; + if (isFolderActive) { + onSetFolderExpanded(folderPath, false); + onSelectFolder(""); + return; + } + onSetFolderExpanded(folderPath, true); + onSelectFolder(folderPath); + }} style=${{ paddingLeft: `${kFolderBasePaddingPx + depth * kTreeIndentPx}px`, }} @@ -239,7 +250,8 @@ const TreeNode = ({ node=${childNode} depth=${depth + 1} expandedPaths=${expandedPaths} - onToggleFolder=${onToggleFolder} + onSetFolderExpanded=${onSetFolderExpanded} + onSelectFolder=${onSelectFolder} onSelectFile=${onSelectFile} selectedPath=${selectedPath} draftPaths=${draftPaths} @@ -331,6 +343,11 @@ export const FileTree = ({ rootChildren.forEach((node) => collectFilePaths(node, filePaths)); return filePaths; }, [rootChildren]); + const folderPaths = useMemo(() => { + const nextFolderPaths = new Set(); + rootChildren.forEach((node) => collectFolderPaths(node, nextFolderPaths)); + return nextFolderPaths; + }, [rootChildren]); useEffect(() => { if (!(expandedPaths instanceof Set)) return; @@ -345,12 +362,16 @@ export const FileTree = ({ useEffect(() => { if (!selectedPath) return; const ancestorFolderPaths = collectAncestorFolderPaths(selectedPath); - if (!ancestorFolderPaths.length) return; + const selectedIsFolder = folderPaths.has(selectedPath); + const pathsToExpand = selectedIsFolder + ? [...ancestorFolderPaths, selectedPath] + : ancestorFolderPaths; + if (!pathsToExpand.length) return; setExpandedPaths((previousPaths) => { if (!(previousPaths instanceof Set)) return previousPaths; let didChange = false; const nextPaths = new Set(previousPaths); - ancestorFolderPaths.forEach((ancestorPath) => { + pathsToExpand.forEach((ancestorPath) => { if (!nextPaths.has(ancestorPath)) { nextPaths.add(ancestorPath); didChange = true; @@ -358,7 +379,7 @@ export const FileTree = ({ }); return didChange ? nextPaths : previousPaths; }); - }, [selectedPath]); + }, [selectedPath, folderPaths]); useEffect(() => { const handleDraftIndexChanged = (event) => { @@ -423,16 +444,31 @@ export const FileTree = ({ onPreviewFile(""); }, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]); - const toggleFolder = (folderPath) => { + const setFolderExpanded = (folderPath, nextExpanded) => { setExpandedPaths((previousPaths) => { const nextPaths = previousPaths instanceof Set ? new Set(previousPaths) : new Set(); + if (nextExpanded === true) { + nextPaths.add(folderPath); + return nextPaths; + } + if (nextExpanded === false) { + nextPaths.delete(folderPath); + return nextPaths; + } if (nextPaths.has(folderPath)) nextPaths.delete(folderPath); else nextPaths.add(folderPath); return nextPaths; }); }; + const selectFolder = (folderPath) => { + onSelectFile(folderPath, { + directory: true, + preservePreview: true, + }); + }; + const updateSearchQuery = (nextQuery) => { setSearchQuery(nextQuery); }; @@ -546,7 +582,8 @@ export const FileTree = ({ key=${node.path || node.name} node=${node} expandedPaths=${safeExpandedPaths} - onToggleFolder=${toggleFolder} + onSetFolderExpanded=${setFolderExpanded} + onSelectFolder=${selectFolder} onSelectFile=${onSelectFile} selectedPath=${selectedPath} draftPaths=${draftPaths} diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js index ec6d45b2..7d8b02af 100644 --- a/lib/public/js/components/file-viewer/index.js +++ b/lib/public/js/components/file-viewer/index.js @@ -63,12 +63,16 @@ export const FileViewer = ({ isProtectedLocked=${derived.isProtectedLocked} handleEditProtectedFile=${actions.handleEditProtectedFile} /> - <${FrontmatterPanel} - isMarkdownFile=${state.isMarkdownFile} - parsedFrontmatter=${derived.parsedFrontmatter} - frontmatterCollapsed=${state.frontmatterCollapsed} - setFrontmatterCollapsed=${actions.setFrontmatterCollapsed} - /> + ${!state.isDiffView + ? html` + <${FrontmatterPanel} + isMarkdownFile=${state.isMarkdownFile} + parsedFrontmatter=${derived.parsedFrontmatter} + frontmatterCollapsed=${state.frontmatterCollapsed} + setFrontmatterCollapsed=${actions.setFrontmatterCollapsed} + /> + ` + : null} ${state.loading ? html`
    diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js index e75b728c..a14e8a6b 100644 --- a/lib/public/js/components/sidebar-git-panel.js +++ b/lib/public/js/components/sidebar-git-panel.js @@ -62,6 +62,14 @@ const formatDelta = (value, prefix) => { return `${prefix}${numericValue}`; }; +const isDirectoryChangePath = (changedPath, statusKind) => { + const safePath = String(changedPath || "").trim(); + const safeStatusKind = String(statusKind || "").toUpperCase(); + if (!safePath) return false; + if (safePath.endsWith("/")) return true; + return safeStatusKind === "U" && safePath.endsWith("\\"); +}; + const getRemoteSyncPresentation = (summary) => { const safeState = String(summary?.syncState || "").trim(); const aheadCount = Number(summary?.aheadCount) || 0; @@ -108,19 +116,25 @@ const getRemoteSyncPresentation = (summary) => { }; }; -const buildSyncCommitMessage = (changedFiles) => { - const filePaths = Array.isArray(changedFiles) - ? changedFiles - .map((file) => String(file?.path || "").trim()) - .filter(Boolean) - : []; - const totalCount = filePaths.length; +const buildSyncCommitMessage = (summary) => { + const changedFiles = Array.isArray(summary?.changedFiles) ? summary.changedFiles : []; + const changedFilesCount = Number(summary?.changedFilesCount) || 0; + const filePaths = changedFiles + .map((file) => String(file?.path || "").trim()) + .filter(Boolean); + const totalCount = changedFilesCount || filePaths.length; if (totalCount <= 0) return "sync changes"; - const fileNames = filePaths.map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath); + const fileNames = filePaths + .map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath); const uniqueFileNames = Array.from(new Set(fileNames)); + if (uniqueFileNames.length <= 0) { + const noun = totalCount === 1 ? "file" : "files"; + return `Edited ${totalCount} ${noun}`; + } + const shownFileNames = uniqueFileNames.slice(0, kSyncCommitFileNameLimit); - const remainingCount = Math.max(0, uniqueFileNames.length - shownFileNames.length); + const remainingCount = Math.max(0, totalCount - shownFileNames.length); const noun = totalCount === 1 ? "file" : "files"; const suffix = remainingCount > 0 ? ` +${remainingCount} more` : ""; return `Edited ${totalCount} ${noun} - ${shownFileNames.join(", ")}${suffix}`; @@ -194,7 +208,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { if (!canSyncChanges || syncing) return; try { setSyncing(true); - const commitMessage = buildSyncCommitMessage(summary?.changedFiles || []); + const commitMessage = buildSyncCommitMessage(summary); const syncResult = await syncBrowseChanges(commitMessage); if (syncResult?.committed || syncResult?.pushed) { window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced")); @@ -270,6 +284,17 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { title=${changedPath} onclick=${() => { if (!presentation.canOpen || !changedPath) return; + const directorySelection = isDirectoryChangePath( + changedPath, + changedFile?.statusKind, + ); + if (directorySelection) { + onSelectFile(changedPath, { + directory: true, + preservePreview: true, + }); + return; + } onSelectFile(changedPath, { view: "diff" }); }} > diff --git a/lib/setup/hourly-git-sync.sh b/lib/setup/hourly-git-sync.sh index c625f2a2..acf0c876 100644 --- a/lib/setup/hourly-git-sync.sh +++ b/lib/setup/hourly-git-sync.sh @@ -81,5 +81,32 @@ NODE maybe_restore_if_runtime_only "cron/jobs.json" maybe_restore_if_runtime_only "crons.json" +resolve_alphaclaw_cmd() { + if command -v alphaclaw >/dev/null 2>&1; then + command -v alphaclaw + return 0 + fi + + local candidate_paths=( + "/app/node_modules/.bin/alphaclaw" + "$REPO/node_modules/.bin/alphaclaw" + "$REPO/../node_modules/.bin/alphaclaw" + ) + local candidate + for candidate in "${candidate_paths[@]}"; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return 0 + fi + done + + return 1 +} + msg="Auto-commit hourly sync $(date -u +'%Y-%m-%dT%H:%M:%SZ')" -alphaclaw git-sync -m "$msg" +alphaclaw_cmd="$(resolve_alphaclaw_cmd || true)" +if [[ -z "${alphaclaw_cmd:-}" ]]; then + echo "hourly-git-sync: alphaclaw CLI not found in PATH or known install paths" >&2 + exit 127 +fi +"$alphaclaw_cmd" git-sync -m "$msg" From a83c0d9b3712d3b6620de3199c434591764719b5 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 07:56:35 -0800 Subject: [PATCH 063/417] Add browse delete/restore UX and unify file policy sources. Introduce file delete and restore flows with diff-aware recovery, improve folder selection behavior in browse/git panels, and move protected/locked browse paths to a shared JSON source used by both frontend and backend with documented runtime dependency guardrails. --- AGENTS.md | 20 +++ lib/public/css/explorer.css | 31 ++++ lib/public/js/app.js | 31 ++-- lib/public/js/components/file-tree.js | 152 ++++++++++++++++-- lib/public/js/components/file-viewer/index.js | 34 ++++ .../components/file-viewer/status-banners.js | 17 +- .../js/components/file-viewer/toolbar.js | 44 ++++- .../use-editor-selection-restore.js | 6 + .../components/file-viewer/use-file-diff.js | 11 ++ .../components/file-viewer/use-file-loader.js | 14 +- .../components/file-viewer/use-file-viewer.js | 96 ++++++++++- lib/public/js/components/icons.js | 26 +++ lib/public/js/components/sidebar-git-panel.js | 4 +- lib/public/js/lib/api.js | 18 +++ lib/public/js/lib/browse-file-policies.js | 40 +++-- .../js/lib/syntax-highlighters/index.js | 11 +- lib/public/shared/browse-file-policies.json | 13 ++ lib/server/constants.js | 19 ++- lib/server/routes/browse/index.js | 92 ++++++++++- 19 files changed, 618 insertions(+), 61 deletions(-) create mode 100644 lib/public/shared/browse-file-policies.json diff --git a/AGENTS.md b/AGENTS.md index e004e722..e3664c19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,26 @@ Use this release flow when promoting tested beta builds to production: - `@chrysb/alphaclaw: "latest"` 6. Optionally keep beta branch/tag flows active for next release cycle. +### Runtime Dependency Guardrails (Express 4 vs 5) + +AlphaClaw currently expects Express 4 semantics in its setup API layer. A broken container dependency tree can accidentally resolve `express@5` at `/app/node_modules/express`, which causes subtle request handling regressions (for example body parsing behavior on certain methods). + +Known root cause pattern: + +- Mutating `/app/node_modules` in-place (for example copy-over installs used for emergency package swaps) can leave the runtime tree inconsistent with `/app/package.json`. +- This can hoist `express@5` to the app root, so `require("express")` inside AlphaClaw resolves the wrong major version. + +Preferred fix/recovery: + +1. Ensure template `package.json` pins the intended `@chrysb/alphaclaw` version. +2. Rebuild the `openclaw` container from scratch (no cache) and recreate it: + - `docker compose down` + - `docker compose build --no-cache openclaw` + - `docker compose up -d openclaw` +3. Verify runtime resolution inside the container: + - `node -p "require('express/package.json').version"` should be `4.x` + - `npm ls express` should show `@chrysb/alphaclaw` on `express@4.x` (OpenClaw can still carry its own `express@5` subtree). + ### Telegram Notice Format (AlphaClaw) Use this format for any Telegram notices sent from AlphaClaw services (watchdog, system alerts, repair notices): diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 51ca0209..6bfd6bf9 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -574,6 +574,37 @@ flex-shrink: 0; } +.file-viewer-icon-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-right: 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + color: var(--text-muted); + transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease; +} + +.file-viewer-icon-action:hover { + border-color: rgba(148, 163, 184, 0.45); + color: var(--text); + background: rgba(148, 163, 184, 0.08); +} + +.file-viewer-icon-action.is-disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.file-viewer-icon-action-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + .file-viewer-view-toggle { display: flex; align-items: center; diff --git a/lib/public/js/app.js b/lib/public/js/app.js index b20150e0..dac6d893 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -647,19 +647,23 @@ const App = () => { setLocation(`/${screen}`); setMobileSidebarOpen(false); }; + const handleBrowsePreviewFile = useCallback((nextPreviewPath) => { + const normalizedPreviewPath = normalizeBrowsePath(nextPreviewPath); + setBrowsePreviewPath(normalizedPreviewPath); + }, []); const navigateToBrowseFile = (relativePath, options = {}) => { const normalizedTargetPath = normalizeBrowsePath(relativePath); const selectingDirectory = !!options.directory || String(relativePath || "").trim().endsWith("/"); const shouldPreservePreview = selectingDirectory && !!options.preservePreview; - setBrowsePreviewPath((currentPreviewPath) => { - if (!shouldPreservePreview) return ""; - const activePath = normalizeBrowsePath( - currentPreviewPath || selectedBrowsePath || "", - ); - if (!activePath || activePath === normalizedTargetPath) return ""; - return activePath; - }); + const activePath = normalizeBrowsePath( + browsePreviewPath || selectedBrowsePath || "", + ); + const nextPreviewPath = + shouldPreservePreview && activePath && activePath !== normalizedTargetPath + ? activePath + : ""; + setBrowsePreviewPath(nextPreviewPath); const routeOptions = selectingDirectory ? { ...options, view: "edit" } : options; @@ -894,7 +898,7 @@ const App = () => { onSelectNavItem=${handleSelectNavItem} selectedBrowsePath=${selectedBrowsePath} onSelectBrowseFile=${navigateToBrowseFile} - onPreviewBrowseFile=${setBrowsePreviewPath} + onPreviewBrowseFile=${handleBrowsePreviewFile} acHasUpdate=${acHasUpdate} acLatest=${acLatest} acDismissed=${acDismissed} @@ -940,10 +944,7 @@ const App = () => { ? html` <${FileViewer} filePath=${activeBrowsePath} - isPreviewOnly=${Boolean( - browsePreviewPath && - browsePreviewPath !== selectedBrowsePath, - )} + isPreviewOnly=${false} browseView=${browseViewerMode} onRequestEdit=${(targetPath) => { const normalizedTargetPath = String(targetPath || ""); @@ -956,6 +957,10 @@ const App = () => { } setLocation(buildBrowseRoute(selectedBrowsePath, { view: "edit" })); }} + onRequestClearSelection=${() => { + setBrowsePreviewPath(""); + setLocation("/browse"); + }} /> ` : html` diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index 7f9eb9ae..532a53c6 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -8,12 +8,14 @@ import { } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { fetchBrowseTree } from "../lib/api.js"; +import { deleteBrowseFile } from "../lib/api.js"; import { kDraftIndexChangedEventName, readStoredDraftPaths, } from "../lib/browse-draft-state.js"; import { kLockedBrowsePaths, + kProtectedBrowsePaths, matchesBrowsePolicyPath, normalizeBrowsePolicyPath, } from "../lib/browse-file-policies.js"; @@ -32,6 +34,8 @@ import { LockLineIcon, } from "./icons.js"; import { LoadingSpinner } from "./loading-spinner.js"; +import { ConfirmDialog } from "./confirm-dialog.js"; +import { showToast } from "./toast.js"; const html = htm.bind(h); const kTreeIndentPx = 9; @@ -71,6 +75,23 @@ const collectFilePaths = (node, filePaths) => { ); }; +const removeTreePath = (node, targetPath) => { + if (!node) return null; + const safeTargetPath = String(targetPath || "").trim(); + if (!safeTargetPath) return node; + const nodePath = String(node.path || "").trim(); + if (nodePath === safeTargetPath) return null; + if (node.type !== "folder") return node; + const nextChildren = (node.children || []) + .map((childNode) => removeTreePath(childNode, safeTargetPath)) + .filter(Boolean); + if (nextChildren.length === (node.children || []).length) return node; + return { + ...node, + children: nextChildren, + }; +}; + const filterTreeNode = (node, normalizedQuery) => { if (!node) return null; const query = String(normalizedQuery || "") @@ -95,50 +116,68 @@ const filterTreeNode = (node, normalizedQuery) => { const getFileIconMeta = (fileName) => { const normalizedName = String(fileName || "").toLowerCase(); - if (normalizedName.endsWith(".md")) { + const normalizedNameWithoutBakSuffix = normalizedName.replace(/(\.bak)+$/i, ""); + if (normalizedNameWithoutBakSuffix.endsWith(".md")) { return { icon: MarkdownFillIcon, className: "file-icon file-icon-md", }; } - if (normalizedName.endsWith(".js") || normalizedName.endsWith(".mjs")) { + if ( + normalizedNameWithoutBakSuffix.endsWith(".js") || + normalizedNameWithoutBakSuffix.endsWith(".mjs") + ) { return { icon: JavascriptFillIcon, className: "file-icon file-icon-js", }; } - if (normalizedName.endsWith(".json") || normalizedName.endsWith(".jsonl")) { + if ( + normalizedNameWithoutBakSuffix.endsWith(".json") || + normalizedNameWithoutBakSuffix.endsWith(".jsonl") + ) { return { icon: BracesLineIcon, className: "file-icon file-icon-json", }; } - if (normalizedName.endsWith(".css") || normalizedName.endsWith(".scss")) { + if ( + normalizedNameWithoutBakSuffix.endsWith(".css") || + normalizedNameWithoutBakSuffix.endsWith(".scss") + ) { return { icon: HashtagIcon, className: "file-icon file-icon-css", }; } - if (/\.(html?)$/i.test(normalizedName)) { + if (/\.(html?)$/i.test(normalizedNameWithoutBakSuffix)) { return { icon: FileCodeLineIcon, className: "file-icon file-icon-html", }; } - if (/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(normalizedName)) { + if ( + /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test( + normalizedNameWithoutBakSuffix, + ) + ) { return { icon: Image2FillIcon, className: "file-icon file-icon-image", }; } - if (/\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test(normalizedName)) { + if ( + /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test( + normalizedNameWithoutBakSuffix, + ) + ) { return { icon: FileMusicLineIcon, className: "file-icon file-icon-audio", }; } if ( - /\.(sh|bash|zsh|command)$/i.test(normalizedName) || + /\.(sh|bash|zsh|command)$/i.test(normalizedNameWithoutBakSuffix) || [ ".bashrc", ".zshrc", @@ -146,7 +185,7 @@ const getFileIconMeta = (fileName) => { ".bash_profile", ".zprofile", ".zshenv", - ].includes(normalizedName) + ].includes(normalizedNameWithoutBakSuffix) ) { return { icon: TerminalFillIcon, @@ -155,7 +194,7 @@ const getFileIconMeta = (fileName) => { } if ( /\.(db|sqlite|sqlite3|db3|sdb|sqlitedb|duckdb|mdb|accdb)$/i.test( - normalizedName, + normalizedNameWithoutBakSuffix, ) ) { return { @@ -175,6 +214,7 @@ const TreeNode = ({ expandedPaths, onSetFolderExpanded, onSelectFolder, + onRequestDelete, onSelectFile, selectedPath = "", draftPaths, @@ -197,6 +237,15 @@ const TreeNode = ({ onSelectFile(node.path)} + onKeyDown=${(event) => { + const isDeleteKey = + event.key === "Delete" || event.key === "Backspace"; + if (!isDeleteKey || !isActive) return; + event.preventDefault(); + onRequestDelete(node.path); + }} + tabindex="0" + role="button" style=${{ paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px`, }} @@ -252,6 +301,7 @@ const TreeNode = ({ expandedPaths=${expandedPaths} onSetFolderExpanded=${onSetFolderExpanded} onSelectFolder=${onSelectFolder} + onRequestDelete=${onRequestDelete} onSelectFile=${onSelectFile} selectedPath=${selectedPath} draftPaths=${draftPaths} @@ -277,6 +327,8 @@ export const FileTree = ({ const [draftPaths, setDraftPaths] = useState(readStoredDraftPaths); const [searchQuery, setSearchQuery] = useState(""); const [searchActivePath, setSearchActivePath] = useState(""); + const [deleteTargetPath, setDeleteTargetPath] = useState(""); + const [deletingFile, setDeletingFile] = useState(false); const searchInputRef = useRef(null); const treeSignatureRef = useRef(""); @@ -312,16 +364,23 @@ export const FileTree = ({ const refreshTree = () => { loadTree({ showLoading: false }); }; + const handleFileDeleted = (event) => { + const deletedPath = String(event?.detail?.path || "").trim(); + if (!deletedPath) return; + setTreeRoot((previousRoot) => removeTreePath(previousRoot, deletedPath)); + }; const refreshInterval = window.setInterval( refreshTree, kTreeRefreshIntervalMs, ); window.addEventListener("alphaclaw:browse-file-saved", refreshTree); window.addEventListener("alphaclaw:browse-tree-refresh", refreshTree); + window.addEventListener("alphaclaw:browse-file-deleted", handleFileDeleted); return () => { window.clearInterval(refreshInterval); window.removeEventListener("alphaclaw:browse-file-saved", refreshTree); window.removeEventListener("alphaclaw:browse-tree-refresh", refreshTree); + window.removeEventListener("alphaclaw:browse-file-deleted", handleFileDeleted); }; }, [loadTree]); @@ -343,6 +402,11 @@ export const FileTree = ({ rootChildren.forEach((node) => collectFilePaths(node, filePaths)); return filePaths; }, [rootChildren]); + const allTreeFilePaths = useMemo(() => { + const filePaths = []; + (treeRoot?.children || []).forEach((node) => collectFilePaths(node, filePaths)); + return new Set(filePaths); + }, [treeRoot]); const folderPaths = useMemo(() => { const nextFolderPaths = new Set(); rootChildren.forEach((node) => collectFolderPaths(node, nextFolderPaths)); @@ -469,6 +533,57 @@ export const FileTree = ({ }); }; + const requestDelete = (targetPath) => { + const normalizedTargetPath = normalizeBrowsePolicyPath(targetPath); + if (!normalizedTargetPath) return; + if (!allTreeFilePaths.has(targetPath)) { + showToast("Only files can be deleted", "warning"); + return; + } + if ( + matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedTargetPath) || + matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedTargetPath) + ) { + showToast("Protected or locked files cannot be deleted", "warning"); + return; + } + setDeleteTargetPath(targetPath); + }; + + const confirmDelete = async () => { + if (!deleteTargetPath || deletingFile) return; + setDeletingFile(true); + try { + await deleteBrowseFile(deleteTargetPath); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-saved", { + detail: { path: deleteTargetPath }, + }), + ); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-deleted", { + detail: { path: deleteTargetPath }, + }), + ); + setTreeRoot((previousRoot) => + removeTreePath(previousRoot, deleteTargetPath), + ); + window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + onSelectFile(""); + showToast("File deleted", "success"); + setDeleteTargetPath(""); + } catch (deleteError) { + const message = deleteError.message || "Could not delete file"; + if (/path is not a file/i.test(message)) { + showToast("Only files can be deleted", "warning"); + } else { + showToast(message, "error"); + } + } finally { + setDeletingFile(false); + } + }; + const updateSearchQuery = (nextQuery) => { setSearchQuery(nextQuery); }; @@ -584,6 +699,7 @@ export const FileTree = ({ expandedPaths=${safeExpandedPaths} onSetFolderExpanded=${setFolderExpanded} onSelectFolder=${selectFolder} + onRequestDelete=${requestDelete} onSelectFile=${onSelectFile} selectedPath=${selectedPath} draftPaths=${draftPaths} @@ -593,6 +709,22 @@ export const FileTree = ({ `, )} + <${ConfirmDialog} + visible=${!!deleteTargetPath} + title="Delete file?" + message=${`Delete ${deleteTargetPath || "this file"}? This can be restored from diff view before sync.`} + confirmLabel="Delete" + confirmLoadingLabel="Deleting..." + cancelLabel="Cancel" + confirmTone="warning" + confirmLoading=${deletingFile} + confirmDisabled=${deletingFile} + onCancel=${() => { + if (deletingFile) return; + setDeleteTargetPath(""); + }} + onConfirm=${confirmDelete} + />
    `; }; diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js index 7d8b02af..b1402a6b 100644 --- a/lib/public/js/components/file-viewer/index.js +++ b/lib/public/js/components/file-viewer/index.js @@ -1,6 +1,8 @@ import { h } from "https://esm.sh/preact"; +import { useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { LoadingSpinner } from "../loading-spinner.js"; +import { ConfirmDialog } from "../confirm-dialog.js"; import { SqliteViewer } from "./sqlite-viewer.js"; import { FileViewerToolbar } from "./toolbar.js"; import { FileViewerStatusBanners } from "./status-banners.js"; @@ -19,11 +21,15 @@ export const FileViewer = ({ isPreviewOnly = false, browseView = "edit", onRequestEdit = () => {}, + onRequestClearSelection = () => {}, }) => { + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const { state, derived, refs, actions, context } = useFileViewer({ filePath, isPreviewOnly, browseView, + onRequestClearSelection, + onRequestEdit, }); if (!state.hasSelectedPath) { @@ -53,11 +59,20 @@ export const FileViewer = ({ isAudioFile=${state.isAudioFile} isSqliteFile=${state.isSqliteFile} saving=${state.saving} + deleting=${state.deleting} + restoring=${state.restoring} + canDeleteFile=${derived.canDeleteFile} + isDeleteBlocked=${derived.isDeleteBlocked} + isProtectedFile=${derived.isProtectedFile} + canRestoreDeletedDiff=${state.isDiffView && !!state.diffStatus?.isDeleted} + onRequestDelete=${() => setDeleteConfirmOpen(true)} + onRequestRestore=${actions.handleRestore} /> <${FileViewerStatusBanners} isDiffView=${state.isDiffView} onRequestEdit=${onRequestEdit} normalizedPath=${context.normalizedPath} + isDeletedDiff=${!!state.diffStatus?.isDeleted} isLockedFile=${derived.isLockedFile} isProtectedFile=${derived.isProtectedFile} isProtectedLocked=${derived.isProtectedLocked} @@ -163,6 +178,25 @@ export const FileViewer = ({ /> `} `} + <${ConfirmDialog} + visible=${deleteConfirmOpen} + title="Delete file?" + message=${`Delete ${context.normalizedPath || "this file"}? This can be restored from diff view before sync.`} + confirmLabel="Delete" + confirmLoadingLabel="Deleting..." + cancelLabel="Cancel" + confirmTone="warning" + confirmLoading=${state.deleting} + confirmDisabled=${!derived.canDeleteFile || state.deleting} + onCancel=${() => { + if (state.deleting) return; + setDeleteConfirmOpen(false); + }} + onConfirm=${async () => { + await actions.handleDelete(); + setDeleteConfirmOpen(false); + }} + />
    `; }; diff --git a/lib/public/js/components/file-viewer/status-banners.js b/lib/public/js/components/file-viewer/status-banners.js index ee80ba83..37e59b48 100644 --- a/lib/public/js/components/file-viewer/status-banners.js +++ b/lib/public/js/components/file-viewer/status-banners.js @@ -9,6 +9,7 @@ export const FileViewerStatusBanners = ({ isDiffView, onRequestEdit, normalizedPath, + isDeletedDiff = false, isLockedFile, isProtectedFile, isProtectedLocked, @@ -18,12 +19,16 @@ export const FileViewerStatusBanners = ({ ? html`
    Viewing unsynced changes
    - <${ActionButton} - onClick=${() => onRequestEdit(normalizedPath)} - tone="secondary" - size="sm" - idleLabel="View file" - /> + ${!isDeletedDiff + ? html` + <${ActionButton} + onClick=${() => onRequestEdit(normalizedPath)} + tone="secondary" + size="sm" + idleLabel="View file" + /> + ` + : null}
    ` : null} diff --git a/lib/public/js/components/file-viewer/toolbar.js b/lib/public/js/components/file-viewer/toolbar.js index 83f1aacc..cbb6cd9c 100644 --- a/lib/public/js/components/file-viewer/toolbar.js +++ b/lib/public/js/components/file-viewer/toolbar.js @@ -2,7 +2,7 @@ import { h } from "https://esm.sh/preact"; import htm from "https://esm.sh/htm"; import { ActionButton } from "../action-button.js"; import { SegmentedControl } from "../segmented-control.js"; -import { SaveFillIcon } from "../icons.js"; +import { DeleteBinLineIcon, RestartLineIcon, SaveFillIcon } from "../icons.js"; const html = htm.bind(h); @@ -22,6 +22,14 @@ export const FileViewerToolbar = ({ isAudioFile, isSqliteFile, saving, + deleting, + restoring, + canDeleteFile, + isDeleteBlocked, + isProtectedFile, + canRestoreDeletedDiff, + onRequestDelete, + onRequestRestore, }) => html`
    @@ -58,6 +66,24 @@ export const FileViewerToolbar = ({ ${!isDiffView ? !isImageFile && !isAudioFile && !isSqliteFile ? html` + ${!isProtectedFile + ? html` + + ` + : null} <${ActionButton} onClick=${handleSave} disabled=${loading || !isDirty || !canEditFile || isEditBlocked} @@ -73,5 +99,21 @@ export const FileViewerToolbar = ({ ` : null : null} + ${isDiffView && canRestoreDeletedDiff + ? html` + <${ActionButton} + onClick=${onRequestRestore} + disabled=${restoring} + loading=${restoring} + tone="secondary" + size="sm" + idleLabel="Restore" + loadingLabel="Restoring..." + idleIcon=${RestartLineIcon} + idleIconClassName="file-viewer-save-icon" + className="file-viewer-save-action" + /> + ` + : null}
    `; diff --git a/lib/public/js/components/file-viewer/use-editor-selection-restore.js b/lib/public/js/components/file-viewer/use-editor-selection-restore.js index 59c19452..8adad925 100644 --- a/lib/public/js/components/file-viewer/use-editor-selection-restore.js +++ b/lib/public/js/components/file-viewer/use-editor-selection-restore.js @@ -5,6 +5,7 @@ import { getScrollRatio } from "./scroll-sync.js"; export const useEditorSelectionRestore = ({ canEditFile, + isEditBlocked, loading, hasSelectedPath, normalizedPath, @@ -18,6 +19,10 @@ export const useEditorSelectionRestore = ({ viewScrollRatioRef, }) => { useEffect(() => { + if (isEditBlocked) { + restoredSelectionPathRef.current = ""; + return () => {}; + } if (!canEditFile || loading || !hasSelectedPath) return () => {}; if (loadedFilePathRef.current !== normalizedPath) return () => {}; if (restoredSelectionPathRef.current === normalizedPath) return () => {}; @@ -72,6 +77,7 @@ export const useEditorSelectionRestore = ({ }; }, [ canEditFile, + isEditBlocked, loading, hasSelectedPath, normalizedPath, diff --git a/lib/public/js/components/file-viewer/use-file-diff.js b/lib/public/js/components/file-viewer/use-file-diff.js index 24effe6d..374518da 100644 --- a/lib/public/js/components/file-viewer/use-file-diff.js +++ b/lib/public/js/components/file-viewer/use-file-diff.js @@ -10,6 +10,10 @@ export const useFileDiff = ({ const [diffLoading, setDiffLoading] = useState(false); const [diffError, setDiffError] = useState(""); const [diffContent, setDiffContent] = useState(""); + const [diffStatus, setDiffStatus] = useState({ + statusKind: "", + isDeleted: false, + }); useEffect(() => { let active = true; @@ -17,6 +21,7 @@ export const useFileDiff = ({ setDiffLoading(false); setDiffError(""); setDiffContent(""); + setDiffStatus({ statusKind: "", isDeleted: false }); return () => { active = false; }; @@ -28,9 +33,14 @@ export const useFileDiff = ({ const data = await fetchBrowseFileDiff(normalizedPath); if (!active) return; setDiffContent(String(data?.content || "")); + setDiffStatus({ + statusKind: String(data?.statusKind || ""), + isDeleted: !!data?.isDeleted, + }); } catch (nextError) { if (!active) return; setDiffError(nextError.message || "Could not load diff"); + setDiffStatus({ statusKind: "", isDeleted: false }); } finally { if (active) setDiffLoading(false); } @@ -45,5 +55,6 @@ export const useFileDiff = ({ diffLoading, diffError, diffContent, + diffStatus, }; }; diff --git a/lib/public/js/components/file-viewer/use-file-loader.js b/lib/public/js/components/file-viewer/use-file-loader.js index fa72c88a..a146a7f9 100644 --- a/lib/public/js/components/file-viewer/use-file-loader.js +++ b/lib/public/js/components/file-viewer/use-file-loader.js @@ -11,6 +11,7 @@ import { kFileRefreshIntervalMs, kSqlitePageSize } from "./constants.js"; export const useFileLoader = ({ hasSelectedPath, normalizedPath, + isDiffView, isSqliteFile, sqliteSelectedTable, sqliteTableOffset, @@ -83,6 +84,14 @@ export const useFileLoader = ({ setIsFolderPath(false); setExternalChangeNoticeShown(false); viewScrollRatioRef.current = 0; + if (isDiffView) { + setLoading(false); + loadedFilePathRef.current = normalizedPath; + restoredSelectionPathRef.current = ""; + return () => { + active = false; + }; + } const loadFile = async () => { setLoading(true); @@ -206,7 +215,7 @@ export const useFileLoader = ({ return () => { active = false; }; - }, [hasSelectedPath, normalizedPath]); + }, [hasSelectedPath, normalizedPath, isDiffView]); useEffect(() => { if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) { @@ -242,7 +251,7 @@ export const useFileLoader = ({ }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]); useEffect(() => { - if (!hasSelectedPath || isFolderPath || !canEditFile) return () => {}; + if (!hasSelectedPath || isFolderPath || !canEditFile || isDiffView) return () => {}; const refreshFromDisk = async () => { if (loading || saving) return; if (fileRefreshInFlightRef.current) return; @@ -287,6 +296,7 @@ export const useFileLoader = ({ hasSelectedPath, isFolderPath, canEditFile, + isDiffView, loading, saving, normalizedPath, diff --git a/lib/public/js/components/file-viewer/use-file-viewer.js b/lib/public/js/components/file-viewer/use-file-viewer.js index 62362bb1..8e71b017 100644 --- a/lib/public/js/components/file-viewer/use-file-viewer.js +++ b/lib/public/js/components/file-viewer/use-file-viewer.js @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks"; import { marked } from "https://esm.sh/marked"; -import { saveFileContent } from "../../lib/api.js"; +import { deleteBrowseFile, restoreBrowseFile, saveFileContent } from "../../lib/api.js"; import { getFileSyntaxKind, highlightEditorLines, @@ -32,6 +32,8 @@ export const useFileViewer = ({ filePath = "", isPreviewOnly = false, browseView = "edit", + onRequestClearSelection = () => {}, + onRequestEdit = () => {}, }) => { const normalizedPath = String(filePath || "").trim(); const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath); @@ -50,6 +52,8 @@ export const useFileViewer = ({ const [loading, setLoading] = useState(false); const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false); const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [restoring, setRestoring] = useState(false); const [error, setError] = useState(""); const [isFolderPath, setIsFolderPath] = useState(false); const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false); @@ -83,6 +87,7 @@ export const useFileViewer = ({ const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({ hasSelectedPath, normalizedPath, + isDiffView, isSqliteFile, sqliteSelectedTable, sqliteTableOffset, @@ -111,7 +116,7 @@ export const useFileViewer = ({ viewScrollRatioRef, }); - const { diffLoading, diffError, diffContent } = useFileDiff({ + const { diffLoading, diffError, diffContent, diffStatus } = useFileDiff({ hasSelectedPath, isDiffView, isPreviewOnly, @@ -131,6 +136,15 @@ export const useFileViewer = ({ matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath); const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath); const isEditBlocked = isLockedFile || isProtectedLocked; + const isDeleteBlocked = isLockedFile || isProtectedFile; + const canDeleteFile = + hasSelectedPath && + !isFolderPath && + !isPreviewOnly && + !isDiffView && + !deleting && + !saving && + !isDeleteBlocked; const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]); const isMarkdownFile = syntaxKind === "markdown"; const shouldUseHighlightedEditor = syntaxKind !== "plain"; @@ -187,6 +201,10 @@ export const useFileViewer = ({ } }, [isMarkdownFile, viewMode]); + useEffect(() => { + setProtectedEditBypassPaths(new Set()); + }, [normalizedPath]); + useEffect(() => { try { window.localStorage.setItem(kFileViewerModeStorageKey, viewMode); @@ -216,6 +234,7 @@ export const useFileViewer = ({ useEditorSelectionRestore({ canEditFile, + isEditBlocked, loading, hasSelectedPath, normalizedPath, @@ -256,6 +275,72 @@ export const useFileViewer = ({ } }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content]); + const handleDelete = useCallback(async () => { + if (!canDeleteFile) return; + setDeleting(true); + setError(""); + try { + const data = await deleteBrowseFile(normalizedPath); + const deletedPath = String(data?.path || normalizedPath); + setExternalChangeNoticeShown(false); + clearStoredFileDraft(normalizedPath); + updateDraftIndex(normalizedPath, false, { + dispatchEvent: (event) => window.dispatchEvent(event), + }); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-saved", { + detail: { path: deletedPath }, + }), + ); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-deleted", { + detail: { path: deletedPath }, + }), + ); + window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + showToast("File deleted", "success"); + onRequestClearSelection(); + } catch (deleteError) { + const message = deleteError.message || "Could not delete file"; + setError(message); + if (/path is not a file/i.test(message)) { + showToast("Only files can be deleted", "warning"); + onRequestClearSelection(); + } else { + showToast(message, "error"); + } + } finally { + setDeleting(false); + } + }, [canDeleteFile, normalizedPath, onRequestClearSelection]); + + const handleRestore = useCallback(async () => { + if (!isDiffView || !diffStatus?.isDeleted || restoring) return; + setRestoring(true); + try { + const data = await restoreBrowseFile(normalizedPath); + const restoredPath = String(data?.path || normalizedPath); + window.dispatchEvent( + new CustomEvent("alphaclaw:browse-file-saved", { + detail: { path: restoredPath }, + }), + ); + window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + showToast("File restored", "success"); + onRequestEdit(restoredPath); + } catch (restoreError) { + showToast(restoreError.message || "Could not restore file", "error"); + } finally { + setRestoring(false); + } + }, [ + diffStatus?.isDeleted, + isDiffView, + normalizedPath, + onRequestEdit, + restoring, + ]); + useFileViewerHotkeys({ canEditFile, isPreviewOnly, @@ -315,6 +400,8 @@ export const useFileViewer = ({ isPreviewOnly, loading, saving, + deleting, + restoring, showDelayedLoadingSpinner, error, isFolderPath, @@ -333,6 +420,7 @@ export const useFileViewer = ({ diffLoading, diffError, diffContent, + diffStatus, isMarkdownFile, frontmatterCollapsed, previewHtml, @@ -343,6 +431,8 @@ export const useFileViewer = ({ pathSegments, isDirty, canEditFile, + canDeleteFile, + isDeleteBlocked, isEditBlocked, isLockedFile, isProtectedFile, @@ -366,6 +456,8 @@ export const useFileViewer = ({ setSqliteTableOffset, handleChangeViewMode, handleSave, + handleDelete, + handleRestore, handleEditProtectedFile, handleContentInput, handleEditorScroll, diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 39e1845c..4ddcfd73 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -248,3 +248,29 @@ export const LockLineIcon = ({ className = "" }) => html` /> `; + +export const DeleteBinLineIcon = ({ className = "" }) => html` + +`; + +export const RestartLineIcon = ({ className = "" }) => html` + +`; diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js index a14e8a6b..b678507e 100644 --- a/lib/public/js/components/sidebar-git-panel.js +++ b/lib/public/js/components/sidebar-git-panel.js @@ -43,8 +43,8 @@ const getChangedFilePresentation = (changedFile) => { return { statusLabel: "D", statusClass: "is-deleted", - rowClass: "", - canOpen: false, + rowClass: "is-clickable", + canOpen: true, }; } return { diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 7ce5312b..90c099ad 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -389,6 +389,24 @@ export const saveFileContent = async (filePath, content) => { return parseJsonOrThrow(res, 'Could not save file'); }; +export const deleteBrowseFile = async (filePath) => { + const res = await authFetch('/api/browse/delete', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: String(filePath || '') }), + }); + return parseJsonOrThrow(res, 'Could not delete file'); +}; + +export const restoreBrowseFile = async (filePath) => { + const res = await authFetch('/api/browse/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: String(filePath || '') }), + }); + return parseJsonOrThrow(res, 'Could not restore file'); +}; + export const fetchBrowseGitSummary = async () => { const res = await authFetch('/api/browse/git-summary'); return parseJsonOrThrow(res, 'Could not load git summary'); diff --git a/lib/public/js/lib/browse-file-policies.js b/lib/public/js/lib/browse-file-policies.js index d479f916..ee7de336 100644 --- a/lib/public/js/lib/browse-file-policies.js +++ b/lib/public/js/lib/browse-file-policies.js @@ -1,15 +1,33 @@ -export const kProtectedBrowsePaths = new Set([ - "openclaw.json", - "devices/paired.json", -]); +const kBrowseFilePoliciesUrl = new URL( + "../../shared/browse-file-policies.json", + import.meta.url, +); -export const kLockedBrowsePaths = new Set([ - "hooks/bootstrap/agents.md", - "hooks/bootstrap/tools.md", - "skills/control-ui/skill.md", - ".alphaclaw/hourly-git-sync.sh", - ".alphaclaw/.cli-device-auto-approved", -]); +let kBrowseFilePolicies = { + protectedPaths: [], + lockedPaths: [], +}; +try { + const policyResponse = await fetch(kBrowseFilePoliciesUrl); + if (policyResponse.ok) { + const policyJson = await policyResponse.json(); + if (policyJson && typeof policyJson === "object") { + kBrowseFilePolicies = policyJson; + } + } +} catch {} + +export const kProtectedBrowsePaths = new Set( + Array.isArray(kBrowseFilePolicies?.protectedPaths) + ? kBrowseFilePolicies.protectedPaths + : [], +); + +export const kLockedBrowsePaths = new Set( + Array.isArray(kBrowseFilePolicies?.lockedPaths) + ? kBrowseFilePolicies.lockedPaths + : [], +); export const normalizeBrowsePolicyPath = (inputPath) => String(inputPath || "") diff --git a/lib/public/js/lib/syntax-highlighters/index.js b/lib/public/js/lib/syntax-highlighters/index.js index 37c91e70..bca3eca9 100644 --- a/lib/public/js/lib/syntax-highlighters/index.js +++ b/lib/public/js/lib/syntax-highlighters/index.js @@ -8,11 +8,12 @@ import { escapeHtml, toLineObjects } from "./utils.js"; export const getFileSyntaxKind = (filePath) => { const normalizedPath = String(filePath || "").toLowerCase(); - if (/\.(md|markdown|mdx)$/i.test(normalizedPath)) return "markdown"; - if (/\.(json|jsonl)$/i.test(normalizedPath)) return "json"; - if (/\.(html|htm)$/i.test(normalizedPath)) return "html"; - if (/\.(js|mjs|cjs)$/i.test(normalizedPath)) return "javascript"; - if (/\.(css|scss)$/i.test(normalizedPath)) return "css"; + const pathWithoutBakSuffix = normalizedPath.replace(/(\.bak)+$/i, ""); + if (/\.(md|markdown|mdx)$/i.test(pathWithoutBakSuffix)) return "markdown"; + if (/\.(json|jsonl)$/i.test(pathWithoutBakSuffix)) return "json"; + if (/\.(html|htm)$/i.test(pathWithoutBakSuffix)) return "html"; + if (/\.(js|mjs|cjs)$/i.test(pathWithoutBakSuffix)) return "javascript"; + if (/\.(css|scss)$/i.test(pathWithoutBakSuffix)) return "css"; return "plain"; }; diff --git a/lib/public/shared/browse-file-policies.json b/lib/public/shared/browse-file-policies.json new file mode 100644 index 00000000..c2a305d3 --- /dev/null +++ b/lib/public/shared/browse-file-policies.json @@ -0,0 +1,13 @@ +{ + "protectedPaths": [ + "openclaw.json", + "devices/paired.json" + ], + "lockedPaths": [ + "hooks/bootstrap/agents.md", + "hooks/bootstrap/tools.md", + "skills/control-ui/skill.md", + ".alphaclaw/hourly-git-sync.sh", + ".alphaclaw/.cli-device-auto-approved" + ] +} diff --git a/lib/server/constants.js b/lib/server/constants.js index 23cef4e0..38cc77f1 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -1,5 +1,6 @@ const os = require("os"); const path = require("path"); +const kBrowseFilePolicies = require("../public/shared/browse-file-policies.json"); const parsePositiveIntEnv = (value, fallbackValue) => { const parsed = Number.parseInt(String(value || ""), 10); @@ -274,13 +275,16 @@ const kChannelDefs = { telegram: { envKey: "TELEGRAM_BOT_TOKEN" }, discord: { envKey: "DISCORD_BOT_TOKEN" }, }; -const kLockedBrowsePaths = new Set([ - "hooks/bootstrap/agents.md", - "hooks/bootstrap/tools.md", - "skills/control-ui/skill.md", - ".alphaclaw/hourly-git-sync.sh", - ".alphaclaw/.cli-device-auto-approved", -]); +const kProtectedBrowsePaths = new Set( + Array.isArray(kBrowseFilePolicies?.protectedPaths) + ? kBrowseFilePolicies.protectedPaths + : [], +); +const kLockedBrowsePaths = new Set( + Array.isArray(kBrowseFilePolicies?.lockedPaths) + ? kBrowseFilePolicies.lockedPaths + : [], +); const SETUP_API_PREFIXES = [ "/api/status", @@ -350,6 +354,7 @@ module.exports = { kSystemVars, kKnownVars, kKnownKeys, + kProtectedBrowsePaths, kLockedBrowsePaths, SCOPE_MAP, REVERSE_SCOPE_MAP, diff --git a/lib/server/routes/browse/index.js b/lib/server/routes/browse/index.js index e461ec19..27e68b6e 100644 --- a/lib/server/routes/browse/index.js +++ b/lib/server/routes/browse/index.js @@ -1,5 +1,5 @@ const path = require("path"); -const { kLockedBrowsePaths } = require("../../constants"); +const { kLockedBrowsePaths, kProtectedBrowsePaths } = require("../../constants"); const { kDefaultTreeDepth, kIgnoredDirectoryNames, @@ -30,7 +30,6 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { const kRootResolved = path.resolve(kRootDir); const kRootWithSep = `${kRootResolved}${path.sep}`; const kRootDisplayName = "kRootDir/.openclaw"; - if (!fs.existsSync(kRootResolved)) { fs.mkdirSync(kRootResolved, { recursive: true }); } @@ -369,7 +368,14 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .split("\n") .map((line) => line.trim()) .filter(Boolean); + const rawStatus = statusLines[0]?.slice(0, 2) || ""; const isUntracked = statusLines.some((line) => line.startsWith("??")); + const statusKind = + rawStatus === "??" || rawStatus.includes("A") + ? "U" + : rawStatus.includes("D") + ? "D" + : "M"; const diffResult = isUntracked ? await runGitCommandWithExitCode( @@ -397,6 +403,8 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { ok: true, path: relativePath, content, + statusKind, + isDeleted: statusKind === "D", }); } catch (error) { return res.status(500).json({ @@ -567,6 +575,86 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { .json({ ok: false, error: error.message || "Could not save file" }); } }); + + app.delete("/api/browse/delete", (req, res) => { + const targetPath = String(req.body?.path || "").trim(); + const resolvedPath = resolveSafePath( + targetPath, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath); + if ( + matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath) || + matchesPolicyPath(kProtectedBrowsePaths, normalizedPolicyPath) + ) { + return res.status(403).json({ + ok: false, + error: "This file cannot be deleted from the explorer.", + }); + } + try { + if (!fs.existsSync(resolvedPath.absolutePath)) { + return res.status(404).json({ ok: false, error: "File does not exist" }); + } + const stats = fs.statSync(resolvedPath.absolutePath); + if (!stats.isFile()) { + return res.status(400).json({ ok: false, error: "Path is not a file" }); + } + fs.rmSync(resolvedPath.absolutePath, { force: true }); + return res.json({ + ok: true, + path: resolvedPath.relativePath, + }); + } catch (error) { + return res.status(500).json({ + ok: false, + error: error.message || "Could not delete file", + }); + } + }); + + app.post("/api/browse/restore", async (req, res) => { + const { path: targetPath } = req.body || {}; + const resolvedPath = resolveSafePath( + targetPath, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + const relativePath = String(resolvedPath.relativePath || "").trim(); + if (!relativePath) { + return res.status(400).json({ ok: false, error: "path is required" }); + } + const restoreResult = await runGitCommand( + ["restore", "--staged", "--worktree", "--", relativePath], + kRootResolved, + ); + const fallbackResult = !restoreResult.ok + ? await runGitCommand(["checkout", "--", relativePath], kRootResolved) + : { ok: true }; + if (!restoreResult.ok && !fallbackResult.ok) { + return res.status(500).json({ + ok: false, + error: + restoreResult.error || + fallbackResult.error || + "Could not restore file from git", + }); + } + return res.json({ + ok: true, + path: relativePath, + restored: fs.existsSync(resolvedPath.absolutePath), + }); + }); }; module.exports = { registerBrowseRoutes }; From f8458d32f47e3f5966838e558d83a3fcf655254f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 08:01:41 -0800 Subject: [PATCH 064/417] Add browse route coverage for delete and restore flows. Exercise delete path validation and protected file guards, plus restore behavior for tracked files removed from disk. --- tests/server/routes-browse.test.js | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index 75c98541..47b84473 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -1,6 +1,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); +const { execSync } = require("child_process"); const express = require("express"); const request = require("supertest"); @@ -16,6 +17,14 @@ const createApp = (kRootDir) => { return app; }; +const runGit = (cwd, args) => + execSync(`git ${args}`, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }) + .toString() + .trim(); + describe("server/routes/browse", () => { it("returns browse tree rooted at configured directory", async () => { const rootDir = createTestRoot(); @@ -291,6 +300,89 @@ describe("server/routes/browse", () => { expect(fs.readFileSync(lockedPath, "utf8")).toBe("before\n"); }); + it("deletes regular files", async () => { + const rootDir = createTestRoot(); + const filePath = path.join(rootDir, "deleteme.txt"); + fs.writeFileSync(filePath, "delete me\n", "utf8"); + const app = createApp(rootDir); + + const res = await request(app).delete("/api/browse/delete").send({ + path: "deleteme.txt", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + path: "deleteme.txt", + }); + expect(fs.existsSync(filePath)).toBe(false); + }); + + it("rejects deleting protected files", async () => { + const rootDir = createTestRoot(); + const filePath = path.join(rootDir, "openclaw.json"); + fs.writeFileSync(filePath, '{"ok":true}\n', "utf8"); + const app = createApp(rootDir); + + const res = await request(app).delete("/api/browse/delete").send({ + path: "openclaw.json", + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + ok: false, + error: "This file cannot be deleted from the explorer.", + }); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it("rejects deleting directories", async () => { + const rootDir = createTestRoot(); + const dirPath = path.join(rootDir, "delivery-queue"); + fs.mkdirSync(dirPath, { recursive: true }); + const app = createApp(rootDir); + + const res = await request(app).delete("/api/browse/delete").send({ + path: "delivery-queue", + }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + ok: false, + error: "Path is not a file", + }); + expect(fs.existsSync(dirPath)).toBe(true); + }); + + it("restores a tracked deleted file from git", async () => { + const rootDir = createTestRoot(); + const app = createApp(rootDir); + const filePath = path.join(rootDir, "restore-me.json"); + fs.writeFileSync(filePath, '{"restore":true}\n', "utf8"); + + runGit(rootDir, "init"); + runGit(rootDir, "config user.email test@example.com"); + runGit(rootDir, "config user.name Test User"); + runGit(rootDir, "add restore-me.json"); + runGit(rootDir, "commit -m \"test commit\""); + + fs.rmSync(filePath, { force: true }); + expect(fs.existsSync(filePath)).toBe(false); + + const res = await request(app).post("/api/browse/restore").send({ + path: "restore-me.json", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + path: "restore-me.json", + restored: true, + }); + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.readFileSync(filePath, "utf8")).toBe('{"restore":true}\n'); + }); + it("returns non-repo git summary outside git repositories", async () => { const rootDir = createTestRoot(); const app = createApp(rootDir); From d5dfe341c99361bab5ec6d567f7faaec58a2c97b Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 08:13:42 -0800 Subject: [PATCH 065/417] Add runtime guard test for express major version. Ensure AlphaClaw test runs fail fast if top-level express resolves to v5 instead of the expected v4 runtime. --- tests/server/express-runtime-guard.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/server/express-runtime-guard.test.js diff --git a/tests/server/express-runtime-guard.test.js b/tests/server/express-runtime-guard.test.js new file mode 100644 index 00000000..e5b6530e --- /dev/null +++ b/tests/server/express-runtime-guard.test.js @@ -0,0 +1,11 @@ +const expressPackage = require("express/package.json"); + +describe("server runtime dependency guard", () => { + it("resolves express v4 at top-level runtime", () => { + const majorVersion = Number.parseInt( + String(expressPackage.version || "").split(".")[0] || "0", + 10, + ); + expect(majorVersion).toBe(4); + }); +}); From 3faab572a337e603fffb7cf7e8c34d34ff876026 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 10:33:33 -0800 Subject: [PATCH 066/417] Add multi-account Google account management across setup. Refactor Google routes and UI to support company and personal account flows on top of gog native clients, while simplifying labels/bootstrap behavior and improving auth/API status handling. --- bin/alphaclaw.js | 32 +- lib/public/js/app.js | 207 ++++--- lib/public/js/components/credentials-modal.js | 44 +- lib/public/js/components/file-tree.js | 8 +- lib/public/js/components/google.js | 228 ------- .../js/components/google/account-row.js | 97 +++ .../js/components/google/add-account-modal.js | 93 +++ lib/public/js/components/google/index.js | 415 +++++++++++++ .../components/google/use-google-accounts.js | 41 ++ lib/public/js/components/sidebar-git-panel.js | 8 +- lib/public/js/components/sidebar.js | 151 ++--- lib/public/js/lib/api.js | 62 +- lib/server/constants.js | 7 + lib/server/google-state.js | 164 +++++ lib/server/helpers.js | 16 +- lib/server/onboarding/workspace.js | 39 ++ lib/server/routes/google.js | 586 +++++++++++------- 17 files changed, 1524 insertions(+), 674 deletions(-) delete mode 100644 lib/public/js/components/google.js create mode 100644 lib/public/js/components/google/account-row.js create mode 100644 lib/public/js/components/google/add-account-modal.js create mode 100644 lib/public/js/components/google/index.js create mode 100644 lib/public/js/components/google/use-google-accounts.js create mode 100644 lib/server/google-state.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index e323aebf..1ac3d58e 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -626,37 +626,7 @@ try { } catch {} // --------------------------------------------------------------------------- -// 10. Configure gog credentials (if env vars present) -// --------------------------------------------------------------------------- - -if (process.env.GOG_CLIENT_CREDENTIALS_JSON && process.env.GOG_REFRESH_TOKEN) { - try { - const tmpCreds = `/tmp/gog-creds-${process.pid}.json`; - const tmpToken = `/tmp/gog-token-${process.pid}.json`; - fs.writeFileSync(tmpCreds, process.env.GOG_CLIENT_CREDENTIALS_JSON); - execSync(`gog auth credentials set "${tmpCreds}"`, { stdio: "ignore" }); - fs.unlinkSync(tmpCreds); - fs.writeFileSync( - tmpToken, - JSON.stringify({ - email: process.env.GOG_ACCOUNT || "", - refresh_token: process.env.GOG_REFRESH_TOKEN, - }), - ); - execSync(`gog auth tokens import "${tmpToken}"`, { stdio: "ignore" }); - fs.unlinkSync(tmpToken); - console.log( - `[alphaclaw] gog CLI configured for ${process.env.GOG_ACCOUNT || "account"}`, - ); - } catch (e) { - console.log(`[alphaclaw] gog credentials setup skipped: ${e.message}`); - } -} else { - console.log("[alphaclaw] Google credentials not set -- skipping gog setup"); -} - -// --------------------------------------------------------------------------- -// 11. Reconcile channels if already onboarded +// 10. Reconcile channels if already onboarded // --------------------------------------------------------------------------- const configPath = path.join(openclawDir, "openclaw.json"); diff --git a/lib/public/js/app.js b/lib/public/js/app.js index dac6d893..463de8f3 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -28,7 +28,7 @@ import { Gateway } from "./components/gateway.js"; import { Channels, ALL_CHANNELS } from "./components/channels.js"; import { Pairings } from "./components/pairings.js"; import { DevicePairings } from "./components/device-pairings.js"; -import { Google } from "./components/google.js"; +import { Google } from "./components/google/index.js"; import { Features } from "./components/features.js"; import { Providers } from "./components/providers.js"; import { Welcome } from "./components/welcome.js"; @@ -107,7 +107,6 @@ const GeneralTab = ({ onOpenclawVersionActionComplete = () => {}, onOpenclawUpdate, }) => { - const [googleKey, setGoogleKey] = useState(0); const [dashboardLoading, setDashboardLoading] = useState(false); const [repairingWatchdog, setRepairingWatchdog] = useState(false); const status = statusData; @@ -180,7 +179,6 @@ const GeneralTab = ({ onRefreshStatuses(); pairingsPoll.refresh(); devicePoll.refresh(); - setGoogleKey((k) => k + 1); }, [isActive]); useEffect(() => { @@ -281,7 +279,7 @@ const GeneralTab = ({ onReject=${handleReject} /> <${Features} onSwitchTab=${onSwitchTab} /> - <${Google} key=${googleKey} gatewayStatus=${gatewayStatus} /> + <${Google} gatewayStatus=${gatewayStatus} /> ${repo && html` @@ -940,113 +938,116 @@ const App = () => {
    - ${isBrowseRoute - ? html` - <${FileViewer} - filePath=${activeBrowsePath} - isPreviewOnly=${false} - browseView=${browseViewerMode} - onRequestEdit=${(targetPath) => { - const normalizedTargetPath = String(targetPath || ""); - if ( - normalizedTargetPath && - normalizedTargetPath !== selectedBrowsePath - ) { - navigateToBrowseFile(normalizedTargetPath, { view: "edit" }); - return; - } - setLocation(buildBrowseRoute(selectedBrowsePath, { view: "edit" })); - }} - onRequestClearSelection=${() => { - setBrowsePreviewPath(""); - setLocation("/browse"); - }} +
    + <${FileViewer} + filePath=${activeBrowsePath} + isPreviewOnly=${false} + browseView=${browseViewerMode} + onRequestEdit=${(targetPath) => { + const normalizedTargetPath = String(targetPath || ""); + if ( + normalizedTargetPath && + normalizedTargetPath !== selectedBrowsePath + ) { + navigateToBrowseFile(normalizedTargetPath, { view: "edit" }); + return; + } + setLocation(buildBrowseRoute(selectedBrowsePath, { view: "edit" })); + }} + onRequestClearSelection=${() => { + setBrowsePreviewPath(""); + setLocation("/browse"); + }} + /> +
    +
    +
    +
    + <${GeneralTab} + statusData=${sharedStatus} + watchdogData=${sharedWatchdogStatus} + onRefreshStatuses=${refreshSharedStatuses} + onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} + onNavigate=${navigateToSubScreen} + isActive=${location === "/general"} + restartingGateway=${restartingGateway} + onRestartGateway=${handleGatewayRestart} + restartSignal=${gatewayRestartSignal} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} + onOpenclawUpdate=${handleOpenclawUpdate} /> - ` - : html` - <${Switch}> - <${Route} path="/telegram"> -
    - <${TelegramWorkspace} onBack=${exitSubScreen} /> -
    - - <${Route} path="/general"> -
    - <${GeneralTab} - statusData=${sharedStatus} - watchdogData=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} - onNavigate=${navigateToSubScreen} - isActive=${location === "/general"} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} - openclawUpdateInProgress=${openclawUpdateInProgress} - onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} - onOpenclawUpdate=${handleOpenclawUpdate} - /> -
    - - <${Route} path="/providers"> -
    - <${Providers} onRestartRequired=${setRestartRequired} /> -
    - - <${Route} path="/watchdog"> -
    - <${WatchdogTab} - gatewayStatus=${sharedStatus?.gateway || null} - openclawVersion=${sharedStatus?.openclawVersion || null} - watchdogStatus=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} - openclawUpdateInProgress=${openclawUpdateInProgress} - onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} - onOpenclawUpdate=${handleOpenclawUpdate} - /> -
    - - <${Route} path="/usage/:sessionId"> - ${(params) => html` +
    +
    + ${!isBrowseRoute && location !== "/general" + ? html` + <${Switch}> + <${Route} path="/telegram"> +
    + <${TelegramWorkspace} onBack=${exitSubScreen} /> +
    + + <${Route} path="/providers"> +
    + <${Providers} onRestartRequired=${setRestartRequired} /> +
    + + <${Route} path="/watchdog"> +
    + <${WatchdogTab} + gatewayStatus=${sharedStatus?.gateway || null} + openclawVersion=${sharedStatus?.openclawVersion || null} + watchdogStatus=${sharedWatchdogStatus} + onRefreshStatuses=${refreshSharedStatuses} + restartingGateway=${restartingGateway} + onRestartGateway=${handleGatewayRestart} + restartSignal=${gatewayRestartSignal} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} + onOpenclawUpdate=${handleOpenclawUpdate} + /> +
    + + <${Route} path="/usage/:sessionId"> + ${(params) => html` +
    + <${UsageTab} + sessionId=${decodeURIComponent(params.sessionId || "")} + onSelectSession=${(id) => + setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} + onBackToSessions=${() => setLocation("/usage")} + /> +
    + `} + + <${Route} path="/usage">
    <${UsageTab} - sessionId=${decodeURIComponent(params.sessionId || "")} onSelectSession=${(id) => setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} onBackToSessions=${() => setLocation("/usage")} />
    - `} - - <${Route} path="/usage"> -
    - <${UsageTab} - onSelectSession=${(id) => - setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} - onBackToSessions=${() => setLocation("/usage")} - /> -
    - - <${Route} path="/envars"> -
    - <${Envars} onRestartRequired=${setRestartRequired} /> -
    - - <${Route} path="/webhooks/:hookName"> - ${(params) => - renderWebhooks(decodeURIComponent(params.hookName || ""))} - - <${Route} path="/webhooks"> - ${() => renderWebhooks("")} - - <${Route}> - <${RouteRedirect} to="/general" /> - - - `} + + <${Route} path="/envars"> +
    + <${Envars} onRestartRequired=${setRestartRequired} /> +
    + + <${Route} path="/webhooks/:hookName"> + ${(params) => + renderWebhooks(decodeURIComponent(params.hookName || ""))} + + <${Route} path="/webhooks"> + ${() => renderWebhooks("")} + + <${Route}> + <${RouteRedirect} to="/general" /> + + + ` + : null} +
    <${ToastContainer} className="fixed bottom-10 right-4 z-50 space-y-2 pointer-events-none" diff --git a/lib/public/js/components/credentials-modal.js b/lib/public/js/components/credentials-modal.js index 403965f3..972b1da1 100644 --- a/lib/public/js/components/credentials-modal.js +++ b/lib/public/js/components/credentials-modal.js @@ -1,5 +1,5 @@ import { h } from "https://esm.sh/preact"; -import { useState, useRef } from "https://esm.sh/preact/hooks"; +import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { saveGoogleCredentials } from "../lib/api.js"; import { SecretInput } from "./secret-input.js"; @@ -9,16 +9,37 @@ import { ActionButton } from "./action-button.js"; import { CloseIcon } from "./icons.js"; const html = htm.bind(h); -export const CredentialsModal = ({ visible, onClose, onSaved }) => { +export const CredentialsModal = ({ + visible, + onClose, + onSaved, + title = "Connect Google Workspace", + submitLabel = "Connect Google", + defaultInstrType = "workspace", + client = "default", + personal = false, + accountId = "", + initialValues = {}, +}) => { const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [email, setEmail] = useState(""); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); - const [instrType, setInstrType] = useState("workspace"); + const [instrType, setInstrType] = useState(defaultInstrType); const [redirectUriCopied, setRedirectUriCopied] = useState(false); const fileRef = useRef(null); + useEffect(() => { + if (!visible) return; + setClientId(String(initialValues.clientId || "")); + setClientSecret(String(initialValues.clientSecret || "")); + setEmail(String(initialValues.email || "")); + setInstrType(defaultInstrType); + setError(""); + setRedirectUriCopied(false); + }, [visible, initialValues, defaultInstrType]); + if (!visible) return null; const redirectUri = `${window.location.origin}/auth/google/callback`; @@ -50,15 +71,22 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => { const submit = async () => { setError(""); if (!clientId || !clientSecret || !email) { - setError("All fields required"); + setError("Client ID, Client Secret, and Email are required"); return; } setSaving(true); try { - const data = await saveGoogleCredentials(clientId, clientSecret, email); + const data = await saveGoogleCredentials({ + clientId, + clientSecret, + email, + client, + personal, + accountId, + }); if (data.ok) { onClose(); - onSaved(); + onSaved?.(data.account); } else setError(data.error || "Failed to save credentials"); } catch { setError("Request failed"); @@ -104,7 +132,7 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => { panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" > <${PageHeader} - title="Connect Google Workspace" + title=${title} actions=${html` `} -
  • - <${ScopePicker} - scopes=${scopes} - onToggle=${handleToggle} - apiStatus=${isAuthed ? apiStatus : {}} - loading=${isAuthed && checkingApis} - /> -
    -
    - - -
    - -
    -
    - ` - : html` -
    -

    - Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, - and Meet. -

    - -
    - `} -
    - <${CredentialsModal} - visible=${modalOpen} - onClose=${() => setModalOpen(false)} - onSaved=${refresh} - /> - <${ConfirmDialog} - visible=${disconnectDialogOpen} - title="Disconnect Google account?" - message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect." - confirmLabel="Disconnect" - cancelLabel="Cancel" - onCancel=${() => setDisconnectDialogOpen(false)} - onConfirm=${async () => { - setDisconnectDialogOpen(false); - await handleDisconnect(); - }} - /> - `; -} diff --git a/lib/public/js/components/google/account-row.js b/lib/public/js/components/google/account-row.js new file mode 100644 index 00000000..f3ae6bb7 --- /dev/null +++ b/lib/public/js/components/google/account-row.js @@ -0,0 +1,97 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { Badge } from "../badge.js"; +import { ScopePicker } from "../scope-picker.js"; + +const html = htm.bind(h); + +const scopeListsEqual = (a = [], b = []) => + a.length === b.length && a.every((scope) => b.includes(scope)); + +export const GoogleAccountRow = ({ + account, + personal = false, + expanded, + onToggleExpanded, + scopes = [], + savedScopes = [], + apiStatus = {}, + checkingApis = false, + onToggleScope, + onCheckApis, + onUpdatePermissions, + onEditCredentials, + onDisconnect, +}) => { + const scopesChanged = !scopeListsEqual(scopes, savedScopes); + return html` +
    + + ${expanded + ? html` +
    +
    + Select permissions + ${account.authenticated + ? html`` + : null} +
    + <${ScopePicker} + scopes=${scopes} + onToggle=${(scope) => onToggleScope?.(account.id, scope)} + apiStatus=${account.authenticated ? apiStatus : {}} + loading=${account.authenticated && checkingApis} + /> +
    +
    + + +
    + +
    +
    + ` + : null} +
    + `; +}; diff --git a/lib/public/js/components/google/add-account-modal.js b/lib/public/js/components/google/add-account-modal.js new file mode 100644 index 00000000..cafa1623 --- /dev/null +++ b/lib/public/js/components/google/add-account-modal.js @@ -0,0 +1,93 @@ +import { h } from "https://esm.sh/preact"; +import { useEffect, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { ActionButton } from "../action-button.js"; +import { ModalShell } from "../modal-shell.js"; +import { PageHeader } from "../page-header.js"; +import { CloseIcon } from "../icons.js"; + +const html = htm.bind(h); + +export const AddGoogleAccountModal = ({ + visible, + onClose, + onSubmit, + loading = false, + defaultEmail = "", + title = "Add Company Account", +}) => { + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (!visible) return; + setEmail(String(defaultEmail || "")); + setError(""); + }, [visible, defaultEmail]); + + if (!visible) return null; + + const submit = async () => { + setError(""); + const nextEmail = String(email || "").trim(); + if (!nextEmail) { + setError("Email is required"); + return; + } + await onSubmit?.({ + email: nextEmail, + setError, + }); + }; + + return html`<${ModalShell} + visible=${visible} + onClose=${onClose} + closeOnOverlayClick=${false} + panelClassName="bg-modal border border-border rounded-xl p-6 max-w-md w-full space-y-4" + > + <${PageHeader} + title=${title} + actions=${html` + + `} + /> +
    +
    + +

    + This adds another account to the same company workspace. Only one company workspace is supported. +

    + setEmail(e.target.value)} + placeholder="you@company.com" + class="w-full bg-black/40 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-gray-500" + /> +
    + ${error ? html`
    ${error}
    ` : null} +
    +
    + <${ActionButton} + onClick=${submit} + disabled=${loading} + loading=${loading} + tone="primary" + size="lg" + idleLabel="Add Account" + loadingLabel="Saving..." + className="w-full px-4 py-2 rounded-lg text-sm" + /> +
    + `; +}; diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js new file mode 100644 index 00000000..7525bbd4 --- /dev/null +++ b/lib/public/js/components/google/index.js @@ -0,0 +1,415 @@ +import { h } from "https://esm.sh/preact"; +import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { + checkGoogleApis, + disconnectGoogle, + saveGoogleAccount, +} from "../../lib/api.js"; +import { getDefaultScopes, toggleScopeLogic } from "../scope-picker.js"; +import { CredentialsModal } from "../credentials-modal.js"; +import { ConfirmDialog } from "../confirm-dialog.js"; +import { showToast } from "../toast.js"; +import { PageHeader } from "../page-header.js"; +import { GoogleAccountRow } from "./account-row.js"; +import { AddGoogleAccountModal } from "./add-account-modal.js"; +import { useGoogleAccounts } from "./use-google-accounts.js"; + +const html = htm.bind(h); + +const hasScopesChanged = (nextScopes = [], savedScopes = []) => + nextScopes.length !== savedScopes.length || + nextScopes.some((scope) => !savedScopes.includes(scope)); + +const isLikelyPersonalEmail = (email = "") => { + const normalized = String(email || "").trim().toLowerCase(); + return normalized.endsWith("@gmail.com") || normalized.endsWith("@googlemail.com"); +}; + +const isPersonalAccount = (account = {}) => + Boolean(account.personal) || isLikelyPersonalEmail(account.email); + +export const Google = ({ gatewayStatus }) => { + const { + accounts, + loading, + hasCompanyCredentials, + refreshAccounts, + } = useGoogleAccounts({ gatewayStatus }); + const [expandedAccountId, setExpandedAccountId] = useState(""); + const [scopesByAccountId, setScopesByAccountId] = useState({}); + const [savedScopesByAccountId, setSavedScopesByAccountId] = useState({}); + const [apiStatusByAccountId, setApiStatusByAccountId] = useState({}); + const [checkingByAccountId, setCheckingByAccountId] = useState({}); + const [addMenuOpen, setAddMenuOpen] = useState(false); + const [credentialsModalState, setCredentialsModalState] = useState({ + visible: false, + accountId: "", + client: "default", + personal: false, + title: "Connect Google Workspace", + submitLabel: "Connect Google", + defaultInstrType: "workspace", + initialValues: {}, + }); + const [addCompanyModalOpen, setAddCompanyModalOpen] = useState(false); + const [savingAddCompany, setSavingAddCompany] = useState(false); + const [disconnectAccountId, setDisconnectAccountId] = useState(""); + + const hasPersonalAccount = useMemo( + () => accounts.some((account) => isPersonalAccount(account)), + [accounts], + ); + const hasCompanyAccount = useMemo( + () => accounts.some((account) => !isPersonalAccount(account)), + [accounts], + ); + + const getAccountById = useCallback( + (accountId) => accounts.find((account) => account.id === accountId) || null, + [accounts], + ); + + const ensureScopesForAccount = useCallback((account) => { + const nextScopes = Array.isArray(account.activeScopes) && account.activeScopes.length + ? account.activeScopes + : Array.isArray(account.services) && account.services.length + ? account.services + : getDefaultScopes(); + setSavedScopesByAccountId((prev) => ({ ...prev, [account.id]: [...nextScopes] })); + setScopesByAccountId((prev) => { + const current = prev[account.id]; + if (!current || !hasScopesChanged(current, nextScopes)) { + return { ...prev, [account.id]: [...nextScopes] }; + } + return prev; + }); + }, []); + + useEffect(() => { + if (!accounts.length) { + setExpandedAccountId(""); + return; + } + const firstId = accounts[0]?.id || ""; + setExpandedAccountId((prev) => (prev && accounts.some((item) => item.id === prev) ? prev : firstId)); + accounts.forEach((account) => ensureScopesForAccount(account)); + }, [accounts, ensureScopesForAccount]); + + const startAuth = useCallback( + (accountId) => { + const account = getAccountById(accountId); + if (!account) return; + const scopes = scopesByAccountId[accountId] || account.activeScopes || getDefaultScopes(); + if (!scopes.length) { + window.alert("Select at least one service"); + return; + } + const authUrl = + `/auth/google/start?accountId=${encodeURIComponent(accountId)}` + + `&services=${encodeURIComponent(scopes.join(","))}&_ts=${Date.now()}`; + const popup = window.open( + authUrl, + `google-auth-${accountId}`, + "popup=yes,width=500,height=700", + ); + if (!popup || popup.closed) window.location.href = authUrl; + }, + [getAccountById, scopesByAccountId], + ); + + const handleToggleScope = (accountId, scope) => { + setScopesByAccountId((prev) => ({ + ...prev, + [accountId]: toggleScopeLogic(prev[accountId] || [], scope), + })); + }; + + const handleCheckApis = useCallback(async (accountId) => { + setCheckingByAccountId((prev) => ({ ...prev, [accountId]: true })); + try { + const data = await checkGoogleApis(accountId); + if (data.results) { + setApiStatusByAccountId((prev) => ({ ...prev, [accountId]: data.results })); + } + } finally { + setCheckingByAccountId((prev) => ({ ...prev, [accountId]: false })); + } + }, []); + + useEffect(() => { + const handler = async (event) => { + if (event.data?.google === "success") { + showToast("✓ Google account connected", "success"); + const accountId = String(event.data?.accountId || "").trim(); + setApiStatusByAccountId({}); + await refreshAccounts(); + if (accountId) { + await handleCheckApis(accountId); + } + } else if (event.data?.google === "error") { + showToast(`✗ Google auth failed: ${event.data.message || "unknown"}`, "error"); + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [handleCheckApis, refreshAccounts]); + + useEffect(() => { + if (!expandedAccountId) return; + const account = getAccountById(expandedAccountId); + if (!account?.authenticated) return; + if (checkingByAccountId[expandedAccountId]) return; + if (apiStatusByAccountId[expandedAccountId]) return; + handleCheckApis(expandedAccountId); + }, [ + accounts, + apiStatusByAccountId, + checkingByAccountId, + expandedAccountId, + getAccountById, + handleCheckApis, + ]); + + const handleDisconnect = async (accountId) => { + const data = await disconnectGoogle(accountId); + if (!data.ok) { + showToast(`Failed to disconnect: ${data.error || "unknown"}`, "error"); + return; + } + showToast("Google account disconnected", "success"); + setApiStatusByAccountId((prev) => { + const next = { ...prev }; + delete next[accountId]; + return next; + }); + await refreshAccounts(); + }; + + const openCredentialsModal = ({ + accountId = "", + client = "default", + personal = false, + title = "Connect Google Workspace", + submitLabel = "Connect Google", + defaultInstrType = personal ? "personal" : "workspace", + initialValues = {}, + }) => { + setCredentialsModalState({ + visible: true, + accountId, + client, + personal, + title, + submitLabel, + defaultInstrType, + initialValues, + }); + }; + + const closeCredentialsModal = () => { + setCredentialsModalState((prev) => ({ ...prev, visible: false })); + }; + + const handleCredentialsSaved = async (account) => { + await refreshAccounts(); + if (account?.id) startAuth(account.id); + }; + + const handleAddCompanyAccount = async ({ email, setError }) => { + setSavingAddCompany(true); + try { + const data = await saveGoogleAccount({ + email, + client: "default", + personal: false, + services: getDefaultScopes(), + }); + if (!data.ok) { + setError?.(data.error || "Could not add account"); + return; + } + setAddCompanyModalOpen(false); + await refreshAccounts(); + if (data.accountId) startAuth(data.accountId); + } finally { + setSavingAddCompany(false); + } + }; + + const handleAddCompanyClick = () => { + setAddMenuOpen(false); + if (hasCompanyAccount && hasCompanyCredentials) { + setAddCompanyModalOpen(true); + return; + } + openCredentialsModal({ + client: "default", + personal: false, + title: "Add Company Account", + submitLabel: "Save Credentials", + defaultInstrType: "workspace", + }); + }; + + const handleAddPersonalClick = () => { + setAddMenuOpen(false); + openCredentialsModal({ + client: "personal", + personal: true, + title: "Add Personal Account", + submitLabel: "Save Credentials", + defaultInstrType: "personal", + }); + }; + + const handleEditCredentials = (accountId) => { + const account = getAccountById(accountId); + if (!account) return; + const personal = isPersonalAccount(account); + openCredentialsModal({ + accountId: account.id, + client: personal ? "personal" : (account.client || "default"), + personal, + title: `Edit Credentials (${account.email})`, + submitLabel: "Save Credentials", + defaultInstrType: personal ? "personal" : "workspace", + initialValues: { + email: account.email, + }, + }); + }; + + const renderEmptyState = () => html` +
    +

    + Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and Meet. +

    +
    + + +
    +
    + `; + + return html` +
    + <${PageHeader} + title="Google Workspace" + actions=${html` + ${accounts.length + ? html` +
    + + ${addMenuOpen + ? html` +
    + + ${!hasPersonalAccount + ? html`` + : null} +
    + ` + : null} +
    + ` + : null} + `} + /> + ${loading + ? html`
    Loading...
    ` + : accounts.length + ? html` +
    + ${accounts.map((account) => + html`<${GoogleAccountRow} + key=${account.id} + account=${account} + personal=${isPersonalAccount(account)} + expanded=${expandedAccountId === account.id} + onToggleExpanded=${(accountId) => + setExpandedAccountId((prev) => (prev === accountId ? "" : accountId))} + scopes=${scopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} + savedScopes=${savedScopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} + apiStatus=${apiStatusByAccountId[account.id] || {}} + checkingApis=${Boolean(checkingByAccountId[account.id])} + onToggleScope=${handleToggleScope} + onCheckApis=${handleCheckApis} + onUpdatePermissions=${(accountId) => startAuth(accountId)} + onEditCredentials=${handleEditCredentials} + onDisconnect=${(accountId) => setDisconnectAccountId(accountId)} + />`, + )} +
    + ` + : renderEmptyState()} +
    + + <${CredentialsModal} + visible=${credentialsModalState.visible} + onClose=${closeCredentialsModal} + onSaved=${handleCredentialsSaved} + title=${credentialsModalState.title} + submitLabel=${credentialsModalState.submitLabel} + defaultInstrType=${credentialsModalState.defaultInstrType} + client=${credentialsModalState.client} + personal=${credentialsModalState.personal} + accountId=${credentialsModalState.accountId} + initialValues=${credentialsModalState.initialValues} + /> + + <${AddGoogleAccountModal} + visible=${addCompanyModalOpen} + onClose=${() => setAddCompanyModalOpen(false)} + onSubmit=${handleAddCompanyAccount} + loading=${savingAddCompany} + title="Add Company Account" + /> + + <${ConfirmDialog} + visible=${Boolean(disconnectAccountId)} + title="Disconnect Google account?" + message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect." + confirmLabel="Disconnect" + cancelLabel="Cancel" + onCancel=${() => setDisconnectAccountId("")} + onConfirm=${async () => { + const accountId = disconnectAccountId; + setDisconnectAccountId(""); + await handleDisconnect(accountId); + }} + /> + `; +}; diff --git a/lib/public/js/components/google/use-google-accounts.js b/lib/public/js/components/google/use-google-accounts.js new file mode 100644 index 00000000..d0b59880 --- /dev/null +++ b/lib/public/js/components/google/use-google-accounts.js @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks"; +import { fetchGoogleAccounts } from "../../lib/api.js"; + +export const useGoogleAccounts = ({ gatewayStatus }) => { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [hasCompanyCredentials, setHasCompanyCredentials] = useState(false); + const [hasPersonalCredentials, setHasPersonalCredentials] = useState(false); + + const refreshAccounts = useCallback(async () => { + setLoading(true); + try { + const data = await fetchGoogleAccounts(); + if (data.ok) { + setAccounts(Array.isArray(data.accounts) ? data.accounts : []); + setHasCompanyCredentials(Boolean(data.hasCompanyCredentials)); + setHasPersonalCredentials(Boolean(data.hasPersonalCredentials)); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refreshAccounts(); + }, [refreshAccounts]); + + useEffect(() => { + if (gatewayStatus === "running") { + refreshAccounts(); + } + }, [gatewayStatus, refreshAccounts]); + + return { + accounts, + loading, + hasCompanyCredentials, + hasPersonalCredentials, + refreshAccounts, + }; +}; diff --git a/lib/public/js/components/sidebar-git-panel.js b/lib/public/js/components/sidebar-git-panel.js index b678507e..01f18224 100644 --- a/lib/public/js/components/sidebar-git-panel.js +++ b/lib/public/js/components/sidebar-git-panel.js @@ -140,13 +140,17 @@ const buildSyncCommitMessage = (summary) => { return `Edited ${totalCount} ${noun} - ${shownFileNames.join(", ")}${suffix}`; }; -export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { +export const SidebarGitPanel = ({ + onSelectFile = () => {}, + isActive = true, +}) => { const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(""); const [summary, setSummary] = useState(null); useEffect(() => { + if (!isActive) return () => {}; let active = true; let intervalId = null; @@ -178,7 +182,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => { if (intervalId) window.clearInterval(intervalId); window.removeEventListener("alphaclaw:browse-file-saved", handleFileSaved); }; - }, []); + }, [isActive]); if (loading) { return html` diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index d4b13ca0..52e679d6 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -172,83 +172,84 @@ export const AppSidebar = ({ <${FolderLineIcon} className="sidebar-tab-icon" />
    - ${sidebarTab === "menu" - ? navSections.map( - (section) => html` - - - `, - ) - : html` - `; }; diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 90c099ad..68ff4f8d 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -38,27 +38,73 @@ export async function rejectPairing(id, channel) { return res.json(); } -export async function fetchGoogleStatus() { - const res = await authFetch('/api/google/status'); +export async function fetchGoogleAccounts() { + const res = await authFetch('/api/google/accounts'); return res.json(); } -export async function checkGoogleApis() { - const res = await authFetch('/api/google/check'); +export async function fetchGoogleStatus(accountId = "") { + const params = new URLSearchParams(); + if (accountId) params.set("accountId", String(accountId)); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const res = await authFetch(`/api/google/status${suffix}`); return res.json(); } -export async function saveGoogleCredentials(clientId, clientSecret, email) { +export async function checkGoogleApis(accountId = "") { + const params = new URLSearchParams(); + if (accountId) params.set("accountId", String(accountId)); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const res = await authFetch(`/api/google/check${suffix}`); + return res.json(); +} + +export async function saveGoogleCredentials({ + clientId, + clientSecret, + email, + services = [], + client = "default", + personal = false, + accountId = "", +}) { const res = await authFetch('/api/google/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId, clientSecret, email }), + body: JSON.stringify({ + clientId, + clientSecret, + email, + services, + client, + personal, + accountId, + }), + }); + return res.json(); +} + +export async function saveGoogleAccount({ + email, + services = [], + client = "default", + personal = false, + accountId = "", +}) { + const res = await authFetch('/api/google/accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, services, client, personal, accountId }), }); return res.json(); } -export async function disconnectGoogle() { - const res = await authFetch('/api/google/disconnect', { method: 'POST' }); +export async function disconnectGoogle(accountId = "") { + const res = await authFetch('/api/google/disconnect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accountId }), + }); return res.json(); } diff --git a/lib/server/constants.js b/lib/server/constants.js index 38cc77f1..714c1627 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -259,6 +259,11 @@ const GOG_CONFIG_DIR = path.join(OPENCLAW_DIR, "gogcli"); const GOG_CREDENTIALS_PATH = path.join(GOG_CONFIG_DIR, "credentials.json"); const GOG_STATE_PATH = path.join(GOG_CONFIG_DIR, "state.json"); const GOG_KEYRING_PASSWORD = process.env.GOG_KEYRING_PASSWORD || "alphaclaw"; +const kMaxGoogleAccounts = 5; +const gogClientCredentialsPath = (clientName = "default") => + clientName === "default" + ? GOG_CREDENTIALS_PATH + : path.join(GOG_CONFIG_DIR, `credentials-${clientName}.json`); const API_TEST_COMMANDS = { gmail: "gmail labels list", @@ -363,6 +368,8 @@ module.exports = { GOG_CREDENTIALS_PATH, GOG_STATE_PATH, GOG_KEYRING_PASSWORD, + kMaxGoogleAccounts, + gogClientCredentialsPath, API_TEST_COMMANDS, kChannelDefs, SETUP_API_PREFIXES, diff --git a/lib/server/google-state.js b/lib/server/google-state.js new file mode 100644 index 00000000..4fb08c4d --- /dev/null +++ b/lib/server/google-state.js @@ -0,0 +1,164 @@ +const crypto = require("crypto"); + +const kGoogleStateVersion = 2; +const kDefaultGoogleClient = "default"; +const kDefaultGoogleScopes = [ + "gmail:read", + "calendar:read", + "calendar:write", + "drive:read", + "sheets:read", + "docs:read", +]; + +const createEmptyGoogleState = () => ({ + version: kGoogleStateVersion, + accounts: [], +}); + +const createGoogleAccountId = () => crypto.randomBytes(4).toString("hex"); + +const normalizeScopes = (services) => { + if (!Array.isArray(services)) return [...kDefaultGoogleScopes]; + const deduped = Array.from( + new Set( + services + .map((scope) => String(scope || "").trim()) + .filter(Boolean), + ), + ); + return deduped.length ? deduped : [...kDefaultGoogleScopes]; +}; + +const normalizeGoogleAccount = (account = {}) => ({ + id: String(account.id || createGoogleAccountId()), + email: String(account.email || "").trim(), + client: String(account.client || kDefaultGoogleClient).trim() || kDefaultGoogleClient, + personal: Boolean(account.personal), + services: normalizeScopes(account.services), + authenticated: Boolean(account.authenticated), +}); + +const normalizeGoogleStateV2 = (state = {}) => { + const accounts = Array.isArray(state.accounts) + ? state.accounts.map((account) => normalizeGoogleAccount(account)) + : []; + return { + version: kGoogleStateVersion, + accounts, + }; +}; + +const hasPersonalGoogleAccount = (state = {}) => + (state.accounts || []).some((account) => account.personal); + +const writeGoogleState = ({ fs, statePath, state }) => { + const normalized = normalizeGoogleStateV2(state); + fs.writeFileSync(statePath, JSON.stringify(normalized, null, 2)); + return normalized; +}; + +const migrateGoogleStateV1 = ({ fs, statePath, rawState = {} }) => { + const email = String(rawState.email || "").trim(); + const accounts = email + ? [ + normalizeGoogleAccount({ + id: createGoogleAccountId(), + email, + services: rawState.services, + authenticated: Boolean(rawState.authenticated), + client: kDefaultGoogleClient, + personal: false, + }), + ] + : []; + const migrated = { + version: kGoogleStateVersion, + accounts, + }; + fs.writeFileSync(statePath, JSON.stringify(migrated, null, 2)); + return migrated; +}; + +const readGoogleState = ({ fs, statePath }) => { + if (!fs.existsSync(statePath)) return createEmptyGoogleState(); + try { + const raw = JSON.parse(fs.readFileSync(statePath, "utf8")); + if (raw && raw.version === kGoogleStateVersion && Array.isArray(raw.accounts)) { + return normalizeGoogleStateV2(raw); + } + return migrateGoogleStateV1({ fs, statePath, rawState: raw || {} }); + } catch { + return createEmptyGoogleState(); + } +}; + +const listGoogleAccounts = (state = {}) => [...(state.accounts || [])]; + +const getGoogleAccountById = (state = {}, accountId = "") => + (state.accounts || []).find((account) => account.id === accountId) || null; + +const getGoogleAccountByEmailAndClient = ( + state = {}, + email = "", + client = kDefaultGoogleClient, +) => + (state.accounts || []).find( + (account) => account.email === email && account.client === client, + ) || null; + +const upsertGoogleAccount = ({ + state, + account, + maxAccounts = 5, +}) => { + const nextState = normalizeGoogleStateV2(state); + const normalized = normalizeGoogleAccount(account); + if (!normalized.email) throw new Error("Account email is required"); + const existingIdx = nextState.accounts.findIndex((item) => item.id === normalized.id); + + if (normalized.personal) { + const personalExists = nextState.accounts.some( + (item, idx) => item.personal && idx !== existingIdx, + ); + if (personalExists) { + throw new Error("Only one personal account is allowed"); + } + } + + if (existingIdx >= 0) { + nextState.accounts[existingIdx] = normalized; + return { state: nextState, account: normalized }; + } + + if (nextState.accounts.length >= maxAccounts) { + throw new Error(`Maximum ${maxAccounts} Google accounts allowed`); + } + + nextState.accounts.push(normalized); + return { state: nextState, account: normalized }; +}; + +const removeGoogleAccount = ({ state, accountId }) => { + const nextState = normalizeGoogleStateV2(state); + const removed = getGoogleAccountById(nextState, accountId); + if (!removed) return { state: nextState, account: null }; + nextState.accounts = nextState.accounts.filter((account) => account.id !== accountId); + return { state: nextState, account: removed }; +}; + +module.exports = { + kGoogleStateVersion, + kDefaultGoogleClient, + kDefaultGoogleScopes, + createGoogleAccountId, + createEmptyGoogleState, + readGoogleState, + writeGoogleState, + listGoogleAccounts, + getGoogleAccountById, + getGoogleAccountByEmailAndClient, + upsertGoogleAccount, + removeGoogleAccount, + hasPersonalGoogleAccount, +}; diff --git a/lib/server/helpers.js b/lib/server/helpers.js index 1cd6f919..7013f301 100644 --- a/lib/server/helpers.js +++ b/lib/server/helpers.js @@ -3,7 +3,7 @@ const crypto = require("crypto"); const { CODEX_JWT_CLAIM_PATH, kOnboardingModelProviders, - GOG_CREDENTIALS_PATH, + gogClientCredentialsPath, } = require("./constants"); const normalizeOpenclawVersion = (rawVersion) => { @@ -170,9 +170,10 @@ const getApiEnableUrl = (svc, projectId) => { return `https://console.developers.google.com/apis/api/${api}/overview${project}`; }; -const readGoogleCredentials = () => { +const readGoogleCredentials = (clientName = "default") => { try { - const c = JSON.parse(fs.readFileSync(GOG_CREDENTIALS_PATH, "utf8")); + const credentialsPath = gogClientCredentialsPath(clientName); + const c = JSON.parse(fs.readFileSync(credentialsPath, "utf8")); return { clientId: c.web?.client_id || c.installed?.client_id || c.client_id || null, @@ -181,9 +182,16 @@ const readGoogleCredentials = () => { c.installed?.client_secret || c.client_secret || null, + path: credentialsPath, + client: clientName, }; } catch { - return { clientId: null, clientSecret: null }; + return { + clientId: null, + clientSecret: null, + path: gogClientCredentialsPath(clientName), + client: clientName, + }; } }; diff --git a/lib/server/onboarding/workspace.js b/lib/server/onboarding/workspace.js index 9f35f839..06b1c8e0 100644 --- a/lib/server/onboarding/workspace.js +++ b/lib/server/onboarding/workspace.js @@ -1,6 +1,7 @@ const path = require("path"); const { kSetupDir, OPENCLAW_DIR } = require("../constants"); const { renderTopicRegistryMarkdown } = require("../topic-registry"); +const { readGoogleState } = require("../google-state"); const resolveSetupUiUrl = (baseUrl) => { const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, ""); @@ -32,6 +33,40 @@ const isTelegramWorkspaceEnabled = (fs) => { } }; +const renderGoogleAccountsMarkdown = (fs) => { + try { + const googleStatePath = `${OPENCLAW_DIR}/gogcli/state.json`; + const state = readGoogleState({ fs, statePath: googleStatePath }); + const accounts = Array.isArray(state.accounts) ? state.accounts : []; + let section = "\n\n## Available Google Accounts\n\n"; + if (!accounts.length) { + section += "No Google accounts are currently configured.\n"; + return section; + } + section += "Configured in AlphaClaw (use `--account ` for gog commands):\n\n"; + section += accounts + .map((account) => { + const email = String(account.email || "").trim() || "(unknown email)"; + const client = String(account.client || "default").trim() || "default"; + const personal = account.personal ? "personal" : "company"; + const auth = account.authenticated ? "authenticated" : "awaiting sign-in"; + const services = Array.isArray(account.services) ? account.services.join(", ") : ""; + const metaParts = [ + `type: ${personal}`, + `client: ${client}`, + `status: ${auth}`, + services ? `services: ${services}` : null, + ].filter(Boolean); + return `- ${email} (${metaParts.join("; ")})`; + }) + .join("\n"); + section += "\n"; + return section; + } catch { + return ""; + } +}; + const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => { try { const setupUiUrl = resolveSetupUiUrl(baseUrl); @@ -51,6 +86,10 @@ const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => { if (topicSection) { toolsContent += topicSection; } + const googleAccountsSection = renderGoogleAccountsMarkdown(fs); + if (googleAccountsSection) { + toolsContent += googleAccountsSection; + } fs.writeFileSync(`${bootstrapDir}/TOOLS.md`, toolsContent); console.log("[onboard] Bootstrap prompt files synced"); diff --git a/lib/server/routes/google.js b/lib/server/routes/google.js index 002f3b8a..ffb28046 100644 --- a/lib/server/routes/google.js +++ b/lib/server/routes/google.js @@ -1,3 +1,36 @@ +const { + kDefaultGoogleClient, + kDefaultGoogleScopes, + createGoogleAccountId, + readGoogleState, + writeGoogleState, + listGoogleAccounts, + getGoogleAccountById, + getGoogleAccountByEmailAndClient, + upsertGoogleAccount, + removeGoogleAccount, +} = require("../google-state"); +const { syncBootstrapPromptFiles } = require("../onboarding/workspace"); + +const quoteShellArg = (value) => `"${String(value || "").replace(/(["\\$`])/g, "\\$1")}"`; + +const parseJsonSafe = (raw, fallbackValue) => { + try { + return JSON.parse(String(raw || "")); + } catch { + return fallbackValue; + } +}; + +const uniqueServiceLabels = (scopes) => + Array.from( + new Set( + (scopes || []) + .map((scope) => String(scope || "").split(":")[0]) + .filter(Boolean), + ), + ); + const registerGoogleRoutes = ({ app, fs, @@ -9,15 +42,110 @@ const registerGoogleRoutes = ({ constants, }) => { const { - GOG_CREDENTIALS_PATH, GOG_CONFIG_DIR, GOG_STATE_PATH, API_TEST_COMMANDS, BASE_SCOPES, SCOPE_MAP, REVERSE_SCOPE_MAP, + kMaxGoogleAccounts, + gogClientCredentialsPath, } = constants; + const readState = () => readGoogleState({ fs, statePath: GOG_STATE_PATH }); + const saveState = (state) => writeGoogleState({ fs, statePath: GOG_STATE_PATH, state }); + const syncBootstrapTools = (req) => { + try { + syncBootstrapPromptFiles({ + fs, + workspaceDir: constants.WORKSPACE_DIR, + baseUrl: getBaseUrl(req), + }); + } catch {} + }; + + const listAuthenticatedAccounts = async (state) => { + const configuredClients = new Set([kDefaultGoogleClient]); + listGoogleAccounts(state).forEach((account) => { + const client = String(account.client || kDefaultGoogleClient).trim() || kDefaultGoogleClient; + configuredClients.add(client); + }); + const combined = []; + for (const client of configuredClients) { + const command = + client === kDefaultGoogleClient + ? "auth list --json --check" + : `--client ${quoteShellArg(client)} auth list --json --check`; + const result = await gogCmd(command, { quiet: true }); + if (!result.ok) continue; + const parsed = parseJsonSafe(result.stdout, { accounts: [] }); + const accounts = Array.isArray(parsed?.accounts) ? parsed.accounts : []; + accounts.forEach((entry) => { + combined.push({ + ...entry, + client: String(entry.client || client || kDefaultGoogleClient).trim() || kDefaultGoogleClient, + }); + }); + } + return combined; + }; + + const accountIsAuthenticated = ({ account, authenticatedAccounts }) => + authenticatedAccounts.some( + (entry) => + String(entry.email || "").trim().toLowerCase() === String(account.email || "").trim().toLowerCase() && + String(entry.client || kDefaultGoogleClient).trim() === String(account.client || kDefaultGoogleClient).trim() && + (entry.valid !== false), + ); + + const getSelectedAccount = ({ state, accountId, fallbackToFirst = true }) => { + if (accountId) { + return getGoogleAccountById(state, accountId); + } + return fallbackToFirst ? listGoogleAccounts(state)[0] || null : null; + }; + + const ensureClientCredentials = ({ client, clientId, clientSecret, req }) => { + const credentialsPath = gogClientCredentialsPath(client); + fs.mkdirSync(GOG_CONFIG_DIR, { recursive: true }); + const credentials = { + web: { + client_id: clientId, + client_secret: clientSecret, + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + redirect_uris: [`${getBaseUrl(req)}/auth/google/callback`], + }, + }; + fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2)); + return credentialsPath; + }; + + app.get("/api/google/accounts", async (req, res) => { + const state = readState(); + const authenticatedAccounts = await listAuthenticatedAccounts(state); + const accounts = listGoogleAccounts(state).map((account) => { + const activeScopes = account.services || []; + const services = uniqueServiceLabels(activeScopes).join(", "); + const hasCredentials = fs.existsSync(gogClientCredentialsPath(account.client)); + return { + ...account, + services, + activeScopes, + hasCredentials, + authenticated: + hasCredentials && + (Boolean(account.authenticated) || accountIsAuthenticated({ account, authenticatedAccounts })), + }; + }); + res.json({ + ok: true, + hasCompanyCredentials: fs.existsSync(gogClientCredentialsPath(kDefaultGoogleClient)), + hasPersonalCredentials: fs.existsSync(gogClientCredentialsPath("personal")), + accounts, + }); + }); + app.get("/api/google/status", async (req, res) => { if (!(await isGatewayRunning())) { return res.json({ @@ -25,106 +153,156 @@ const registerGoogleRoutes = ({ authenticated: false, email: "", services: "", + activeScopes: [], }); } - const hasCredentials = fs.existsSync(GOG_CREDENTIALS_PATH); - let authenticated = false; - let email = ""; - - if (hasCredentials) { - const result = await gogCmd("auth list --plain", { quiet: true }); - if (result.ok && result.stdout && !result.stdout.includes("no accounts")) { - authenticated = true; - email = result.stdout.split("\n")[0]?.split("\t")[0] || ""; - } - if (!email) { - try { - const state = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8")); - email = state.email || ""; - } catch {} - } + const state = readState(); + const selected = getSelectedAccount({ + state, + accountId: String(req.query.accountId || ""), + fallbackToFirst: true, + }); + if (!selected) { + return res.json({ + hasCredentials: false, + authenticated: false, + email: "", + services: "", + activeScopes: [], + }); } - - let services = ""; - let activeScopes = []; - try { - const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8")); - activeScopes = stateData.services || []; - services = activeScopes - .map((s) => s.split(":")[0]) - .filter((v, i, a) => a.indexOf(v) === i) - .join(", "); - } catch {} - - const status = { + const authenticatedAccounts = await listAuthenticatedAccounts(state); + const activeScopes = selected.services || []; + const services = uniqueServiceLabels(activeScopes).join(", "); + const hasCredentials = fs.existsSync(gogClientCredentialsPath(selected.client)); + res.json({ + accountId: selected.id, + client: selected.client, + personal: selected.personal, hasCredentials, - authenticated, - email, + authenticated: + hasCredentials && + (Boolean(selected.authenticated) || + accountIsAuthenticated({ account: selected, authenticatedAccounts })), + email: selected.email, services, activeScopes, - }; - res.json(status); + }); }); app.post("/api/google/credentials", async (req, res) => { - const { clientId, clientSecret, email } = req.body; + const body = req.body || {}; + const clientId = String(body.clientId || "").trim(); + const clientSecret = String(body.clientSecret || "").trim(); + const email = String(body.email || "").trim(); + const accountId = String(body.accountId || "").trim(); + const personal = Boolean(body.personal); + const client = String(body.client || (personal ? "personal" : kDefaultGoogleClient)).trim() + || kDefaultGoogleClient; if (!clientId || !clientSecret || !email) { return res.json({ ok: false, error: "Missing fields" }); } try { - fs.mkdirSync(GOG_CONFIG_DIR, { recursive: true }); - const credentials = { - web: { - client_id: clientId, - client_secret: clientSecret, - auth_uri: "https://accounts.google.com/o/oauth2/auth", - token_uri: "https://oauth2.googleapis.com/token", - redirect_uris: [`${getBaseUrl(req)}/auth/google/callback`], - }, - }; - - fs.writeFileSync(GOG_CREDENTIALS_PATH, JSON.stringify(credentials, null, 2)); - const result = await gogCmd(`auth credentials set ${GOG_CREDENTIALS_PATH}`); - console.log(`[alphaclaw] gog credentials set: ${JSON.stringify(result)}`); + const credentialsPath = ensureClientCredentials({ + client, + clientId, + clientSecret, + req, + }); + const command = client === kDefaultGoogleClient + ? `auth credentials set ${quoteShellArg(credentialsPath)}` + : `--client ${quoteShellArg(client)} auth credentials set ${quoteShellArg(credentialsPath)}`; + const result = await gogCmd(command, { quiet: true }); + if (!result.ok) { + throw new Error(result.stderr || "Failed to set Google client credentials"); + } - const services = req.body.services || [ - "gmail:read", - "calendar:read", - "calendar:write", - "drive:read", - "sheets:read", - "docs:read", - ]; - fs.writeFileSync(GOG_STATE_PATH, JSON.stringify({ email, services })); + const state = readState(); + const existing = accountId ? getGoogleAccountById(state, accountId) : null; + const { state: nextState, account } = upsertGoogleAccount({ + state, + maxAccounts: kMaxGoogleAccounts, + account: { + id: existing?.id || accountId || createGoogleAccountId(), + email, + personal, + client, + services: body.services || existing?.services || kDefaultGoogleScopes, + authenticated: Boolean(existing?.authenticated), + }, + }); + saveState(nextState); + syncBootstrapTools(req); - res.json({ ok: true }); + res.json({ ok: true, accountId: account.id, account }); } catch (err) { console.error("[alphaclaw] Failed to save Google credentials:", err); res.json({ ok: false, error: err.message }); } }); - app.get("/api/google/check", async (req, res) => { - let email = ""; - let activeScopes = []; + app.post("/api/google/accounts", (req, res) => { + const body = req.body || {}; + const email = String(body.email || "").trim(); + const accountId = String(body.accountId || "").trim(); + const personal = Boolean(body.personal); + const client = String(body.client || (personal ? "personal" : kDefaultGoogleClient)).trim() + || kDefaultGoogleClient; + if (!email) { + return res.json({ ok: false, error: "Missing fields" }); + } + if (!fs.existsSync(gogClientCredentialsPath(client))) { + return res.json({ + ok: false, + error: "Credentials missing for selected client. Save credentials first.", + }); + } try { - const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8")); - email = stateData.email || ""; - activeScopes = stateData.services || []; - } catch {} - if (!email) return res.json({ error: "No Google account configured" }); + const state = readState(); + const existing = accountId ? getGoogleAccountById(state, accountId) : null; + const { state: nextState, account } = upsertGoogleAccount({ + state, + maxAccounts: kMaxGoogleAccounts, + account: { + id: existing?.id || accountId || createGoogleAccountId(), + email, + personal, + client, + services: body.services || existing?.services || kDefaultGoogleScopes, + authenticated: Boolean(existing?.authenticated), + }, + }); + saveState(nextState); + syncBootstrapTools(req); + res.json({ ok: true, accountId: account.id, account }); + } catch (err) { + res.json({ ok: false, error: err.message }); + } + }); - const enabledServices = activeScopes - .map((s) => s.split(":")[0]) - .filter((v, i, a) => a.indexOf(v) === i); + app.get("/api/google/check", async (req, res) => { + const state = readState(); + const account = getSelectedAccount({ + state, + accountId: String(req.query.accountId || ""), + fallbackToFirst: true, + }); + if (!account) return res.json({ error: "No Google account configured" }); + + const enabledServices = uniqueServiceLabels(account.services || []); const results = {}; - for (const svc of enabledServices) { const cmd = API_TEST_COMMANDS[svc]; if (!cmd) continue; - - const result = await gogCmd(`${cmd} --account ${email}`, { quiet: true }); + const clientArg = + account.client === kDefaultGoogleClient + ? "" + : `--client ${quoteShellArg(account.client)} `; + const result = await gogCmd( + `${clientArg}${cmd} --account ${quoteShellArg(account.email)}`, + { quiet: true }, + ); const stderr = result.stderr || ""; if (stderr.includes("has not been used") || stderr.includes("is not enabled")) { const projectMatch = stderr.match(/project=(\d+)/); @@ -135,7 +313,6 @@ const registerGoogleRoutes = ({ } else if (result.ok || stderr.includes("not found") || stderr.includes("Not Found")) { results[svc] = { status: "ok", enableUrl: getApiEnableUrl(svc) }; } else { - console.log(`[alphaclaw] API check ${svc} error: ${result.stderr?.slice(0, 300)}`); results[svc] = { status: "error", message: result.stderr?.slice(0, 200), @@ -143,58 +320,47 @@ const registerGoogleRoutes = ({ }; } } - - res.json({ email, results }); + res.json({ accountId: account.id, email: account.email, results }); }); app.post("/api/google/disconnect", async (req, res) => { + const accountId = String(req.body?.accountId || "").trim(); + const state = readState(); + const account = getSelectedAccount({ state, accountId, fallbackToFirst: true }); + if (!account) return res.json({ ok: true }); try { - let email = ""; - try { - const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8")); - email = stateData.email || ""; - } catch {} - - if (email) { - const exportResult = await gogCmd( - `auth tokens export ${email} --out /tmp/gog-revoke.json --overwrite`, - { quiet: true }, - ); - if (exportResult.ok) { - try { - const tokenData = JSON.parse(fs.readFileSync("/tmp/gog-revoke.json", "utf8")); - if (tokenData.refresh_token) { - await fetch(`https://oauth2.googleapis.com/revoke?token=${tokenData.refresh_token}`, { - method: "POST", - }); - console.log(`[alphaclaw] Revoked Google token for ${email}`); - } - fs.unlinkSync("/tmp/gog-revoke.json"); - } catch {} - } - await gogCmd(`auth remove ${email} --force`); - } - - for (const f of [GOG_STATE_PATH, GOG_CREDENTIALS_PATH]) { + const revokeFile = `/tmp/gog-revoke-${Date.now()}.json`; + const clientArg = + account.client === kDefaultGoogleClient + ? "" + : `--client ${quoteShellArg(account.client)} `; + const exportResult = await gogCmd( + `${clientArg}auth tokens export ${quoteShellArg(account.email)} --out ${quoteShellArg(revokeFile)} --overwrite`, + { quiet: true }, + ); + if (exportResult.ok && fs.existsSync(revokeFile)) { try { - fs.unlinkSync(f); - console.log(`[alphaclaw] Deleted ${f}`); - } catch (e) { - if (e.code !== "ENOENT") { - console.error(`[alphaclaw] Failed to delete ${f}: ${e.message}`); + const tokenData = parseJsonSafe(fs.readFileSync(revokeFile, "utf8"), {}); + if (tokenData.refresh_token) { + await fetch(`https://oauth2.googleapis.com/revoke?token=${tokenData.refresh_token}`, { + method: "POST", + }); } - } - } - - const stateStillExists = fs.existsSync(GOG_STATE_PATH); - const credsStillExists = fs.existsSync(GOG_CREDENTIALS_PATH); - if (stateStillExists || credsStillExists) { - console.error( - `[alphaclaw] Files survived deletion! state=${stateStillExists} creds=${credsStillExists}`, - ); + } catch {} } - - console.log(`[alphaclaw] Google disconnected: ${email}`); + try { + fs.unlinkSync(revokeFile); + } catch {} + await gogCmd( + `${clientArg}auth remove ${quoteShellArg(account.email)} --force`, + { quiet: true }, + ); + const { state: nextState } = removeGoogleAccount({ + state, + accountId: account.id, + }); + saveState(nextState); + syncBootstrapTools(req); res.json({ ok: true }); } catch (err) { console.error("[alphaclaw] Google disconnect error:", err); @@ -203,31 +369,44 @@ const registerGoogleRoutes = ({ }); app.get("/auth/google/start", (req, res) => { - const email = req.query.email || ""; + const state = readState(); + const requestedAccountId = String(req.query.accountId || "").trim(); + const requestedClient = String(req.query.client || "").trim(); + let account = requestedAccountId + ? getGoogleAccountById(state, requestedAccountId) + : null; + if (!account && req.query.email) { + account = getGoogleAccountByEmailAndClient( + state, + String(req.query.email || "").trim(), + requestedClient || kDefaultGoogleClient, + ); + } + const client = account?.client || requestedClient || kDefaultGoogleClient; + const email = account?.email || String(req.query.email || "").trim(); const services = ( req.query.services || - "gmail:read,calendar:read,calendar:write,drive:read,sheets:read,docs:read" + (account?.services || kDefaultGoogleScopes).join(",") ) .split(",") + .map((scope) => String(scope || "").trim()) .filter(Boolean); - try { - const { clientId } = readGoogleCredentials(); + const { clientId } = readGoogleCredentials(client); if (!clientId) throw new Error("No client_id found"); - const scopes = [ ...BASE_SCOPES, - ...services.map((s) => SCOPE_MAP[s]).filter(Boolean), + ...services.map((scope) => SCOPE_MAP[scope]).filter(Boolean), ].join(" "); - console.log( - `[alphaclaw] Google OAuth scopes: services=${services.join(",")} resolved=${scopes}`, - ); - const redirectUri = `${getBaseUrl(req)}/auth/google/callback`; - const state = Buffer.from(JSON.stringify({ email, services })).toString( - "base64url", - ); - + const encodedState = Buffer.from( + JSON.stringify({ + accountId: account?.id || requestedAccountId || "", + client, + email, + services, + }), + ).toString("base64url"); const authUrl = new URL("https://accounts.google.com/o/oauth2/auth"); authUrl.searchParams.set("client_id", clientId); authUrl.searchParams.set("redirect_uri", redirectUri); @@ -235,31 +414,41 @@ const registerGoogleRoutes = ({ authUrl.searchParams.set("scope", scopes); authUrl.searchParams.set("access_type", "offline"); authUrl.searchParams.set("prompt", "consent"); - authUrl.searchParams.set("state", state); + authUrl.searchParams.set("state", encodedState); if (email) authUrl.searchParams.set("login_hint", email); - res.redirect(authUrl.toString()); } catch (err) { console.error("[alphaclaw] Failed to start Google auth:", err); - res.redirect("/setup?google=error&message=" + encodeURIComponent(err.message)); + res.redirect(`/setup?google=error&message=${encodeURIComponent(err.message)}`); } }); app.get("/auth/google/callback", async (req, res) => { const { code, error, state } = req.query; - if (error) return res.redirect("/setup?google=error&message=" + encodeURIComponent(error)); + if (error) return res.redirect(`/setup?google=error&message=${encodeURIComponent(error)}`); if (!code) return res.redirect("/setup?google=error&message=no_code"); try { - let email = ""; - try { - const decoded = JSON.parse(Buffer.from(state, "base64url").toString()); - email = decoded.email || ""; - } catch {} - - const { clientId, clientSecret } = readGoogleCredentials(); + const decodedState = parseJsonSafe( + Buffer.from(String(state || ""), "base64url").toString(), + {}, + ); + const accountId = String(decodedState.accountId || "").trim(); + const requestedClient = String(decodedState.client || "").trim(); + const stateData = readState(); + const existingAccount = accountId + ? getGoogleAccountById(stateData, accountId) + : getGoogleAccountByEmailAndClient( + stateData, + String(decodedState.email || "").trim(), + requestedClient || kDefaultGoogleClient, + ); + const client = existingAccount?.client || requestedClient || kDefaultGoogleClient; + const { clientId, clientSecret } = readGoogleCredentials(client); + if (!clientId || !clientSecret) { + throw new Error(`Google credentials missing for client "${client}"`); + } const redirectUri = `${getBaseUrl(req)}/auth/google/callback`; - const tokenRes = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -271,42 +460,25 @@ const registerGoogleRoutes = ({ grant_type: "authorization_code", }), }); - const tokens = await tokenRes.json(); if (!tokenRes.ok || tokens.error) { - console.log( - `[alphaclaw] Google token exchange failed: status=${tokenRes.status} error=${tokens.error} desc=${tokens.error_description}`, - ); + throw new Error(`Google token error: ${tokens.error_description || tokens.error || "exchange_failed"}`); } - if (tokens.error) { - throw new Error(`Google token error: ${tokens.error_description || tokens.error}`); - } - - if (!tokens.refresh_token) { - let hasExisting = false; - try { - const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8")); - hasExisting = stateData.authenticated; - } catch {} - if (hasExisting) { - console.log( - "[alphaclaw] No new refresh token (already authorized), keeping existing", - ); - } else { - throw new Error( - "No refresh token received. Revoke app access at myaccount.google.com/permissions and retry.", - ); - } + if (!tokens.refresh_token && !existingAccount?.authenticated) { + throw new Error( + "No refresh token received. Revoke app access at myaccount.google.com/permissions and retry.", + ); } + let email = String(existingAccount?.email || decodedState.email || "").trim(); if (!email && tokens.access_token) { try { const infoRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { headers: { Authorization: `Bearer ${tokens.access_token}` }, }); const info = await infoRes.json(); - email = info.email || email; + email = String(info.email || "").trim(); } catch {} } @@ -314,73 +486,61 @@ const registerGoogleRoutes = ({ const tokenFile = `/tmp/gog-token-${Date.now()}.json`; const tokenData = { email, - client: "default", + client, created_at: new Date().toISOString(), refresh_token: tokens.refresh_token, }; fs.writeFileSync(tokenFile, JSON.stringify(tokenData, null, 2)); - const result = await gogCmd(`auth tokens import ${tokenFile}`); - if (result.ok) { - console.log(`[alphaclaw] Google token imported for ${email}`); - } else { - console.error(`[alphaclaw] Token import failed: ${result.stderr}`); - } - - if (!result.ok) { - console.error("[alphaclaw] Token import failed, trying gog auth add --manual"); - const keyringDir = `${GOG_CONFIG_DIR}/keyring`; - fs.mkdirSync(keyringDir, { recursive: true }); - fs.writeFileSync( - `${keyringDir}/token-${email}.json`, - JSON.stringify(tokenData, null, 2), - ); - console.log( - `[alphaclaw] Token written directly to keyring: ${keyringDir}/token-${email}.json`, - ); - } - + const importCmd = + client === kDefaultGoogleClient + ? `auth tokens import ${quoteShellArg(tokenFile)}` + : `--client ${quoteShellArg(client)} auth tokens import ${quoteShellArg(tokenFile)}`; + const result = await gogCmd(importCmd, { quiet: true }); try { fs.unlinkSync(tokenFile); } catch {} + if (!result.ok) { + throw new Error(result.stderr || "Failed to import Google token"); + } } - let services = []; - try { - const decoded = JSON.parse(Buffer.from(state, "base64url").toString()); - services = decoded.services || []; - } catch {} - + const requestedServices = Array.isArray(decodedState.services) + ? decodedState.services + : []; const grantedServices = tokens.scope ? tokens.scope .split(" ") - .map((s) => REVERSE_SCOPE_MAP[s]) + .map((scope) => REVERSE_SCOPE_MAP[scope]) .filter(Boolean) - : services; - console.log( - `[alphaclaw] Requested: ${services.join(",")} → Granted: ${grantedServices.join(",")}`, - ); - - fs.writeFileSync( - GOG_STATE_PATH, - JSON.stringify({ + : requestedServices; + const { state: nextState, account } = upsertGoogleAccount({ + state: stateData, + maxAccounts: kMaxGoogleAccounts, + account: { + id: existingAccount?.id || accountId || createGoogleAccountId(), email, - clientId, - clientSecret, - services: grantedServices, + personal: Boolean(existingAccount?.personal), + client, + services: grantedServices.length ? grantedServices : requestedServices, authenticated: true, - }), - ); + }, + }); + saveState(nextState); + syncBootstrapTools(req); + const safeEmail = String(email || "").replace(/'/g, "\\'"); + const safeAccountId = String(account.id || "").replace(/'/g, "\\'"); res.send(`

    Google connected! You can close this window.

    `); } catch (err) { console.error("[alphaclaw] Google OAuth callback error:", err); + const safeMessage = String(err.message || "unknown_error").replace(/'/g, "\\'"); res.send(`

    Error: ${err.message}. You can close this window.

    `); +

    Error: ${safeMessage}. You can close this window.

    `); } }); }; From 1108272e32d3ab8529c3e8b019120af90d95bc8c Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 13:55:23 -0800 Subject: [PATCH 067/417] Harden Google credential rotation and preserve browse panel state across tab switches. This keeps file-tree/search interactions stable while preventing stale OAuth tokens from surviving credential re-upload, so account re-auth and client selection stay consistent. --- lib/public/css/explorer.css | 13 +++++ lib/public/js/components/file-tree.js | 19 +++++++- .../js/components/google/account-row.js | 5 +- lib/public/js/components/google/index.js | 28 ++++++++--- lib/public/js/components/sidebar.js | 19 +++++++- lib/server/onboarding/index.js | 8 ++-- lib/server/onboarding/workspace.js | 3 +- lib/server/routes/google.js | 47 +++++++++++++++++-- lib/setup/gitignore | 3 ++ 9 files changed, 127 insertions(+), 18 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 6bfd6bf9..b687208c 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -225,6 +225,19 @@ color: var(--accent); } +.tree-folder-toggle { + border: 0; + margin: 0; + padding: 0; + background: transparent; + color: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex: 0 0 auto; +} + .arrow { font-size: 10px; transition: transform 0.15s; diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index 8ed27561..ce328417 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -288,7 +288,24 @@ const TreeNode = ({ }} title=${folderPath || node.name} > - + ${node.name}
      diff --git a/lib/public/js/components/google/account-row.js b/lib/public/js/components/google/account-row.js index f3ae6bb7..6ba0f788 100644 --- a/lib/public/js/components/google/account-row.js +++ b/lib/public/js/components/google/account-row.js @@ -51,9 +51,10 @@ export const GoogleAccountRow = ({ ? html`` : null} diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js index 7525bbd4..66985051 100644 --- a/lib/public/js/components/google/index.js +++ b/lib/public/js/components/google/index.js @@ -91,8 +91,14 @@ export const Google = ({ gatewayStatus }) => { setExpandedAccountId(""); return; } - const firstId = accounts[0]?.id || ""; - setExpandedAccountId((prev) => (prev && accounts.some((item) => item.id === prev) ? prev : firstId)); + const firstAwaitingSignInId = + accounts.find((account) => !account.authenticated)?.id || ""; + setExpandedAccountId((previousId) => { + if (previousId && accounts.some((account) => account.id === previousId)) { + return previousId; + } + return firstAwaitingSignInId; + }); accounts.forEach((account) => ensureScopesForAccount(account)); }, [accounts, ensureScopesForAccount]); @@ -126,14 +132,24 @@ export const Google = ({ gatewayStatus }) => { }; const handleCheckApis = useCallback(async (accountId) => { - setCheckingByAccountId((prev) => ({ ...prev, [accountId]: true })); + setApiStatusByAccountId((prev) => { + const next = { ...prev }; + delete next[accountId]; + return next; + }); + setCheckingByAccountId({ [accountId]: true }); try { const data = await checkGoogleApis(accountId); if (data.results) { setApiStatusByAccountId((prev) => ({ ...prev, [accountId]: data.results })); } } finally { - setCheckingByAccountId((prev) => ({ ...prev, [accountId]: false })); + setCheckingByAccountId((prev) => { + if (!prev[accountId]) return prev; + const next = { ...prev }; + delete next[accountId]; + return next; + }); } }, []); @@ -307,7 +323,7 @@ export const Google = ({ gatewayStatus }) => { return html`
      <${PageHeader} - title="Google Workspace" + title="Google Accounts" actions=${html` ${accounts.length ? html` @@ -364,7 +380,7 @@ export const Google = ({ gatewayStatus }) => { scopes=${scopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} savedScopes=${savedScopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} apiStatus=${apiStatusByAccountId[account.id] || {}} - checkingApis=${Boolean(checkingByAccountId[account.id])} + checkingApis=${expandedAccountId === account.id && Boolean(checkingByAccountId[account.id])} onToggleScope=${handleToggleScope} onCheckApis=${handleCheckApis} onUpdatePermissions=${(accountId) => startAuth(accountId)} diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index 52e679d6..76e27d70 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -172,7 +172,14 @@ export const AppSidebar = ({ <${FolderLineIcon} className="sidebar-tab-icon" />
      -
      +
      ${navSections.map( (section) => html` @@ -205,7 +212,15 @@ export const AppSidebar = ({ : null}
      -
      +
      <${ToastContainer} - className="fixed bottom-10 right-4 z-50 space-y-2 pointer-events-none" + className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none" />
      diff --git a/lib/public/js/components/file-viewer/use-file-viewer.js b/lib/public/js/components/file-viewer/use-file-viewer.js index 6f28ffcd..17dede41 100644 --- a/lib/public/js/components/file-viewer/use-file-viewer.js +++ b/lib/public/js/components/file-viewer/use-file-viewer.js @@ -265,7 +265,6 @@ export const useFileViewer = ({ detail: { path: normalizedPath }, }), ); - showToast("Saved", "success"); } catch (saveError) { const message = saveError.message || "Could not save file"; setError(message); diff --git a/lib/public/js/components/google/account-row.js b/lib/public/js/components/google/account-row.js index 78d718e7..51eec035 100644 --- a/lib/public/js/components/google/account-row.js +++ b/lib/public/js/components/google/account-row.js @@ -70,14 +70,31 @@ export const GoogleAccountRow = ({ apiStatus=${account.authenticated ? apiStatus : {}} loading=${account.authenticated && checkingApis} /> - <${GmailWatchToggle} - account=${account} - watchStatus=${gmailWatchStatus} - busy=${gmailWatchBusy} - onEnable=${() => onEnableGmailWatch?.(account.id)} - onDisable=${() => onDisableGmailWatch?.(account.id)} - onOpenSetup=${() => onOpenGmailSetup?.(account.id)} - /> + ${account.authenticated + ? html` +
      +
      +
      + Incoming events + +
      + <${GmailWatchToggle} + account=${account} + watchStatus=${gmailWatchStatus} + busy=${gmailWatchBusy} + onEnable=${() => onEnableGmailWatch?.(account.id)} + onDisable=${() => onDisableGmailWatch?.(account.id)} + /> +
      +
      + ` + : null}
      @@ -64,16 +66,21 @@ export const GmailSetupWizard = ({ const [step, setStep] = useState(0); const [projectIdInput, setProjectIdInput] = useState(""); const [localError, setLocalError] = useState(""); + const [projectIdResolved, setProjectIdResolved] = useState(false); useEffect(() => { if (!visible) return; setStep(0); setLocalError(""); setProjectIdInput(""); + setProjectIdResolved(false); }, [visible, account?.id]); const commands = clientConfig?.commands || null; - const needsProjectId = !String(clientConfig?.projectId || "").trim() && !commands; + const hasProjectIdFromConfig = Boolean( + String(clientConfig?.projectId || "").trim() || commands, + ); + const needsProjectId = !hasProjectIdFromConfig && !projectIdResolved; const detectedProjectId = String(clientConfig?.projectId || "").trim() || String(projectIdInput || "").trim() || @@ -96,19 +103,6 @@ export const GmailSetupWizard = ({ showToast("Could not copy text", "error"); }; - const handleSaveProjectId = async () => { - setLocalError(""); - try { - await onSaveSetup({ - client, - projectId: String(projectIdInput || "").trim(), - }); - showToast("Project ID saved", "success"); - } catch (err) { - setLocalError(err.message || "Could not save project id"); - } - }; - const handleFinish = async () => { try { await onFinish({ @@ -120,30 +114,73 @@ export const GmailSetupWizard = ({ } }; + const handleNext = async () => { + if (saving) return; + if (needsProjectId) { + if (!canAdvance) return; + setLocalError(""); + try { + await onSaveSetup({ + client, + projectId: String(projectIdInput || "").trim(), + }); + setProjectIdResolved(true); + } catch (err) { + setLocalError(err.message || "Could not save project id"); + return; + } + return; + } + setStep((prev) => Math.min(prev + 1, kTotalSteps - 1)); + }; + return html` <${ModalShell} visible=${visible} onClose=${onClose} closeOnOverlayClick=${false} closeOnEscape=${false} - panelClassName="bg-modal border border-border rounded-xl p-5 max-w-2xl w-full space-y-3" + panelClassName="relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4" > -
      -
      Gmail Setup Wizard
      -
      - ${account?.email - ? `Set up Gmail Pub/Sub for ${account.email}.` - : "Set up Gmail Pub/Sub for this Google client."} -
      + +
      Gmail Pub / Sub Setup
      +
      + ${kStepTitles.map( + (title, idx) => html` +
      + `, + )}
      -
      Step ${step + 1} of 5: ${kStepTitles[step]}
      + <${PageHeader} + title=${`Step ${step + 1} of ${kTotalSteps}: ${kStepTitles[step]}`} + actions=${null} + /> ${localError ? html`
      ${localError}
      ` : null} ${needsProjectId ? html`
      Project ID required
      - Could not detect project_id from the OAuth credentials file for this client. + Find it in the${" "} + + Google Cloud Console Project Selector +
      - <${ActionButton} - onClick=${handleSaveProjectId} - disabled=${saving || !canAdvance} - loading=${saving} - tone="primary" - size="sm" - idleLabel="Save Project ID" - loadingLabel="Saving..." - />
      ` : null} ${!needsProjectId && step === 0 ? html` -
      -
      Install and authenticate gcloud
      +
      - If gcloud is not installed, follow the official install guide: + If gcloud is not installed on your computer, follow the official install guide:${" "} Google Cloud SDK install docs
      ${renderCommandBlock( - `gcloud --version\n` + - `gcloud auth login\n` + + `gcloud auth login\n` + `gcloud config set project ${detectedProjectId}`, () => handleCopy( - `gcloud --version\n` + - `gcloud auth login\n` + + `gcloud auth login\n` + `gcloud config set project ${detectedProjectId}`, ), )} @@ -223,38 +248,32 @@ export const GmailSetupWizard = ({
      ` : null} -
      +
      -
      - - ${step < 4 - ? html`` - : html`<${ActionButton} - onClick=${handleFinish} - disabled=${saving} - loading=${saving} - tone="primary" - size="sm" - idleLabel="Finish Setup" - loadingLabel="Finishing..." - />`} -
      + ${step < kTotalSteps - 1 + ? html`` + : html``}
      `; diff --git a/lib/public/js/components/google/gmail-watch-toggle.js b/lib/public/js/components/google/gmail-watch-toggle.js index d53c8225..51351cfb 100644 --- a/lib/public/js/components/google/gmail-watch-toggle.js +++ b/lib/public/js/components/google/gmail-watch-toggle.js @@ -1,27 +1,16 @@ import { h } from "https://esm.sh/preact"; import htm from "https://esm.sh/htm"; import { Badge } from "../badge.js"; -import { ActionButton } from "../action-button.js"; +import { ToggleSwitch } from "../toggle-switch.js"; +import { InfoTooltip } from "../info-tooltip.js"; const html = htm.bind(h); -const formatDateTime = (value) => { - const parsed = Number.parseInt(String(value || ""), 10); - if (!Number.isFinite(parsed) || parsed <= 0) return "n/a"; - try { - return new Date(parsed).toLocaleString(); - } catch { - return "n/a"; - } -}; - -const resolveWatchState = ({ - watchStatus, - busy = false, -}) => { +const resolveWatchState = ({ watchStatus, busy = false }) => { if (busy) return { label: "Starting", tone: "warning" }; if (!watchStatus?.enabled) return { label: "Stopped", tone: "neutral" }; - if (watchStatus.enabled && !watchStatus.running) return { label: "Error", tone: "danger" }; + if (watchStatus.enabled && !watchStatus.running) + return { label: "Error", tone: "danger" }; return { label: "Watching", tone: "success" }; }; @@ -31,7 +20,6 @@ export const GmailWatchToggle = ({ busy = false, onEnable = () => {}, onDisable = () => {}, - onOpenSetup = () => {}, }) => { const hasGmailReadScope = Array.isArray(account?.activeScopes) ? account.activeScopes.includes("gmail:read") @@ -40,9 +28,10 @@ export const GmailWatchToggle = ({ : false; if (!hasGmailReadScope) { return html` -
      +
      - Gmail watch requires gmail:read. Add it in permissions above, then update permissions. + Gmail watch requires gmail:read. Add it in permissions + above, then update permissions.
      `; @@ -51,44 +40,26 @@ export const GmailWatchToggle = ({ const state = resolveWatchState({ watchStatus, busy }); const enabled = Boolean(watchStatus?.enabled); return html` -
      -
      -
      Gmail Watch
      -
      - <${Badge} tone=${state.tone}>${state.label} - -
      -
      -
      -
      Expiration: ${formatDateTime(watchStatus?.expiration)}
      -
      Last push: ${formatDateTime(watchStatus?.lastPushAt)}
      +
      +
      + 🔔 Gmail Watch + <${InfoTooltip} + text="Watches this inbox for new email events and routes them to your agent via the Gmail hook." + widthClass="w-72" + />
      - ${enabled - ? html`<${ActionButton} - onClick=${onDisable} - disabled=${busy} - loading=${busy} - tone="secondary" - size="sm" - idleLabel="Disable Watch" - loadingLabel="Stopping..." - />` - : html`<${ActionButton} - onClick=${onEnable} - disabled=${busy} - loading=${busy} - tone="primary" - size="sm" - idleLabel="Enable Watch" - loadingLabel="Starting..." - />`} + <${Badge} tone=${state.tone}>${state.label} + <${ToggleSwitch} + checked=${enabled} + disabled=${busy} + label="" + onChange=${(nextChecked) => { + if (busy) return; + if (nextChecked) onEnable?.(); + else onDisable?.(); + }} + />
      `; diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js index 7ebe8361..4bcd1eaf 100644 --- a/lib/public/js/components/google/index.js +++ b/lib/public/js/components/google/index.js @@ -4,6 +4,7 @@ import htm from "https://esm.sh/htm"; import { checkGoogleApis, disconnectGoogle, + fetchGoogleCredentials, saveGoogleAccount, } from "../../lib/api.js"; import { getDefaultScopes, toggleScopeLogic } from "../scope-picker.js"; @@ -28,7 +29,7 @@ const isPersonalAccount = (account = {}) => Boolean(account.personal); const kGoogleIconPath = "/assets/icons/google_icon.svg"; -export const Google = ({ gatewayStatus }) => { +export const Google = ({ gatewayStatus, onRestartRequired = () => {} }) => { const { accounts, loading, @@ -295,19 +296,36 @@ export const Google = ({ gatewayStatus }) => { }); }; - const handleEditCredentials = (accountId) => { + const handleEditCredentials = async (accountId) => { const account = getAccountById(accountId); if (!account) return; const personal = isPersonalAccount(account); + const client = personal ? "personal" : (account.client || "default"); + let credentialValues = {}; + try { + const credentialResponse = await fetchGoogleCredentials({ + accountId: account.id, + client, + }); + if (credentialResponse?.ok) { + credentialValues = { + clientId: String(credentialResponse.clientId || ""), + clientSecret: String(credentialResponse.clientSecret || ""), + }; + } + } catch { + showToast("Could not load saved client credentials", "warning"); + } openCredentialsModal({ accountId: account.id, - client: personal ? "personal" : (account.client || "default"), + client, personal, title: `Edit Credentials (${account.email})`, submitLabel: "Save Credentials", defaultInstrType: personal ? "personal" : "workspace", initialValues: { email: account.email, + ...credentialValues, }, }); }; @@ -336,7 +354,10 @@ export const Google = ({ gatewayStatus }) => { return; } try { - await startWatchForAccount(accountId); + const result = await startWatchForAccount(accountId); + if (result?.restartRequired) { + onRestartRequired(true); + } showToast("Gmail watch enabled", "success"); } catch (err) { showToast(err.message || "Could not enable Gmail watch", "error"); diff --git a/lib/public/js/components/google/use-gmail-watch.js b/lib/public/js/components/google/use-gmail-watch.js index 72162657..58e73eab 100644 --- a/lib/public/js/components/google/use-gmail-watch.js +++ b/lib/public/js/components/google/use-gmail-watch.js @@ -67,8 +67,9 @@ export const useGmailWatch = ({ gatewayStatus, accounts = [] }) => { const key = String(accountId || ""); setBusy(key, true); try { - await startGmailWatch(key); + const data = await startGmailWatch(key); await refresh(); + return data; } finally { setBusy(key, false); } diff --git a/lib/public/js/components/scope-picker.js b/lib/public/js/components/scope-picker.js index 5b04db64..b2d55e6c 100644 --- a/lib/public/js/components/scope-picker.js +++ b/lib/public/js/components/scope-picker.js @@ -68,7 +68,7 @@ export function ScopePicker({ scopes, onToggle, apiStatus, loading }) { diff --git a/lib/public/js/components/webhooks.js b/lib/public/js/components/webhooks.js index 56e88c4d..da423360 100644 --- a/lib/public/js/components/webhooks.js +++ b/lib/public/js/components/webhooks.js @@ -198,6 +198,7 @@ export const Webhooks = ({ onSelectHook = () => {}, onBackToList = () => {}, onRestartRequired = () => {}, + onOpenFile = () => {}, }) => { const [isCreating, setIsCreating] = useState(false); const [newName, setNewName] = useState(""); @@ -262,13 +263,29 @@ export const Webhooks = ({ const bearerTokenValue = authHeaderValue.startsWith("Authorization: ") ? authHeaderValue.slice("Authorization: ".length) : authHeaderValue; - const webhookTestPayload = useMemo( - () => ({ + const webhookTestPayload = useMemo(() => { + if (String(selectedHookName || "").trim().toLowerCase() === "gmail") { + return { + payload: { + account: "test@gmail.com", + messages: [ + { + id: "test-message-1", + from: "alerts@example.com", + to: ["test@gmail.com"], + subject: "Test Gmail webhook event", + snippet: "This is a simulated Gmail message payload for webhook testing.", + receivedAt: new Date().toISOString(), + }, + ], + }, + }; + } + return { source: "manual-test", message: `This is a test of the ${selectedHookName || "webhook"} webhook.`, - }), - [selectedHookName], - ); + }; + }, [selectedHookName]); const webhookTestPayloadJson = JSON.stringify(webhookTestPayload); const curlCommandHeaders = `curl -X POST "${webhookUrl}" ` + @@ -279,8 +296,9 @@ export const Webhooks = ({ `curl -X POST "${webhookUrlWithQueryToken}" ` + `-H "Content-Type: application/json" ` + `-d '${webhookTestPayloadJson}'`; + const effectiveAuthMode = selectedWebhookManaged ? "headers" : authMode; const activeCurlCommand = - authMode === "query" ? curlCommandQuery : curlCommandHeaders; + effectiveAuthMode === "query" ? curlCommandQuery : curlCommandHeaders; const canCreate = useMemo(() => { const name = String(newName || "") @@ -378,9 +396,9 @@ export const Webhooks = ({ if (!selectedHookName || sendingTestWebhook) return; setSendingTestWebhook(true); const requestUrl = - authMode === "query" ? webhookUrlWithQueryToken : webhookUrl; + effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl; const headers = { "Content-Type": "application/json" }; - if (authMode === "headers") { + if (effectiveAuthMode === "headers") { headers.Authorization = bearerTokenValue; } try { @@ -414,8 +432,8 @@ export const Webhooks = ({ setSendingTestWebhook(false); } }, [ - authMode, bearerTokenValue, + effectiveAuthMode, requestsPoll.refresh, selectedHookName, sendingTestWebhook, @@ -432,9 +450,9 @@ export const Webhooks = ({ return; } const requestUrl = - authMode === "query" ? webhookUrlWithQueryToken : webhookUrl; + effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl; const headers = { "Content-Type": "application/json" }; - if (authMode === "headers") { + if (effectiveAuthMode === "headers") { headers.Authorization = bearerTokenValue; } setReplayingRequestId(item.id); @@ -470,8 +488,8 @@ export const Webhooks = ({ } }, [ - authMode, bearerTokenValue, + effectiveAuthMode, replayingRequestId, requestsPoll.refresh, webhookUrl, @@ -543,36 +561,40 @@ export const Webhooks = ({
      -
      -

      Auth mode

      -
      - - -
      -
      + ${selectedWebhookManaged + ? null + : html` +
      +

      Auth mode

      +
      + + +
      +
      + `}

      Webhook URL

      { try { await navigator.clipboard.writeText( - authMode === "query" + effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl, ); @@ -596,44 +618,46 @@ export const Webhooks = ({
      - ${authMode === "headers" - ? html` -
      -

      Auth headers

      -
      - - + ${selectedWebhookManaged + ? null + : effectiveAuthMode === "headers" + ? html` +
      +

      Auth headers

      +
      + + +
      -
      - ` - : html` -

      - Always use auth headers when possible. Query string is - less secure. -

      - `} + ` + : html` +

      + Always use auth headers when possible. Query string is + less secure. +

      + `}
      Transform: - ${selectedWebhook?.transformPath || "—"} + ${selectedWebhook?.transformPath + ? html`` + : html``}
      - ${selectedWebhookManaged - ? html` -
      -

      - This webhook is managed by Gmail Watch setup and cannot - be deleted or edited from this page. -

      -
      - ` - : null} -

      Created: ${formatDateTime(selectedWebhook?.createdAt)}

      - <${ActionButton} - onClick=${() => { - if (selectedWebhookManaged) return; - if (deleting) return; - setDeleteTransformDir(true); - setShowDeleteConfirm(true); - }} - disabled=${deleting || selectedWebhookManaged} - loading=${deleting} - tone="danger" - size="sm" - idleLabel=${selectedWebhookManaged ? "Managed" : "Delete"} - loadingLabel="Deleting..." - className="shrink-0 px-2.5 py-1" - /> + ${selectedWebhookManaged + ? null + : html`<${ActionButton} + onClick=${() => { + if (deleting) return; + setDeleteTransformDir(true); + setShowDeleteConfirm(true); + }} + disabled=${deleting} + loading=${deleting} + tone="danger" + size="sm" + idleLabel="Delete" + loadingLabel="Deleting..." + className="shrink-0 px-2.5 py-1" + />`}
      + ${selectedWebhookManaged + ? html` +
      +

      + This webhook is managed by Gmail Watch setup and cannot + be deleted or edited from this page. +

      +
      + ` + : null}
      diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 6ca7b19a..9a3c8834 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -70,6 +70,18 @@ export async function fetchGoogleStatus(accountId = "") { return res.json(); } +export async function fetchGoogleCredentials({ + accountId = "", + client = "", +} = {}) { + const params = new URLSearchParams(); + if (accountId) params.set("accountId", String(accountId)); + if (client) params.set("client", String(client)); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const res = await authFetch(`/api/google/credentials${suffix}`); + return res.json(); +} + export async function checkGoogleApis(accountId = "") { const params = new URLSearchParams(); if (accountId) params.set("accountId", String(accountId)); diff --git a/lib/server/gmail-serve.js b/lib/server/gmail-serve.js index 1abaafbe..3b376f0a 100644 --- a/lib/server/gmail-serve.js +++ b/lib/server/gmail-serve.js @@ -2,6 +2,15 @@ const { spawn } = require("child_process"); const kDefaultStopTimeoutMs = 5000; const kDefaultBindHost = "127.0.0.1"; +const kDefaultServerHost = "127.0.0.1"; +const kPersonalClientName = "personal"; + +const resolveClientName = (account = {}) => { + const rawClient = String(account?.client || "").trim(); + if (rawClient) return rawClient; + if (account?.personal) return kPersonalClientName; + return "default"; +}; const isPidRunning = (pid) => { const normalizedPid = Number.parseInt(String(pid || ""), 10); @@ -51,7 +60,7 @@ const createGmailServeManager = ({ port, webhookToken, }) => { - const client = String(account?.client || "default").trim() || "default"; + const client = resolveClientName(account); const args = []; if (client !== "default") { args.push("--client", client); @@ -69,7 +78,7 @@ const createGmailServeManager = ({ "--path", "/", "--hook-url", - `${constants.GATEWAY_URL}/hooks/gmail`, + `http://${kDefaultServerHost}:${constants.PORT}/hooks/gmail`, "--hook-token", String(webhookToken || ""), "--include-body", @@ -124,14 +133,14 @@ const createGmailServeManager = ({ child.stderr.on("data", (chunk) => { const line = String(chunk || "").trim(); if (line) { - console.log(`[alphaclaw] gmail watch serve err (${email}): ${line}`); + console.log(`[alphaclaw] gmail watch serve stderr (${email}): ${line}`); } }); const nextEntry = { accountId, email, - client: String(account?.client || "default").trim() || "default", + client: resolveClientName(account), port: normalizedPort, startedAt: new Date().toISOString(), child, diff --git a/lib/server/gmail-watch.js b/lib/server/gmail-watch.js index 205c9e4d..76369ef8 100644 --- a/lib/server/gmail-watch.js +++ b/lib/server/gmail-watch.js @@ -124,18 +124,61 @@ const createGmailWatchService = ({ reloadEnv, restartRequiredState, }) => { + const ensureAccountClientMappings = ({ state }) => { + const configDir = String(constants.GOG_CONFIG_DIR || "").trim(); + if (!configDir) return; + const configPath = path.join(configDir, "config.json"); + let currentConfig = {}; + try { + if (fs.existsSync(configPath)) { + const raw = String(fs.readFileSync(configPath, "utf8") || "").trim(); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + currentConfig = parsed; + } + } + } + } catch {} + + const nextAccountClients = { + ...(currentConfig.account_clients && + typeof currentConfig.account_clients === "object" && + !Array.isArray(currentConfig.account_clients) + ? currentConfig.account_clients + : {}), + }; + for (const account of listGoogleAccounts(state)) { + const email = String(account?.email || "").trim().toLowerCase(); + const client = String(account?.client || "default").trim() || "default"; + if (!email) continue; + nextAccountClients[email] = client; + } + const nextConfig = { + ...currentConfig, + account_clients: nextAccountClients, + }; + const previousSerialized = JSON.stringify(currentConfig); + const nextSerialized = JSON.stringify(nextConfig); + if (previousSerialized === nextSerialized) return; + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`); + }; + const readState = () => readGoogleState({ fs, statePath: constants.GOG_STATE_PATH, }); - const saveState = (state) => + const saveState = (state) => { + ensureAccountClientMappings({ state }); writeGoogleState({ fs, statePath: constants.GOG_STATE_PATH, state, }); + }; const markRestartRequired = (source = "gmail-watch") => { try { @@ -513,7 +556,7 @@ const createGmailWatchService = ({ await serveManager.stopServe({ accountId }); const watchStop = await runGogForAccount({ account, - command: `gmail watch stop --account ${quoteShellArg(account.email)}`, + command: `gmail watch stop --account ${quoteShellArg(account.email)} --force`, }); if (!watchStop.ok) { console.log( diff --git a/lib/server/routes/gmail.js b/lib/server/routes/gmail.js index 37e28327..85366302 100644 --- a/lib/server/routes/gmail.js +++ b/lib/server/routes/gmail.js @@ -13,6 +13,16 @@ const registerGmailRoutes = ({ reloadEnv, restartRequiredState, }) => { + const getRestartSnapshot = async () => { + try { + return (await restartRequiredState?.getSnapshot?.()) || { + restartRequired: false, + }; + } catch { + return { restartRequired: false }; + } + }; + const gmailWatchService = createGmailWatchService({ fs, constants, @@ -51,7 +61,11 @@ const registerGmailRoutes = ({ const accountId = String(req.body?.accountId || "").trim(); if (!accountId) return res.status(400).json({ ok: false, error: "accountId is required" }); const result = await gmailWatchService.startWatch({ accountId, req }); - return res.json(result); + const snapshot = await getRestartSnapshot(); + return res.json({ + ...result, + restartRequired: Boolean(snapshot?.restartRequired), + }); } catch (err) { return res.status(400).json({ ok: false, error: err.message }); } diff --git a/lib/server/routes/google.js b/lib/server/routes/google.js index 07d47e7b..95a21e7c 100644 --- a/lib/server/routes/google.js +++ b/lib/server/routes/google.js @@ -216,6 +216,25 @@ const registerGoogleRoutes = ({ }); }); + app.get("/api/google/credentials", (req, res) => { + const state = readState(); + const accountId = String(req.query.accountId || "").trim(); + const requestedClient = String(req.query.client || "").trim(); + const account = accountId ? getGoogleAccountById(state, accountId) : null; + const client = + String(account?.client || requestedClient || kDefaultGoogleClient).trim() + || kDefaultGoogleClient; + const credentials = readGoogleCredentials(client); + const hasCredentials = Boolean(credentials.clientId && credentials.clientSecret); + res.json({ + ok: true, + client, + hasCredentials, + clientId: credentials.clientId || "", + clientSecret: credentials.clientSecret || "", + }); + }); + app.post("/api/google/credentials", async (req, res) => { const body = req.body || {}; const clientId = String(body.clientId || "").trim(); diff --git a/lib/server/webhook-middleware.js b/lib/server/webhook-middleware.js index d68389e5..1928e47e 100644 --- a/lib/server/webhook-middleware.js +++ b/lib/server/webhook-middleware.js @@ -3,6 +3,8 @@ const https = require("https"); const { URL } = require("url"); const kRedactedHeaderKeys = new Set(["authorization", "cookie", "x-webhook-token"]); +const kGmailDedupeTtlMs = 24 * 60 * 60 * 1000; +const kGmailDedupeCleanupIntervalMs = 60 * 1000; const normalizeIp = (ip) => String(ip || "").replace(/^::ffff:/, ""); @@ -74,6 +76,51 @@ const resolveGatewayPath = ({ pathname, search }) => { return `${pathname}${search || ""}`; }; +const parseJsonSafe = (rawValue) => { + try { + return JSON.parse(String(rawValue || "").trim() || "{}"); + } catch { + return null; + } +}; + +const getGmailPayloadData = (parsedBody) => { + if (!parsedBody || typeof parsedBody !== "object") return null; + if (parsedBody.payload && typeof parsedBody.payload === "object") { + return parsedBody.payload; + } + return parsedBody; +}; + +const getGmailMessageId = (message = {}) => { + const preferredId = String(message?.id || "").trim(); + if (preferredId) return preferredId; + const fallbackId = String(message?.messageId || "").trim(); + return fallbackId; +}; + +const buildGmailDedupedBodyBuffer = ({ parsedBody, filteredMessages }) => { + if (parsedBody?.payload && typeof parsedBody.payload === "object") { + return Buffer.from( + JSON.stringify({ + ...parsedBody, + payload: { + ...parsedBody.payload, + messages: filteredMessages, + }, + }), + "utf8", + ); + } + return Buffer.from( + JSON.stringify({ + ...(parsedBody || {}), + messages: filteredMessages, + }), + "utf8", + ); +}; + const createWebhookMiddleware = ({ gatewayUrl, insertRequest, @@ -81,6 +128,18 @@ const createWebhookMiddleware = ({ }) => { const gateway = new URL(gatewayUrl); const protocolClient = gateway.protocol === "https:" ? https : http; + const gmailSeenMessageIds = new Map(); + let lastGmailDedupeCleanupAt = 0; + + const pruneGmailSeenMessageIds = (nowMs) => { + if (nowMs - lastGmailDedupeCleanupAt < kGmailDedupeCleanupIntervalMs) return; + for (const [messageKey, seenAt] of gmailSeenMessageIds.entries()) { + if (nowMs - seenAt > kGmailDedupeTtlMs) { + gmailSeenMessageIds.delete(messageKey); + } + } + lastGmailDedupeCleanupAt = nowMs; + }; return (req, res) => { const inboundUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); @@ -90,8 +149,47 @@ const createWebhookMiddleware = ({ inboundUrl.searchParams.delete("token"); } - const bodyBuffer = extractBodyBuffer(req); + let bodyBuffer = extractBodyBuffer(req); const hookName = resolveHookName(req); + + if (hookName === "gmail" && bodyBuffer.length > 0) { + const parsedBody = parseJsonSafe(bodyBuffer.toString("utf8")); + const payloadData = getGmailPayloadData(parsedBody); + const accountKey = String( + payloadData?.account || payloadData?.email || payloadData?.inbox || "unknown", + ) + .trim() + .toLowerCase(); + const messages = Array.isArray(payloadData?.messages) ? payloadData.messages : []; + if (messages.length > 0) { + const nowMs = Date.now(); + pruneGmailSeenMessageIds(nowMs); + const unseenMessages = []; + for (const message of messages) { + const messageId = getGmailMessageId(message); + if (!messageId) { + unseenMessages.push(message); + continue; + } + const dedupeKey = `${accountKey}:${messageId}`; + if (gmailSeenMessageIds.has(dedupeKey)) { + continue; + } + gmailSeenMessageIds.set(dedupeKey, nowMs); + unseenMessages.push(message); + } + if (unseenMessages.length === 0) { + return res.status(200).json({ ok: true, deduped: true }); + } + if (unseenMessages.length < messages.length && parsedBody) { + bodyBuffer = buildGmailDedupedBodyBuffer({ + parsedBody, + filteredMessages: unseenMessages, + }); + } + } + } + const sourceIp = normalizeIp( req.ip || req.headers["x-forwarded-for"] || req.socket?.remoteAddress || "", ); From b729c5652978d82e239d248dee695c2f26464def Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Tue, 3 Mar 2026 23:20:27 -0800 Subject: [PATCH 075/417] Refine Gmail incoming events row navigation and hover behavior. Make the Gmail row open webhook details, route webhook detail back behavior through browser history with fallback, and fix tooltip clipping by allowing overflow in the account row container. --- lib/public/js/app.js | 30 ++++++++++++++++++- .../js/components/google/account-row.js | 4 ++- .../components/google/gmail-watch-toggle.js | 21 +++++++++++-- lib/public/js/components/google/index.js | 7 ++++- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 05c35c4c..827afdbf 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -124,6 +124,7 @@ const GeneralTab = ({ onRefreshStatuses = () => {}, onSwitchTab, onNavigate, + onOpenGmailWebhook = () => {}, isActive, restartingGateway, onRestartGateway, @@ -308,6 +309,7 @@ const GeneralTab = ({ <${Google} gatewayStatus=${gatewayStatus} onRestartRequired=${onRestartRequired} + onOpenGmailWebhook=${onOpenGmailWebhook} /> ${repo && @@ -455,6 +457,7 @@ const App = () => { const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000); const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false); const menuRef = useRef(null); + const routeHistoryRef = useRef([]); const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, { enabled: onboarded === true, }); @@ -826,6 +829,16 @@ const App = () => { setBrowsePreviewPath(""); }, [location]); + useEffect(() => { + const historyStack = routeHistoryRef.current; + const lastEntry = historyStack[historyStack.length - 1]; + if (lastEntry === location) return; + historyStack.push(location); + if (historyStack.length > 100) { + historyStack.shift(); + } + }, [location]); + useEffect(() => { if (location.startsWith("/browse")) return; if (location === "/telegram") return; @@ -909,7 +922,21 @@ const App = () => { <${Webhooks} selectedHookName=${hookName} onSelectHook=${(name) => setLocation(`/webhooks/${encodeURIComponent(name)}`)} - onBackToList=${() => setLocation("/webhooks")} + onBackToList=${() => { + const historyStack = routeHistoryRef.current; + const hasPreviousRoute = historyStack.length > 1; + if (!hasPreviousRoute) { + setLocation("/webhooks"); + return; + } + const currentPath = getHashPath(); + window.history.back(); + window.setTimeout(() => { + if (getHashPath() === currentPath) { + setLocation("/webhooks"); + } + }, 180); + }} onRestartRequired=${setRestartRequired} onOpenFile=${(relativePath) => navigateToBrowseFile(String(relativePath || "").trim(), { view: "edit" })} @@ -1015,6 +1042,7 @@ const App = () => { onRefreshStatuses=${refreshSharedStatuses} onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} onNavigate=${navigateToSubScreen} + onOpenGmailWebhook=${() => setLocation("/webhooks/gmail")} isActive=${location === "/general"} restartingGateway=${restartingGateway} onRestartGateway=${handleGatewayRestart} diff --git a/lib/public/js/components/google/account-row.js b/lib/public/js/components/google/account-row.js index 51eec035..97abfd02 100644 --- a/lib/public/js/components/google/account-row.js +++ b/lib/public/js/components/google/account-row.js @@ -28,10 +28,11 @@ export const GoogleAccountRow = ({ onEnableGmailWatch, onDisableGmailWatch, onOpenGmailSetup, + onOpenGmailWebhook, }) => { const scopesChanged = !scopeListsEqual(scopes, savedScopes); return html` -
      +
      diff --git a/lib/public/js/components/google/gmail-watch-toggle.js b/lib/public/js/components/google/gmail-watch-toggle.js index 51351cfb..d2d27685 100644 --- a/lib/public/js/components/google/gmail-watch-toggle.js +++ b/lib/public/js/components/google/gmail-watch-toggle.js @@ -20,6 +20,7 @@ export const GmailWatchToggle = ({ busy = false, onEnable = () => {}, onDisable = () => {}, + onOpenWebhook = () => {}, }) => { const hasGmailReadScope = Array.isArray(account?.activeScopes) ? account.activeScopes.includes("gmail:read") @@ -40,15 +41,29 @@ export const GmailWatchToggle = ({ const state = resolveWatchState({ watchStatus, busy }); const enabled = Boolean(watchStatus?.enabled); return html` -
      +
      onOpenWebhook?.()} + onKeyDown=${(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onOpenWebhook?.(); + }} + >
      - 🔔 Gmail Watch + 🔔 Gmail <${InfoTooltip} text="Watches this inbox for new email events and routes them to your agent via the Gmail hook." widthClass="w-72" />
      -
      +
      event.stopPropagation()} + onKeyDown=${(event) => event.stopPropagation()} + > <${Badge} tone=${state.tone}>${state.label} <${ToggleSwitch} checked=${enabled} diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js index 4bcd1eaf..d614b1eb 100644 --- a/lib/public/js/components/google/index.js +++ b/lib/public/js/components/google/index.js @@ -29,7 +29,11 @@ const isPersonalAccount = (account = {}) => Boolean(account.personal); const kGoogleIconPath = "/assets/icons/google_icon.svg"; -export const Google = ({ gatewayStatus, onRestartRequired = () => {} }) => { +export const Google = ({ + gatewayStatus, + onRestartRequired = () => {}, + onOpenGmailWebhook = () => {}, +}) => { const { accounts, loading, @@ -492,6 +496,7 @@ export const Google = ({ gatewayStatus, onRestartRequired = () => {} }) => { onEnableGmailWatch=${handleEnableGmailWatch} onDisableGmailWatch=${handleDisableGmailWatch} onOpenGmailSetup=${openGmailSetupWizard} + onOpenGmailWebhook=${onOpenGmailWebhook} />`, )}
      From 489752aef3835b39435b10f992d94d118eb66b72 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 07:16:19 -0800 Subject: [PATCH 076/417] Decompose usage tab, add shared formatters, fix browse panel persistence and hourly sync. - Split monolithic usage-tab.js into feature folder (index, hook, sections, constants, formatters). - Add agent/source cost distribution to usage summary API and tests. - Extract shared date/time and currency formatters into lib/format.js; adopt in webhooks and git panel. - Add ac-surface-inset CSS class for nested surface treatments. - Fix browse git panel losing its drag position on tab switch (skip ResizeObserver clamp when layout hidden). - Fix hourly-git-sync.sh resolving REPO to .alphaclaw/ subdirectory instead of the repo root after internal-files migration. - Update AGENTS.md with code structure and shared formatting conventions. --- AGENTS.md | 39 +- lib/public/css/theme.css | 7 + lib/public/js/app.js | 2 +- lib/public/js/components/sidebar-git-panel.js | 11 +- lib/public/js/components/sidebar.js | 2 + lib/public/js/components/usage-tab.js | 539 ------------------ .../js/components/usage-tab/constants.js | 31 + .../js/components/usage-tab/formatters.js | 24 + lib/public/js/components/usage-tab/index.js | 72 +++ .../components/usage-tab/overview-section.js | 147 +++++ .../components/usage-tab/sessions-section.js | 175 ++++++ .../js/components/usage-tab/use-usage-tab.js | 241 ++++++++ lib/public/js/components/webhooks.js | 26 +- lib/public/js/lib/format.js | 71 +++ lib/server/usage-db.js | 169 ++++++ lib/setup/hourly-git-sync.sh | 2 +- tests/server/usage-db.test.js | 104 ++++ 17 files changed, 1085 insertions(+), 577 deletions(-) delete mode 100644 lib/public/js/components/usage-tab.js create mode 100644 lib/public/js/components/usage-tab/constants.js create mode 100644 lib/public/js/components/usage-tab/formatters.js create mode 100644 lib/public/js/components/usage-tab/index.js create mode 100644 lib/public/js/components/usage-tab/overview-section.js create mode 100644 lib/public/js/components/usage-tab/sessions-section.js create mode 100644 lib/public/js/components/usage-tab/use-usage-tab.js create mode 100644 lib/public/js/lib/format.js diff --git a/AGENTS.md b/AGENTS.md index e3664c19..0094ab4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,5 @@ +## Project Overview + ### AlphaClaw Project Context AlphaClaw is the ops and setup layer around OpenClaw. It provides a browser-based setup UI, gateway lifecycle management, watchdog recovery flows, and integrations (for example Telegram, Discord, Google Workspace, and webhooks) so users can operate OpenClaw without manual server intervention. @@ -24,7 +26,9 @@ Runtime model: - Preact + `htm` frontend patterns for Setup UI components. - Vitest + Supertest for server and route testing. -### Coding And Change Patterns +## Coding Conventions + +### Change Patterns - Keep edits targeted and production-safe; favor small, reviewable changes. - Preserve existing behavior unless the task explicitly requires behavior changes. @@ -33,11 +37,19 @@ Runtime model: - Update tests when behavior changes in routes, watchdog flows, or setup state. - Before running tests in a fresh checkout, run `npm install` so `vitest` (devDependency) is available for `npm test`. +### Code Structure + +- Avoid monolithic implementation files for new features. For new UI areas and new API areas, start with a decomposed structure (focused components/hooks/utilities for UI; focused route modules/services/helpers for server) rather than building one large file first and splitting later. +- When adding a new feature area, follow the existing project patterns from day one (for example feature folders with `index.js` plus `use-*` hooks in UI, and route + service separation on server) so code stays maintainable as the feature grows. +- When continuing to build on a file that is growing large or accumulating unrelated concerns, stop and decompose it before adding more code rather than letting it drift into a monolith. + ### Where To Put Agent Guidance - **This file (`AGENTS.md`):** Project-level guidance for coding agents working on the AlphaClaw codebase — architecture, conventions, release flow, UI patterns, etc. - **`lib/setup/core-prompts/AGENTS.md`:** Runtime prompt injected into the OpenClaw agent's system prompt. Only write there when the guidance is meant for the deployed agent's behavior, not for coding on this project. +## Operations + ### Release Flow (Beta -> Production) Use this release flow when promoting tested beta builds to production: @@ -95,21 +107,19 @@ Use this format for any Telegram notices sent from AlphaClaw services (watchdog, - `Trigger: \`crash_loop\`` 6. Do not use HTML tags (``, ``) for Telegram watchdog notices. -### UI Conventions +## UI Conventions Use these conventions for all UI work under `lib/public/js` and `lib/public/css`. -#### Component structure +### Component structure - Use arrow-function components and helpers. - Prefer shared components over one-off markup when a pattern already exists. - Keep constants in `kName` format (e.g. `kUiTabs`, `kGroupOrder`, `kNamePattern`). - Keep component-level helpers near the top of the file, before the main export. -- Avoid monolithic component files. Prefer breaking large features into focused child components and hooks. -- Preferred pattern for non-trivial UI features: a feature folder with `index.js` for rendering/composition and `use-*.js` for business logic/state/effects/handlers. - Treat `index.js` as a presentational shell whenever possible: keep business logic in hooks and pass derived state/actions down as props. -#### Rendering and composition +### Rendering and composition - Use the `htm` + `preact` pattern: - `const html = htm.bind(h);` @@ -117,8 +127,10 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css` - Prefer early return for hidden states (e.g. `if (!visible) return null;`). - Use `` for tab/page headers that need a title and right-side actions. - Use card shells consistently: `bg-surface border border-border rounded-xl`. +- For nested "surface on surface" blocks (content inside a `bg-surface` card), use `ac-surface-inset` for the inner container treatment so inset sections match shared history/sessions styling. +- For internal section dividers, use `border-t border-border` (avoid opacity variants) with comfortable vertical spacing around the divider. -#### Buttons +### Buttons - Primary actions: `ac-btn-cyan` - Secondary actions: `ac-btn-secondary` @@ -132,7 +144,7 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css` - In setup/onboarding auth flows (e.g. Codex OAuth), prefer `` over raw ` -
      +const renderCommandBlock = (command = "", onCopy = () => {}) => html` +
      +
      +${command}
      +
      +
      - `; +
      +`; export const GmailSetupWizard = ({ visible = false, @@ -67,6 +77,13 @@ export const GmailSetupWizard = ({ const [projectIdInput, setProjectIdInput] = useState(""); const [localError, setLocalError] = useState(""); const [projectIdResolved, setProjectIdResolved] = useState(false); + const [watchEnabled, setWatchEnabled] = useState(false); + const [sendingToAgent, setSendingToAgent] = useState(false); + const [agentMessageSent, setAgentMessageSent] = useState(false); + const [agentSessions, setAgentSessions] = useState([]); + const [selectedSessionKey, setSelectedSessionKey] = useState(""); + const [loadingAgentSessions, setLoadingAgentSessions] = useState(false); + const [agentSessionsError, setAgentSessionsError] = useState(""); useEffect(() => { if (!visible) return; @@ -74,6 +91,44 @@ export const GmailSetupWizard = ({ setLocalError(""); setProjectIdInput(""); setProjectIdResolved(false); + setWatchEnabled(false); + setSendingToAgent(false); + setAgentMessageSent(false); + setAgentSessions([]); + setSelectedSessionKey(""); + setLoadingAgentSessions(false); + setAgentSessionsError(""); + }, [visible, account?.id]); + + useEffect(() => { + if (!visible) return; + let active = true; + const loadAgentSessions = async () => { + try { + setLoadingAgentSessions(true); + setAgentSessionsError(""); + const data = await fetchAgentSessions(); + if (!active) return; + const sessions = Array.isArray(data?.sessions) ? data.sessions : []; + setAgentSessions(sessions); + const defaultSession = sessions.find((sessionRow) => { + const key = String(sessionRow?.key || "").toLowerCase(); + return key.includes(":direct:") || key.includes(":group:"); + }); + setSelectedSessionKey((currentKey) => currentKey || String(defaultSession?.key || "")); + } catch (err) { + if (!active) return; + setAgentSessions([]); + setSelectedSessionKey(""); + setAgentSessionsError(err.message || "Could not load sessions"); + } finally { + if (active) setLoadingAgentSessions(false); + } + }; + loadAgentSessions(); + return () => { + active = false; + }; }, [visible, account?.id]); const commands = clientConfig?.commands || null; @@ -85,7 +140,9 @@ export const GmailSetupWizard = ({ String(clientConfig?.projectId || "").trim() || String(projectIdInput || "").trim() || ""; - const client = String(account?.client || clientConfig?.client || "default").trim() || "default"; + const client = + String(account?.client || clientConfig?.client || "default").trim() || + "default"; const canAdvance = useMemo(() => { if (needsProjectId) { @@ -93,6 +150,14 @@ export const GmailSetupWizard = ({ } return true; }, [needsProjectId, projectIdInput]); + const selectableAgentSessions = useMemo( + () => + agentSessions.filter((sessionRow) => { + const key = String(sessionRow?.key || "").toLowerCase(); + return key.includes(":direct:") || key.includes(":group:"); + }), + [agentSessions], + ); const handleCopy = async (value) => { const ok = await copyText(value); @@ -105,10 +170,13 @@ export const GmailSetupWizard = ({ const handleFinish = async () => { try { + setLocalError(""); await onFinish({ client, projectId: String(projectIdInput || "").trim(), }); + setWatchEnabled(true); + setStep((prev) => Math.min(prev + 1, kTotalSteps - 1)); } catch (err) { setLocalError(err.message || "Could not finish setup"); } @@ -134,6 +202,28 @@ export const GmailSetupWizard = ({ setStep((prev) => Math.min(prev + 1, kTotalSteps - 1)); }; + const handleSendToAgent = async () => { + if (sendingToAgent || agentMessageSent) return; + try { + setSendingToAgent(true); + const accountEmail = String(account?.email || "this account").trim() || "this account"; + const message = + `I just enabled Gmail watch for "${accountEmail}", set up the webhook, ` + + `and created the transform file. Help me set up what I want to do ` + + `with incoming email.`; + await sendAgentMessage({ + message, + sessionKey: selectedSessionKey, + }); + setAgentMessageSent(true); + showToast("Message sent to your agent", "success"); + } catch (err) { + showToast(err.message || "Could not send message to agent", "error"); + } finally { + setSendingToAgent(false); + } + }; + return html` <${ModalShell} visible=${visible} @@ -167,113 +257,193 @@ export const GmailSetupWizard = ({ actions=${null} /> ${localError ? html`
      ${localError}
      ` : null} - ${needsProjectId - ? html` -
      -
      Project ID required
      -
      - Find it in the${" "} - - Google Cloud Console Project Selector - + ${ + needsProjectId + ? html` +
      +
      Project ID required
      + + setProjectIdInput(event.target.value)} + class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none" + placeholder="my-gcp-project" + />
      - setProjectIdInput(event.target.value)} - class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none" - placeholder="my-gcp-project" - /> -
      - ` - : null} - ${!needsProjectId && step === 0 - ? html` -
      -
      - If gcloud is not installed on your computer, follow the official install guide:${" "} - - Google Cloud SDK install docs - + ` + : null + } + ${ + !needsProjectId && step === 0 + ? html` +
      +
      + If gcloud is not installed on your computer, + follow the official install guide:${" "} + + Google Cloud SDK install docs + +
      -
      - ${renderCommandBlock( - `gcloud auth login\n` + - `gcloud config set project ${detectedProjectId}`, - () => - handleCopy( - `gcloud auth login\n` + - `gcloud config set project ${detectedProjectId}`, - ), - )} - ` - : null} - ${!needsProjectId && step === 1 - ? renderCommandBlock(commands?.enableApis || "", () => handleCopy(commands?.enableApis || "")) - : null} - ${!needsProjectId && step === 2 - ? html` - ${renderCommandBlock( - `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(), - () => - handleCopy( - `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(), - ), - )} - ` - : null} - ${!needsProjectId && step === 3 - ? renderCommandBlock( - commands?.createSubscription || "", - () => handleCopy(commands?.createSubscription || ""), - ) - : null} - ${step === 4 - ? html` -
      -
      Ready to enable watch
      -
      - Click finish to save setup for this client and enable Gmail watch for this account. + ${renderCommandBlock( + `gcloud auth login\n` + + `gcloud config set project ${detectedProjectId}`, + () => + handleCopy( + `gcloud auth login\n` + + `gcloud config set project ${detectedProjectId}`, + ), + )} + ` + : null + } + ${ + !needsProjectId && step === 1 + ? renderCommandBlock(commands?.enableApis || "", () => + handleCopy(commands?.enableApis || ""), + ) + : null + } + ${ + !needsProjectId && step === 2 + ? html` + ${renderCommandBlock( + `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(), + () => + handleCopy( + `${commands?.createTopic || ""}\n\n${commands?.grantPublisher || ""}`.trim(), + ), + )} + ` + : null + } + ${ + !needsProjectId && step === 3 + ? renderCommandBlock(commands?.createSubscription || "", () => + handleCopy(commands?.createSubscription || ""), + ) + : null + } + ${ + step === 4 + ? html` +
      +
      +
      Continue with your agent
      +
      + Tell your OpenClaw agent about what you want to build with + incoming email to continue the setup. +
      +
      +
      Send this to session
      +
      + + <${ActionButton} + onClick=${handleSendToAgent} + disabled=${!selectedSessionKey || agentMessageSent} + loading=${sendingToAgent} + loadingMode="inline" + idleLabel=${agentMessageSent ? "Sent" : "Send to Agent"} + loadingLabel="Sending..." + tone="primary" + size="sm" + className="h-[34px] px-3" + /> +
      + ${loadingAgentSessions + ? html`
      Loading sessions...
      ` + : null} + ${agentSessionsError + ? html`
      ${agentSessionsError}
      ` + : null} +
      +
      -
      - ` - : null} + ` + : null + }
      - - ${step < kTotalSteps - 1 - ? html`` - : html`
      ` + : html`<${ActionButton} + onClick=${() => setStep((prev) => Math.max(prev - 1, 0))} disabled=${saving} - class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan disabled:opacity-50 disabled:cursor-not-allowed" - > - ${saving ? "Finishing..." : "Finish Setup"} - `} + idleLabel="Back" + tone="secondary" + size="md" + className="w-full justify-center" + />`} + ${ + step < kTotalSteps - 2 + ? html`<${ActionButton} + onClick=${handleNext} + disabled=${saving || (needsProjectId && !canAdvance)} + idleLabel="Next" + tone="primary" + size="md" + className="w-full justify-center" + />` + : step === kTotalSteps - 2 + ? html`<${ActionButton} + onClick=${handleFinish} + disabled=${false} + loading=${saving} + idleLabel="Enable watch" + loadingLabel="Enabling..." + tone="primary" + size="md" + className="w-full justify-center" + />` + : html`<${ActionButton} + onClick=${onClose} + disabled=${saving || sendingToAgent} + idleLabel="Done" + tone="secondary" + size="md" + className="w-full justify-center" + />` + }
      `; diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js index d614b1eb..3278ab73 100644 --- a/lib/public/js/components/google/index.js +++ b/lib/public/js/components/google/index.js @@ -387,7 +387,6 @@ export const Google = ({ }); await startWatchForAccount(accountId); showToast("Gmail setup complete and watch enabled", "success"); - closeGmailSetupWizard(); }; const renderEmptyState = () => html` diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 9a3c8834..7d1b1d8a 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -193,6 +193,26 @@ export const renewGmailWatch = async ({ return parseJsonOrThrow(res, "Could not renew Gmail watch"); }; +export const fetchAgentSessions = async () => { + const res = await authFetch("/api/agent/sessions"); + return parseJsonOrThrow(res, "Could not load agent sessions"); +}; + +export const sendAgentMessage = async ({ + message = "", + sessionKey = "", +} = {}) => { + const res = await authFetch("/api/agent/message", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: String(message || ""), + sessionKey: String(sessionKey || ""), + }), + }); + return parseJsonOrThrow(res, "Could not send message to agent"); +}; + export async function restartGateway() { const res = await authFetch('/api/gateway/restart', { method: 'POST' }); return parseJsonOrThrow(res, 'Could not restart gateway'); diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index 28e7ced0..43b346f8 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -50,6 +50,63 @@ const registerSystemRoutes = ({ `${schedule} root bash "${kSystemCronScriptPath}" >> /var/log/openclaw-hourly-sync.log 2>&1`, "", ].join("\n"); + const shellEscapeArg = (value) => { + const safeValue = String(value || ""); + return `'${safeValue.replace(/'/g, `'\\''`)}'`; + }; + const parseJsonFromStdout = (stdout) => { + const raw = String(stdout || "").trim(); + if (!raw) return null; + const candidateStarts = [raw.indexOf("{"), raw.indexOf("[")].filter((idx) => idx >= 0); + for (const start of candidateStarts) { + const candidate = raw.slice(start); + try { + return JSON.parse(candidate); + } catch {} + } + return null; + }; + const buildSessionLabel = (sessionRow = {}) => { + const key = String(sessionRow?.key || ""); + if (key === "agent:main:main") return "Main agent thread"; + const telegramMatch = key.match(/:telegram:direct:([^:]+)$/); + if (telegramMatch) { + return `Telegram ${telegramMatch[1]}`; + } + const directMatch = key.match(/:direct:([^:]+)$/); + if (directMatch) { + return `Direct ${directMatch[1]}`; + } + return key || "Session"; + }; + const listSendableAgentSessions = async () => { + const result = await clawCmd("sessions --json", { quiet: true }); + if (!result.ok) { + throw new Error(result.stderr || "Could not load agent sessions"); + } + const payload = parseJsonFromStdout(result.stdout); + const sessions = Array.isArray(payload?.sessions) ? payload.sessions : []; + return sessions + .filter((sessionRow) => { + const key = String(sessionRow?.key || "").toLowerCase(); + if (!key) return false; + if (key.includes(":hook:") || key.includes(":cron:")) return false; + return true; + }) + .map((sessionRow) => { + const key = String(sessionRow?.key || ""); + const telegramMatch = key.match(/:telegram:direct:([^:]+)$/); + return { + key, + sessionId: String(sessionRow?.sessionId || ""), + updatedAt: Number(sessionRow?.updatedAt) || 0, + label: buildSessionLabel(sessionRow), + replyChannel: telegramMatch ? "telegram" : "", + replyTo: telegramMatch ? String(telegramMatch[1] || "") : "", + }; + }) + .sort((a, b) => b.updatedAt - a.updatedAt); + }; const readSystemCronConfig = () => { try { const raw = fs.readFileSync(kSystemCronConfigPath, "utf8"); @@ -258,6 +315,56 @@ const registerSystemRoutes = ({ res.json(result); }); + app.get("/api/agent/sessions", async (req, res) => { + try { + const sessions = await listSendableAgentSessions(); + return res.json({ ok: true, sessions }); + } catch (err) { + return res.status(502).json({ ok: false, error: err.message }); + } + }); + + app.post("/api/agent/message", async (req, res) => { + const rawMessage = String(req.body?.message || ""); + const message = rawMessage.trim(); + const sessionKey = String(req.body?.sessionKey || "").trim(); + if (!message) { + return res.status(400).json({ ok: false, error: "message is required" }); + } + if (message.length > 4000) { + return res + .status(400) + .json({ ok: false, error: "message must be 4000 characters or fewer" }); + } + let command = `agent --agent main --message ${shellEscapeArg(message)}`; + if (sessionKey) { + let selectedSession = null; + try { + const sessions = await listSendableAgentSessions(); + selectedSession = sessions.find((sessionRow) => sessionRow.key === sessionKey) || null; + } catch (err) { + return res.status(502).json({ ok: false, error: err.message }); + } + if (!selectedSession) { + return res.status(400).json({ ok: false, error: "Selected session was not found" }); + } + if (selectedSession.replyChannel && selectedSession.replyTo) { + command += + ` --deliver --reply-channel ${shellEscapeArg(selectedSession.replyChannel)}` + + ` --reply-to ${shellEscapeArg(selectedSession.replyTo)}`; + } else if (selectedSession.sessionId) { + command += ` --session-id ${shellEscapeArg(selectedSession.sessionId)}`; + } + } + const result = await clawCmd(command, { quiet: true }); + if (!result.ok) { + return res + .status(502) + .json({ ok: false, error: result.stderr || "Could not send message to agent" }); + } + return res.json({ ok: true, stdout: result.stdout || "" }); + }); + app.get("/api/gateway/dashboard", async (req, res) => { if (!isOnboarded()) return res.json({ ok: false, url: "/openclaw" }); const result = await clawCmd("dashboard --no-open"); From 195762be6ca1c32cb90cd2c0772b3415902df773 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 10:20:07 -0800 Subject: [PATCH 078/417] 0.4.1-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b3cf288..4a072f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.0", + "version": "0.4.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.0", + "version": "0.4.1-beta.0", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index d7838453..4c6dd73a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.0", + "version": "0.4.1-beta.0", "publishConfig": { "access": "public" }, From 14629175ee5b83faa519267566de290e69bc568e Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 10:23:35 -0800 Subject: [PATCH 079/417] Update frontend API tests for authFetch Headers behavior. Adjust fetch call assertions to expect the Headers instance now attached by authFetch and verify Content-Type through Headers accessors where request bodies are JSON. --- tests/frontend/api.test.js | 72 +++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index ccb75d98..4446f42c 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -8,6 +8,17 @@ const mockJsonResponse = (status, payload) => ({ }); describe("frontend/api", () => { + const expectLastFetchHeaders = (expectedContentType = "") => { + const callArgs = global.fetch.mock.calls[global.fetch.mock.calls.length - 1] || []; + const options = callArgs[1] || {}; + const headers = options.headers; + expect(headers).toBeInstanceOf(Headers); + if (expectedContentType) { + expect(headers.get("Content-Type")).toBe(expectedContentType); + } + return { callArgs, options, headers }; + }; + beforeEach(() => { global.fetch = vi.fn(); global.window = { location: { href: "http://localhost/" } }; @@ -20,7 +31,10 @@ describe("frontend/api", () => { const result = await api.fetchStatus(); - expect(global.fetch).toHaveBeenCalledWith("/api/status", {}); + expect(global.fetch).toHaveBeenCalledWith( + "/api/status", + expect.objectContaining({ headers: expect.any(Headers) }), + ); expect(result).toEqual(payload); expect(window.location.href).toBe("http://localhost/"); }); @@ -41,11 +55,15 @@ describe("frontend/api", () => { const result = await api.runOnboard(vars, modelKey); - expect(global.fetch).toHaveBeenCalledWith("/api/onboard", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ vars, modelKey }), - }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/onboard", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ vars, modelKey }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); expect(result).toEqual({ ok: true }); }); @@ -56,11 +74,15 @@ describe("frontend/api", () => { const result = await api.saveEnvVars(vars); - expect(global.fetch).toHaveBeenCalledWith("/api/env", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ vars }), - }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/env", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ vars }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); expect(result).toEqual({ ok: true, changed: true }); }); @@ -79,7 +101,10 @@ describe("frontend/api", () => { const result = await api.fetchUsageSummary(90); - expect(global.fetch).toHaveBeenCalledWith("/api/usage/summary?days=90", {}); + expect(global.fetch).toHaveBeenCalledWith( + "/api/usage/summary?days=90", + expect.objectContaining({ headers: expect.any(Headers) }), + ); expect(result).toEqual({ ok: true, summary: { daily: [] } }); }); @@ -89,7 +114,10 @@ describe("frontend/api", () => { const result = await api.fetchUsageSessions(100); - expect(global.fetch).toHaveBeenCalledWith("/api/usage/sessions?limit=100", {}); + expect(global.fetch).toHaveBeenCalledWith( + "/api/usage/sessions?limit=100", + expect.objectContaining({ headers: expect.any(Headers) }), + ); expect(result).toEqual({ ok: true, sessions: [] }); }); @@ -101,7 +129,7 @@ describe("frontend/api", () => { expect(global.fetch).toHaveBeenCalledWith( "/api/usage/sessions/agent%3Amain%3Atelegram%3Agroup%3A-1%3Atopic%3A2", - {}, + expect.objectContaining({ headers: expect.any(Headers) }), ); expect(result).toEqual({ ok: true, detail: { sessionId: "x" } }); }); @@ -112,11 +140,15 @@ describe("frontend/api", () => { const result = await api.syncBrowseChanges("sync changes"); - expect(global.fetch).toHaveBeenCalledWith("/api/browse/git-sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "sync changes" }), - }); + expect(global.fetch).toHaveBeenCalledWith( + "/api/browse/git-sync", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ message: "sync changes" }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); expect(result).toEqual({ ok: true, committed: true }); }); @@ -128,7 +160,7 @@ describe("frontend/api", () => { expect(global.fetch).toHaveBeenCalledWith( "/api/browse/git-diff?path=workspace%2Fhooks%2Fbootstrap%2FAGENTS.md", - {}, + expect.objectContaining({ headers: expect.any(Headers) }), ); expect(result).toEqual({ ok: true, content: "diff --git" }); }); From 8da19405553723961bda1ba84ff6e0b3d07ef212 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 11:25:18 -0800 Subject: [PATCH 080/417] Standardize webhook token handling and harden config scrubbing. Use WEBHOOK_TOKEN consistently across webhook and Gmail watch flows, prevent partial secret substitutions in openclaw.json, and auto-expand newly added Google accounts awaiting sign-in. --- lib/public/js/components/google/index.js | 131 +++++++++++------- lib/public/js/components/sidebar.js | 2 +- .../telegram-workspace/onboarding.js | 2 +- lib/public/js/components/webhooks.js | 4 +- lib/server/constants.js | 1 - lib/server/gmail-serve.js | 2 +- lib/server/gmail-watch.js | 36 ++--- lib/server/onboarding/openclaw.js | 10 +- lib/server/routes/webhooks.js | 8 +- lib/server/webhooks.js | 2 +- tests/server/onboarding-openclaw.test.js | 44 ++++++ 11 files changed, 153 insertions(+), 89 deletions(-) create mode 100644 tests/server/onboarding-openclaw.test.js diff --git a/lib/public/js/components/google/index.js b/lib/public/js/components/google/index.js index 3278ab73..aad94e4b 100644 --- a/lib/public/js/components/google/index.js +++ b/lib/public/js/components/google/index.js @@ -1,5 +1,10 @@ import { h } from "https://esm.sh/preact"; -import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import { + useCallback, + useEffect, + useMemo, + useState, +} from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { checkGoogleApis, @@ -34,12 +39,8 @@ export const Google = ({ onRestartRequired = () => {}, onOpenGmailWebhook = () => {}, }) => { - const { - accounts, - loading, - hasCompanyCredentials, - refreshAccounts, - } = useGoogleAccounts({ gatewayStatus }); + const { accounts, loading, hasCompanyCredentials, refreshAccounts } = + useGoogleAccounts({ gatewayStatus }); const [expandedAccountId, setExpandedAccountId] = useState(""); const [scopesByAccountId, setScopesByAccountId] = useState({}); const [savedScopesByAccountId, setSavedScopesByAccountId] = useState({}); @@ -90,12 +91,16 @@ export const Google = ({ ); const ensureScopesForAccount = useCallback((account) => { - const nextScopes = Array.isArray(account.activeScopes) && account.activeScopes.length - ? account.activeScopes - : Array.isArray(account.services) && account.services.length - ? account.services - : getDefaultScopes(); - setSavedScopesByAccountId((prev) => ({ ...prev, [account.id]: [...nextScopes] })); + const nextScopes = + Array.isArray(account.activeScopes) && account.activeScopes.length + ? account.activeScopes + : Array.isArray(account.services) && account.services.length + ? account.services + : getDefaultScopes(); + setSavedScopesByAccountId((prev) => ({ + ...prev, + [account.id]: [...nextScopes], + })); setScopesByAccountId((prev) => { const current = prev[account.id]; if (!current || !hasScopesChanged(current, nextScopes)) { @@ -125,7 +130,10 @@ export const Google = ({ (accountId) => { const account = getAccountById(accountId); if (!account) return; - const scopes = scopesByAccountId[accountId] || account.activeScopes || getDefaultScopes(); + const scopes = + scopesByAccountId[accountId] || + account.activeScopes || + getDefaultScopes(); if (!scopes.length) { window.alert("Select at least one service"); return; @@ -160,7 +168,10 @@ export const Google = ({ try { const data = await checkGoogleApis(accountId); if (data.results) { - setApiStatusByAccountId((prev) => ({ ...prev, [accountId]: data.results })); + setApiStatusByAccountId((prev) => ({ + ...prev, + [accountId]: data.results, + })); } } finally { setCheckingByAccountId((prev) => { @@ -184,7 +195,10 @@ export const Google = ({ await handleCheckApis(accountId); } } else if (event.data?.google === "error") { - showToast(`✗ Google auth failed: ${event.data.message || "unknown"}`, "error"); + showToast( + `✗ Google auth failed: ${event.data.message || "unknown"}`, + "error", + ); } }; window.addEventListener("message", handler); @@ -249,6 +263,9 @@ export const Google = ({ }; const handleCredentialsSaved = async (account) => { + if (account?.id) { + setExpandedAccountId(account.id); + } await refreshAccounts(); if (account?.id) startAuth(account.id); }; @@ -267,6 +284,9 @@ export const Google = ({ return; } setAddCompanyModalOpen(false); + if (data.accountId) { + setExpandedAccountId(data.accountId); + } await refreshAccounts(); if (data.accountId) startAuth(data.accountId); } finally { @@ -304,7 +324,7 @@ export const Google = ({ const account = getAccountById(accountId); if (!account) return; const personal = isPersonalAccount(account); - const client = personal ? "personal" : (account.client || "default"); + const client = personal ? "personal" : account.client || "default"; let credentialValues = {}; try { const credentialResponse = await fetchGoogleCredentials({ @@ -390,22 +410,23 @@ export const Google = ({ }; const renderEmptyState = () => html` -
      +
      -
      +
      Google logo

      - Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and Meet. + Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and + Meet.

      -
      +
      <${ActionButton} onClick=${handleAddCompanyClick} tone="primary" @@ -469,34 +490,46 @@ export const Google = ({ `} /> ${loading - ? html`
      Loading...
      ` + ? html`
      + Loading... +
      ` : accounts.length ? html`
      - ${accounts.map((account) => - html`<${GoogleAccountRow} - key=${account.id} - account=${account} - personal=${isPersonalAccount(account)} - expanded=${expandedAccountId === account.id} - onToggleExpanded=${(accountId) => - setExpandedAccountId((prev) => (prev === accountId ? "" : accountId))} - scopes=${scopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} - savedScopes=${savedScopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()} - apiStatus=${apiStatusByAccountId[account.id] || {}} - checkingApis=${expandedAccountId === account.id && Boolean(checkingByAccountId[account.id])} - onToggleScope=${handleToggleScope} - onCheckApis=${handleCheckApis} - onUpdatePermissions=${(accountId) => startAuth(accountId)} - onEditCredentials=${handleEditCredentials} - onDisconnect=${(accountId) => setDisconnectAccountId(accountId)} - gmailWatchStatus=${watchByAccountId.get(account.id) || null} - gmailWatchBusy=${Boolean(busyByAccountId[account.id])} - onEnableGmailWatch=${handleEnableGmailWatch} - onDisableGmailWatch=${handleDisableGmailWatch} - onOpenGmailSetup=${openGmailSetupWizard} - onOpenGmailWebhook=${onOpenGmailWebhook} - />`, + ${accounts.map( + (account) => + html`<${GoogleAccountRow} + key=${account.id} + account=${account} + personal=${isPersonalAccount(account)} + expanded=${expandedAccountId === account.id} + onToggleExpanded=${(accountId) => + setExpandedAccountId((prev) => + prev === accountId ? "" : accountId, + )} + scopes=${scopesByAccountId[account.id] || + account.activeScopes || + getDefaultScopes()} + savedScopes=${savedScopesByAccountId[account.id] || + account.activeScopes || + getDefaultScopes()} + apiStatus=${apiStatusByAccountId[account.id] || {}} + checkingApis=${expandedAccountId === account.id && + Boolean(checkingByAccountId[account.id])} + onToggleScope=${handleToggleScope} + onCheckApis=${handleCheckApis} + onUpdatePermissions=${(accountId) => startAuth(accountId)} + onEditCredentials=${handleEditCredentials} + onDisconnect=${(accountId) => + setDisconnectAccountId(accountId)} + gmailWatchStatus=${watchByAccountId.get(account.id) || + null} + gmailWatchBusy=${Boolean(busyByAccountId[account.id])} + onEnableGmailWatch=${handleEnableGmailWatch} + onDisableGmailWatch=${handleDisableGmailWatch} + onOpenGmailSetup=${openGmailSetupWizard} + onOpenGmailWebhook=${onOpenGmailWebhook} + />`, )}
      ` @@ -528,7 +561,9 @@ export const Google = ({ visible=${gmailWizardState.visible} account=${getAccountById(gmailWizardState.accountId)} clientConfig=${clientConfigByClient.get( - String(getAccountById(gmailWizardState.accountId)?.client || "default").trim() || "default", + String( + getAccountById(gmailWizardState.accountId)?.client || "default", + ).trim() || "default", ) || null} saving=${savingClient || gmailLoading} onClose=${closeGmailSetupWizard} diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index a4bf46b6..e17cf137 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -12,7 +12,7 @@ const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx"; const kBrowsePanelMinHeightPx = 120; const kBrowseBottomMinHeightPx = 120; const kBrowseResizerHeightPx = 6; -const kDefaultBrowseBottomPanelHeightPx = 160; +const kDefaultBrowseBottomPanelHeightPx = 260; const readStoredBrowseBottomPanelHeight = () => { try { diff --git a/lib/public/js/components/telegram-workspace/onboarding.js b/lib/public/js/components/telegram-workspace/onboarding.js index 61be7282..59ae12e1 100644 --- a/lib/public/js/components/telegram-workspace/onboarding.js +++ b/lib/public/js/components/telegram-workspace/onboarding.js @@ -116,7 +116,7 @@ export const CreateGroupStep = ({ onNext, onBack }) => html`

      Create the group

      1. - Open Telegram and create a + Open Telegram and create a${" "} new group
      2. diff --git a/lib/public/js/components/webhooks.js b/lib/public/js/components/webhooks.js index 918ec516..edae7cae 100644 --- a/lib/public/js/components/webhooks.js +++ b/lib/public/js/components/webhooks.js @@ -232,7 +232,7 @@ export const Webhooks = ({ selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`; const webhookUrlWithQueryToken = selectedWebhook?.queryStringUrl || - `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=`; + `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=`; const derivedTokenFromQuery = (() => { try { const parsed = new URL(webhookUrlWithQueryToken); @@ -245,7 +245,7 @@ export const Webhooks = ({ selectedWebhook?.authHeaderValue || (derivedTokenFromQuery ? `Authorization: Bearer ${derivedTokenFromQuery}` - : "Authorization: Bearer "); + : "Authorization: Bearer "); const bearerTokenValue = authHeaderValue.startsWith("Authorization: ") ? authHeaderValue.slice("Authorization: ".length) : authHeaderValue; diff --git a/lib/server/constants.js b/lib/server/constants.js index d76e7c84..50e72f2d 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -147,7 +147,6 @@ const kLogMaxBytes = parsePositiveIntEnv( const kSystemVars = new Set([ "WEBHOOK_TOKEN", - "OPENCLAW_HOOKS_TOKEN", "OPENCLAW_GATEWAY_TOKEN", "SETUP_PASSWORD", "PORT", diff --git a/lib/server/gmail-serve.js b/lib/server/gmail-serve.js index 3b376f0a..129c149b 100644 --- a/lib/server/gmail-serve.js +++ b/lib/server/gmail-serve.js @@ -110,7 +110,7 @@ const createGmailServeManager = ({ } const token = String(webhookToken || "").trim(); if (!token) { - throw new Error("OPENCLAW_HOOKS_TOKEN is required to start Gmail watch serve"); + throw new Error("WEBHOOK_TOKEN is required to start Gmail watch serve"); } const args = buildArgs({ account, port: normalizedPort, webhookToken: token }); diff --git a/lib/server/gmail-watch.js b/lib/server/gmail-watch.js index 76369ef8..8b5ca947 100644 --- a/lib/server/gmail-watch.js +++ b/lib/server/gmail-watch.js @@ -203,39 +203,19 @@ const createGmailWatchService = ({ }; const ensureWebhookToken = () => { - const existing = String( - process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "", - ).trim(); + const existing = String(process.env.WEBHOOK_TOKEN || "").trim(); if (existing) return { token: existing, changed: false }; const vars = readEnvFile(); - const tokenFromOpenclawHooks = String( - vars.find((entry) => entry.key === "OPENCLAW_HOOKS_TOKEN")?.value || "", - ).trim(); - const tokenFromLegacyWebhook = String( + const tokenFromFile = String( vars.find((entry) => entry.key === "WEBHOOK_TOKEN")?.value || "", ).trim(); - const tokenFromFile = tokenFromOpenclawHooks || tokenFromLegacyWebhook; if (tokenFromFile) { - process.env.OPENCLAW_HOOKS_TOKEN = tokenFromFile; - if (!process.env.WEBHOOK_TOKEN) { - process.env.WEBHOOK_TOKEN = tokenFromFile; - } - if (!tokenFromOpenclawHooks) { - const nextVars = vars.filter( - (entry) => entry.key !== "OPENCLAW_HOOKS_TOKEN", - ); - nextVars.push({ key: "OPENCLAW_HOOKS_TOKEN", value: tokenFromFile }); - writeEnvFile(nextVars); - reloadEnv(); - return { token: tokenFromFile, changed: true }; - } + process.env.WEBHOOK_TOKEN = tokenFromFile; return { token: tokenFromFile, changed: false }; } const token = generatePushToken(); - const nextVars = vars.filter( - (entry) => entry.key !== "OPENCLAW_HOOKS_TOKEN", - ); - nextVars.push({ key: "OPENCLAW_HOOKS_TOKEN", value: token }); + const nextVars = vars.filter((entry) => entry.key !== "WEBHOOK_TOKEN"); + nextVars.push({ key: "WEBHOOK_TOKEN", value: token }); writeEnvFile(nextVars); reloadEnv(); return { token, changed: true }; @@ -258,7 +238,7 @@ const createGmailWatchService = ({ changed = true; } if (typeof cfg.hooks.token !== "string" || !cfg.hooks.token.trim()) { - cfg.hooks.token = "${OPENCLAW_HOOKS_TOKEN}"; + cfg.hooks.token = "${WEBHOOK_TOKEN}"; changed = true; } if (!Array.isArray(cfg.hooks.presets)) { @@ -337,7 +317,7 @@ const createGmailWatchService = ({ const watch = getAccountGmailWatch(account || {}); if (!account || !watch.enabled || !watch.port) return; const token = String( - process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "", + process.env.WEBHOOK_TOKEN || "", ).trim(); if (!token) return; const status = await serveManager.startServe({ @@ -633,7 +613,7 @@ const createGmailWatchService = ({ try { let state = readState(); const hookToken = String( - process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "", + process.env.WEBHOOK_TOKEN || "", ).trim(); const enabled = listWatchEnabledAccounts(state); for (const account of enabled) { diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js index 253686c8..149c5466 100644 --- a/lib/server/onboarding/openclaw.js +++ b/lib/server/onboarding/openclaw.js @@ -177,7 +177,15 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { const replacements = buildSecretReplacements(varMap, process.env); for (const [secret, envRef] of replacements) { if (secret) { - content = content.split(secret).join(envRef); + // Only replace exact JSON string values so path substrings are never mutated. + const secretJson = JSON.stringify(secret); + content = content.replace( + new RegExp( + secretJson.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"), + "g", + ), + JSON.stringify(envRef), + ); } } fs.writeFileSync(configPath, content); diff --git a/lib/server/routes/webhooks.js b/lib/server/routes/webhooks.js index 90148c3e..04050b1b 100644 --- a/lib/server/routes/webhooks.js +++ b/lib/server/routes/webhooks.js @@ -52,15 +52,13 @@ const normalizeStatusFilter = (rawStatus) => { const buildWebhookUrls = ({ baseUrl, name }) => { const fullUrl = `${baseUrl}/hooks/${name}`; - const token = String( - process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "", - ).trim(); + const token = String(process.env.WEBHOOK_TOKEN || "").trim(); const queryStringUrl = token ? `${fullUrl}?token=${encodeURIComponent(token)}` - : `${fullUrl}?token=`; + : `${fullUrl}?token=`; const authHeaderValue = token ? `Authorization: Bearer ${token}` - : "Authorization: Bearer "; + : "Authorization: Bearer "; return { fullUrl, queryStringUrl, authHeaderValue, hasRuntimeToken: !!token }; }; diff --git a/lib/server/webhooks.js b/lib/server/webhooks.js index ab11ab33..f535b2ea 100644 --- a/lib/server/webhooks.js +++ b/lib/server/webhooks.js @@ -53,7 +53,7 @@ const ensureHooksRoot = (cfg) => { if (typeof cfg.hooks.path !== "string" || !cfg.hooks.path.trim()) cfg.hooks.path = "/hooks"; if (typeof cfg.hooks.token !== "string" || !cfg.hooks.token.trim()) { - cfg.hooks.token = "${OPENCLAW_HOOKS_TOKEN}"; + cfg.hooks.token = "${WEBHOOK_TOKEN}"; } if ( typeof cfg.hooks.defaultSessionKey !== "string" || diff --git a/tests/server/onboarding-openclaw.test.js b/tests/server/onboarding-openclaw.test.js new file mode 100644 index 00000000..382d88a3 --- /dev/null +++ b/tests/server/onboarding-openclaw.test.js @@ -0,0 +1,44 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + writeSanitizedOpenclawConfig, +} = require("../../lib/server/onboarding/openclaw"); + +const createTempOpenclawDir = () => + fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-onboarding-openclaw-test-")); + +describe("server/onboarding/openclaw", () => { + it("only scrubs exact secret string values in JSON", () => { + const openclawDir = createTempOpenclawDir(); + const configPath = path.join(openclawDir, "openclaw.json"); + const pluginPath = "/app/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker"; + fs.writeFileSync( + configPath, + JSON.stringify( + { + plugins: { load: { paths: [pluginPath] }, entries: {} }, + channels: {}, + notes: "alphaclaw", + }, + null, + 2, + ), + "utf8", + ); + + writeSanitizedOpenclawConfig({ + fs, + openclawDir, + varMap: { GOG_KEYRING_PASSWORD: "alphaclaw" }, + }); + + const next = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(next.notes).toBe("${GOG_KEYRING_PASSWORD}"); + expect(next.plugins.load.paths).toContain(pluginPath); + expect(next.plugins.load.paths).not.toContain( + "/app/node_modules/@chrysb/${GOG_KEYRING_PASSWORD}/lib/plugin/usage-tracker", + ); + }); +}); From b411d8a8ea3f1e977211d97a18cba627bd116608 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 11:25:24 -0800 Subject: [PATCH 081/417] 0.4.1-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4a072f48..914230f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.0", + "version": "0.4.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.0", + "version": "0.4.1-beta.1", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 4c6dd73a..9947625d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.0", + "version": "0.4.1-beta.1", "publishConfig": { "access": "public" }, From 6c8a525cbebe5e52a29e9d25767f147d339dec11 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 19:14:35 -0800 Subject: [PATCH 082/417] Pin openclaw dependency to 2026.3.2 --- package-lock.json | 1833 ++++++++++++++++++++++++--------------------- package.json | 2 +- 2 files changed, 963 insertions(+), 872 deletions(-) diff --git a/package-lock.json b/package-lock.json index 914230f8..b5fd8ee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "latest" + "openclaw": "2026.3.2" }, "bin": { "alphaclaw": "bin/alphaclaw.js" @@ -194,49 +194,49 @@ } }, "node_modules/@aws-sdk/client-bedrock": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.997.0.tgz", - "integrity": "sha512-PMRqxSzfkQHbU7ADVlT4jYLB7beFQWLXN9CGI9D9P8eqCIaDVv3YxTfwcT3FcBVucqktdTBTEowhvKn0whr/rA==", + "version": "3.1002.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1002.0.tgz", + "integrity": "sha512-AKSrIoK+J8I0i2byjEQJuj9T1BTgpZ+7/5kaOvdfOIAbY+ZebVhOidGx+Ix8LmdDI82EisMc2MqUjT1ywvUKNQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/token-providers": "3.997.0", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/credential-provider-node": "^3.972.16", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/token-providers": "3.1002.0", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -245,56 +245,56 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.997.0.tgz", - "integrity": "sha512-yEgCc/HvI7dLeXQLCuc4cnbzwE/NbNpKX8NmSSWTy3jnjiMZwrNKdHMBgPoNvaEb0klHhnTyO+JCHVVCPI/eYw==", + "version": "3.1002.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1002.0.tgz", + "integrity": "sha512-xUmzgTvTeQFVxBqla8U4nXpZNXLcZ0xszfZ4yxdTUNyChQQb7JLaH4E8pAbl7ulg0RoJ4ChNWtOqMJC/N3+qcQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-node": "^3.972.12", - "@aws-sdk/eventstream-handler-node": "^3.972.7", - "@aws-sdk/middleware-eventstream": "^3.972.4", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/middleware-websocket": "^3.972.8", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/token-providers": "3.997.0", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/eventstream-serde-browser": "^4.2.9", - "@smithy/eventstream-serde-config-resolver": "^4.3.9", - "@smithy/eventstream-serde-node": "^4.2.9", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/credential-provider-node": "^3.972.16", + "@aws-sdk/eventstream-handler-node": "^3.972.9", + "@aws-sdk/middleware-eventstream": "^3.972.6", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/middleware-websocket": "^3.972.11", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/token-providers": "3.1002.0", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/eventstream-serde-browser": "^4.2.10", + "@smithy/eventstream-serde-config-resolver": "^4.3.10", + "@smithy/eventstream-serde-node": "^4.2.10", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", - "@smithy/util-stream": "^4.5.14", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", + "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -303,22 +303,22 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.13.tgz", - "integrity": "sha512-eCFiLyBhJR7c/i8hZOETdzj2wsLFzi2L/w9/jajOgwmGqO8xrUExqkTZqdjROkwU62owqeqSuw4sIzlCv1E/ww==", + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.17.tgz", + "integrity": "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/xml-builder": "^3.972.6", - "@smithy/core": "^3.23.4", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/signature-v4": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/xml-builder": "^3.972.9", + "@smithy/core": "^3.23.7", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.9", + "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -327,15 +327,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.11.tgz", - "integrity": "sha512-hbyoFuVm3qOAGfIPS9t7jCs8GFLFoaOs8ZmYp/chqciuHDyEGv+J365ip7YSvXSrxxUbeW9NyB1hTLt40NBMRg==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.15.tgz", + "integrity": "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -343,20 +343,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.13.tgz", - "integrity": "sha512-a864QxQWFkdCZ5wQF0QZNKTbqAc/DFQNeARp4gOyZZdql5RHjj4CppUSfwAzS9cpw2IPY3eeJjWqLZ1QiDB/6w==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.17.tgz", + "integrity": "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/property-provider": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/util-stream": "^4.5.14", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" }, "engines": { @@ -364,24 +364,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.11.tgz", - "integrity": "sha512-kvPFn626ABLzxmjFMoqMRtmFKMeiUdWPhwxhmuPu233tqHnNuXzHv0MtrZlkzHd+rwlh9j0zCbQo89B54wIazQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.15.tgz", + "integrity": "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/credential-provider-env": "^3.972.11", - "@aws-sdk/credential-provider-http": "^3.972.13", - "@aws-sdk/credential-provider-login": "^3.972.11", - "@aws-sdk/credential-provider-process": "^3.972.11", - "@aws-sdk/credential-provider-sso": "^3.972.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.11", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/credential-provider-imds": "^4.2.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/credential-provider-env": "^3.972.15", + "@aws-sdk/credential-provider-http": "^3.972.17", + "@aws-sdk/credential-provider-login": "^3.972.15", + "@aws-sdk/credential-provider-process": "^3.972.15", + "@aws-sdk/credential-provider-sso": "^3.972.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.15", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/credential-provider-imds": "^4.2.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -389,18 +389,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.11.tgz", - "integrity": "sha512-stdy09EpBTmsxGiXe1vB5qtXNww9wact36/uWLlSV0/vWbCOUAY2JjhPXoDVLk8n+E6r0M5HeZseLk+iTtifxg==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.15.tgz", + "integrity": "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/protocol-http": "^5.3.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/protocol-http": "^5.3.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -408,22 +408,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.12.tgz", - "integrity": "sha512-gMWGnHbNSKWRj+PAiuSg0EDpEwpyIgk0v9U6EuZ1C/5/BUv25Way+E+UFB7r+YYkscuBJMJ+ai8E2K0Q8dx50g==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.16.tgz", + "integrity": "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.11", - "@aws-sdk/credential-provider-http": "^3.972.13", - "@aws-sdk/credential-provider-ini": "^3.972.11", - "@aws-sdk/credential-provider-process": "^3.972.11", - "@aws-sdk/credential-provider-sso": "^3.972.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.11", - "@aws-sdk/types": "^3.973.2", - "@smithy/credential-provider-imds": "^4.2.9", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/credential-provider-env": "^3.972.15", + "@aws-sdk/credential-provider-http": "^3.972.17", + "@aws-sdk/credential-provider-ini": "^3.972.15", + "@aws-sdk/credential-provider-process": "^3.972.15", + "@aws-sdk/credential-provider-sso": "^3.972.15", + "@aws-sdk/credential-provider-web-identity": "^3.972.15", + "@aws-sdk/types": "^3.973.4", + "@smithy/credential-provider-imds": "^4.2.10", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -431,16 +431,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.11.tgz", - "integrity": "sha512-B049fvbv41vf0Fs5bCtbzHpruBDp61sPiFDxUmkAJ/zvgSAturpj2rqzV1rj2clg4mb44Uxp9rgpcODexNFlFA==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.15.tgz", + "integrity": "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -448,18 +448,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.11.tgz", - "integrity": "sha512-vX9z8skN8vPtamVWmSCm4KQohub+1uMuRzIo4urZ2ZUMBAl1bqHatVD/roCb3qRfAyIGvZXCA/AWS03BQRMyCQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.15.tgz", + "integrity": "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/token-providers": "3.997.0", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/token-providers": "3.1002.0", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -467,17 +467,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.11.tgz", - "integrity": "sha512-VR2Ju/QBdOjnWNIYuxRml63eFDLGc6Zl8aDwLi1rzgWo3rLBgtaWhWVBAijhVXzyPdQIOqdL8hvll5ybqumjeQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.15.tgz", + "integrity": "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -485,14 +485,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.7.tgz", - "integrity": "sha512-p8k2ZWKJVrR3KIcBbI+/+FcWXdwe3LLgGnixsA7w8lDwWjzSVDHFp6uPeSqBt5PQpRxzak9EheJ1xTmOnHGf4g==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.9.tgz", + "integrity": "sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/eventstream-codec": "^4.2.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/eventstream-codec": "^4.2.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -500,14 +500,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.4.tgz", - "integrity": "sha512-0t+2Dn46cRE9iu5ynUXINBtR0wNHi/Jz3FbrqS5k3dGot2O7Ln1xCqXbJUAtGM5ZAqN77SbnpETAgVWC84DeoA==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.6.tgz", + "integrity": "sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -515,14 +515,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.4.tgz", - "integrity": "sha512-4q2Vg7/zOB10huDBLjzzTwVjBpG22X3J3ief2XrJEgTaANZrNfA3/cGbCVNAibSbu/nIYA7tDk8WCdsIzDDc4Q==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", + "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -530,13 +530,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.4.tgz", - "integrity": "sha512-xFqPvTysuZAHSkdygT+ken/5rzkR7fhOoDPejAJQslZpp0XBepmCJnDOqA57ERtCTBpu8wpjTFI1ETd4S0AXEw==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", + "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -544,15 +544,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.4.tgz", - "integrity": "sha512-tVbRaayUZ7y2bOb02hC3oEPTqQf2A0HpPDwdMl1qTmye/q8Mq1F1WiIoFkQwG/YQFvbyErYIDMbYzIlxzzLtjQ==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", + "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", + "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -560,17 +560,17 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.13.tgz", - "integrity": "sha512-p1kVYbzBxRmhuOHoL/ANJPCedqUxnVgkEjxPoxt5pQv/yzppHM7aBWciYEE9TZY59M421D3GjLfZIZBoEFboVQ==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.17.tgz", + "integrity": "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@smithy/core": "^3.23.4", - "@smithy/protocol-http": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@smithy/core": "^3.23.7", + "@smithy/protocol-http": "^5.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -578,19 +578,19 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.8.tgz", - "integrity": "sha512-KPUXz8lRw73Rh12/QkELxiryC9Wi9Ah1xNzFe2Vtbz2/81c2ZA0yM8er+u0iCF/SRMMhDQshLcmRNgn/ueA+gA==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.11.tgz", + "integrity": "sha512-cWf+8iUUnitgFuUu/ryK2uVfx7f5ezdhGwsjLLEEC1Nk716Ld2Hw4LA8iipyVcQI3EarvK6ExY2dSBET/0PYng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-format-url": "^3.972.4", - "@smithy/eventstream-codec": "^4.2.9", - "@smithy/eventstream-serde-browser": "^4.2.9", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/protocol-http": "^5.3.9", - "@smithy/signature-v4": "^5.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-format-url": "^3.972.6", + "@smithy/eventstream-codec": "^4.2.10", + "@smithy/eventstream-serde-browser": "^4.2.10", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/protocol-http": "^5.3.10", + "@smithy/signature-v4": "^5.3.10", + "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", @@ -601,47 +601,47 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.1.tgz", - "integrity": "sha512-XHVLFRGkuV2gh2uwBahCt65ALMb5wMpqplXEZIvFnWOCPlk60B7h7M5J9Em243K8iICDiWY6KhBEqVGfjTqlLA==", + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.5.tgz", + "integrity": "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/middleware-host-header": "^3.972.4", - "@aws-sdk/middleware-logger": "^3.972.4", - "@aws-sdk/middleware-recursion-detection": "^3.972.4", - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/region-config-resolver": "^3.972.4", - "@aws-sdk/types": "^3.973.2", - "@aws-sdk/util-endpoints": "^3.996.1", - "@aws-sdk/util-user-agent-browser": "^3.972.4", - "@aws-sdk/util-user-agent-node": "^3.972.12", - "@smithy/config-resolver": "^4.4.7", - "@smithy/core": "^3.23.4", - "@smithy/fetch-http-handler": "^5.3.10", - "@smithy/hash-node": "^4.2.9", - "@smithy/invalid-dependency": "^4.2.9", - "@smithy/middleware-content-length": "^4.2.9", - "@smithy/middleware-endpoint": "^4.4.18", - "@smithy/middleware-retry": "^4.4.35", - "@smithy/middleware-serde": "^4.2.10", - "@smithy/middleware-stack": "^4.2.9", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/node-http-handler": "^4.4.11", - "@smithy/protocol-http": "^5.3.9", - "@smithy/smithy-client": "^4.11.7", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/middleware-host-header": "^3.972.6", + "@aws-sdk/middleware-logger": "^3.972.6", + "@aws-sdk/middleware-recursion-detection": "^3.972.6", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/region-config-resolver": "^3.972.6", + "@aws-sdk/types": "^3.973.4", + "@aws-sdk/util-endpoints": "^3.996.3", + "@aws-sdk/util-user-agent-browser": "^3.972.6", + "@aws-sdk/util-user-agent-node": "^3.973.2", + "@smithy/config-resolver": "^4.4.9", + "@smithy/core": "^3.23.7", + "@smithy/fetch-http-handler": "^5.3.12", + "@smithy/hash-node": "^4.2.10", + "@smithy/invalid-dependency": "^4.2.10", + "@smithy/middleware-content-length": "^4.2.10", + "@smithy/middleware-endpoint": "^4.4.21", + "@smithy/middleware-retry": "^4.4.38", + "@smithy/middleware-serde": "^4.2.11", + "@smithy/middleware-stack": "^4.2.10", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-http-handler": "^4.4.13", + "@smithy/protocol-http": "^5.3.10", + "@smithy/smithy-client": "^4.12.1", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.34", - "@smithy/util-defaults-mode-node": "^4.2.37", - "@smithy/util-endpoints": "^3.2.9", - "@smithy/util-middleware": "^4.2.9", - "@smithy/util-retry": "^4.2.9", + "@smithy/util-defaults-mode-browser": "^4.3.37", + "@smithy/util-defaults-mode-node": "^4.2.40", + "@smithy/util-endpoints": "^3.3.1", + "@smithy/util-middleware": "^4.2.10", + "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, @@ -650,15 +650,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.4.tgz", - "integrity": "sha512-3GrJYv5eI65oCKveBZP7Q246dVP+tqeys9aKMB0dfX1glUWfppWlxIu52derqdNb9BX9lxYmeiaBcBIqOAYSgQ==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", + "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/config-resolver": "^4.4.7", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/config-resolver": "^4.4.9", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -666,17 +666,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.997.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.997.0.tgz", - "integrity": "sha512-UdG36F7lU9aTqGFRieEyuRUJlgEJBqKeKKekC0esH21DbUSKhPR1kZBah214kYasIaWe1hLJLaqUigoTa5hZAQ==", + "version": "3.1002.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1002.0.tgz", + "integrity": "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.13", - "@aws-sdk/nested-clients": "^3.996.1", - "@aws-sdk/types": "^3.973.2", - "@smithy/property-provider": "^4.2.9", - "@smithy/shared-ini-file-loader": "^4.4.4", - "@smithy/types": "^4.12.1", + "@aws-sdk/core": "^3.973.17", + "@aws-sdk/nested-clients": "^3.996.5", + "@aws-sdk/types": "^3.973.4", + "@smithy/property-provider": "^4.2.10", + "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -684,12 +684,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.2.tgz", - "integrity": "sha512-maTZwGsALtnAw4TJr/S6yERAosTwPduu0XhUV+SdbvRZtCOgSgk1ttL2R0XYzvkYSpvbtJocn77tBXq2AKglBw==", + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", + "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -697,15 +697,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.1.tgz", - "integrity": "sha512-7cJyd+M5i0IoqWkJa1KFx8KNCGIx+Ywu+lT53KpqX7ReVwz03DCKUqvZ/y65vdKwo9w9/HptSAeLDluO5MpGIg==", + "version": "3.996.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", + "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", - "@smithy/url-parser": "^4.2.9", - "@smithy/util-endpoints": "^3.2.9", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.10", + "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { @@ -713,14 +713,14 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.4.tgz", - "integrity": "sha512-rPm9g4WvgTz4ko5kqseIG5Vp5LUAbWBBDalm4ogHLMc0i20ChwQWqwuTUPJSu8zXn43jIM0xO2KZaYQsFJb+ew==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.6.tgz", + "integrity": "sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/querystring-builder": "^4.2.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/querystring-builder": "^4.2.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -740,27 +740,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.4.tgz", - "integrity": "sha512-GHb+8XHv6hfLWKQKAKaSOm+vRvogg07s+FWtbR3+eCXXPSFn9XVmiYF4oypAxH7dGIvoxkVG/buHEnzYukyJiA==", + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", + "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.2", - "@smithy/types": "^4.12.1", + "@aws-sdk/types": "^3.973.4", + "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.12.tgz", - "integrity": "sha512-c1n3wBK6te+Vd9qU86nF8AsYuiBsxLn0AADGWyFX7vEADr3btaAg5iPQT6GYj6rvzSOEVVisvaAatOWInlJUbQ==", + "version": "3.973.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.2.tgz", + "integrity": "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.13", - "@aws-sdk/types": "^3.973.2", - "@smithy/node-config-provider": "^4.3.9", - "@smithy/types": "^4.12.1", + "@aws-sdk/middleware-user-agent": "^3.972.17", + "@aws-sdk/types": "^3.973.4", + "@smithy/node-config-provider": "^4.3.10", + "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -776,13 +776,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.6.tgz", - "integrity": "sha512-YrXu+UnfC8IdARa4ZkrpcyuRmA/TVgYW6Lcdtvi34NQgRjM1hTirNirN+rGb+s/kNomby8oJiIAu0KNbiZC7PA==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", + "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.1", - "fast-xml-parser": "5.3.6", + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { @@ -1683,9 +1683,9 @@ } }, "node_modules/@google/genai": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz", - "integrity": "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", + "integrity": "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -1736,9 +1736,9 @@ } }, "node_modules/@grammyjs/types": { - "version": "3.24.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.24.0.tgz", - "integrity": "sha512-qQIEs4lN5WqUdr4aT8MeU6UFpMbGYAvcvYSW1A4OO1PABGJQHz/KLON6qvpf+5RxaNDQBxiY2k2otIhg/AG7RQ==", + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", + "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", "license": "MIT" }, "node_modules/@hapi/boom": { @@ -2773,21 +2773,21 @@ } }, "node_modules/@mariozechner/pi-agent-core": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.55.0.tgz", - "integrity": "sha512-8RLaOpmESBSqTSpA/6E9ihxYybhrkNa5LOYNdJst57LuDSDytfvkiTXlKA4DjsHua4PKopG9p0Wgqaem+kKvCA==", + "version": "0.55.3", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.55.3.tgz", + "integrity": "sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.55.0" + "@mariozechner/pi-ai": "^0.55.3" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@mariozechner/pi-ai": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.55.0.tgz", - "integrity": "sha512-G5rutF5h1hFZgU1W2yYktZJegKUZVDhdGCxvl7zPOonrGBczuNBKmM87VXvl1m+t9718rYMsgTSBseGN0RhYug==", + "version": "0.55.3", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.55.3.tgz", + "integrity": "sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -2812,25 +2812,26 @@ } }, "node_modules/@mariozechner/pi-coding-agent": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.55.0.tgz", - "integrity": "sha512-neflZvWsbFDph3RG+b3/ItfFtGaQnOFJO+N+fsnIC3BG/FEUu1IK1lcMwrM1FGGSMfJnCv7Q3Zk5MSBiRj4azQ==", + "version": "0.55.3", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.55.3.tgz", + "integrity": "sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==", "license": "MIT", "dependencies": { "@mariozechner/jiti": "^2.6.2", - "@mariozechner/pi-agent-core": "^0.55.0", - "@mariozechner/pi-ai": "^0.55.0", - "@mariozechner/pi-tui": "^0.55.0", + "@mariozechner/pi-agent-core": "^0.55.3", + "@mariozechner/pi-ai": "^0.55.3", + "@mariozechner/pi-tui": "^0.55.3", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", + "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", - "minimatch": "^10.1.1", + "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "yaml": "^2.8.2" }, @@ -2845,9 +2846,9 @@ } }, "node_modules/@mariozechner/pi-tui": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.55.0.tgz", - "integrity": "sha512-qFdBsA0CTIQbUlN5hp1yJOSgJJiuTegx+oNPzpHxaMMBPjwMuh3Y8szBqE/2HxroA6mGSQfp/fzuPinTK1+Iyg==", + "version": "0.55.3", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.55.3.tgz", + "integrity": "sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -3192,9 +3193,9 @@ } }, "node_modules/@node-llama-cpp/linux-arm64": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-arm64/-/linux-arm64-3.15.1.tgz", - "integrity": "sha512-g7JC/WwDyyBSmkIjSvRF2XLW+YA0z2ZVBSAKSv106mIPO4CzC078woTuTaPsykWgIaKcQRyXuW5v5XQMcT1OOA==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-arm64/-/linux-arm64-3.16.2.tgz", + "integrity": "sha512-CxzgPsS84wL3W5sZRgxP3c9iJKEW+USrak1SmX6EAJxW/v9QGzehvT6W/aR1FyfidiIyQtOp3ga0Gg/9xfJPGw==", "cpu": [ "arm64", "x64" @@ -3210,9 +3211,9 @@ } }, "node_modules/@node-llama-cpp/linux-armv7l": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-armv7l/-/linux-armv7l-3.15.1.tgz", - "integrity": "sha512-MSxR3A0vFSVWbmVSkNqNXQnI45L2Vg7/PRgJukcjChk7YzRxs9L+oQMeycVW3BsQ03mIZ0iORsZ9MNIBEbdS3g==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-armv7l/-/linux-armv7l-3.16.2.tgz", + "integrity": "sha512-9G6W/MkQ/DLwGmpcj143NQ50QJg5gQZfzVf5RYx77VczBqhgwkgYHILekYrOs4xanOeqeJ8jnOnQQSp1YaJZUg==", "cpu": [ "arm", "x64" @@ -3228,9 +3229,9 @@ } }, "node_modules/@node-llama-cpp/linux-x64": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64/-/linux-x64-3.15.1.tgz", - "integrity": "sha512-w4SdxJaA9eJLVYWX+Jv48hTP4oO79BJQIFURMi7hXIFXbxyyOov/r6sVaQ1WiL83nVza37U5Qg4L9Gb/KRdNWQ==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64/-/linux-x64-3.16.2.tgz", + "integrity": "sha512-OXYf8rVfoDyvN+YrfKk8F9An9a5GOxVIM8OcR1U911tc0oRNf8yfJrQ8KrM75R26gwq0Y6YZwVTP0vRCInwWOw==", "cpu": [ "x64" ], @@ -3245,9 +3246,9 @@ } }, "node_modules/@node-llama-cpp/linux-x64-cuda": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda/-/linux-x64-cuda-3.15.1.tgz", - "integrity": "sha512-kngwoq1KdrqSr/b6+tn5jbtGHI0tZnW5wofKssZy+Il2ge3eN9FowKbXG4FH452g6qSSVoDccAoTvYOxyLyX+w==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda/-/linux-x64-cuda-3.16.2.tgz", + "integrity": "sha512-LTBQFqjin7tyrLNJz0XWTB5QAHDsZV71/qiiRRjXdBKSZHVVaPLfdgxypGu7ggPeBNsv+MckRXdlH5C7yMtE4A==", "cpu": [ "x64" ], @@ -3262,9 +3263,9 @@ } }, "node_modules/@node-llama-cpp/linux-x64-cuda-ext": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda-ext/-/linux-x64-cuda-ext-3.15.1.tgz", - "integrity": "sha512-toepvLcXjgaQE/QGIThHBD58jbHGBWT1jhblJkCjYBRHfVOO+6n/PmVsJt+yMfu5Z93A2gF8YOvVyZXNXmGo5g==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-cuda-ext/-/linux-x64-cuda-ext-3.16.2.tgz", + "integrity": "sha512-47d9myCJauZyzAlN7IK1eIt/4CcBMslF+yHy4q+yJotD/RV/S6qRpK2kGn+ybtdVjkPGNCoPkHKcyla9iIVjbw==", "cpu": [ "x64" ], @@ -3279,9 +3280,9 @@ } }, "node_modules/@node-llama-cpp/linux-x64-vulkan": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-vulkan/-/linux-x64-vulkan-3.15.1.tgz", - "integrity": "sha512-CMsyQkGKpHKeOH9+ZPxo0hO0usg8jabq5/aM3JwdX9CiuXhXUa3nu3NH4RObiNi596Zwn/zWzlps0HRwcpL8rw==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/linux-x64-vulkan/-/linux-x64-vulkan-3.16.2.tgz", + "integrity": "sha512-HDLAw4ZhwJuhKuF6n4x520yZXAQZahUOXtCGvPubjfpmIOElKrfDvCVlRsthAP0JwcwINzIQlVys3boMIXfBgw==", "cpu": [ "x64" ], @@ -3296,9 +3297,9 @@ } }, "node_modules/@node-llama-cpp/mac-arm64-metal": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-arm64-metal/-/mac-arm64-metal-3.15.1.tgz", - "integrity": "sha512-ePTweqohcy6Gjs1agXWy4FxAw5W4Avr7NeqqiFWJ5ngZ1U3ZXdruUHB8L/vDxyn3FzKvstrFyN7UScbi0pzXrA==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-arm64-metal/-/mac-arm64-metal-3.16.2.tgz", + "integrity": "sha512-nEZ74qB0lUohF88yR741YUrUqz/qD+FJFzUTHj0FwxAynSZCjvwtzEDtavRlh3qd3yLD/0ChNn00/RQ54ISImw==", "cpu": [ "arm64", "x64" @@ -3314,9 +3315,9 @@ } }, "node_modules/@node-llama-cpp/mac-x64": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-x64/-/mac-x64-3.15.1.tgz", - "integrity": "sha512-NAetSQONxpNXTBnEo7oOkKZ84wO2avBy6V9vV9ntjJLb/07g7Rar8s/jVaicc/rVl6C+8ljZNwqJeynirgAC5w==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/mac-x64/-/mac-x64-3.16.2.tgz", + "integrity": "sha512-BjA+DgeDt+kRxVMV6kChb9XVXm7U5b90jUif7Z/s6ZXtOOnV6exrTM2W09kbSqAiNhZmctcVY83h2dwNTZ/yIw==", "cpu": [ "x64" ], @@ -3331,9 +3332,9 @@ } }, "node_modules/@node-llama-cpp/win-arm64": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-arm64/-/win-arm64-3.15.1.tgz", - "integrity": "sha512-1O9tNSUgvgLL5hqgEuYiz7jRdA3+9yqzNJyPW1jExlQo442OA0eIpHBmeOtvXLwMkY7qv7wE75FdOPR7NVEnvg==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-arm64/-/win-arm64-3.16.2.tgz", + "integrity": "sha512-XHNFQzUjYODtkZjIn4NbQVrBtGB9RI9TpisiALryqfrIqagQmjBh6dmxZWlt5uduKAfT7M2/2vrABGR490FACA==", "cpu": [ "arm64", "x64" @@ -3349,9 +3350,9 @@ } }, "node_modules/@node-llama-cpp/win-x64": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64/-/win-x64-3.15.1.tgz", - "integrity": "sha512-jtoXBa6h+VPsQgefrO7HDjYv4WvxfHtUO30ABwCUDuEgM0e05YYhxMZj1z2Ns47UrquNvd/LUPCyjHKqHUN+5Q==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64/-/win-x64-3.16.2.tgz", + "integrity": "sha512-etrivzbyLNVhZlUosFW8JSL0OSiuKQf9qcI3dNdehD907sHquQbBJrG7lXcdL6IecvXySp3oAwCkM87VJ0b3Fg==", "cpu": [ "x64" ], @@ -3366,9 +3367,9 @@ } }, "node_modules/@node-llama-cpp/win-x64-cuda": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda/-/win-x64-cuda-3.15.1.tgz", - "integrity": "sha512-swoyx0/dY4ixiu3mEWrIAinx0ffHn9IncELDNREKG+iIXfx6w0OujOMQ6+X+lGj+sjE01yMUP/9fv6GEp2pmBw==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda/-/win-x64-cuda-3.16.2.tgz", + "integrity": "sha512-jStDELHrU3rKQMOk5Hs5bWEazyjE2hzHwpNf6SblOpaGkajM/HJtxEZoL0mLHJx5qeXs4oOVkr7AzuLy0WPpNA==", "cpu": [ "x64" ], @@ -3383,9 +3384,9 @@ } }, "node_modules/@node-llama-cpp/win-x64-cuda-ext": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda-ext/-/win-x64-cuda-ext-3.15.1.tgz", - "integrity": "sha512-mO3Tf6D3UlFkjQF5J96ynTkjdF7dac/f5f61cEh6oU4D3hdx+cwnmBWT1gVhDSLboJYzCHtx7U2EKPP6n8HoWA==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-cuda-ext/-/win-x64-cuda-ext-3.16.2.tgz", + "integrity": "sha512-sdv4Kzn9bOQWNBRvw6B/zcn8dQRfZhjIHv5AfDBIOfRlSCgjebFpBeYUoU4wZPpjr3ISwcqO5MEWsw+AbUdV3Q==", "cpu": [ "x64" ], @@ -3400,9 +3401,9 @@ } }, "node_modules/@node-llama-cpp/win-x64-vulkan": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-vulkan/-/win-x64-vulkan-3.15.1.tgz", - "integrity": "sha512-BPBjUEIkFTdcHSsQyblP0v/aPPypi6uqQIq27mo4A49CYjX22JDmk4ncdBLk6cru+UkvwEEe+F2RomjoMt32aQ==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/@node-llama-cpp/win-x64-vulkan/-/win-x64-vulkan-3.16.2.tgz", + "integrity": "sha512-9xuHFCOhCQjZgQSFrk79EuSKn9nGWt/SAq/3wujQSQLtgp8jGdtZgwcmuDUoemInf10en2dcOmEt7t8dQdC3XA==", "cpu": [ "x64" ], @@ -4813,9 +4814,9 @@ "license": "MIT" }, "node_modules/@smithy/abort-controller": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", - "integrity": "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -4826,16 +4827,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.9.tgz", - "integrity": "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -4843,20 +4844,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.6", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.6.tgz", - "integrity": "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.8.tgz", + "integrity": "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.11", - "@smithy/protocol-http": "^5.3.10", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-stream": "^4.5.15", - "@smithy/util-utf8": "^4.2.1", - "@smithy/uuid": "^1.1.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -4864,15 +4865,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.10.tgz", - "integrity": "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", + "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -4880,14 +4881,14 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.10.tgz", - "integrity": "sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", + "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -4895,12 +4896,12 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.10.tgz", - "integrity": "sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", + "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", + "@smithy/eventstream-serde-universal": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -4909,9 +4910,9 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.10.tgz", - "integrity": "sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==", + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", + "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -4922,12 +4923,12 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.10.tgz", - "integrity": "sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", + "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.10", + "@smithy/eventstream-serde-universal": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -4936,12 +4937,12 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.10.tgz", - "integrity": "sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", + "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.10", + "@smithy/eventstream-codec": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -4950,15 +4951,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz", - "integrity": "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==", + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -4966,14 +4967,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", - "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -4981,9 +4982,9 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz", - "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -4994,9 +4995,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz", - "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5006,12 +5007,12 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz", - "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5020,18 +5021,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.20.tgz", - "integrity": "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==", + "version": "4.4.22", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.22.tgz", + "integrity": "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/core": "^3.23.8", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-middleware": "^4.2.10", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -5039,19 +5040,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.37", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.37.tgz", - "integrity": "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==", + "version": "4.4.39", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.39.tgz", + "integrity": "sha512-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/service-error-classification": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/uuid": "^1.1.1", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -5059,12 +5060,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.11.tgz", - "integrity": "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5073,9 +5074,9 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.10.tgz", - "integrity": "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -5086,13 +5087,13 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.10.tgz", - "integrity": "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==", + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5101,14 +5102,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.12.tgz", - "integrity": "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5117,9 +5118,9 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.10.tgz", - "integrity": "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -5130,9 +5131,9 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz", - "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -5143,13 +5144,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.10.tgz", - "integrity": "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -5157,9 +5158,9 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.10.tgz", - "integrity": "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -5170,9 +5171,9 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.10.tgz", - "integrity": "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0" @@ -5182,9 +5183,9 @@ } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.5.tgz", - "integrity": "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -5195,18 +5196,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz", - "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==", + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", - "@smithy/protocol-http": "^5.3.10", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-uri-escape": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -5214,17 +5215,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.0.tgz", - "integrity": "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.2.tgz", + "integrity": "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", + "@smithy/core": "^3.23.8", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" }, "engines": { @@ -5244,12 +5245,12 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.10.tgz", - "integrity": "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.10", + "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5258,13 +5259,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz", - "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -5272,9 +5273,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz", - "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5284,9 +5285,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz", - "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5296,12 +5297,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz", - "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -5309,9 +5310,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz", - "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5321,13 +5322,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.36", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.36.tgz", - "integrity": "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==", + "version": "4.3.38", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.38.tgz", + "integrity": "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5336,16 +5337,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.39", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.39.tgz", - "integrity": "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==", + "version": "4.2.41", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.41.tgz", + "integrity": "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.9", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5354,12 +5355,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.1.tgz", - "integrity": "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5368,9 +5369,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", - "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5380,9 +5381,9 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz", - "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -5393,12 +5394,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.10.tgz", - "integrity": "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.10", + "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -5407,18 +5408,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.15", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.15.tgz", - "integrity": "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==", + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -5426,9 +5427,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz", - "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5438,12 +5439,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", - "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -5451,9 +5452,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz", - "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5798,9 +5799,9 @@ } }, "node_modules/@types/aws-lambda": { - "version": "8.10.160", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz", - "integrity": "sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA==", + "version": "8.10.161", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", + "integrity": "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==", "license": "MIT", "peer": true }, @@ -5979,6 +5980,16 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -6315,22 +6326,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", "license": "ISC", - "peer": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } + "optional": true }, "node_modules/argparse": { "version": "2.0.1", @@ -6527,9 +6524,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6538,6 +6535,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -6660,6 +6666,7 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "license": "ISC", + "optional": true, "engines": { "node": ">=10" } @@ -6856,30 +6863,37 @@ } }, "node_modules/cmake-js": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-7.4.0.tgz", - "integrity": "sha512-Lw0JxEHrmk+qNj1n9W9d4IvkDdYTBn7l2BW6XmtLj7WPpIo2shvxUy+YokfjMxAAOELNonQwX3stkPhM5xSC2Q==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", + "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", "license": "MIT", "peer": true, "dependencies": { - "axios": "^1.6.5", - "debug": "^4", - "fs-extra": "^11.2.0", - "memory-stream": "^1.0.0", - "node-api-headers": "^1.1.0", - "npmlog": "^6.0.2", - "rc": "^1.2.7", - "semver": "^7.5.4", - "tar": "^6.2.0", + "debug": "^4.4.3", + "fs-extra": "^11.3.3", + "node-api-headers": "^1.8.0", + "rc": "1.2.8", + "semver": "^7.7.3", + "tar": "^7.5.6", "url-join": "^4.0.1", - "which": "^2.0.2", + "which": "^6.0.0", "yargs": "^17.7.2" }, "bin": { "cmake-js": "bin/cmake-js" }, "engines": { - "node": ">= 14.15.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cmake-js/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=18" } }, "node_modules/cmake-js/node_modules/debug": { @@ -6900,12 +6914,18 @@ } } }, - "node_modules/cmake-js/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "peer": true + "node_modules/cmake-js/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "peer": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } }, "node_modules/cmake-js/node_modules/ms": { "version": "2.1.3", @@ -6914,20 +6934,31 @@ "license": "MIT", "peer": true }, - "node_modules/cmake-js/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", + "node_modules/cmake-js/node_modules/tar": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", + "license": "BlueOak-1.0.0", "peer": true, "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">= 8" + "node": ">=18" + } + }, + "node_modules/cmake-js/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=18" } }, "node_modules/color-convert": { @@ -6953,6 +6984,7 @@ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "license": "ISC", + "optional": true, "bin": { "color-support": "bin.js" } @@ -7000,7 +7032,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -7200,7 +7233,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/depd": { "version": "2.0.0", @@ -7376,6 +7410,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7655,6 +7698,49 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -7701,10 +7787,22 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "funding": [ { "type": "github", @@ -7713,12 +7811,22 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -7938,9 +8046,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "license": "MIT", "peer": true, "dependencies": { @@ -7957,6 +8065,7 @@ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -7969,6 +8078,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -8007,50 +8117,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "peer": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/gauge/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", @@ -8066,6 +8132,21 @@ "node": ">=18" } }, + "node_modules/gaxios/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/gaxios/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/gaxios/node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -8103,12 +8184,12 @@ "license": "ISC" }, "node_modules/gaxios/node_modules/minimatch": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", - "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -8238,6 +8319,21 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -8337,12 +8433,12 @@ "license": "ISC" }, "node_modules/grammy": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.40.0.tgz", - "integrity": "sha512-ssuE7fc1AwqlUxHr931OCVW3fU+oFDjHZGgvIedPKXfTdjXvzP19xifvVGCnPtYVUig1Kz+gwxe4A9M5WdkT4Q==", + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", + "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", "license": "MIT", "dependencies": { - "@grammyjs/types": "3.24.0", + "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" @@ -8414,7 +8510,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/hashery": { "version": "1.5.0", @@ -8796,6 +8893,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ipull/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/is-electron": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", @@ -8869,13 +8983,13 @@ "license": "MIT" }, "node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "license": "BlueOak-1.0.0", "peer": true, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/istanbul-lib-coverage": { @@ -8977,9 +9091,9 @@ "license": "MIT" }, "node_modules/json-with-bigint": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.3.tgz", - "integrity": "sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==", + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", "license": "MIT", "peer": true }, @@ -9427,16 +9541,6 @@ "node": ">= 0.8" } }, - "node_modules/memory-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-1.0.0.tgz", - "integrity": "sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww==", - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "^3.4.0" - } - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -9502,9 +9606,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", - "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" @@ -9540,6 +9644,7 @@ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "license": "MIT", + "optional": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -9553,6 +9658,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -9565,6 +9671,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "license": "MIT", + "optional": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -9697,23 +9804,13 @@ "peer": true }, "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], + "name": "@nolyfill/domexception", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", + "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", "license": "MIT", "engines": { - "node": ">=10.5.0" + "node": ">=12.4.0" } }, "node_modules/node-edge-tts": { @@ -9751,41 +9848,41 @@ } }, "node_modules/node-llama-cpp": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/node-llama-cpp/-/node-llama-cpp-3.15.1.tgz", - "integrity": "sha512-/fBNkuLGR2Q8xj2eeV12KXKZ9vCS2+o6aP11lW40pB9H6f0B3wOALi/liFrjhHukAoiH6C9wFTPzv6039+5DRA==", + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/node-llama-cpp/-/node-llama-cpp-3.16.2.tgz", + "integrity": "sha512-ovhuTaXSWfcoyfI8ljWxO2Rg63mNxqQQAbDGkXRhlgsL7UjPqm2Nsy1bTNa0ZaQRg3vezG4agnCJTImrICY/0A==", "hasInstallScript": true, "license": "MIT", "peer": true, "dependencies": { - "@huggingface/jinja": "^0.5.3", + "@huggingface/jinja": "^0.5.5", "async-retry": "^1.3.3", "bytes": "^3.1.2", - "chalk": "^5.4.1", + "chalk": "^5.6.2", "chmodrp": "^1.0.2", - "cmake-js": "^7.4.0", + "cmake-js": "^8.0.0", "cross-spawn": "^7.0.6", "env-var": "^7.5.0", "filenamify": "^6.0.0", "fs-extra": "^11.3.0", "ignore": "^7.0.4", - "ipull": "^3.9.2", + "ipull": "^3.9.5", "is-unicode-supported": "^2.1.0", - "lifecycle-utils": "^3.0.1", - "log-symbols": "^7.0.0", - "nanoid": "^5.1.5", - "node-addon-api": "^8.3.1", - "octokit": "^5.0.3", - "ora": "^8.2.0", - "pretty-ms": "^9.2.0", + "lifecycle-utils": "^3.1.1", + "log-symbols": "^7.0.1", + "nanoid": "^5.1.6", + "node-addon-api": "^8.5.0", + "octokit": "^5.0.5", + "ora": "^9.3.0", + "pretty-ms": "^9.3.0", "proper-lockfile": "^4.1.2", "semver": "^7.7.1", - "simple-git": "^3.27.0", - "slice-ansi": "^7.1.0", + "simple-git": "^3.31.1", + "slice-ansi": "^8.0.0", "stdout-update": "^4.0.1", - "strip-ansi": "^7.1.0", - "validate-npm-package-name": "^6.0.0", - "which": "^5.0.0", + "strip-ansi": "^7.1.2", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1", "yargs": "^17.7.2" }, "bin": { @@ -9800,19 +9897,19 @@ "url": "https://github.com/sponsors/giladgd" }, "optionalDependencies": { - "@node-llama-cpp/linux-arm64": "3.15.1", - "@node-llama-cpp/linux-armv7l": "3.15.1", - "@node-llama-cpp/linux-x64": "3.15.1", - "@node-llama-cpp/linux-x64-cuda": "3.15.1", - "@node-llama-cpp/linux-x64-cuda-ext": "3.15.1", - "@node-llama-cpp/linux-x64-vulkan": "3.15.1", - "@node-llama-cpp/mac-arm64-metal": "3.15.1", - "@node-llama-cpp/mac-x64": "3.15.1", - "@node-llama-cpp/win-arm64": "3.15.1", - "@node-llama-cpp/win-x64": "3.15.1", - "@node-llama-cpp/win-x64-cuda": "3.15.1", - "@node-llama-cpp/win-x64-cuda-ext": "3.15.1", - "@node-llama-cpp/win-x64-vulkan": "3.15.1" + "@node-llama-cpp/linux-arm64": "3.16.2", + "@node-llama-cpp/linux-armv7l": "3.16.2", + "@node-llama-cpp/linux-x64": "3.16.2", + "@node-llama-cpp/linux-x64-cuda": "3.16.2", + "@node-llama-cpp/linux-x64-cuda-ext": "3.16.2", + "@node-llama-cpp/linux-x64-vulkan": "3.16.2", + "@node-llama-cpp/mac-arm64-metal": "3.16.2", + "@node-llama-cpp/mac-x64": "3.16.2", + "@node-llama-cpp/win-arm64": "3.16.2", + "@node-llama-cpp/win-x64": "3.16.2", + "@node-llama-cpp/win-x64-cuda": "3.16.2", + "@node-llama-cpp/win-x64-cuda-ext": "3.16.2", + "@node-llama-cpp/win-x64-vulkan": "3.16.2" }, "peerDependencies": { "typescript": ">=5.0.0" @@ -9846,23 +9943,6 @@ "node": ">=6" } }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "peer": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -9998,13 +10078,13 @@ } }, "node_modules/openclaw": { - "version": "2026.2.24", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.2.24.tgz", - "integrity": "sha512-a6zrcS6v5tUWqzsFh5cNtyu5+Tra1UW5yvPtYhRYCKSS/q6lXrLu+dj0ylJPOHRPAho2alZZL1gw1Qd2hAd2sQ==", + "version": "2026.3.2", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.3.2.tgz", + "integrity": "sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==", "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "0.14.1", - "@aws-sdk/client-bedrock": "^3.997.0", + "@aws-sdk/client-bedrock": "^3.1000.0", "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.0.1", "@discordjs/voice": "^0.19.0", @@ -10014,10 +10094,10 @@ "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.55.0", - "@mariozechner/pi-ai": "0.55.0", - "@mariozechner/pi-coding-agent": "0.55.0", - "@mariozechner/pi-tui": "0.55.0", + "@mariozechner/pi-agent-core": "0.55.3", + "@mariozechner/pi-ai": "0.55.3", + "@mariozechner/pi-coding-agent": "0.55.3", + "@mariozechner/pi-tui": "0.55.3", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", @@ -10034,7 +10114,9 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "^21.3.0", - "grammy": "^1.40.0", + "gaxios": "7.1.3", + "google-auth-library": "10.6.1", + "grammy": "^1.41.0", "https-proxy-agent": "^7.0.6", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", @@ -10043,14 +10125,16 @@ "linkedom": "^0.18.12", "long": "^5.3.2", "markdown-it": "^14.1.1", + "node-domexception": "npm:@nolyfill/domexception@^1.0.28", "node-edge-tts": "^1.2.10", "opusscript": "^0.1.1", "osc-progress": "^0.3.0", - "pdfjs-dist": "^5.4.624", + "pdfjs-dist": "^5.5.207", "playwright-core": "1.58.2", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "sqlite-vec": "0.1.7-alpha.2", + "strip-ansi": "^7.2.0", "tar": "7.5.9", "tslog": "^4.10.2", "undici": "^7.22.0", @@ -10069,7 +10153,7 @@ }, "peerDependencies": { "@napi-rs/canvas": "^0.1.89", - "node-llama-cpp": "3.15.1" + "node-llama-cpp": "3.16.2" } }, "node_modules/openclaw/node_modules/accepts": { @@ -10425,79 +10509,53 @@ "license": "MIT" }, "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", "license": "MIT", "peer": true, "dependencies": { - "chalk": "^5.3.0", + "chalk": "^5.6.2", "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", + "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT", - "peer": true - }, - "node_modules/ora/node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "node_modules/ora/node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "license": "MIT", "peer": true, "engines": { - "node": ">=12" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ora/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", "license": "MIT", "peer": true, "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10727,18 +10785,24 @@ "license": "MIT" }, "node_modules/pdfjs-dist": { - "version": "5.4.624", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", - "integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==", + "version": "5.5.207", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", + "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", "license": "Apache-2.0", "engines": { - "node": ">=20.16.0 || >=22.3.0" + "node": ">=20.19.0 || >=22.13.0 || >=24" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.88", + "@napi-rs/canvas": "^0.1.95", "node-readable-to-web-readable-stream": "^0.4.2" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11029,6 +11093,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -11124,6 +11198,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -11478,7 +11553,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/setimmediate": { "version": "1.0.5", @@ -11643,9 +11719,9 @@ "license": "ISC" }, "node_modules/simple-git": { - "version": "3.32.2", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.32.2.tgz", - "integrity": "sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==", + "version": "3.32.3", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.32.3.tgz", + "integrity": "sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==", "license": "MIT", "peer": true, "dependencies": { @@ -11697,17 +11773,17 @@ "peer": true }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "license": "MIT", "peer": true, "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" @@ -11922,9 +11998,9 @@ "license": "MIT" }, "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", "license": "MIT", "peer": true, "engines": { @@ -11993,6 +12069,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", + "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -12087,12 +12164,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -12134,9 +12211,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -12263,6 +12340,7 @@ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", + "optional": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -12280,6 +12358,7 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "license": "ISC", + "optional": true, "engines": { "node": ">=8" } @@ -12551,13 +12630,13 @@ } }, "node_modules/validate-npm-package-name": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", - "integrity": "sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", "license": "ISC", "peer": true, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/vary": { @@ -12748,19 +12827,19 @@ } }, "node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "peer": true, "dependencies": { - "isexe": "^3.1.1" + "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/why-is-node-running": { @@ -12785,6 +12864,7 @@ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", "license": "ISC", + "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } @@ -12942,7 +13022,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/yaml": { "version": "2.8.2", @@ -12986,6 +13067,16 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 9947625d..a185a608 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "latest" + "openclaw": "2026.3.2" }, "devDependencies": { "@vitest/coverage-v8": "^4.0.18", From 618a57dfcc93b3f72761531bd02f09df1d5819e1 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 19:20:07 -0800 Subject: [PATCH 083/417] Add dynamic gog-cli agent skill based on connected Google services Generates a SKILL.md at sync time containing only the command reference sections for services the user has actually connected, keeping token usage minimal. Per-service docs live as standalone markdown files under lib/setup/skills/gog-cli/ for easy review. The skill re-syncs on server startup and whenever Google accounts are added, updated, or disconnected. --- lib/public/shared/browse-file-policies.json | 1 + lib/server.js | 2 + lib/server/gog-skill.js | 169 ++++++++++++++++++++ lib/server/routes/google.js | 4 + lib/setup/skills/gog-cli/calendar.md | 63 ++++++++ lib/setup/skills/gog-cli/contacts.md | 30 ++++ lib/setup/skills/gog-cli/docs.md | 46 ++++++ lib/setup/skills/gog-cli/drive.md | 48 ++++++ lib/setup/skills/gog-cli/gmail.md | 64 ++++++++ lib/setup/skills/gog-cli/meet.md | 6 + lib/setup/skills/gog-cli/sheets.md | 43 +++++ lib/setup/skills/gog-cli/tasks.md | 32 ++++ 12 files changed, 508 insertions(+) create mode 100644 lib/server/gog-skill.js create mode 100644 lib/setup/skills/gog-cli/calendar.md create mode 100644 lib/setup/skills/gog-cli/contacts.md create mode 100644 lib/setup/skills/gog-cli/docs.md create mode 100644 lib/setup/skills/gog-cli/drive.md create mode 100644 lib/setup/skills/gog-cli/gmail.md create mode 100644 lib/setup/skills/gog-cli/meet.md create mode 100644 lib/setup/skills/gog-cli/sheets.md create mode 100644 lib/setup/skills/gog-cli/tasks.md diff --git a/lib/public/shared/browse-file-policies.json b/lib/public/shared/browse-file-policies.json index c2a305d3..89fe2caf 100644 --- a/lib/public/shared/browse-file-policies.json +++ b/lib/public/shared/browse-file-policies.json @@ -7,6 +7,7 @@ "hooks/bootstrap/agents.md", "hooks/bootstrap/tools.md", "skills/control-ui/skill.md", + "skills/gog-cli/skill.md", ".alphaclaw/hourly-git-sync.sh", ".alphaclaw/.cli-device-auto-approved" ] diff --git a/lib/server.js b/lib/server.js index eedf11fa..0ea20c05 100644 --- a/lib/server.js +++ b/lib/server.js @@ -79,6 +79,7 @@ const { const { migrateManagedInternalFiles, } = require("./server/internal-files-migration"); +const { installGogCliSkill } = require("./server/gog-skill"); const { createTelegramApi } = require("./server/telegram-api"); const { createDiscordApi } = require("./server/discord-api"); const { createWatchdogNotifier } = require("./server/watchdog-notify"); @@ -282,6 +283,7 @@ const doSyncPromptFiles = () => { openclawDir: constants.OPENCLAW_DIR, baseUrl: setupUiUrl, }); + installGogCliSkill({ fs, openclawDir: constants.OPENCLAW_DIR }); }; doSyncPromptFiles(); registerTelegramRoutes({ diff --git a/lib/server/gog-skill.js b/lib/server/gog-skill.js new file mode 100644 index 00000000..98c58d45 --- /dev/null +++ b/lib/server/gog-skill.js @@ -0,0 +1,169 @@ +const path = require("path"); +const { readGoogleState } = require("./google-state"); + +const kSkillPartsDir = path.join(__dirname, "..", "setup", "skills", "gog-cli"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const uniqueServiceLabels = (scopes) => + Array.from( + new Set( + (scopes || []) + .map((scope) => String(scope || "").split(":")[0]) + .filter(Boolean), + ), + ); + +const collectConnectedServices = (accounts) => { + const serviceSet = new Set(); + for (const account of accounts) { + if (!account.authenticated) continue; + for (const label of uniqueServiceLabels(account.services)) { + serviceSet.add(label); + } + } + return serviceSet; +}; + +const kServiceDisplayNames = { + gmail: "Gmail", + calendar: "Calendar", + drive: "Drive", + sheets: "Sheets", + docs: "Docs", + tasks: "Tasks", + contacts: "Contacts", + meet: "Meet", +}; + +// Stable ordering for service sections +const kServiceOrder = [ + "gmail", + "calendar", + "drive", + "sheets", + "docs", + "tasks", + "contacts", + "meet", +]; + +const readServiceSection = (fs, service) => { + try { + return fs.readFileSync(path.join(kSkillPartsDir, `${service}.md`), "utf8"); + } catch { + return null; + } +}; + +// --------------------------------------------------------------------------- +// Skill content builder +// --------------------------------------------------------------------------- + +const buildGogSkillContent = ({ fs, accounts }) => { + const authenticatedAccounts = accounts.filter((a) => a.authenticated); + if (!authenticatedAccounts.length) return null; + + const connectedServices = collectConnectedServices(authenticatedAccounts); + if (!connectedServices.size) return null; + + const serviceNames = kServiceOrder + .filter((svc) => connectedServices.has(svc)) + .map((svc) => kServiceDisplayNames[svc] || svc); + + const lines = []; + + // Frontmatter + lines.push("---"); + lines.push("name: gog-cli"); + lines.push( + `description: Google Workspace CLI (gog) — command reference for ${serviceNames.join(", ")}.`, + ); + lines.push("---"); + lines.push(""); + + // Header + lines.push("# gog — Google Workspace CLI"); + lines.push(""); + lines.push( + "Fast, script-friendly CLI for Google Workspace. All commands output structured JSON with `--json` or stable TSV with `--plain`.", + ); + lines.push(""); + + // Global flags + lines.push("## Global Flags"); + lines.push(""); + lines.push("```"); + lines.push("--account Account to use (or set GOG_ACCOUNT)"); + lines.push("--client OAuth client (default: \"default\")"); + lines.push("--json Structured JSON output"); + lines.push("--plain Stable TSV output (no colors)"); + lines.push("--force Skip confirmations"); + lines.push("--verbose Verbose logging"); + lines.push("```"); + lines.push(""); + + // Account table + lines.push("## Connected Accounts"); + lines.push(""); + lines.push("| Email | Client | Services |"); + lines.push("| ----- | ------ | -------- |"); + for (const account of authenticatedAccounts) { + const email = String(account.email || "").trim() || "(unknown)"; + const client = String(account.client || "default").trim(); + const services = uniqueServiceLabels(account.services).join(", "); + lines.push(`| ${email} | ${client} | ${services} |`); + } + lines.push(""); + lines.push( + "Always pass `--account ` (and `--client ` if not \"default\") so gog targets the correct account.", + ); + lines.push(""); + + // Per-service sections (read from markdown files) + for (const svc of kServiceOrder) { + if (!connectedServices.has(svc)) continue; + const section = readServiceSection(fs, svc); + if (section) { + lines.push(section.trimEnd()); + lines.push(""); + } + } + + return lines.join("\n"); +}; + +// --------------------------------------------------------------------------- +// Installer (reads state, writes SKILL.md) +// --------------------------------------------------------------------------- + +const installGogCliSkill = ({ fs, openclawDir }) => { + try { + const statePath = path.join(openclawDir, "gogcli", "state.json"); + const state = readGoogleState({ fs, statePath }); + const accounts = Array.isArray(state.accounts) ? state.accounts : []; + const content = buildGogSkillContent({ fs, accounts }); + + const skillDir = path.join(openclawDir, "skills", "gog-cli"); + + if (!content) { + // No authenticated accounts — remove stale skill if present + const skillPath = path.join(skillDir, "SKILL.md"); + if (fs.existsSync(skillPath)) { + fs.unlinkSync(skillPath); + console.log("[gog-skill] Removed stale gog-cli skill (no connected accounts)"); + } + return; + } + + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), content); + console.log("[gog-skill] gog-cli skill installed"); + } catch (e) { + console.error("[gog-skill] Install error:", e.message); + } +}; + +module.exports = { buildGogSkillContent, installGogCliSkill }; diff --git a/lib/server/routes/google.js b/lib/server/routes/google.js index 95a21e7c..b552cb05 100644 --- a/lib/server/routes/google.js +++ b/lib/server/routes/google.js @@ -11,6 +11,7 @@ const { removeGoogleAccount, } = require("../google-state"); const { syncBootstrapPromptFiles } = require("../onboarding/workspace"); +const { installGogCliSkill } = require("../gog-skill"); const quoteShellArg = (value) => `"${String(value || "").replace(/(["\\$`])/g, "\\$1")}"`; @@ -62,6 +63,9 @@ const registerGoogleRoutes = ({ baseUrl: getBaseUrl(req), }); } catch {} + try { + installGogCliSkill({ fs, openclawDir: constants.OPENCLAW_DIR }); + } catch {} }; const listAuthenticatedAccounts = async (state) => { diff --git a/lib/setup/skills/gog-cli/calendar.md b/lib/setup/skills/gog-cli/calendar.md new file mode 100644 index 00000000..e638e63b --- /dev/null +++ b/lib/setup/skills/gog-cli/calendar.md @@ -0,0 +1,63 @@ +## Calendar + +```bash +# List calendars +gog calendar calendars + +# Today's events +gog calendar events --today +gog calendar events --tomorrow +gog calendar events --week +gog calendar events --days 3 + +# Events from all calendars +gog calendar events --all --today + +# Date range +gog calendar events --from 2025-01-15T00:00:00Z --to 2025-01-22T00:00:00Z + +# Search events +gog calendar search "meeting" --today +gog calendar search "standup" --days 30 + +# Get single event +gog calendar event + +# Create event +gog calendar create --summary "Meeting" --from 2025-01-15T10:00:00Z --to 2025-01-15T11:00:00Z +gog calendar create --summary "Team Sync" --from 2025-01-15T14:00:00Z --to 2025-01-15T15:00:00Z --attendees "alice@example.com,bob@example.com" --location "Zoom" + +# Recurrence + reminders +gog calendar create --summary "Weekly" --from 2025-01-15T09:00:00Z --to 2025-01-15T09:30:00Z --rrule "RRULE:FREQ=WEEKLY" --reminder "popup:15m" + +# Update event +gog calendar update --summary "Updated" --from 2025-01-15T11:00:00Z --to 2025-01-15T12:00:00Z + +# Add attendees without replacing existing +gog calendar update --add-attendee "alice@example.com" + +# Send notifications +gog calendar create --summary "Sync" --from ... --to ... --send-updates all +gog calendar update --send-updates externalOnly + +# Delete event +gog calendar delete + +# RSVP to invitation +gog calendar respond --status accepted +gog calendar respond --status declined +gog calendar respond --status tentative + +# Free/busy check +gog calendar freebusy --calendars "primary,work@example.com" --from 2025-01-15T00:00:00Z --to 2025-01-16T00:00:00Z + +# Conflict detection +gog calendar conflicts --calendars "primary" --today + +# Special event types +gog calendar create primary --event-type focus-time --from ... --to ... +gog calendar create primary --event-type out-of-office --from ... --to ... --all-day +``` + +JSON output includes `startDayOfWeek`, `endDayOfWeek`, `timezone`, and `startLocal`/`endLocal` fields. +Use `primary` as calendarId for the user's default calendar. diff --git a/lib/setup/skills/gog-cli/contacts.md b/lib/setup/skills/gog-cli/contacts.md new file mode 100644 index 00000000..b0b26269 --- /dev/null +++ b/lib/setup/skills/gog-cli/contacts.md @@ -0,0 +1,30 @@ +## Contacts + +```bash +# List personal contacts +gog contacts list --max 50 + +# Search contacts +gog contacts search "Ada" --max 50 + +# Get contact by resource name or email +gog contacts get people/ +gog contacts get user@example.com + +# Create contact +gog contacts create --given "John" --family "Doe" --email "john@example.com" --phone "+1234567890" + +# Update contact +gog contacts update people/ --given "Jane" --email "jane@example.com" --notes "Updated" + +# Delete contact +gog contacts delete people/ + +# Other contacts (people you've interacted with) +gog contacts other list --max 50 +gog contacts other search "John" --max 50 + +# Workspace directory (Google Workspace only) +gog contacts directory list --max 50 +gog contacts directory search "Jane" --max 50 +``` diff --git a/lib/setup/skills/gog-cli/docs.md b/lib/setup/skills/gog-cli/docs.md new file mode 100644 index 00000000..8cece4dd --- /dev/null +++ b/lib/setup/skills/gog-cli/docs.md @@ -0,0 +1,46 @@ +## Docs + +```bash +# Document info +gog docs info + +# Read document text +gog docs cat +gog docs cat --max-bytes 10000 +gog docs cat --tab "Notes" +gog docs cat --all-tabs + +# List tabs +gog docs list-tabs + +# Create document +gog docs create "My Doc" +gog docs create "My Doc" --file ./doc.md + +# Copy document +gog docs copy "My Doc Copy" + +# Export +gog docs export --format pdf --out ./doc.pdf +gog docs export --format docx --out ./doc.docx +gog docs export --format txt --out ./doc.txt + +# Update document content (markdown) +gog docs update --format markdown --content-file ./doc.md +gog docs write --replace --markdown --file ./doc.md + +# Find and replace +gog docs find-replace "old text" "new text" + +# Sed-style editing (sedmat) with markdown formatting +gog docs sed 's/hello/**hello**/' # bold +gog docs sed 's/hello/*hello*/' # italic +gog docs sed 's/hello/`hello`/' # monospace +gog docs sed 's/hello/__hello__/' # underline +gog docs sed 's/Google/[Google](https://google.com)/' # link +gog docs sed 's/{{LOGO}}/![](https://example.com/logo.png)/' # image + +# Tables via sedmat +gog docs sed 's/{{TABLE}}/|3x4|/' # create 3-row, 4-col table +gog docs sed 's/|1|[A1]/**Name**/' # set cell A1 +``` diff --git a/lib/setup/skills/gog-cli/drive.md b/lib/setup/skills/gog-cli/drive.md new file mode 100644 index 00000000..525eba3a --- /dev/null +++ b/lib/setup/skills/gog-cli/drive.md @@ -0,0 +1,48 @@ +## Drive + +```bash +# List files (default: all accessible files including shared drives) +gog drive ls --max 20 +gog drive ls --parent --max 20 +gog drive ls --no-all-drives + +# Search files +gog drive search "invoice" --max 20 +gog drive search "mimeType = 'application/pdf'" --raw-query + +# Get file metadata +gog drive get +gog drive url + +# Download file +gog drive download --out ./downloaded.bin + +# Export Google Workspace files +gog drive download --format pdf --out ./exported.pdf +gog drive download --format docx --out ./doc.docx + +# Upload file +gog drive upload ./path/to/file --parent +gog drive upload ./file.docx --convert +gog drive upload ./file --replace + +# Copy file +gog drive copy "Copy Name" + +# Organize +gog drive mkdir "New Folder" +gog drive mkdir "New Folder" --parent +gog drive rename "New Name" +gog drive move --parent +gog drive delete +gog drive delete --permanent + +# Permissions +gog drive permissions +gog drive share --to user --email user@example.com --role reader +gog drive share --to user --email user@example.com --role writer +gog drive unshare --permission-id + +# Shared drives +gog drive drives --max 100 +``` diff --git a/lib/setup/skills/gog-cli/gmail.md b/lib/setup/skills/gog-cli/gmail.md new file mode 100644 index 00000000..b7a76300 --- /dev/null +++ b/lib/setup/skills/gog-cli/gmail.md @@ -0,0 +1,64 @@ +## Gmail + +```bash +# Search threads (returns thread IDs + snippets) +gog gmail search 'newer_than:7d' --max 10 +gog gmail search 'from:alice@example.com subject:invoice' --max 20 + +# Search individual messages (add --include-body to fetch bodies) +gog gmail messages search 'newer_than:7d' --max 10 --include-body + +# Read a thread and optionally download attachments +gog gmail thread get +gog gmail thread get --download --out-dir ./attachments + +# Read a single message +gog gmail get + +# Modify labels on a thread +gog gmail thread modify --add STARRED --remove INBOX + +# Send email (plain text) +gog gmail send --to recipient@example.com --subject "Subject" --body "Body text" + +# Send email (HTML) +gog gmail send --to recipient@example.com --subject "Subject" --body "Plain fallback" --body-html "

        Hello

        " + +# Reply to a message (with quoted original) +gog gmail send --reply-to-message-id --quote --to recipient@example.com --subject "Re: Subject" --body "Reply text" + +# Send with body from file or stdin +gog gmail send --to recipient@example.com --subject "Subject" --body-file ./message.txt +gog gmail send --to recipient@example.com --subject "Subject" --body-file - + +# Labels +gog gmail labels list +gog gmail labels get INBOX --json +gog gmail labels create "My Label" +gog gmail labels delete + +# Drafts +gog gmail drafts list +gog gmail drafts create --to recipient@example.com --subject "Draft" --body "Body" +gog gmail drafts update --subject "Updated" --body "New body" +gog gmail drafts send + +# Batch operations +gog gmail batch modify --add STARRED --remove INBOX +gog gmail batch delete + +# Filters +gog gmail filters list +gog gmail filters create --from 'noreply@example.com' --add-label 'Notifications' +gog gmail filters delete + +# Vacation / auto-reply +gog gmail vacation get +gog gmail vacation enable --subject "Out of office" --message "I'm away" +gog gmail vacation disable + +# History (for incremental sync) +gog gmail history --since +``` + +Output: use `--json` for structured output, `--plain` for TSV. diff --git a/lib/setup/skills/gog-cli/meet.md b/lib/setup/skills/gog-cli/meet.md new file mode 100644 index 00000000..59abc010 --- /dev/null +++ b/lib/setup/skills/gog-cli/meet.md @@ -0,0 +1,6 @@ +## Meet + +```bash +# List meeting spaces +gog meet spaces list +``` diff --git a/lib/setup/skills/gog-cli/sheets.md b/lib/setup/skills/gog-cli/sheets.md new file mode 100644 index 00000000..d7745433 --- /dev/null +++ b/lib/setup/skills/gog-cli/sheets.md @@ -0,0 +1,43 @@ +## Sheets + +```bash +# Spreadsheet metadata (sheets list, properties) +gog sheets metadata + +# Read cell range +gog sheets get 'Sheet1!A1:B10' + +# Write cells (pipe-delimited rows, comma-separated columns) +gog sheets update 'A1' 'val1|val2,val3|val4' +gog sheets update 'A1' --values-json '[["a","b"],["c","d"]]' + +# Append rows +gog sheets append 'Sheet1!A:C' 'new|row|data' + +# Clear range +gog sheets clear 'Sheet1!A1:B10' + +# Create spreadsheet +gog sheets create "My Spreadsheet" --sheets "Sheet1,Sheet2" + +# Copy spreadsheet +gog sheets copy "Copy Name" + +# Export +gog sheets export --format pdf --out ./sheet.pdf +gog sheets export --format xlsx --out ./sheet.xlsx + +# Format cells +gog sheets format 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold' + +# Insert rows/columns +gog sheets insert "Sheet1" rows 2 --count 3 +gog sheets insert "Sheet1" cols 3 --after + +# Cell notes and hyperlinks +gog sheets notes 'Sheet1!A1:B10' +gog sheets links 'Sheet1!A1:B10' +``` + +Write format: rows separated by `,` and columns by `|`. Use `--values-json` for complex data. +`--copy-validation-from` copies data validation from a reference range when updating/appending. diff --git a/lib/setup/skills/gog-cli/tasks.md b/lib/setup/skills/gog-cli/tasks.md new file mode 100644 index 00000000..a87e693c --- /dev/null +++ b/lib/setup/skills/gog-cli/tasks.md @@ -0,0 +1,32 @@ +## Tasks + +```bash +# List task lists +gog tasks lists + +# List tasks in a list +gog tasks list --max 50 + +# Get single task +gog tasks get + +# Create task list +gog tasks lists create "My List" + +# Add task +gog tasks add --title "Task title" +gog tasks add --title "Weekly sync" --due 2025-02-01 --repeat weekly --repeat-count 4 + +# Update task +gog tasks update --title "Updated title" + +# Complete / uncomplete task +gog tasks done +gog tasks undo + +# Delete task / clear completed +gog tasks delete +gog tasks clear +``` + +Due dates are date-only; time components may be ignored by Google Tasks. From 5cfad8e57ee6a1f4d69861f641e7bd55147d05e6 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 19:46:36 -0800 Subject: [PATCH 084/417] Recover onboarding from stale setup failures and scope errors by step. Persist setup-step failures in onboarding local storage to recover reloads back to step one while preserving entered values, and split welcome onboarding logic into focused hooks for storage, Codex auth, and pairing. --- .../onboarding/use-welcome-codex.js | 129 +++++++++ .../onboarding/use-welcome-pairing.js | 74 +++++ .../onboarding/use-welcome-storage.js | 60 ++++ lib/public/js/components/welcome.js | 267 ++++-------------- 4 files changed, 320 insertions(+), 210 deletions(-) create mode 100644 lib/public/js/components/onboarding/use-welcome-codex.js create mode 100644 lib/public/js/components/onboarding/use-welcome-pairing.js create mode 100644 lib/public/js/components/onboarding/use-welcome-storage.js diff --git a/lib/public/js/components/onboarding/use-welcome-codex.js b/lib/public/js/components/onboarding/use-welcome-codex.js new file mode 100644 index 00000000..843f6cc8 --- /dev/null +++ b/lib/public/js/components/onboarding/use-welcome-codex.js @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks"; +import { + disconnectCodex, + exchangeCodexOAuth, + fetchCodexStatus, +} from "../../lib/api.js"; + +export const useWelcomeCodex = ({ setFormError } = {}) => { + const [codexStatus, setCodexStatus] = useState({ connected: false }); + const [codexLoading, setCodexLoading] = useState(true); + const [codexManualInput, setCodexManualInput] = useState(""); + const [codexExchanging, setCodexExchanging] = useState(false); + const [codexAuthStarted, setCodexAuthStarted] = useState(false); + const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); + const codexPopupPollRef = useRef(null); + + const refreshCodexStatus = async () => { + try { + const status = await fetchCodexStatus(); + setCodexStatus(status); + if (status?.connected) { + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + } + } catch { + setCodexStatus({ connected: false }); + } finally { + setCodexLoading(false); + } + }; + + useEffect(() => { + refreshCodexStatus(); + }, []); + + useEffect(() => { + const onMessage = async (e) => { + if (e.data?.codex === "success") { + await refreshCodexStatus(); + } + if (e.data?.codex === "error") { + setFormError(`Codex auth failed: ${e.data.message || "unknown error"}`); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [setFormError]); + + useEffect( + () => () => { + if (codexPopupPollRef.current) { + clearInterval(codexPopupPollRef.current); + codexPopupPollRef.current = null; + } + }, + [], + ); + + const startCodexAuth = () => { + if (codexStatus.connected) return; + setCodexAuthStarted(true); + setCodexAuthWaiting(true); + const authUrl = "/auth/codex/start"; + const popup = window.open( + authUrl, + "codex-auth", + "popup=yes,width=640,height=780", + ); + if (!popup || popup.closed) { + setCodexAuthWaiting(false); + window.location.href = authUrl; + return; + } + if (codexPopupPollRef.current) { + clearInterval(codexPopupPollRef.current); + } + codexPopupPollRef.current = setInterval(() => { + if (popup.closed) { + clearInterval(codexPopupPollRef.current); + codexPopupPollRef.current = null; + setCodexAuthWaiting(false); + } + }, 500); + }; + + const completeCodexAuth = async () => { + if (!codexManualInput.trim() || codexExchanging) return; + setCodexExchanging(true); + setFormError(null); + try { + const result = await exchangeCodexOAuth(codexManualInput.trim()); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexStatus(); + } catch (err) { + setFormError(err.message || "Codex OAuth exchange failed"); + } finally { + setCodexExchanging(false); + } + }; + + const handleCodexDisconnect = async () => { + const result = await disconnectCodex(); + if (!result.ok) { + setFormError(result.error || "Failed to disconnect Codex"); + return; + } + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + setCodexManualInput(""); + await refreshCodexStatus(); + }; + + return { + codexStatus, + codexLoading, + codexManualInput, + setCodexManualInput, + codexExchanging, + codexAuthStarted, + codexAuthWaiting, + startCodexAuth, + completeCodexAuth, + handleCodexDisconnect, + }; +}; diff --git a/lib/public/js/components/onboarding/use-welcome-pairing.js b/lib/public/js/components/onboarding/use-welcome-pairing.js new file mode 100644 index 00000000..27819d1c --- /dev/null +++ b/lib/public/js/components/onboarding/use-welcome-pairing.js @@ -0,0 +1,74 @@ +import { useEffect, useState } from "https://esm.sh/preact/hooks"; +import { approvePairing, fetchPairings, fetchStatus, rejectPairing } from "../../lib/api.js"; +import { usePolling } from "../../hooks/usePolling.js"; +import { isChannelPaired } from "./pairing-utils.js"; + +export const useWelcomePairing = ({ + isPairingStep = false, + selectedPairingChannel = "", +} = {}) => { + const [pairingError, setPairingError] = useState(null); + const [pairingComplete, setPairingComplete] = useState(false); + + const pairingStatusPoll = usePolling(fetchStatus, 3000, { + enabled: isPairingStep, + }); + const pairingRequestsPoll = usePolling( + async () => { + const payload = await fetchPairings(); + const allPending = payload.pending || []; + return allPending.filter((p) => p.channel === selectedPairingChannel); + }, + 1000, + { enabled: isPairingStep && !!selectedPairingChannel }, + ); + const pairingChannels = pairingStatusPoll.data?.channels || {}; + const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel); + + useEffect(() => { + if (isPairingStep && canFinishPairing) { + setPairingComplete(true); + } + }, [isPairingStep, canFinishPairing]); + + const handlePairingApprove = async (id, channel) => { + try { + setPairingError(null); + const result = await approvePairing(id, channel); + if (!result.ok) throw new Error(result.error || "Could not approve pairing"); + setPairingComplete(true); + pairingRequestsPoll.refresh(); + pairingStatusPoll.refresh(); + } catch (err) { + setPairingError(err.message || "Could not approve pairing"); + } + }; + + const handlePairingReject = async (id, channel) => { + try { + setPairingError(null); + const result = await rejectPairing(id, channel); + if (!result.ok) throw new Error(result.error || "Could not reject pairing"); + pairingRequestsPoll.refresh(); + } catch (err) { + setPairingError(err.message || "Could not reject pairing"); + } + }; + + const resetPairingState = () => { + setPairingError(null); + setPairingComplete(false); + }; + + return { + pairingStatusPoll, + pairingRequestsPoll, + pairingChannels, + canFinishPairing, + pairingError, + pairingComplete, + handlePairingApprove, + handlePairingReject, + resetPairingState, + }; +}; diff --git a/lib/public/js/components/onboarding/use-welcome-storage.js b/lib/public/js/components/onboarding/use-welcome-storage.js new file mode 100644 index 00000000..b831dac9 --- /dev/null +++ b/lib/public/js/components/onboarding/use-welcome-storage.js @@ -0,0 +1,60 @@ +import { useEffect, useState } from "https://esm.sh/preact/hooks"; + +export const kOnboardingStorageKey = "openclaw_setup"; +export const kOnboardingStepKey = "_step"; +export const kPairingChannelKey = "_pairingChannel"; +export const kOnboardingSetupErrorKey = "_lastSetupError"; + +const loadInitialSetupState = () => { + try { + return JSON.parse(localStorage.getItem(kOnboardingStorageKey) || "{}"); + } catch { + return {}; + } +}; + +export const useWelcomeStorage = ({ + kSetupStepIndex, + kPairingStepIndex, +} = {}) => { + const [initialSetupState] = useState(loadInitialSetupState); + const [vals, setVals] = useState(() => ({ ...initialSetupState })); + const [setupError, setSetupError] = useState(null); + const initialSetupError = String( + initialSetupState?.[kOnboardingSetupErrorKey] || "", + ).trim(); + const shouldRecoverFromSetupState = !!initialSetupError; + const [step, setStep] = useState(() => { + const parsedStep = Number.parseInt( + String(initialSetupState?.[kOnboardingStepKey] || ""), + 10, + ); + if (!Number.isFinite(parsedStep)) return 0; + const clampedStep = Math.max(0, Math.min(kPairingStepIndex, parsedStep)); + if (clampedStep === kSetupStepIndex && shouldRecoverFromSetupState) return 0; + return clampedStep; + }); + + useEffect(() => { + localStorage.setItem( + kOnboardingStorageKey, + JSON.stringify({ + ...vals, + [kOnboardingStepKey]: step, + ...(setupError ? { [kOnboardingSetupErrorKey]: setupError } : {}), + }), + ); + }, [vals, step, setupError]); + + const setValue = (key, value) => setVals((prev) => ({ ...prev, [key]: value })); + + return { + vals, + setVals, + setValue, + step, + setStep, + setupError, + setSetupError, + }; +}; diff --git a/lib/public/js/components/welcome.js b/lib/public/js/components/welcome.js index a3a7ce3b..75b55bee 100644 --- a/lib/public/js/components/welcome.js +++ b/lib/public/js/components/welcome.js @@ -1,19 +1,11 @@ import { h } from "https://esm.sh/preact"; -import { useState, useEffect, useRef } from "https://esm.sh/preact/hooks"; +import { useState, useEffect } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { runOnboard, verifyGithubOnboardingRepo, fetchModels, - fetchCodexStatus, - disconnectCodex, - exchangeCodexOAuth, - fetchStatus, - fetchPairings, - approvePairing, - rejectPairing, } from "../lib/api.js"; -import { usePolling } from "../hooks/usePolling.js"; import { getModelProvider, getFeaturedModels, @@ -27,41 +19,46 @@ import { WelcomeHeader } from "./onboarding/welcome-header.js"; import { WelcomeSetupStep } from "./onboarding/welcome-setup-step.js"; import { WelcomeFormStep } from "./onboarding/welcome-form-step.js"; import { WelcomePairingStep } from "./onboarding/welcome-pairing-step.js"; +import { getPreferredPairingChannel } from "./onboarding/pairing-utils.js"; import { - getPreferredPairingChannel, - isChannelPaired, -} from "./onboarding/pairing-utils.js"; + kOnboardingStorageKey, + kPairingChannelKey, + useWelcomeStorage, +} from "./onboarding/use-welcome-storage.js"; +import { useWelcomeCodex } from "./onboarding/use-welcome-codex.js"; +import { useWelcomePairing } from "./onboarding/use-welcome-pairing.js"; const html = htm.bind(h); -const kOnboardingStorageKey = "openclaw_setup"; -const kOnboardingStepKey = "_step"; -const kPairingChannelKey = "_pairingChannel"; const kMaxOnboardingVars = 64; const kMaxEnvKeyLength = 128; const kMaxEnvValueLength = 4096; export const Welcome = ({ onComplete }) => { - const [initialSetupState] = useState(() => { - try { - return JSON.parse(localStorage.getItem(kOnboardingStorageKey) || "{}"); - } catch { - return {}; - } - }); - const [vals, setVals] = useState(() => ({ ...initialSetupState })); + const kSetupStepIndex = kWelcomeGroups.length; + const kPairingStepIndex = kSetupStepIndex + 1; + const { vals, setVals, setValue, step, setStep, setupError, setSetupError } = + useWelcomeStorage({ + kSetupStepIndex, + kPairingStepIndex, + }); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); const [modelsError, setModelsError] = useState(null); const [showAllModels, setShowAllModels] = useState(false); - const [codexStatus, setCodexStatus] = useState({ connected: false }); - const [codexLoading, setCodexLoading] = useState(true); - const [codexManualInput, setCodexManualInput] = useState(""); - const [codexExchanging, setCodexExchanging] = useState(false); - const [codexAuthStarted, setCodexAuthStarted] = useState(false); - const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); const [loading, setLoading] = useState(false); const [githubStepLoading, setGithubStepLoading] = useState(false); - const [error, setError] = useState(null); - const codexPopupPollRef = useRef(null); + const [formError, setFormError] = useState(null); + const { + codexStatus, + codexLoading, + codexManualInput, + setCodexManualInput, + codexExchanging, + codexAuthStarted, + codexAuthWaiting, + startCodexAuth, + completeCodexAuth, + handleCodexDisconnect, + } = useWelcomeCodex({ setFormError }); useEffect(() => { fetchModels() @@ -78,50 +75,6 @@ export const Welcome = ({ onComplete }) => { .finally(() => setModelsLoading(false)); }, []); - const refreshCodexStatus = async () => { - try { - const status = await fetchCodexStatus(); - setCodexStatus(status); - if (status?.connected) { - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - } - } catch { - setCodexStatus({ connected: false }); - } finally { - setCodexLoading(false); - } - }; - - useEffect(() => { - refreshCodexStatus(); - }, []); - - useEffect(() => { - const onMessage = async (e) => { - if (e.data?.codex === "success") { - await refreshCodexStatus(); - } - if (e.data?.codex === "error") { - setError(`Codex auth failed: ${e.data.message || "unknown error"}`); - } - }; - window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); - }, []); - - useEffect( - () => () => { - if (codexPopupPollRef.current) { - clearInterval(codexPopupPollRef.current); - codexPopupPollRef.current = null; - } - }, - [], - ); - - const set = (key, value) => setVals((prev) => ({ ...prev, [key]: value })); - const selectedProvider = getModelProvider(vals.MODEL_KEY); const featuredModels = getFeaturedModels(models); const baseModelOptions = showAllModels @@ -152,18 +105,6 @@ export const Welcome = ({ onComplete }) => { : false; const allValid = kWelcomeGroups.every((g) => g.validate(vals, { hasAi })); - const kSetupStepIndex = kWelcomeGroups.length; - const kPairingStepIndex = kSetupStepIndex + 1; - const [step, setStep] = useState(() => { - const parsedStep = Number.parseInt( - String(initialSetupState?.[kOnboardingStepKey] || ""), - 10, - ); - if (!Number.isFinite(parsedStep)) return 0; - return Math.max(0, Math.min(kPairingStepIndex, parsedStep)); - }); - const [pairingError, setPairingError] = useState(null); - const [pairingComplete, setPairingComplete] = useState(false); const isSetupStep = step === kSetupStepIndex; const isPairingStep = step === kPairingStepIndex; const activeGroup = step < kSetupStepIndex ? kWelcomeGroups[step] : null; @@ -173,94 +114,20 @@ export const Welcome = ({ onComplete }) => { const selectedPairingChannel = String( vals[kPairingChannelKey] || getPreferredPairingChannel(vals), ); - const pairingStatusPoll = usePolling(fetchStatus, 3000, { - enabled: isPairingStep, + const { + pairingStatusPoll, + pairingRequestsPoll, + pairingChannels, + canFinishPairing, + pairingError, + pairingComplete, + handlePairingApprove, + handlePairingReject, + resetPairingState, + } = useWelcomePairing({ + isPairingStep, + selectedPairingChannel, }); - const pairingRequestsPoll = usePolling( - async () => { - const payload = await fetchPairings(); - const allPending = payload.pending || []; - return allPending.filter((p) => p.channel === selectedPairingChannel); - }, - 1000, - { enabled: isPairingStep && !!selectedPairingChannel }, - ); - const pairingChannels = pairingStatusPoll.data?.channels || {}; - const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel); - - useEffect(() => { - if (isPairingStep && canFinishPairing) { - setPairingComplete(true); - } - }, [isPairingStep, canFinishPairing]); - - useEffect(() => { - localStorage.setItem( - kOnboardingStorageKey, - JSON.stringify({ - ...vals, - [kOnboardingStepKey]: step, - }), - ); - }, [vals, step]); - - const startCodexAuth = () => { - if (codexStatus.connected) return; - setCodexAuthStarted(true); - setCodexAuthWaiting(true); - const authUrl = "/auth/codex/start"; - const popup = window.open( - authUrl, - "codex-auth", - "popup=yes,width=640,height=780", - ); - if (!popup || popup.closed) { - setCodexAuthWaiting(false); - window.location.href = authUrl; - return; - } - if (codexPopupPollRef.current) { - clearInterval(codexPopupPollRef.current); - } - codexPopupPollRef.current = setInterval(() => { - if (popup.closed) { - clearInterval(codexPopupPollRef.current); - codexPopupPollRef.current = null; - setCodexAuthWaiting(false); - } - }, 500); - }; - - const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - setError(null); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexStatus(); - } catch (err) { - setError(err.message || "Codex OAuth exchange failed"); - } finally { - setCodexExchanging(false); - } - }; - - const handleCodexDisconnect = async () => { - const result = await disconnectCodex(); - if (!result.ok) { - setError(result.error || "Failed to disconnect Codex"); - return; - } - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - setCodexManualInput(""); - await refreshCodexStatus(); - }; const handleSubmit = async () => { if (!allValid || loading) return; @@ -294,14 +161,16 @@ export const Welcome = ({ onComplete }) => { return ""; })(); if (preflightError) { - setError(preflightError); + setFormError(preflightError); + setSetupError(null); setStep(Math.max(0, kWelcomeGroups.findIndex((g) => g.id === "github"))); return; } setStep(kSetupStepIndex); setLoading(true); - setError(null); - setPairingError(null); + setFormError(null); + setSetupError(null); + resetPairingState(); try { const result = await runOnboard(vars, vals.MODEL_KEY); @@ -316,38 +185,15 @@ export const Welcome = ({ onComplete }) => { })); setLoading(false); setStep(kPairingStepIndex); - setPairingComplete(false); + resetPairingState(); + setSetupError(null); } catch (err) { console.error("Onboard error:", err); - setError(err.message); + setSetupError(err.message || "Onboarding failed"); setLoading(false); } }; - const handlePairingApprove = async (id, channel) => { - try { - setPairingError(null); - const result = await approvePairing(id, channel); - if (!result.ok) throw new Error(result.error || "Could not approve pairing"); - setPairingComplete(true); - pairingRequestsPoll.refresh(); - pairingStatusPoll.refresh(); - } catch (err) { - setPairingError(err.message || "Could not approve pairing"); - } - }; - - const handlePairingReject = async (id, channel) => { - try { - setPairingError(null); - const result = await rejectPairing(id, channel); - if (!result.ok) throw new Error(result.error || "Could not reject pairing"); - pairingRequestsPoll.refresh(); - } catch (err) { - setPairingError(err.message || "Could not reject pairing"); - } - }; - const finishOnboarding = () => { localStorage.removeItem(kOnboardingStorageKey); onComplete(); @@ -355,17 +201,18 @@ export const Welcome = ({ onComplete }) => { const goBack = () => { if (isSetupStep) return; - setError(null); + setFormError(null); setStep((prev) => Math.max(0, prev - 1)); }; const goBackFromSetupError = () => { setLoading(false); + setSetupError(null); setStep(kWelcomeGroups.length - 1); }; const goNext = async () => { if (!activeGroup || !currentGroupValid) return; - setError(null); + setFormError(null); if (activeGroup.id === "github") { setGithubStepLoading(true); try { @@ -374,11 +221,11 @@ export const Welcome = ({ onComplete }) => { vals.GITHUB_TOKEN, ); if (!result?.ok) { - setError(result?.error || "GitHub verification failed"); + setFormError(result?.error || "GitHub verification failed"); return; } } catch (err) { - setError(err?.message || "GitHub verification failed"); + setFormError(err?.message || "GitHub verification failed"); return; } finally { setGithubStepLoading(false); @@ -412,7 +259,7 @@ export const Welcome = ({ onComplete }) => {
        ${isSetupStep ? html`<${WelcomeSetupStep} - error=${error} + error=${setupError} loading=${loading} onRetry=${handleSubmit} onBack=${goBackFromSetupError} @@ -434,7 +281,7 @@ export const Welcome = ({ onComplete }) => { activeGroup=${activeGroup} vals=${vals} hasAi=${hasAi} - setValue=${set} + setValue=${setValue} modelOptions=${modelOptions} modelsLoading=${modelsLoading} modelsError=${modelsError} @@ -453,7 +300,7 @@ export const Welcome = ({ onComplete }) => { completeCodexAuth=${completeCodexAuth} codexExchanging=${codexExchanging} visibleAiFieldKeys=${visibleAiFieldKeys} - error=${error} + error=${formError} step=${step} totalGroups=${kWelcomeGroups.length} currentGroupValid=${currentGroupValid} From 9ac3fe020e152939821ec8c852f09fecf675471b Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 20:16:18 -0800 Subject: [PATCH 085/417] Add file/folder creation, context menu, drag-to-move, and folder locking to explorer Introduce create-file, create-folder, and move API endpoints on the server and wire them into the file tree UI. Files and folders can now be created from toolbar buttons (context-aware based on selection) or via right-click context menu. Drag-and-drop moves items between folders. Folder-level locking (e.g. skills/control-ui, skills/gog-cli) prevents modifications to managed directories and all their contents. Folder selection stays local to the tree so the viewer pane is never affected, and the search bar with create buttons is pinned above the scrollable tree. --- lib/public/css/explorer.css | 160 ++++- lib/public/js/components/file-tree.js | 581 ++++++++++++++++-- lib/public/js/components/file-viewer/index.js | 10 +- lib/public/js/components/icons.js | 26 + lib/public/js/lib/api.js | 27 + lib/public/js/lib/browse-file-policies.js | 4 +- lib/public/shared/browse-file-policies.json | 4 +- lib/server/routes/browse/index.js | 129 +++- lib/server/routes/browse/path-utils.js | 4 +- tests/server/routes-browse.test.js | 15 +- 10 files changed, 884 insertions(+), 76 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index b687208c..4a0a2fda 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -101,8 +101,18 @@ .file-tree-wrap { width: 100%; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + padding: 6px 0 0; +} + +.file-tree-scroll { + flex: 1; + min-height: 0; overflow-y: auto; - padding: 6px 0 8px; + padding-bottom: 8px; } .file-tree-wrap-loading { @@ -111,9 +121,19 @@ } .file-tree-search { + display: flex; + align-items: center; + gap: 4px; padding: 0 8px 6px; } +.file-tree-search-actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + .file-tree-search-input { width: 100%; height: 28px; @@ -136,15 +156,15 @@ box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18); } -.file-tree-wrap::-webkit-scrollbar { +.file-tree-scroll::-webkit-scrollbar { width: 6px; } -.file-tree-wrap::-webkit-scrollbar-track { +.file-tree-scroll::-webkit-scrollbar-track { background: transparent; } -.file-tree-wrap::-webkit-scrollbar-thumb { +.file-tree-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } @@ -320,6 +340,138 @@ color: var(--text-dim); } +.tree-folder-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: color 0.1s, background 0.1s; +} + +.tree-folder-action:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.08); +} + +.tree-folder-action-icon { + width: 16px; + height: 16px; + display: block; +} + +.tree-create-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 10px 2px 18px; +} + +.tree-create-input { + flex: 1; + min-width: 0; + height: 22px; + border-radius: 4px; + border: 1px solid rgba(99, 235, 255, 0.45); + background: rgba(0, 0, 0, 0.3); + color: var(--text); + font-size: 12px; + font-family: inherit; + padding: 0 6px; + outline: none; +} + +.tree-create-input::placeholder { + color: var(--text-dim); +} + +.tree-create-input:focus { + box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18); +} + +.tree-create-icon { + flex-shrink: 0; + width: 15px; + height: 15px; + display: block; + color: var(--text-dim); +} + +.tree-context-menu { + position: fixed; + z-index: 100; + min-width: 160px; + padding: 4px 0; + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} + +.tree-context-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + border: 0; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 12px; + cursor: pointer; + text-align: left; + transition: background 0.1s, color 0.1s; +} + +.tree-context-menu-item:hover { + background: var(--bg-hover); + color: var(--text); +} + +.tree-context-menu-item.is-disabled { + color: var(--text-dim); + cursor: default; + pointer-events: none; +} + +.tree-context-menu-item.is-danger { + color: #f87171; +} + +.tree-context-menu-item.is-danger:hover { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; +} + +.tree-context-menu-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.tree-context-menu-sep { + height: 1px; + margin: 4px 8px; + background: var(--border); +} + +.tree-item.is-dragging { + opacity: 0.4; +} + +.tree-folder.is-drop-target { + outline: 1px dashed var(--accent); + outline-offset: -1px; + background: rgba(99, 235, 255, 0.06); +} + .tree-children { list-style: none; } diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index ce328417..ed85bdd8 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -7,8 +7,13 @@ import { useState, } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; -import { fetchBrowseTree } from "../lib/api.js"; -import { deleteBrowseFile } from "../lib/api.js"; +import { + fetchBrowseTree, + deleteBrowseFile, + createBrowseFile, + createBrowseFolder, + moveBrowsePath, +} from "../lib/api.js"; import { kDraftIndexChangedEventName, readStoredDraftPaths, @@ -32,6 +37,9 @@ import { Database2LineIcon, HashtagIcon, LockLineIcon, + FileAddLineIcon, + FolderAddLineIcon, + DeleteBinLineIcon, } from "./icons.js"; import { LoadingSpinner } from "./loading-spinner.js"; import { ConfirmDialog } from "./confirm-dialog.js"; @@ -208,6 +216,147 @@ const getFileIconMeta = (fileName) => { }; }; +const TreeContextMenu = ({ + x, + y, + targetPath, + targetType, + isLocked, + onNewFile, + onNewFolder, + onDelete, + onClose, +}) => { + const menuRef = useRef(null); + + useEffect(() => { + const handleClick = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) onClose(); + }; + const handleKey = (e) => { + if (e.key === "Escape") onClose(); + }; + const handleScroll = () => onClose(); + window.addEventListener("mousedown", handleClick); + window.addEventListener("keydown", handleKey); + window.addEventListener("scroll", handleScroll, true); + return () => { + window.removeEventListener("mousedown", handleClick); + window.removeEventListener("keydown", handleKey); + window.removeEventListener("scroll", handleScroll, true); + }; + }, [onClose]); + + const isFolder = targetType === "folder"; + const isFile = targetType === "file"; + const isRoot = targetType === "root"; + const contextFolder = isFolder ? targetPath : ""; + const canCreate = !isLocked && (isFolder || isRoot); + const canDelete = !isLocked && (isFolder || isFile) && targetPath; + + return html` +
        + ${canCreate + ? html` + + + ` + : null} + ${canDelete + ? html` + ${canCreate + ? html`
        ` + : null} + + ` + : null} + ${isLocked + ? html` +
        + <${LockLineIcon} className="tree-context-menu-icon" /> + Managed by AlphaClaw +
        + ` + : null} +
        + `; +}; + +const CreationInput = ({ type, depth, onConfirm, onCancel }) => { + const inputRef = useRef(null); + const [value, setValue] = useState(""); + const submittedRef = useRef(false); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const submit = () => { + const name = value.trim(); + if (!name || submittedRef.current) { + onCancel(); + return; + } + submittedRef.current = true; + onConfirm(name); + }; + + const IconComponent = type === "folder" ? FolderAddLineIcon : FileAddLineIcon; + return html` +
      3. +
        + <${IconComponent} className="tree-create-icon" /> + setValue(e.target.value)} + onKeyDown=${(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + onBlur=${submit} + placeholder=${type === "folder" ? "folder name" : "file name"} + autocomplete="off" + spellcheck=${false} + /> +
        +
      4. + `; +}; + const TreeNode = ({ node, depth = 0, @@ -216,10 +365,17 @@ const TreeNode = ({ onSelectFolder, onRequestDelete, onSelectFile, + onContextMenu, + onDragDrop, selectedPath = "", draftPaths, isSearchActive = false, searchActivePath = "", + creatingInFolder = "", + creatingType = "", + onCreationConfirm, + onCreationCancel, + dragSourcePath = "", }) => { if (!node) return null; if (node.type === "file") { @@ -233,10 +389,23 @@ const TreeNode = ({ const fileIconMeta = getFileIconMeta(node.name); const FileTypeIcon = fileIconMeta.icon; return html` -
      5. +
      6. onSelectFile(node.path)} + oncontextmenu=${(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu({ x: e.clientX, y: e.clientY, targetPath: node.path, targetType: "file", isLocked }); + }} + draggable=${!isLocked} + onDragStart=${(e) => { + if (isLocked) { e.preventDefault(); return; } + e.dataTransfer.setData("text/plain", node.path); + e.dataTransfer.effectAllowed = "move"; + onDragDrop("start", node.path); + }} + onDragEnd=${() => onDragDrop("end", "")} onKeyDown=${(event) => { const isDeleteKey = event.key === "Delete" || event.key === "Backspace"; @@ -269,20 +438,60 @@ const TreeNode = ({ const folderPath = node.path || ""; const isCollapsed = isSearchActive ? false : !expandedPaths.has(folderPath); const isFolderActive = selectedPath === folderPath; + const isFolderLocked = folderPath && matchesBrowsePolicyPath( + kLockedBrowsePaths, + normalizeBrowsePolicyPath(folderPath), + ); + const [isDropTarget, setIsDropTarget] = useState(false); + const dropCounterRef = useRef(0); return html` -
      7. +
      8. { if (!folderPath) return; - if (isFolderActive) { - onSetFolderExpanded(folderPath, false); - onSelectFolder(""); - return; - } - onSetFolderExpanded(folderPath, true); + onSetFolderExpanded(folderPath, isCollapsed); onSelectFolder(folderPath); }} + oncontextmenu=${(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu({ x: e.clientX, y: e.clientY, targetPath: folderPath, targetType: "folder", isLocked: isFolderLocked }); + }} + draggable=${!!folderPath && !isFolderLocked} + onDragStart=${(e) => { + if (!folderPath || isFolderLocked) { e.preventDefault(); return; } + e.dataTransfer.setData("text/plain", folderPath); + e.dataTransfer.effectAllowed = "move"; + onDragDrop("start", folderPath); + }} + onDragEnd=${() => { setIsDropTarget(false); dropCounterRef.current = 0; onDragDrop("end", ""); }} + onDragOver=${(e) => { + if (isFolderLocked) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDragEnter=${(e) => { + if (isFolderLocked) return; + e.preventDefault(); + dropCounterRef.current += 1; + if (dropCounterRef.current === 1) setIsDropTarget(true); + }} + onDragLeave=${() => { + dropCounterRef.current -= 1; + if (dropCounterRef.current <= 0) { dropCounterRef.current = 0; setIsDropTarget(false); } + }} + onDrop=${(e) => { + if (isFolderLocked) return; + e.preventDefault(); + e.stopPropagation(); + setIsDropTarget(false); + dropCounterRef.current = 0; + const sourcePath = e.dataTransfer.getData("text/plain"); + if (sourcePath && sourcePath !== folderPath) { + onDragDrop("drop", sourcePath, folderPath); + } + }} style=${{ paddingLeft: `${kFolderBasePaddingPx + depth * kTreeIndentPx}px`, }} @@ -297,19 +506,32 @@ const TreeNode = ({ event.preventDefault(); event.stopPropagation(); if (!folderPath) return; - const shouldCollapse = !isCollapsed; - if (isFolderActive && shouldCollapse) { - onSelectFolder(""); - } onSetFolderExpanded(folderPath, isCollapsed); }} > ${node.name} + ${isFolderLocked + ? html`<${LockLineIcon} + className="tree-lock-icon" + title="Managed by AlphaClaw" + />` + : null}
          - ${(node.children || []).map( + ${creatingInFolder === folderPath && creatingType === "folder" + ? html` + <${CreationInput} + key="__creation__" + type="folder" + depth=${depth + 1} + onConfirm=${onCreationConfirm} + onCancel=${onCreationCancel} + /> + ` + : null} + ${(node.children || []).filter((c) => c.type === "folder").map( (childNode) => html` <${TreeNode} key=${childNode.path || `${folderPath}/${childNode.name}`} @@ -320,10 +542,53 @@ const TreeNode = ({ onSelectFolder=${onSelectFolder} onRequestDelete=${onRequestDelete} onSelectFile=${onSelectFile} + onContextMenu=${onContextMenu} + onDragDrop=${onDragDrop} selectedPath=${selectedPath} draftPaths=${draftPaths} isSearchActive=${isSearchActive} searchActivePath=${searchActivePath} + creatingInFolder=${creatingInFolder} + creatingType=${creatingType} + onCreationConfirm=${onCreationConfirm} + onCreationCancel=${onCreationCancel} + dragSourcePath=${dragSourcePath} + /> + `, + )} + ${creatingInFolder === folderPath && creatingType === "file" + ? html` + <${CreationInput} + key="__creation__" + type="file" + depth=${depth + 1} + onConfirm=${onCreationConfirm} + onCancel=${onCreationCancel} + /> + ` + : null} + ${(node.children || []).filter((c) => c.type !== "folder").map( + (childNode) => html` + <${TreeNode} + key=${childNode.path || `${folderPath}/${childNode.name}`} + node=${childNode} + depth=${depth + 1} + expandedPaths=${expandedPaths} + onSetFolderExpanded=${onSetFolderExpanded} + onSelectFolder=${onSelectFolder} + onRequestDelete=${onRequestDelete} + onSelectFile=${onSelectFile} + onContextMenu=${onContextMenu} + onDragDrop=${onDragDrop} + selectedPath=${selectedPath} + draftPaths=${draftPaths} + isSearchActive=${isSearchActive} + searchActivePath=${searchActivePath} + creatingInFolder=${creatingInFolder} + creatingType=${creatingType} + onCreationConfirm=${onCreationConfirm} + onCreationCancel=${onCreationCancel} + dragSourcePath=${dragSourcePath} /> `, )} @@ -347,6 +612,12 @@ export const FileTree = ({ const [searchActivePath, setSearchActivePath] = useState(""); const [deleteTargetPath, setDeleteTargetPath] = useState(""); const [deletingFile, setDeletingFile] = useState(false); + const [creatingInFolder, setCreatingInFolder] = useState(""); + const [creatingType, setCreatingType] = useState(""); + const [contextMenu, setContextMenu] = useState(null); + const [dragSourcePath, setDragSourcePath] = useState(""); + const [selectedFolder, setSelectedFolder] = useState(""); + const effectiveSelectedPath = selectedFolder || selectedPath; const searchInputRef = useRef(null); const treeSignatureRef = useRef(""); @@ -443,19 +714,19 @@ export const FileTree = ({ } catch {} }, [expandedPaths]); + useEffect(() => { + if (selectedPath) setSelectedFolder(""); + }, [selectedPath]); + useEffect(() => { if (!selectedPath) return; const ancestorFolderPaths = collectAncestorFolderPaths(selectedPath); - const selectedIsFolder = folderPaths.has(selectedPath); - const pathsToExpand = selectedIsFolder - ? [...ancestorFolderPaths, selectedPath] - : ancestorFolderPaths; - if (!pathsToExpand.length) return; + if (!ancestorFolderPaths.length) return; setExpandedPaths((previousPaths) => { if (!(previousPaths instanceof Set)) return previousPaths; let didChange = false; const nextPaths = new Set(previousPaths); - pathsToExpand.forEach((ancestorPath) => { + ancestorFolderPaths.forEach((ancestorPath) => { if (!nextPaths.has(ancestorPath)) { nextPaths.add(ancestorPath); didChange = true; @@ -463,7 +734,7 @@ export const FileTree = ({ }); return didChange ? nextPaths : previousPaths; }); - }, [selectedPath, folderPaths]); + }, [selectedPath]); useEffect(() => { const handleDraftIndexChanged = (event) => { @@ -547,30 +818,30 @@ export const FileTree = ({ }); }; + const handleSelectFile = useCallback((filePath, options) => { + setSelectedFolder(""); + onSelectFile(filePath, options); + }, [onSelectFile]); + const selectFolder = (folderPath) => { - onSelectFile(folderPath, { - directory: true, - preservePreview: true, - }); + setSelectedFolder(folderPath); }; const requestDelete = (targetPath) => { const normalizedTargetPath = normalizeBrowsePolicyPath(targetPath); if (!normalizedTargetPath) return; - if (!allTreeFilePaths.has(targetPath)) { - showToast("Only files can be deleted", "warning"); - return; - } if ( matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedTargetPath) || matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedTargetPath) ) { - showToast("Protected or locked files cannot be deleted", "warning"); + showToast("Protected or locked paths cannot be deleted", "warning"); return; } setDeleteTargetPath(targetPath); }; + const deleteTargetIsFolder = folderPaths.has(deleteTargetPath); + const confirmDelete = async () => { if (!deleteTargetPath || deletingFile) return; setDeletingFile(true); @@ -590,21 +861,115 @@ export const FileTree = ({ removeTreePath(previousRoot, deleteTargetPath), ); window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); - onSelectFile(""); - showToast("File deleted", "success"); + handleSelectFile(""); + showToast(deleteTargetIsFolder ? "Folder deleted" : "File deleted", "success"); setDeleteTargetPath(""); } catch (deleteError) { - const message = deleteError.message || "Could not delete file"; - if (/path is not a file/i.test(message)) { - showToast("Only files can be deleted", "warning"); - } else { - showToast(message, "error"); - } + showToast(deleteError.message || "Could not delete", "error"); } finally { setDeletingFile(false); } }; + const getCreateFolder = (explicitFolder) => { + if (explicitFolder !== undefined) return explicitFolder; + if (!effectiveSelectedPath) return ""; + if (folderPaths.has(effectiveSelectedPath)) return effectiveSelectedPath; + const lastSlash = effectiveSelectedPath.lastIndexOf("/"); + return lastSlash > 0 ? effectiveSelectedPath.slice(0, lastSlash) : ""; + }; + + const requestCreate = (folderPath, type) => { + const target = getCreateFolder(folderPath); + if (target && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizeBrowsePolicyPath(target))) { + showToast("Cannot create inside a locked folder", "warning"); + return; + } + setCreatingInFolder(target); + setCreatingType(type); + if (target) { + setExpandedPaths((prev) => { + const next = prev instanceof Set ? new Set(prev) : new Set(); + next.add(target); + return next; + }); + } + }; + + const requestCreateFromToolbar = (type) => { + requestCreate(getCreateFolder(), type); + }; + + const cancelCreate = () => { + setCreatingInFolder(""); + setCreatingType(""); + }; + + const confirmCreate = async (name) => { + const folder = creatingInFolder; + const type = creatingType; + cancelCreate(); + const fullPath = folder ? `${folder}/${name}` : name; + try { + if (type === "folder") { + await createBrowseFolder(fullPath); + showToast("Folder created", "success"); + } else { + await createBrowseFile(fullPath); + showToast("File created", "success"); + } + window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + if (folder) { + setExpandedPaths((prev) => { + const next = prev instanceof Set ? new Set(prev) : new Set(); + next.add(folder); + return next; + }); + } + if (type === "file") { + handleSelectFile(fullPath); + } + } catch (createError) { + showToast(createError.message || `Could not create ${type}`, "error"); + } + }; + + const openContextMenu = (menu) => { + setContextMenu(menu); + }; + + const closeContextMenu = () => { + setContextMenu(null); + }; + + const handleDragDrop = async (action, sourcePath, targetFolder) => { + if (action === "start") { + setDragSourcePath(sourcePath); + return; + } + if (action === "end") { + setDragSourcePath(""); + return; + } + if (action === "drop") { + setDragSourcePath(""); + const basename = sourcePath.split("/").pop(); + if (!basename) return; + const destination = targetFolder ? `${targetFolder}/${basename}` : basename; + if (sourcePath === destination) return; + try { + await moveBrowsePath(sourcePath, destination); + showToast(`Moved to ${targetFolder || "root"}`, "success"); + window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")); + if (selectedPath === sourcePath) { + handleSelectFile(destination); + } + } catch (moveError) { + showToast(moveError.message || "Could not move", "error"); + } + } + }; + const updateSearchQuery = (nextQuery) => { setSearchQuery(nextQuery); }; @@ -633,7 +998,7 @@ export const FileTree = ({ const targetPath = searchActivePath || (filteredFilePaths.length === 1 ? singlePath : ""); if (!targetPath) return; - onSelectFile(targetPath); + handleSelectFile(targetPath); clearSearch(); }; @@ -673,7 +1038,7 @@ export const FileTree = ({ ${error}
      `; } - if (!rootChildren.length) { + if (!rootChildren.length && !creatingType) { return html`
      ${isSearchActive ? "No matching files." : "No files found."} @@ -710,9 +1093,51 @@ export const FileTree = ({ autocomplete="off" spellcheck=${false} /> + + + +
      -
        - ${rootChildren.map( +
        +
          { + e.preventDefault(); + openContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetType: "root" }); + }} + onDragOver=${(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }} + onDrop=${(e) => { + e.preventDefault(); + const sourcePath = e.dataTransfer.getData("text/plain"); + if (sourcePath) handleDragDrop("drop", sourcePath, ""); + }} + > + ${creatingInFolder === "" && creatingType === "folder" + ? html` + <${CreationInput} + key="__root-creation__" + type="folder" + depth=${0} + onConfirm=${confirmCreate} + onCancel=${cancelCreate} + /> + ` + : null} + ${rootChildren.filter((n) => n.type === "folder").map( (node) => html` <${TreeNode} key=${node.path || node.name} @@ -721,19 +1146,79 @@ export const FileTree = ({ onSetFolderExpanded=${setFolderExpanded} onSelectFolder=${selectFolder} onRequestDelete=${requestDelete} - onSelectFile=${onSelectFile} - selectedPath=${selectedPath} + onSelectFile=${handleSelectFile} + onContextMenu=${openContextMenu} + onDragDrop=${handleDragDrop} + selectedPath=${effectiveSelectedPath} draftPaths=${draftPaths} isSearchActive=${isSearchActive} searchActivePath=${searchActivePath} + creatingInFolder=${creatingInFolder} + creatingType=${creatingType} + onCreationConfirm=${confirmCreate} + onCreationCancel=${cancelCreate} + dragSourcePath=${dragSourcePath} + /> + `, + )} + ${creatingInFolder === "" && creatingType === "file" + ? html` + <${CreationInput} + key="__root-creation__" + type="file" + depth=${0} + onConfirm=${confirmCreate} + onCancel=${cancelCreate} + /> + ` + : null} + ${rootChildren.filter((n) => n.type !== "folder").map( + (node) => html` + <${TreeNode} + key=${node.path || node.name} + node=${node} + expandedPaths=${safeExpandedPaths} + onSetFolderExpanded=${setFolderExpanded} + onSelectFolder=${selectFolder} + onRequestDelete=${requestDelete} + onSelectFile=${handleSelectFile} + onContextMenu=${openContextMenu} + onDragDrop=${handleDragDrop} + selectedPath=${effectiveSelectedPath} + draftPaths=${draftPaths} + isSearchActive=${isSearchActive} + searchActivePath=${searchActivePath} + creatingInFolder=${creatingInFolder} + creatingType=${creatingType} + onCreationConfirm=${confirmCreate} + onCreationCancel=${cancelCreate} + dragSourcePath=${dragSourcePath} /> `, )}
        +
        + ${contextMenu + ? html` + <${TreeContextMenu} + x=${contextMenu.x} + y=${contextMenu.y} + targetPath=${contextMenu.targetPath} + targetType=${contextMenu.targetType} + isLocked=${!!contextMenu.isLocked} + onNewFile=${(folder) => requestCreate(folder, "file")} + onNewFolder=${(folder) => requestCreate(folder, "folder")} + onDelete=${requestDelete} + onClose=${closeContextMenu} + /> + ` + : null} <${ConfirmDialog} visible=${!!deleteTargetPath} - title="Delete file?" - message=${`Delete ${deleteTargetPath || "this file"}? This can be restored from diff view before sync.`} + title=${deleteTargetIsFolder ? "Delete folder?" : "Delete file?"} + message=${deleteTargetIsFolder + ? `Delete folder ${deleteTargetPath || "this folder"} and all its contents?` + : `Delete ${deleteTargetPath || "this file"}? This can be restored from diff view before sync.`} confirmLabel="Delete" confirmLoadingLabel="Deleting..." cancelLabel="Cancel" diff --git a/lib/public/js/components/file-viewer/index.js b/lib/public/js/components/file-viewer/index.js index 2954d55c..d1cdb18a 100644 --- a/lib/public/js/components/file-viewer/index.js +++ b/lib/public/js/components/file-viewer/index.js @@ -32,7 +32,7 @@ export const FileViewer = ({ onRequestEdit, }); - if (!state.hasSelectedPath) { + if (!state.hasSelectedPath || state.isFolderPath) { return html`
        [ ]
        @@ -99,13 +99,7 @@ export const FileViewer = ({ ` : state.error ? html`
        ${state.error}
        ` - : state.isFolderPath - ? html` -
        - Folder selected. Choose a file from this folder in the tree. -
        - ` - : state.isImageFile || state.isAudioFile + : state.isImageFile || state.isAudioFile ? html` <${MediaPreview} isImageFile=${state.isImageFile} diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 4ddcfd73..b080dc82 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -262,6 +262,32 @@ export const DeleteBinLineIcon = ({ className = "" }) => html` `; +export const FileAddLineIcon = ({ className = "" }) => html` + +`; + +export const FolderAddLineIcon = ({ className = "" }) => html` + +`; + export const RestartLineIcon = ({ className = "" }) => html` { return parseJsonOrThrow(res, 'Could not save file'); }; +export const createBrowseFile = async (filePath) => { + const res = await authFetch('/api/browse/create-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: String(filePath || '') }), + }); + return parseJsonOrThrow(res, 'Could not create file'); +}; + +export const createBrowseFolder = async (folderPath) => { + const res = await authFetch('/api/browse/create-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: String(folderPath || '') }), + }); + return parseJsonOrThrow(res, 'Could not create folder'); +}; + +export const moveBrowsePath = async (from, to) => { + const res = await authFetch('/api/browse/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: String(from || ''), to: String(to || '') }), + }); + return parseJsonOrThrow(res, 'Could not move path'); +}; + export const deleteBrowseFile = async (filePath) => { const res = await authFetch('/api/browse/delete', { method: 'DELETE', diff --git a/lib/public/js/lib/browse-file-policies.js b/lib/public/js/lib/browse-file-policies.js index ee7de336..bc7bf272 100644 --- a/lib/public/js/lib/browse-file-policies.js +++ b/lib/public/js/lib/browse-file-policies.js @@ -43,7 +43,9 @@ export const matchesBrowsePolicyPath = (policyPathSet, normalizedPath) => { for (const policyPath of policyPathSet) { if ( safeNormalizedPath === policyPath || - safeNormalizedPath.endsWith(`/${policyPath}`) + safeNormalizedPath.endsWith(`/${policyPath}`) || + safeNormalizedPath.startsWith(`${policyPath}/`) || + safeNormalizedPath.includes(`/${policyPath}/`) ) { return true; } diff --git a/lib/public/shared/browse-file-policies.json b/lib/public/shared/browse-file-policies.json index 89fe2caf..db7e87df 100644 --- a/lib/public/shared/browse-file-policies.json +++ b/lib/public/shared/browse-file-policies.json @@ -6,8 +6,8 @@ "lockedPaths": [ "hooks/bootstrap/agents.md", "hooks/bootstrap/tools.md", - "skills/control-ui/skill.md", - "skills/gog-cli/skill.md", + "skills/control-ui", + "skills/gog-cli", ".alphaclaw/hourly-git-sync.sh", ".alphaclaw/.cli-device-auto-approved" ] diff --git a/lib/server/routes/browse/index.js b/lib/server/routes/browse/index.js index 27e68b6e..7c9337ac 100644 --- a/lib/server/routes/browse/index.js +++ b/lib/server/routes/browse/index.js @@ -576,6 +576,121 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } }); + app.post("/api/browse/create-file", (req, res) => { + const targetPath = String(req.body?.path || "").trim(); + if (!targetPath) { + return res.status(400).json({ ok: false, error: "path is required" }); + } + const resolvedPath = resolveSafePath( + targetPath, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath); + if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) { + return res.status(403).json({ + ok: false, + error: "Cannot create files in a locked path.", + }); + } + try { + if (fs.existsSync(resolvedPath.absolutePath)) { + return res + .status(409) + .json({ ok: false, error: "A file or folder already exists at this path" }); + } + const parentDir = path.dirname(resolvedPath.absolutePath); + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync(resolvedPath.absolutePath, "", "utf8"); + return res.json({ ok: true, path: resolvedPath.relativePath }); + } catch (error) { + return res + .status(500) + .json({ ok: false, error: error.message || "Could not create file" }); + } + }); + + app.post("/api/browse/create-folder", (req, res) => { + const targetPath = String(req.body?.path || "").trim(); + if (!targetPath) { + return res.status(400).json({ ok: false, error: "path is required" }); + } + const resolvedPath = resolveSafePath( + targetPath, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath); + if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) { + return res.status(403).json({ + ok: false, + error: "Cannot create folders in a locked path.", + }); + } + try { + if (fs.existsSync(resolvedPath.absolutePath)) { + return res + .status(409) + .json({ ok: false, error: "A file or folder already exists at this path" }); + } + fs.mkdirSync(resolvedPath.absolutePath, { recursive: true }); + return res.json({ ok: true, path: resolvedPath.relativePath }); + } catch (error) { + return res + .status(500) + .json({ ok: false, error: error.message || "Could not create folder" }); + } + }); + + app.post("/api/browse/move", (req, res) => { + const fromPath = String(req.body?.from || "").trim(); + const toPath = String(req.body?.to || "").trim(); + if (!fromPath || !toPath) { + return res.status(400).json({ ok: false, error: "from and to are required" }); + } + const resolvedFrom = resolveSafePath(fromPath, kRootResolved, kRootWithSep, kRootDisplayName); + if (!resolvedFrom.ok) { + return res.status(400).json({ ok: false, error: resolvedFrom.error }); + } + const resolvedTo = resolveSafePath(toPath, kRootResolved, kRootWithSep, kRootDisplayName); + if (!resolvedTo.ok) { + return res.status(400).json({ ok: false, error: resolvedTo.error }); + } + const normalizedFromPolicy = normalizePolicyPath(resolvedFrom.relativePath); + const normalizedToPolicy = normalizePolicyPath(resolvedTo.relativePath); + if ( + matchesPolicyPath(kLockedBrowsePaths, normalizedFromPolicy) || + matchesPolicyPath(kProtectedBrowsePaths, normalizedFromPolicy) + ) { + return res.status(403).json({ ok: false, error: "Source path is protected and cannot be moved." }); + } + if (matchesPolicyPath(kLockedBrowsePaths, normalizedToPolicy)) { + return res.status(403).json({ ok: false, error: "Cannot move into a locked path." }); + } + try { + if (!fs.existsSync(resolvedFrom.absolutePath)) { + return res.status(404).json({ ok: false, error: "Source path does not exist" }); + } + if (fs.existsSync(resolvedTo.absolutePath)) { + return res.status(409).json({ ok: false, error: "A file or folder already exists at the destination" }); + } + const parentDir = path.dirname(resolvedTo.absolutePath); + fs.mkdirSync(parentDir, { recursive: true }); + fs.renameSync(resolvedFrom.absolutePath, resolvedTo.absolutePath); + return res.json({ ok: true, from: resolvedFrom.relativePath, to: resolvedTo.relativePath }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message || "Could not move path" }); + } + }); + app.delete("/api/browse/delete", (req, res) => { const targetPath = String(req.body?.path || "").trim(); const resolvedPath = resolveSafePath( @@ -594,26 +709,28 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { ) { return res.status(403).json({ ok: false, - error: "This file cannot be deleted from the explorer.", + error: "This path cannot be deleted from the explorer.", }); } try { if (!fs.existsSync(resolvedPath.absolutePath)) { - return res.status(404).json({ ok: false, error: "File does not exist" }); + return res.status(404).json({ ok: false, error: "Path does not exist" }); } const stats = fs.statSync(resolvedPath.absolutePath); - if (!stats.isFile()) { - return res.status(400).json({ ok: false, error: "Path is not a file" }); + const isDirectory = stats.isDirectory(); + if (!stats.isFile() && !isDirectory) { + return res.status(400).json({ ok: false, error: "Path is not a file or folder" }); } - fs.rmSync(resolvedPath.absolutePath, { force: true }); + fs.rmSync(resolvedPath.absolutePath, { recursive: isDirectory, force: true }); return res.json({ ok: true, path: resolvedPath.relativePath, + type: isDirectory ? "folder" : "file", }); } catch (error) { return res.status(500).json({ ok: false, - error: error.message || "Could not delete file", + error: error.message || "Could not delete path", }); } }); diff --git a/lib/server/routes/browse/path-utils.js b/lib/server/routes/browse/path-utils.js index d59d28c2..02bd183b 100644 --- a/lib/server/routes/browse/path-utils.js +++ b/lib/server/routes/browse/path-utils.js @@ -36,7 +36,9 @@ const matchesPolicyPath = (policyPathSet, normalizedPath) => { for (const policyPath of policyPathSet) { if ( safeNormalizedPath === policyPath || - safeNormalizedPath.endsWith(`/${policyPath}`) + safeNormalizedPath.endsWith(`/${policyPath}`) || + safeNormalizedPath.startsWith(`${policyPath}/`) || + safeNormalizedPath.includes(`/${policyPath}/`) ) { return true; } diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index 47b84473..6b8d191f 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -314,6 +314,7 @@ describe("server/routes/browse", () => { expect(res.body).toEqual({ ok: true, path: "deleteme.txt", + type: "file", }); expect(fs.existsSync(filePath)).toBe(false); }); @@ -331,27 +332,29 @@ describe("server/routes/browse", () => { expect(res.status).toBe(403); expect(res.body).toEqual({ ok: false, - error: "This file cannot be deleted from the explorer.", + error: "This path cannot be deleted from the explorer.", }); expect(fs.existsSync(filePath)).toBe(true); }); - it("rejects deleting directories", async () => { + it("deletes directories recursively", async () => { const rootDir = createTestRoot(); const dirPath = path.join(rootDir, "delivery-queue"); fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, "child.txt"), "hi", "utf8"); const app = createApp(rootDir); const res = await request(app).delete("/api/browse/delete").send({ path: "delivery-queue", }); - expect(res.status).toBe(400); + expect(res.status).toBe(200); expect(res.body).toEqual({ - ok: false, - error: "Path is not a file", + ok: true, + path: "delivery-queue", + type: "folder", }); - expect(fs.existsSync(dirPath)).toBe(true); + expect(fs.existsSync(dirPath)).toBe(false); }); it("restores a tracked deleted file from git", async () => { From 8f5608e5231d43127e66b48891f7621e74c3afba Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 20:32:22 -0800 Subject: [PATCH 086/417] 0.4.1-beta.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5fd8ee8..01b31410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.1", + "version": "0.4.1-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.1", + "version": "0.4.1-beta.2", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index a185a608..572b7776 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.1", + "version": "0.4.1-beta.2", "publishConfig": { "access": "public" }, From 9e0dc122336614f83a4f2baabbe18eed196d10a5 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 21:04:01 -0800 Subject: [PATCH 087/417] 0.4.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 01b31410..76f18e61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.2", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.2", + "version": "0.4.2", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 572b7776..5015ac66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.1-beta.2", + "version": "0.4.2", "publishConfig": { "access": "public" }, From 0cc865c0c6e6cfbfa12712fb9d62b720ffc4be06 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 23:35:33 -0800 Subject: [PATCH 088/417] Improve Envars paste handling and post-save masking. Strip surrounding quotes from pasted KEY=VALUE entries and reset all secret inputs to hidden after saving so exposed values are re-obfuscated. --- lib/public/js/components/envars.js | 53 ++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/public/js/components/envars.js b/lib/public/js/components/envars.js index ae9f9846..461a710f 100644 --- a/lib/public/js/components/envars.js +++ b/lib/public/js/components/envars.js @@ -17,6 +17,17 @@ const kGroupLabels = { const kGroupOrder = ["github", "channels", "tools", "custom"]; const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_"); +const stripSurroundingQuotes = (raw) => { + const value = String(raw || "").trim(); + if (value.length < 2) return value; + const startsWithDouble = value.startsWith('"'); + const endsWithDouble = value.endsWith('"'); + if (startsWithDouble && endsWithDouble) return value.slice(1, -1); + const startsWithSingle = value.startsWith("'"); + const endsWithSingle = value.endsWith("'"); + if (startsWithSingle && endsWithSingle) return value.slice(1, -1); + return value; +}; const getVarsSignature = (items) => JSON.stringify( (items || []) @@ -27,6 +38,20 @@ const getVarsSignature = (items) => .sort((a, b) => a.key.localeCompare(b.key)), ); +const sortCustomVarsAlphabetically = (items) => { + const list = Array.isArray(items) ? [...items] : []; + const customSorted = list + .filter((item) => (item?.group || "custom") === "custom") + .sort((a, b) => String(a?.key || "").localeCompare(String(b?.key || ""))); + let customIdx = 0; + return list.map((item) => { + if ((item?.group || "custom") !== "custom") return item; + const next = customSorted[customIdx]; + customIdx += 1; + return next; + }); +}; + const kHintByKey = { ANTHROPIC_API_KEY: html`from console.anthropic.com`, ANTHROPIC_TOKEN: html`from claude setup-token`, @@ -85,6 +110,8 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => { export const Envars = ({ onRestartRequired = () => {} }) => { const [vars, setVars] = useState([]); const [reservedKeys, setReservedKeys] = useState(() => new Set()); + const [pendingCustomKeys, setPendingCustomKeys] = useState([]); + const [secretMaskEpoch, setSecretMaskEpoch] = useState(0); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [newKey, setNewKey] = useState(""); @@ -93,9 +120,10 @@ export const Envars = ({ onRestartRequired = () => {} }) => { const load = useCallback(async () => { try { const data = await fetchEnvVars(); - const nextVars = data.vars || []; + const nextVars = sortCustomVarsAlphabetically(data.vars || []); baselineSignatureRef.current = getVarsSignature(nextVars); setVars(nextVars); + setPendingCustomKeys([]); setReservedKeys(new Set(data.reservedKeys || [])); onRestartRequired(!!data.restartRequired); } catch (err) { @@ -117,6 +145,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => { const handleDelete = (key) => { setVars((prev) => prev.filter((v) => v.key !== key)); + setPendingCustomKeys((prev) => prev.filter((pendingKey) => pendingKey !== key)); }; const handleSave = async () => { @@ -135,7 +164,11 @@ export const Envars = ({ onRestartRequired = () => {} }) => { : "Environment variables saved", "success", ); - baselineSignatureRef.current = getVarsSignature(vars); + const sortedVars = sortCustomVarsAlphabetically(vars); + setVars(sortedVars); + setPendingCustomKeys([]); + setSecretMaskEpoch((prev) => prev + 1); + baselineSignatureRef.current = getVarsSignature(sortedVars); setDirty(false); } catch (err) { showToast("Failed to save: " + err.message, "error"); @@ -158,7 +191,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => { if (eqIdx > 0) pairs.push({ key: line.slice(0, eqIdx).trim(), - value: line.slice(eqIdx + 1).trim(), + value: stripSurroundingQuotes(line.slice(eqIdx + 1)), }); } return pairs; @@ -167,6 +200,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => { const addVars = (pairs) => { let added = 0; const blocked = []; + const addedCustomKeys = []; setVars((prev) => { const next = [...prev]; for (const { key: rawKey, value } of pairs) { @@ -189,11 +223,15 @@ export const Envars = ({ onRestartRequired = () => {} }) => { source: "env_file", editable: true, }); + addedCustomKeys.push(key); } added++; } return next; }); + if (addedCustomKeys.length) { + setPendingCustomKeys((prev) => [...prev, ...addedCustomKeys]); + } return { added, blocked }; }; @@ -264,6 +302,14 @@ export const Envars = ({ onRestartRequired = () => {} }) => { if (!grouped[g]) grouped[g] = []; grouped[g].push(v); } + if (grouped.custom?.length) { + const pending = new Set(pendingCustomKeys); + const nonPending = grouped.custom + .filter((item) => !pending.has(item.key)) + .sort((a, b) => String(a?.key || "").localeCompare(String(b?.key || ""))); + const pendingAtBottom = grouped.custom.filter((item) => pending.has(item.key)); + grouped.custom = [...nonPending, ...pendingAtBottom]; + } return html`
        @@ -295,6 +341,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => { ${grouped[g].map( (v) => html`<${EnvRow} + key=${`${secretMaskEpoch}:${v.key}`} envVar=${v} onChange=${handleChange} onDelete=${handleDelete} From 4891bfa7b495f2de85eac245a5115409d9c8a17b Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 23:52:06 -0800 Subject: [PATCH 089/417] Unify file toolbar delete button styling with ActionButton. Add icon-only ActionButton support and switch the file editor delete action to a gray icon-only button so disabled states match Save/Discard behavior. --- lib/public/js/components/action-button.js | 18 +++++++- .../js/components/file-viewer/toolbar.js | 42 ++++++++++++------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/lib/public/js/components/action-button.js b/lib/public/js/components/action-button.js index 4c3b7931..99d9bcad 100644 --- a/lib/public/js/components/action-button.js +++ b/lib/public/js/components/action-button.js @@ -31,6 +31,11 @@ const kSizeClassBySize = { md: "h-9 text-sm font-medium leading-none px-4 rounded-xl", lg: "h-10 text-sm font-medium leading-none px-5 rounded-lg", }; +const kIconOnlySizeClassBySize = { + sm: "h-7 w-7 p-0 rounded-lg", + md: "h-9 w-9 p-0 rounded-xl", + lg: "h-10 w-10 p-0 rounded-lg", +}; export const ActionButton = ({ onClick, @@ -45,11 +50,16 @@ export const ActionButton = ({ className = "", idleIcon = null, idleIconClassName = "h-3 w-3", + iconOnly = false, + title = "", + ariaLabel = "", }) => { const isDisabled = disabled || loading; const isInteractive = !isDisabled; const toneClass = getToneClass(tone, isInteractive); - const sizeClass = kSizeClassBySize[size] || kSizeClassBySize.sm; + const sizeClass = iconOnly + ? kIconOnlySizeClassBySize[size] || kIconOnlySizeClassBySize.sm + : kSizeClassBySize[size] || kSizeClassBySize.sm; const loadingClass = loading ? `cursor-not-allowed ${ tone === "warning" @@ -60,7 +70,9 @@ export const ActionButton = ({ const spinnerSizeClass = size === "md" || size === "lg" ? "h-4 w-4" : "h-3 w-3"; const isInlineLoading = loadingMode === "inline"; const IdleIcon = idleIcon; - const idleContent = IdleIcon + const idleContent = iconOnly && IdleIcon + ? html`<${IdleIcon} className=${idleIconClassName} />` + : IdleIcon ? html` <${IdleIcon} className=${idleIconClassName} /> @@ -75,6 +87,8 @@ export const ActionButton = ({ type=${type} onclick=${onClick} disabled=${isDisabled} + title=${title} + aria-label=${ariaLabel || null} class="inline-flex items-center justify-center transition-colors whitespace-nowrap ${sizeClass} ${toneClass} ${loadingClass} ${className}" > ${isInlineLoading diff --git a/lib/public/js/components/file-viewer/toolbar.js b/lib/public/js/components/file-viewer/toolbar.js index 8b2efdd3..49fff12a 100644 --- a/lib/public/js/components/file-viewer/toolbar.js +++ b/lib/public/js/components/file-viewer/toolbar.js @@ -39,18 +39,25 @@ export const FileViewerToolbar = ({ ${pathSegments.map( (segment, index) => html` - + ${segment} - ${index < pathSegments.length - 1 && html`>`} + ${index < pathSegments.length - 1 && + html`>`} `, )} - ${isDirty ? html`` : null} + ${isDirty + ? html`` + : null}
        - ${isPreviewOnly ? html`
        Preview
        ` : null} + ${isPreviewOnly + ? html`
        Preview
        ` + : null} ${!isDiffView && isMarkdownFile && html` @@ -69,27 +76,32 @@ export const FileViewerToolbar = ({ ? html` ${!isProtectedFile ? html` - + ariaLabel="Delete file" + /> ` : null} ${isDirty ? html` <${ActionButton} onClick=${handleDiscard} - disabled=${loading || !canEditFile || isEditBlocked || deleting || saving} + disabled=${loading || + !canEditFile || + isEditBlocked || + deleting || + saving} tone="secondary" size="sm" idleLabel="Discard changes" From 500f7fa511b66b44a310a39d0a37d058c8a73920 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 4 Mar 2026 23:57:44 -0800 Subject: [PATCH 090/417] 0.4.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76f18e61..766052d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 5015ac66..cb84c871 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.2", + "version": "0.4.3", "publishConfig": { "access": "public" }, From 64810b9578484abb795ec22fca30b6467d76a8ef Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 08:25:39 -0800 Subject: [PATCH 091/417] Refactor server DB modules into folder-based features. Split usage, watchdog, and webhooks DB logic into `lib/server/db/*` modules and update server/tests imports so database code is easier to maintain without changing behavior. --- lib/server.js | 6 +- lib/server/db/usage/index.js | 35 + lib/server/db/usage/pricing.js | 82 ++ lib/server/db/usage/schema.js | 87 ++ lib/server/db/usage/sessions.js | 217 +++++ lib/server/db/usage/shared.js | 139 +++ lib/server/db/usage/summary.js | 280 ++++++ lib/server/db/usage/timeseries.js | 64 ++ .../{watchdog-db.js => db/watchdog/index.js} | 19 +- lib/server/db/watchdog/schema.js | 21 + .../{webhooks-db.js => db/webhooks/index.js} | 23 +- lib/server/db/webhooks/schema.js | 25 + lib/server/usage-db.js | 838 ------------------ tests/server/usage-db.test.js | 75 +- tests/server/watchdog-db.test.js | 2 +- 15 files changed, 1029 insertions(+), 884 deletions(-) create mode 100644 lib/server/db/usage/index.js create mode 100644 lib/server/db/usage/pricing.js create mode 100644 lib/server/db/usage/schema.js create mode 100644 lib/server/db/usage/sessions.js create mode 100644 lib/server/db/usage/shared.js create mode 100644 lib/server/db/usage/summary.js create mode 100644 lib/server/db/usage/timeseries.js rename lib/server/{watchdog-db.js => db/watchdog/index.js} (87%) create mode 100644 lib/server/db/watchdog/schema.js rename lib/server/{webhooks-db.js => db/webhooks/index.js} (91%) create mode 100644 lib/server/db/webhooks/schema.js delete mode 100644 lib/server/usage-db.js diff --git a/lib/server.js b/lib/server.js index 0ea20c05..c7894321 100644 --- a/lib/server.js +++ b/lib/server.js @@ -30,19 +30,19 @@ const { getRequestById, getHookSummaries, deleteRequestsByHook, -} = require("./server/webhooks-db"); +} = require("./server/db/webhooks"); const { initWatchdogDb, insertWatchdogEvent, getRecentEvents, -} = require("./server/watchdog-db"); +} = require("./server/db/watchdog"); const { initUsageDb, getDailySummary, getSessionsList, getSessionDetail, getSessionTimeSeries, -} = require("./server/usage-db"); +} = require("./server/db/usage"); const { createWebhookMiddleware } = require("./server/webhook-middleware"); const { readEnvFile, diff --git a/lib/server/db/usage/index.js b/lib/server/db/usage/index.js new file mode 100644 index 00000000..c7dc4f73 --- /dev/null +++ b/lib/server/db/usage/index.js @@ -0,0 +1,35 @@ +const fs = require("fs"); +const path = require("path"); +const { DatabaseSync } = require("node:sqlite"); +const { ensureSchema } = require("./schema"); +const { getDailySummary } = require("./summary"); +const { getSessionsList, getSessionDetail } = require("./sessions"); +const { getSessionTimeSeries } = require("./timeseries"); +const { kGlobalModelPricing } = require("./pricing"); + +let db = null; +let usageDbPath = ""; + +const ensureDb = () => { + if (!db) throw new Error("Usage DB not initialized"); + return db; +}; + +const initUsageDb = ({ rootDir }) => { + const dbDir = path.join(rootDir, "db"); + fs.mkdirSync(dbDir, { recursive: true }); + usageDbPath = path.join(dbDir, "usage.db"); + db = new DatabaseSync(usageDbPath); + ensureSchema(db); + return { path: usageDbPath }; +}; + +module.exports = { + initUsageDb, + getDailySummary: (options = {}) => getDailySummary({ database: ensureDb(), ...options }), + getSessionsList: (options = {}) => getSessionsList({ database: ensureDb(), ...options }), + getSessionDetail: (options = {}) => getSessionDetail({ database: ensureDb(), ...options }), + getSessionTimeSeries: (options = {}) => + getSessionTimeSeries({ database: ensureDb(), ...options }), + kGlobalModelPricing, +}; diff --git a/lib/server/db/usage/pricing.js b/lib/server/db/usage/pricing.js new file mode 100644 index 00000000..ba5194aa --- /dev/null +++ b/lib/server/db/usage/pricing.js @@ -0,0 +1,82 @@ +const kTokensPerMillion = 1_000_000; +const kLongContextThresholdTokens = 200_000; +const kGlobalModelPricing = { + "claude-opus-4-6": { + input: (tokens) => (tokens > kLongContextThresholdTokens ? 10.0 : 5.0), + output: (tokens) => (tokens > kLongContextThresholdTokens ? 37.5 : 25.0), + }, + "claude-sonnet-4-6": { input: 3.0, output: 15.0 }, + "claude-haiku-4-6": { input: 0.8, output: 4.0 }, + "gpt-5": { input: 1.25, output: 10.0 }, + "gpt-5.1-codex": { input: 2.5, output: 10.0 }, + "gpt-5.3-codex": { input: 2.5, output: 10.0 }, + "gpt-4.1": { input: 2.0, output: 8.0 }, + "gpt-4o": { input: 2.5, output: 10.0 }, + "gpt-4o-mini": { input: 0.15, output: 0.6 }, + "gemini-3-pro-preview": { input: 2.0, output: 12.0 }, + "gemini-3-flash-preview": { input: 0.5, output: 3.0 }, + "gemini-2.0-flash": { input: 0.1, output: 0.4 }, +}; + +const toInt = (value, fallbackValue = 0) => { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) ? parsed : fallbackValue; +}; + +const resolvePricing = (model) => { + const normalized = String(model || "").toLowerCase(); + if (!normalized) return null; + const exact = kGlobalModelPricing[normalized]; + if (exact) return exact; + const matchKey = Object.keys(kGlobalModelPricing).find((key) => + normalized.includes(key), + ); + return matchKey ? kGlobalModelPricing[matchKey] : null; +}; + +const resolvePerMillionRate = (rate, tokens) => { + if (typeof rate === "function") { + return Number(rate(toInt(tokens))); + } + return Number(rate || 0); +}; + +const deriveCostBreakdown = ({ + inputTokens = 0, + outputTokens = 0, + cacheReadTokens = 0, + cacheWriteTokens = 0, + model = "", +}) => { + const pricing = resolvePricing(model); + if (!pricing) { + return { + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + totalCost: 0, + pricingFound: false, + }; + } + const inputRate = resolvePerMillionRate(pricing.input, inputTokens); + const outputRate = resolvePerMillionRate(pricing.output, outputTokens); + const inputCost = (inputTokens / kTokensPerMillion) * inputRate; + const outputCost = (outputTokens / kTokensPerMillion) * outputRate; + const cacheReadCost = 0; + const cacheWriteRate = resolvePerMillionRate(pricing.input, cacheWriteTokens); + const cacheWriteCost = (cacheWriteTokens / kTokensPerMillion) * cacheWriteRate; + return { + inputCost, + outputCost, + cacheReadCost, + cacheWriteCost, + totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost, + pricingFound: true, + }; +}; + +module.exports = { + kGlobalModelPricing, + deriveCostBreakdown, +}; diff --git a/lib/server/db/usage/schema.js b/lib/server/db/usage/schema.js new file mode 100644 index 00000000..53f31ba5 --- /dev/null +++ b/lib/server/db/usage/schema.js @@ -0,0 +1,87 @@ +const safeAlterTable = (database, sql) => { + try { + database.exec(sql); + } catch (err) { + const message = String(err?.message || "").toLowerCase(); + if (!message.includes("duplicate column name")) throw err; + } +}; + +const ensureSchema = (database) => { + database.exec("PRAGMA journal_mode=WAL;"); + database.exec("PRAGMA synchronous=NORMAL;"); + database.exec("PRAGMA busy_timeout=5000;"); + database.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + session_id TEXT, + session_key TEXT, + run_id TEXT, + provider TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0 + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_usage_events_ts + ON usage_events(timestamp DESC); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_usage_events_session + ON usage_events(session_id); + `); + safeAlterTable( + database, + "ALTER TABLE usage_events ADD COLUMN session_key TEXT;", + ); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_usage_events_session_key + ON usage_events(session_key); + `); + database.exec(` + CREATE TABLE IF NOT EXISTS usage_daily ( + date TEXT NOT NULL, + model TEXT NOT NULL, + provider TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + turn_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, model) + ); + `); + database.exec(` + CREATE TABLE IF NOT EXISTS tool_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + session_id TEXT, + session_key TEXT, + tool_name TEXT NOT NULL, + success INTEGER NOT NULL DEFAULT 1, + duration_ms INTEGER + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_tool_events_session + ON tool_events(session_id); + `); + safeAlterTable( + database, + "ALTER TABLE tool_events ADD COLUMN session_key TEXT;", + ); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_tool_events_session_key + ON tool_events(session_key); + `); +}; + +module.exports = { + ensureSchema, +}; diff --git a/lib/server/db/usage/sessions.js b/lib/server/db/usage/sessions.js new file mode 100644 index 00000000..7d8b3335 --- /dev/null +++ b/lib/server/db/usage/sessions.js @@ -0,0 +1,217 @@ +const { + kDefaultSessionLimit, + kMaxSessionLimit, + coerceInt, + clampInt, + getUsageMetricsFromEventRow, +} = require("./shared"); + +const getSessionsList = ({ database, limit = kDefaultSessionLimit } = {}) => { + const safeLimit = clampInt(limit, 1, kMaxSessionLimit, kDefaultSessionLimit); + const rows = database + .prepare(` + SELECT + COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) AS session_ref, + MAX(session_key) AS session_key, + MAX(session_id) AS session_id, + MIN(timestamp) AS first_activity_ms, + MAX(timestamp) AS last_activity_ms, + COUNT(*) AS turn_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cache_read_tokens) AS cache_read_tokens, + SUM(cache_write_tokens) AS cache_write_tokens, + SUM(total_tokens) AS total_tokens + FROM usage_events + WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) IS NOT NULL + GROUP BY session_ref + ORDER BY last_activity_ms DESC + LIMIT $limit + `) + .all({ $limit: safeLimit }); + return rows.map((row) => { + const eventRows = database + .prepare(` + SELECT + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens + FROM usage_events + WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef + `) + .all({ $sessionRef: row.session_ref }); + let totalCost = 0; + const modelTokenTotals = new Map(); + for (const eventRow of eventRows) { + const metrics = getUsageMetricsFromEventRow(eventRow); + totalCost += metrics.totalCost; + const model = String(eventRow.model || ""); + modelTokenTotals.set(model, (modelTokenTotals.get(model) || 0) + metrics.totalTokens); + } + const dominantModel = Array.from(modelTokenTotals.entries()) + .sort((a, b) => b[1] - a[1])[0]?.[0] || ""; + return { + sessionId: row.session_ref, + sessionKey: String(row.session_key || ""), + rawSessionId: String(row.session_id || ""), + firstActivityMs: coerceInt(row.first_activity_ms), + lastActivityMs: coerceInt(row.last_activity_ms), + durationMs: Math.max( + 0, + coerceInt(row.last_activity_ms) - coerceInt(row.first_activity_ms), + ), + turnCount: coerceInt(row.turn_count), + inputTokens: coerceInt(row.input_tokens), + outputTokens: coerceInt(row.output_tokens), + cacheReadTokens: coerceInt(row.cache_read_tokens), + cacheWriteTokens: coerceInt(row.cache_write_tokens), + totalTokens: coerceInt(row.total_tokens), + totalCost, + dominantModel, + }; + }); +}; + +const getSessionDetail = ({ database, sessionId }) => { + const safeSessionRef = String(sessionId || "").trim(); + if (!safeSessionRef) return null; + const summaryRow = database + .prepare(` + SELECT + MAX(session_key) AS session_key, + MAX(session_id) AS session_id, + MIN(timestamp) AS first_activity_ms, + MAX(timestamp) AS last_activity_ms, + COUNT(*) AS turn_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(cache_read_tokens) AS cache_read_tokens, + SUM(cache_write_tokens) AS cache_write_tokens, + SUM(total_tokens) AS total_tokens + FROM usage_events + WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef + `) + .get({ $sessionRef: safeSessionRef }); + if (!summaryRow || !coerceInt(summaryRow.turn_count)) return null; + + const modelEvents = database + .prepare(` + SELECT + provider, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens + FROM usage_events + WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef + `) + .all({ $sessionRef: safeSessionRef }); + const byProviderModel = new Map(); + for (const eventRow of modelEvents) { + const provider = String(eventRow.provider || "unknown"); + const model = String(eventRow.model || "unknown"); + const mapKey = `${provider}\u0000${model}`; + if (!byProviderModel.has(mapKey)) { + byProviderModel.set(mapKey, { + provider, + model, + turnCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + pricingFound: false, + }); + } + const aggregate = byProviderModel.get(mapKey); + const metrics = getUsageMetricsFromEventRow(eventRow); + aggregate.turnCount += 1; + aggregate.inputTokens += metrics.inputTokens; + aggregate.outputTokens += metrics.outputTokens; + aggregate.cacheReadTokens += metrics.cacheReadTokens; + aggregate.cacheWriteTokens += metrics.cacheWriteTokens; + aggregate.totalTokens += metrics.totalTokens; + aggregate.totalCost += metrics.totalCost; + aggregate.inputCost += metrics.inputCost; + aggregate.outputCost += metrics.outputCost; + aggregate.cacheReadCost += metrics.cacheReadCost; + aggregate.cacheWriteCost += metrics.cacheWriteCost; + aggregate.pricingFound = aggregate.pricingFound || metrics.pricingFound; + } + const modelRows = Array.from(byProviderModel.values()).sort( + (a, b) => b.totalTokens - a.totalTokens, + ); + + const toolRows = database + .prepare(` + SELECT + tool_name, + COUNT(*) AS call_count, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS error_count, + AVG(duration_ms) AS avg_duration_ms, + MIN(duration_ms) AS min_duration_ms, + MAX(duration_ms) AS max_duration_ms + FROM tool_events + WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef + GROUP BY tool_name + ORDER BY call_count DESC + `) + .all({ $sessionRef: safeSessionRef }) + .map((row) => { + const callCount = coerceInt(row.call_count); + const successCount = coerceInt(row.success_count); + const errorCount = coerceInt(row.error_count); + return { + toolName: row.tool_name, + callCount, + successCount, + errorCount, + errorRate: callCount > 0 ? errorCount / callCount : 0, + avgDurationMs: Number(row.avg_duration_ms || 0), + minDurationMs: coerceInt(row.min_duration_ms), + maxDurationMs: coerceInt(row.max_duration_ms), + }; + }); + + const firstActivityMs = coerceInt(summaryRow.first_activity_ms); + const lastActivityMs = coerceInt(summaryRow.last_activity_ms); + const totalCost = modelRows.reduce( + (sum, modelRow) => sum + Number(modelRow.totalCost || 0), + 0, + ); + + return { + sessionId: safeSessionRef, + sessionKey: String(summaryRow.session_key || ""), + rawSessionId: String(summaryRow.session_id || ""), + firstActivityMs, + lastActivityMs, + durationMs: Math.max(0, lastActivityMs - firstActivityMs), + turnCount: coerceInt(summaryRow.turn_count), + inputTokens: coerceInt(summaryRow.input_tokens), + outputTokens: coerceInt(summaryRow.output_tokens), + cacheReadTokens: coerceInt(summaryRow.cache_read_tokens), + cacheWriteTokens: coerceInt(summaryRow.cache_write_tokens), + totalTokens: coerceInt(summaryRow.total_tokens), + totalCost, + modelBreakdown: modelRows, + toolUsage: toolRows, + }; +}; + +module.exports = { + getSessionsList, + getSessionDetail, +}; diff --git a/lib/server/db/usage/shared.js b/lib/server/db/usage/shared.js new file mode 100644 index 00000000..d890905f --- /dev/null +++ b/lib/server/db/usage/shared.js @@ -0,0 +1,139 @@ +const { deriveCostBreakdown } = require("./pricing"); + +const kDefaultSessionLimit = 50; +const kMaxSessionLimit = 200; +const kDefaultDays = 30; +const kDefaultMaxPoints = 100; +const kMaxMaxPoints = 1000; +const kDayMs = 24 * 60 * 60 * 1000; +const kUtcTimeZone = "UTC"; +const kDayKeyFormatterCache = new Map(); + +const coerceInt = (value, fallbackValue = 0) => { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) ? parsed : fallbackValue; +}; + +const clampInt = (value, minValue, maxValue, fallbackValue) => + Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue))); + +const normalizeTimeZone = (value) => { + const raw = String(value || "").trim(); + if (!raw) return kUtcTimeZone; + try { + new Intl.DateTimeFormat("en-US", { timeZone: raw }); + return raw; + } catch { + return kUtcTimeZone; + } +}; + +const getDayKeyFormatter = (timeZone) => { + if (kDayKeyFormatterCache.has(timeZone)) { + return kDayKeyFormatterCache.get(timeZone); + } + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + kDayKeyFormatterCache.set(timeZone, formatter); + return formatter; +}; + +const toTimeZoneDayKey = (timestampMs, timeZone) => { + const parts = getDayKeyFormatter(timeZone).formatToParts(new Date(timestampMs)); + const year = parts.find((part) => part.type === "year")?.value || "0000"; + const month = parts.find((part) => part.type === "month")?.value || "01"; + const day = parts.find((part) => part.type === "day")?.value || "01"; + return `${year}-${month}-${day}`; +}; + +const toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10); + +const getPeriodRange = (days, timeZone = kUtcTimeZone) => { + const now = Date.now(); + const safeDays = clampInt(days, 1, 3650, kDefaultDays); + const startMs = now - safeDays * kDayMs; + const normalizedTimeZone = normalizeTimeZone(timeZone); + const startDay = normalizedTimeZone === kUtcTimeZone + ? toDayKey(startMs) + : toTimeZoneDayKey(startMs, normalizedTimeZone); + return { now, safeDays, startDay, timeZone: normalizedTimeZone }; +}; + +const getUsageMetricsFromEventRow = (row) => { + const inputTokens = coerceInt(row.input_tokens); + const outputTokens = coerceInt(row.output_tokens); + const cacheReadTokens = coerceInt(row.cache_read_tokens); + const cacheWriteTokens = coerceInt(row.cache_write_tokens); + const totalTokens = + coerceInt(row.total_tokens) || + inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; + const cost = deriveCostBreakdown({ + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + model: row.model, + }); + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens, + ...cost, + }; +}; + +const parseAgentAndSourceFromSessionRef = (sessionRef) => { + const raw = String(sessionRef || "").trim(); + if (!raw) { + return { agent: "unknown", source: "chat" }; + } + const parts = raw.split(":"); + const agent = + parts[0] === "agent" && String(parts[1] || "").trim() + ? String(parts[1] || "").trim() + : "unknown"; + const source = parts.includes("hook") + ? "hooks" + : parts.includes("cron") + ? "cron" + : "chat"; + return { agent, source }; +}; + +const downsamplePoints = (points, maxPoints) => { + if (points.length <= maxPoints) return points; + const stride = Math.ceil(points.length / maxPoints); + const sampled = []; + for (let index = 0; index < points.length; index += stride) { + sampled.push(points[index]); + } + const lastPoint = points[points.length - 1]; + if (sampled[sampled.length - 1]?.timestamp !== lastPoint.timestamp) { + sampled.push(lastPoint); + } + return sampled; +}; + +module.exports = { + kDefaultSessionLimit, + kMaxSessionLimit, + kDefaultDays, + kDefaultMaxPoints, + kMaxMaxPoints, + kDayMs, + kUtcTimeZone, + coerceInt, + clampInt, + toTimeZoneDayKey, + toDayKey, + getPeriodRange, + getUsageMetricsFromEventRow, + parseAgentAndSourceFromSessionRef, + downsamplePoints, +}; diff --git a/lib/server/db/usage/summary.js b/lib/server/db/usage/summary.js new file mode 100644 index 00000000..d30dc4d3 --- /dev/null +++ b/lib/server/db/usage/summary.js @@ -0,0 +1,280 @@ +const { + kDefaultDays, + kDayMs, + kUtcTimeZone, + coerceInt, + toDayKey, + toTimeZoneDayKey, + getPeriodRange, + getUsageMetricsFromEventRow, + parseAgentAndSourceFromSessionRef, +} = require("./shared"); + +const getAgentCostDistribution = ({ + eventsRows = [], + startDay = "", + timeZone = kUtcTimeZone, +}) => { + const byAgent = new Map(); + const ensureAgentBucket = (agent) => { + if (byAgent.has(agent)) return byAgent.get(agent); + const bucket = { + agent, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + turnCount: 0, + sourceBreakdown: { + chat: { + source: "chat", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + turnCount: 0, + }, + hooks: { + source: "hooks", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + turnCount: 0, + }, + cron: { + source: "cron", + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + turnCount: 0, + }, + }, + }; + byAgent.set(agent, bucket); + return bucket; + }; + + for (const eventRow of eventsRows) { + const timestamp = coerceInt(eventRow.timestamp); + const dayKey = timeZone === kUtcTimeZone + ? toDayKey(timestamp) + : toTimeZoneDayKey(timestamp, timeZone); + if (dayKey < startDay) continue; + + const metrics = getUsageMetricsFromEventRow(eventRow); + const sessionRef = String(eventRow.session_key || eventRow.session_id || ""); + const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef); + const agentBucket = ensureAgentBucket(agent); + const sourceBucket = agentBucket.sourceBreakdown[source]; + + agentBucket.inputTokens += metrics.inputTokens; + agentBucket.outputTokens += metrics.outputTokens; + agentBucket.cacheReadTokens += metrics.cacheReadTokens; + agentBucket.cacheWriteTokens += metrics.cacheWriteTokens; + agentBucket.totalTokens += metrics.totalTokens; + agentBucket.totalCost += metrics.totalCost; + agentBucket.turnCount += 1; + + sourceBucket.inputTokens += metrics.inputTokens; + sourceBucket.outputTokens += metrics.outputTokens; + sourceBucket.cacheReadTokens += metrics.cacheReadTokens; + sourceBucket.cacheWriteTokens += metrics.cacheWriteTokens; + sourceBucket.totalTokens += metrics.totalTokens; + sourceBucket.totalCost += metrics.totalCost; + sourceBucket.turnCount += 1; + } + + const agents = Array.from(byAgent.values()) + .map((bucket) => ({ + agent: bucket.agent, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cacheReadTokens, + cacheWriteTokens: bucket.cacheWriteTokens, + totalTokens: bucket.totalTokens, + totalCost: bucket.totalCost, + turnCount: bucket.turnCount, + sourceBreakdown: ["chat", "hooks", "cron"].map( + (source) => bucket.sourceBreakdown[source], + ), + })) + .sort((a, b) => b.totalCost - a.totalCost); + + return { + agents, + totals: agents.reduce( + (acc, agentBucket) => { + acc.totalCost += Number(agentBucket.totalCost || 0); + acc.totalTokens += Number(agentBucket.totalTokens || 0); + acc.turnCount += Number(agentBucket.turnCount || 0); + return acc; + }, + { totalCost: 0, totalTokens: 0, turnCount: 0 }, + ), + }; +}; + +const getDailySummary = ({ database, days = kDefaultDays, timeZone = kUtcTimeZone } = {}) => { + const { now, safeDays, startDay, timeZone: normalizedTimeZone } = getPeriodRange( + days, + timeZone, + ); + const lookbackMs = now - (safeDays + 2) * kDayMs; + const eventsRows = database + .prepare(` + SELECT + timestamp, + session_id, + session_key, + provider, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens + FROM usage_events + WHERE timestamp >= $lookbackMs + ORDER BY timestamp ASC + `) + .all({ $lookbackMs: lookbackMs }); + const byDateModel = new Map(); + for (const eventRow of eventsRows) { + const timestamp = coerceInt(eventRow.timestamp); + const dayKey = normalizedTimeZone === kUtcTimeZone + ? toDayKey(timestamp) + : toTimeZoneDayKey(timestamp, normalizedTimeZone); + if (dayKey < startDay) continue; + const model = String(eventRow.model || "unknown"); + const mapKey = `${dayKey}\u0000${model}`; + if (!byDateModel.has(mapKey)) { + byDateModel.set(mapKey, { + date: dayKey, + model, + provider: String(eventRow.provider || "unknown"), + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + turnCount: 0, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + pricingFound: false, + }); + } + const aggregate = byDateModel.get(mapKey); + const metrics = getUsageMetricsFromEventRow(eventRow); + aggregate.inputTokens += metrics.inputTokens; + aggregate.outputTokens += metrics.outputTokens; + aggregate.cacheReadTokens += metrics.cacheReadTokens; + aggregate.cacheWriteTokens += metrics.cacheWriteTokens; + aggregate.totalTokens += metrics.totalTokens; + aggregate.turnCount += 1; + aggregate.totalCost += metrics.totalCost; + aggregate.inputCost += metrics.inputCost; + aggregate.outputCost += metrics.outputCost; + aggregate.cacheReadCost += metrics.cacheReadCost; + aggregate.cacheWriteCost += metrics.cacheWriteCost; + aggregate.pricingFound = aggregate.pricingFound || metrics.pricingFound; + if (!aggregate.provider && eventRow.provider) { + aggregate.provider = String(eventRow.provider || "unknown"); + } + } + const enriched = Array.from(byDateModel.values()).sort((a, b) => { + if (a.date === b.date) return b.totalTokens - a.totalTokens; + return a.date.localeCompare(b.date); + }); + const costByAgent = getAgentCostDistribution({ + eventsRows, + startDay, + timeZone: normalizedTimeZone, + }); + const byDate = new Map(); + for (const row of enriched) { + if (!byDate.has(row.date)) byDate.set(row.date, []); + byDate.get(row.date).push({ + model: row.model, + provider: row.provider, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheWriteTokens: row.cacheWriteTokens, + totalTokens: row.totalTokens, + turnCount: row.turnCount, + totalCost: row.totalCost, + inputCost: row.inputCost, + outputCost: row.outputCost, + cacheReadCost: row.cacheReadCost, + cacheWriteCost: row.cacheWriteCost, + pricingFound: row.pricingFound, + }); + } + const daily = []; + const totals = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + turnCount: 0, + modelCount: 0, + }; + for (const [date, modelRows] of byDate.entries()) { + const aggregate = modelRows.reduce( + (acc, row) => ({ + inputTokens: acc.inputTokens + row.inputTokens, + outputTokens: acc.outputTokens + row.outputTokens, + cacheReadTokens: acc.cacheReadTokens + row.cacheReadTokens, + cacheWriteTokens: acc.cacheWriteTokens + row.cacheWriteTokens, + totalTokens: acc.totalTokens + row.totalTokens, + totalCost: acc.totalCost + row.totalCost, + turnCount: acc.turnCount + row.turnCount, + }), + { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + totalCost: 0, + turnCount: 0, + }, + ); + daily.push({ date, ...aggregate, models: modelRows }); + totals.inputTokens += aggregate.inputTokens; + totals.outputTokens += aggregate.outputTokens; + totals.cacheReadTokens += aggregate.cacheReadTokens; + totals.cacheWriteTokens += aggregate.cacheWriteTokens; + totals.totalTokens += aggregate.totalTokens; + totals.totalCost += aggregate.totalCost; + totals.turnCount += aggregate.turnCount; + totals.modelCount += modelRows.length; + } + return { + updatedAt: Date.now(), + days: safeDays, + timeZone: normalizedTimeZone, + daily, + totals, + costByAgent, + }; +}; + +module.exports = { + getDailySummary, +}; diff --git a/lib/server/db/usage/timeseries.js b/lib/server/db/usage/timeseries.js new file mode 100644 index 00000000..a113b95c --- /dev/null +++ b/lib/server/db/usage/timeseries.js @@ -0,0 +1,64 @@ +const { + kDefaultMaxPoints, + kMaxMaxPoints, + coerceInt, + clampInt, + getUsageMetricsFromEventRow, + downsamplePoints, +} = require("./shared"); + +const getSessionTimeSeries = ({ + database, + sessionId, + maxPoints = kDefaultMaxPoints, +}) => { + const safeSessionRef = String(sessionId || "").trim(); + if (!safeSessionRef) return { sessionId: safeSessionRef, points: [] }; + const rows = database + .prepare(` + SELECT + timestamp, + session_key, + session_id, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens + FROM usage_events + WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef + ORDER BY timestamp ASC + `) + .all({ $sessionRef: safeSessionRef }); + let cumulativeTokens = 0; + let cumulativeCost = 0; + const points = rows.map((row) => { + const metrics = getUsageMetricsFromEventRow(row); + cumulativeTokens += metrics.totalTokens; + cumulativeCost += metrics.totalCost; + return { + timestamp: coerceInt(row.timestamp), + sessionKey: String(row.session_key || ""), + rawSessionId: String(row.session_id || ""), + model: String(row.model || ""), + inputTokens: metrics.inputTokens, + outputTokens: metrics.outputTokens, + cacheReadTokens: metrics.cacheReadTokens, + cacheWriteTokens: metrics.cacheWriteTokens, + totalTokens: metrics.totalTokens, + cost: metrics.totalCost, + cumulativeTokens, + cumulativeCost, + }; + }); + const safeMaxPoints = clampInt(maxPoints, 10, kMaxMaxPoints, kDefaultMaxPoints); + return { + sessionId: safeSessionRef, + points: downsamplePoints(points, safeMaxPoints), + }; +}; + +module.exports = { + getSessionTimeSeries, +}; diff --git a/lib/server/watchdog-db.js b/lib/server/db/watchdog/index.js similarity index 87% rename from lib/server/watchdog-db.js rename to lib/server/db/watchdog/index.js index abd31de3..ad15482c 100644 --- a/lib/server/watchdog-db.js +++ b/lib/server/db/watchdog/index.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const { DatabaseSync } = require("node:sqlite"); +const { createSchema } = require("./schema"); let db = null; let pruneTimer = null; @@ -14,24 +15,6 @@ const ensureDb = () => { return db; }; -const createSchema = (database) => { - database.exec(` - CREATE TABLE IF NOT EXISTS watchdog_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_type TEXT NOT NULL, - source TEXT NOT NULL, - status TEXT NOT NULL, - details TEXT, - correlation_id TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - ); - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_watchdog_events_ts - ON watchdog_events(created_at DESC); - `); -}; - const initWatchdogDb = ({ rootDir, pruneDays = 30 }) => { const dbDir = path.join(rootDir, "db"); fs.mkdirSync(dbDir, { recursive: true }); diff --git a/lib/server/db/watchdog/schema.js b/lib/server/db/watchdog/schema.js new file mode 100644 index 00000000..42f7bcb0 --- /dev/null +++ b/lib/server/db/watchdog/schema.js @@ -0,0 +1,21 @@ +const createSchema = (database) => { + database.exec(` + CREATE TABLE IF NOT EXISTS watchdog_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + source TEXT NOT NULL, + status TEXT NOT NULL, + details TEXT, + correlation_id TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_watchdog_events_ts + ON watchdog_events(created_at DESC); + `); +}; + +module.exports = { + createSchema, +}; diff --git a/lib/server/webhooks-db.js b/lib/server/db/webhooks/index.js similarity index 91% rename from lib/server/webhooks-db.js rename to lib/server/db/webhooks/index.js index 294b28d5..7c3850e3 100644 --- a/lib/server/webhooks-db.js +++ b/lib/server/db/webhooks/index.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const { DatabaseSync } = require("node:sqlite"); +const { createSchema } = require("./schema"); let db = null; let pruneTimer = null; @@ -14,28 +15,6 @@ const ensureDb = () => { return db; }; -const createSchema = (database) => { - database.exec(` - CREATE TABLE IF NOT EXISTS webhook_requests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hook_name TEXT NOT NULL, - method TEXT, - headers TEXT, - payload TEXT, - payload_truncated INTEGER DEFAULT 0, - payload_size INTEGER, - source_ip TEXT, - gateway_status INTEGER, - gateway_body TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - ); - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_webhook_requests_hook_ts - ON webhook_requests(hook_name, created_at DESC); - `); -}; - const initWebhooksDb = ({ rootDir, pruneDays = 30 }) => { const dbDir = path.join(rootDir, "db"); fs.mkdirSync(dbDir, { recursive: true }); diff --git a/lib/server/db/webhooks/schema.js b/lib/server/db/webhooks/schema.js new file mode 100644 index 00000000..d29eef04 --- /dev/null +++ b/lib/server/db/webhooks/schema.js @@ -0,0 +1,25 @@ +const createSchema = (database) => { + database.exec(` + CREATE TABLE IF NOT EXISTS webhook_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_name TEXT NOT NULL, + method TEXT, + headers TEXT, + payload TEXT, + payload_truncated INTEGER DEFAULT 0, + payload_size INTEGER, + source_ip TEXT, + gateway_status INTEGER, + gateway_body TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_webhook_requests_hook_ts + ON webhook_requests(hook_name, created_at DESC); + `); +}; + +module.exports = { + createSchema, +}; diff --git a/lib/server/usage-db.js b/lib/server/usage-db.js deleted file mode 100644 index 1a9f647f..00000000 --- a/lib/server/usage-db.js +++ /dev/null @@ -1,838 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const { DatabaseSync } = require("node:sqlite"); - -const kDefaultSessionLimit = 50; -const kMaxSessionLimit = 200; -const kDefaultDays = 30; -const kDefaultMaxPoints = 100; -const kMaxMaxPoints = 1000; -const kDayMs = 24 * 60 * 60 * 1000; -const kTokensPerMillion = 1_000_000; -const kUtcTimeZone = "UTC"; -const kDayKeyFormatterCache = new Map(); -const kGlobalModelPricing = { - "claude-opus-4-6": { input: 15.0, output: 75.0 }, - "claude-sonnet-4-6": { input: 3.0, output: 15.0 }, - "claude-haiku-4-6": { input: 0.8, output: 4.0 }, - "gpt-5.1-codex": { input: 2.5, output: 10.0 }, - "gpt-5.3-codex": { input: 2.5, output: 10.0 }, - "gpt-4o": { input: 2.5, output: 10.0 }, - "gemini-3-pro-preview": { input: 1.25, output: 5.0 }, - "gemini-3-flash-preview": { input: 0.1, output: 0.4 }, - "gemini-2.0-flash": { input: 0.1, output: 0.4 }, -}; - -let db = null; -let usageDbPath = ""; - -const coerceInt = (value, fallbackValue = 0) => { - const parsed = Number.parseInt(String(value ?? ""), 10); - return Number.isFinite(parsed) ? parsed : fallbackValue; -}; - -const clampInt = (value, minValue, maxValue, fallbackValue) => - Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue))); - -const normalizeTimeZone = (value) => { - const raw = String(value || "").trim(); - if (!raw) return kUtcTimeZone; - try { - new Intl.DateTimeFormat("en-US", { timeZone: raw }); - return raw; - } catch { - return kUtcTimeZone; - } -}; - -const getDayKeyFormatter = (timeZone) => { - if (kDayKeyFormatterCache.has(timeZone)) { - return kDayKeyFormatterCache.get(timeZone); - } - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - kDayKeyFormatterCache.set(timeZone, formatter); - return formatter; -}; - -const toTimeZoneDayKey = (timestampMs, timeZone) => { - const parts = getDayKeyFormatter(timeZone).formatToParts(new Date(timestampMs)); - const year = parts.find((part) => part.type === "year")?.value || "0000"; - const month = parts.find((part) => part.type === "month")?.value || "01"; - const day = parts.find((part) => part.type === "day")?.value || "01"; - return `${year}-${month}-${day}`; -}; - -const resolvePricing = (model) => { - const normalized = String(model || "").toLowerCase(); - if (!normalized) return null; - const exact = kGlobalModelPricing[normalized]; - if (exact) return exact; - const matchKey = Object.keys(kGlobalModelPricing).find((key) => - normalized.includes(key), - ); - return matchKey ? kGlobalModelPricing[matchKey] : null; -}; - -const deriveCostBreakdown = ({ - inputTokens = 0, - outputTokens = 0, - cacheReadTokens = 0, - cacheWriteTokens = 0, - model = "", -}) => { - const pricing = resolvePricing(model); - if (!pricing) { - return { - inputCost: 0, - outputCost: 0, - cacheReadCost: 0, - cacheWriteCost: 0, - totalCost: 0, - pricingFound: false, - }; - } - const inputCost = (inputTokens / kTokensPerMillion) * pricing.input; - const outputCost = (outputTokens / kTokensPerMillion) * pricing.output; - const cacheReadCost = 0; - const cacheWriteCost = (cacheWriteTokens / kTokensPerMillion) * pricing.input; - return { - inputCost, - outputCost, - cacheReadCost, - cacheWriteCost, - totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost, - pricingFound: true, - }; -}; - -const ensureDb = () => { - if (!db) throw new Error("Usage DB not initialized"); - return db; -}; - -const safeAlterTable = (database, sql) => { - try { - database.exec(sql); - } catch (err) { - const message = String(err?.message || "").toLowerCase(); - if (!message.includes("duplicate column name")) throw err; - } -}; - -const ensureSchema = (database) => { - database.exec("PRAGMA journal_mode=WAL;"); - database.exec("PRAGMA synchronous=NORMAL;"); - database.exec("PRAGMA busy_timeout=5000;"); - database.exec(` - CREATE TABLE IF NOT EXISTS usage_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp INTEGER NOT NULL, - session_id TEXT, - session_key TEXT, - run_id TEXT, - provider TEXT NOT NULL, - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, - total_tokens INTEGER NOT NULL DEFAULT 0 - ); - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_usage_events_ts - ON usage_events(timestamp DESC); - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_usage_events_session - ON usage_events(session_id); - `); - safeAlterTable( - database, - "ALTER TABLE usage_events ADD COLUMN session_key TEXT;", - ); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_usage_events_session_key - ON usage_events(session_key); - `); - database.exec(` - CREATE TABLE IF NOT EXISTS usage_daily ( - date TEXT NOT NULL, - model TEXT NOT NULL, - provider TEXT, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, - total_tokens INTEGER NOT NULL DEFAULT 0, - turn_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, model) - ); - `); - database.exec(` - CREATE TABLE IF NOT EXISTS tool_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp INTEGER NOT NULL, - session_id TEXT, - session_key TEXT, - tool_name TEXT NOT NULL, - success INTEGER NOT NULL DEFAULT 1, - duration_ms INTEGER - ); - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_tool_events_session - ON tool_events(session_id); - `); - safeAlterTable( - database, - "ALTER TABLE tool_events ADD COLUMN session_key TEXT;", - ); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_tool_events_session_key - ON tool_events(session_key); - `); -}; - -const initUsageDb = ({ rootDir }) => { - const dbDir = path.join(rootDir, "db"); - fs.mkdirSync(dbDir, { recursive: true }); - usageDbPath = path.join(dbDir, "usage.db"); - db = new DatabaseSync(usageDbPath); - ensureSchema(db); - return { path: usageDbPath }; -}; - -const toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10); - -const getPeriodRange = (days, timeZone = kUtcTimeZone) => { - const now = Date.now(); - const safeDays = clampInt(days, 1, 3650, kDefaultDays); - const startMs = now - safeDays * kDayMs; - const normalizedTimeZone = normalizeTimeZone(timeZone); - const startDay = normalizedTimeZone === kUtcTimeZone - ? toDayKey(startMs) - : toTimeZoneDayKey(startMs, normalizedTimeZone); - return { now, safeDays, startDay, timeZone: normalizedTimeZone }; -}; - -const appendCostToRows = (rows) => - rows.map((row) => { - const inputTokens = coerceInt(row.input_tokens); - const outputTokens = coerceInt(row.output_tokens); - const cacheReadTokens = coerceInt(row.cache_read_tokens); - const cacheWriteTokens = coerceInt(row.cache_write_tokens); - const totalTokens = - coerceInt(row.total_tokens) || - inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; - const cost = deriveCostBreakdown({ - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - model: row.model, - }); - return { - ...row, - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens, - ...cost, - }; - }); - -const parseAgentAndSourceFromSessionRef = (sessionRef) => { - const raw = String(sessionRef || "").trim(); - if (!raw) { - return { agent: "unknown", source: "chat" }; - } - const parts = raw.split(":"); - const agent = - parts[0] === "agent" && String(parts[1] || "").trim() - ? String(parts[1] || "").trim() - : "unknown"; - const source = parts.includes("hook") - ? "hooks" - : parts.includes("cron") - ? "cron" - : "chat"; - return { agent, source }; -}; - -const getAgentCostDistribution = ({ - eventsRows = [], - startDay = "", - timeZone = kUtcTimeZone, -}) => { - const byAgent = new Map(); - const ensureAgentBucket = (agent) => { - if (byAgent.has(agent)) return byAgent.get(agent); - const bucket = { - agent, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - totalCost: 0, - turnCount: 0, - sourceBreakdown: { - chat: { - source: "chat", - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - totalCost: 0, - turnCount: 0, - }, - hooks: { - source: "hooks", - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - totalCost: 0, - turnCount: 0, - }, - cron: { - source: "cron", - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - totalCost: 0, - turnCount: 0, - }, - }, - }; - byAgent.set(agent, bucket); - return bucket; - }; - - for (const eventRow of eventsRows) { - const timestamp = coerceInt(eventRow.timestamp); - const dayKey = timeZone === kUtcTimeZone - ? toDayKey(timestamp) - : toTimeZoneDayKey(timestamp, timeZone); - if (dayKey < startDay) continue; - - const inputTokens = coerceInt(eventRow.input_tokens); - const outputTokens = coerceInt(eventRow.output_tokens); - const cacheReadTokens = coerceInt(eventRow.cache_read_tokens); - const cacheWriteTokens = coerceInt(eventRow.cache_write_tokens); - const totalTokens = - coerceInt(eventRow.total_tokens) || - inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; - const { totalCost } = deriveCostBreakdown({ - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - model: eventRow.model, - }); - const sessionRef = String(eventRow.session_key || eventRow.session_id || ""); - const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef); - const agentBucket = ensureAgentBucket(agent); - const sourceBucket = agentBucket.sourceBreakdown[source]; - - agentBucket.inputTokens += inputTokens; - agentBucket.outputTokens += outputTokens; - agentBucket.cacheReadTokens += cacheReadTokens; - agentBucket.cacheWriteTokens += cacheWriteTokens; - agentBucket.totalTokens += totalTokens; - agentBucket.totalCost += totalCost; - agentBucket.turnCount += 1; - - sourceBucket.inputTokens += inputTokens; - sourceBucket.outputTokens += outputTokens; - sourceBucket.cacheReadTokens += cacheReadTokens; - sourceBucket.cacheWriteTokens += cacheWriteTokens; - sourceBucket.totalTokens += totalTokens; - sourceBucket.totalCost += totalCost; - sourceBucket.turnCount += 1; - } - - const agents = Array.from(byAgent.values()) - .map((bucket) => ({ - agent: bucket.agent, - inputTokens: bucket.inputTokens, - outputTokens: bucket.outputTokens, - cacheReadTokens: bucket.cacheReadTokens, - cacheWriteTokens: bucket.cacheWriteTokens, - totalTokens: bucket.totalTokens, - totalCost: bucket.totalCost, - turnCount: bucket.turnCount, - sourceBreakdown: ["chat", "hooks", "cron"].map( - (source) => bucket.sourceBreakdown[source], - ), - })) - .sort((a, b) => b.totalCost - a.totalCost); - - return { - agents, - totals: agents.reduce( - (acc, agentBucket) => { - acc.totalCost += Number(agentBucket.totalCost || 0); - acc.totalTokens += Number(agentBucket.totalTokens || 0); - acc.turnCount += Number(agentBucket.turnCount || 0); - return acc; - }, - { totalCost: 0, totalTokens: 0, turnCount: 0 }, - ), - }; -}; - -const getDailySummary = ({ days = kDefaultDays, timeZone = kUtcTimeZone } = {}) => { - const database = ensureDb(); - const { now, safeDays, startDay, timeZone: normalizedTimeZone } = getPeriodRange( - days, - timeZone, - ); - let rows = []; - if (normalizedTimeZone === kUtcTimeZone) { - rows = database - .prepare(` - SELECT - date, - model, - provider, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - total_tokens, - turn_count - FROM usage_daily - WHERE date >= $startDay - ORDER BY date ASC, total_tokens DESC - `) - .all({ $startDay: startDay }); - } else { - const lookbackMs = now - (safeDays + 2) * kDayMs; - const eventRows = database - .prepare(` - SELECT - timestamp, - provider, - model, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - total_tokens - FROM usage_events - WHERE timestamp >= $lookbackMs - ORDER BY timestamp ASC - `) - .all({ $lookbackMs: lookbackMs }); - const byDateModel = new Map(); - for (const eventRow of eventRows) { - const dayKey = toTimeZoneDayKey(coerceInt(eventRow.timestamp), normalizedTimeZone); - if (dayKey < startDay) continue; - const model = String(eventRow.model || "unknown"); - const mapKey = `${dayKey}\u0000${model}`; - if (!byDateModel.has(mapKey)) { - byDateModel.set(mapKey, { - date: dayKey, - model, - provider: String(eventRow.provider || "unknown"), - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - total_tokens: 0, - turn_count: 0, - }); - } - const aggregate = byDateModel.get(mapKey); - aggregate.input_tokens += coerceInt(eventRow.input_tokens); - aggregate.output_tokens += coerceInt(eventRow.output_tokens); - aggregate.cache_read_tokens += coerceInt(eventRow.cache_read_tokens); - aggregate.cache_write_tokens += coerceInt(eventRow.cache_write_tokens); - aggregate.total_tokens += coerceInt(eventRow.total_tokens); - aggregate.turn_count += 1; - if (!aggregate.provider && eventRow.provider) { - aggregate.provider = String(eventRow.provider || "unknown"); - } - } - rows = Array.from(byDateModel.values()).sort((a, b) => { - if (a.date === b.date) return b.total_tokens - a.total_tokens; - return a.date.localeCompare(b.date); - }); - } - const enriched = appendCostToRows(rows); - const lookbackMs = now - (safeDays + 2) * kDayMs; - const eventsRows = database - .prepare(` - SELECT - timestamp, - session_id, - session_key, - model, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - total_tokens - FROM usage_events - WHERE timestamp >= $lookbackMs - ORDER BY timestamp ASC - `) - .all({ $lookbackMs: lookbackMs }); - const costByAgent = getAgentCostDistribution({ - eventsRows, - startDay, - timeZone: normalizedTimeZone, - }); - const byDate = new Map(); - for (const row of enriched) { - if (!byDate.has(row.date)) byDate.set(row.date, []); - byDate.get(row.date).push({ - model: row.model, - provider: row.provider, - inputTokens: row.inputTokens, - outputTokens: row.outputTokens, - cacheReadTokens: row.cacheReadTokens, - cacheWriteTokens: row.cacheWriteTokens, - totalTokens: row.totalTokens, - turnCount: coerceInt(row.turn_count), - totalCost: row.totalCost, - inputCost: row.inputCost, - outputCost: row.outputCost, - cacheReadCost: row.cacheReadCost, - cacheWriteCost: row.cacheWriteCost, - pricingFound: row.pricingFound, - }); - } - const daily = []; - const totals = { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - totalCost: 0, - turnCount: 0, - modelCount: 0, - }; - for (const [date, modelRows] of byDate.entries()) { - const aggregate = modelRows.reduce( - (acc, row) => ({ - inputTokens: acc.inputTokens + row.inputTokens, - outputTokens: acc.outputTokens + row.outputTokens, - cacheReadTokens: acc.cacheReadTokens + row.cacheReadTokens, - cacheWriteTokens: acc.cacheWriteTokens + row.cacheWriteTokens, - totalTokens: acc.totalTokens + row.totalTokens, - totalCost: acc.totalCost + row.totalCost, - turnCount: acc.turnCount + row.turnCount, - }), - { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalTokens: 0, - totalCost: 0, - turnCount: 0, - }, - ); - daily.push({ date, ...aggregate, models: modelRows }); - totals.inputTokens += aggregate.inputTokens; - totals.outputTokens += aggregate.outputTokens; - totals.cacheReadTokens += aggregate.cacheReadTokens; - totals.cacheWriteTokens += aggregate.cacheWriteTokens; - totals.totalTokens += aggregate.totalTokens; - totals.totalCost += aggregate.totalCost; - totals.turnCount += aggregate.turnCount; - totals.modelCount += modelRows.length; - } - return { - updatedAt: Date.now(), - days: safeDays, - timeZone: normalizedTimeZone, - daily, - totals, - costByAgent, - }; -}; - -const getSessionsList = ({ limit = kDefaultSessionLimit } = {}) => { - const database = ensureDb(); - const safeLimit = clampInt(limit, 1, kMaxSessionLimit, kDefaultSessionLimit); - const rows = database - .prepare(` - SELECT - COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) AS session_ref, - MAX(session_key) AS session_key, - MAX(session_id) AS session_id, - MIN(timestamp) AS first_activity_ms, - MAX(timestamp) AS last_activity_ms, - COUNT(*) AS turn_count, - SUM(input_tokens) AS input_tokens, - SUM(output_tokens) AS output_tokens, - SUM(cache_read_tokens) AS cache_read_tokens, - SUM(cache_write_tokens) AS cache_write_tokens, - SUM(total_tokens) AS total_tokens - FROM usage_events - WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) IS NOT NULL - GROUP BY session_ref - ORDER BY last_activity_ms DESC - LIMIT $limit - `) - .all({ $limit: safeLimit }); - return rows.map((row) => { - const modelRows = appendCostToRows( - database - .prepare(` - SELECT - model, - SUM(input_tokens) AS input_tokens, - SUM(output_tokens) AS output_tokens, - SUM(cache_read_tokens) AS cache_read_tokens, - SUM(cache_write_tokens) AS cache_write_tokens, - SUM(total_tokens) AS total_tokens - FROM usage_events - WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef - GROUP BY model - ORDER BY total_tokens DESC - `) - .all({ $sessionRef: row.session_ref }), - ); - const dominantModel = String(modelRows[0]?.model || ""); - const totalCost = modelRows.reduce( - (sum, modelRow) => sum + Number(modelRow.totalCost || 0), - 0, - ); - return { - sessionId: row.session_ref, - sessionKey: String(row.session_key || ""), - rawSessionId: String(row.session_id || ""), - firstActivityMs: coerceInt(row.first_activity_ms), - lastActivityMs: coerceInt(row.last_activity_ms), - durationMs: Math.max( - 0, - coerceInt(row.last_activity_ms) - coerceInt(row.first_activity_ms), - ), - turnCount: coerceInt(row.turn_count), - inputTokens: coerceInt(row.input_tokens), - outputTokens: coerceInt(row.output_tokens), - cacheReadTokens: coerceInt(row.cache_read_tokens), - cacheWriteTokens: coerceInt(row.cache_write_tokens), - totalTokens: coerceInt(row.total_tokens), - totalCost, - dominantModel, - }; - }); -}; - -const getSessionDetail = ({ sessionId }) => { - const safeSessionRef = String(sessionId || "").trim(); - if (!safeSessionRef) return null; - const database = ensureDb(); - const summaryRow = database - .prepare(` - SELECT - MAX(session_key) AS session_key, - MAX(session_id) AS session_id, - MIN(timestamp) AS first_activity_ms, - MAX(timestamp) AS last_activity_ms, - COUNT(*) AS turn_count, - SUM(input_tokens) AS input_tokens, - SUM(output_tokens) AS output_tokens, - SUM(cache_read_tokens) AS cache_read_tokens, - SUM(cache_write_tokens) AS cache_write_tokens, - SUM(total_tokens) AS total_tokens - FROM usage_events - WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef - `) - .get({ $sessionRef: safeSessionRef }); - if (!summaryRow || !coerceInt(summaryRow.turn_count)) return null; - - const modelRows = appendCostToRows( - database - .prepare(` - SELECT - provider, - model, - COUNT(*) AS turn_count, - SUM(input_tokens) AS input_tokens, - SUM(output_tokens) AS output_tokens, - SUM(cache_read_tokens) AS cache_read_tokens, - SUM(cache_write_tokens) AS cache_write_tokens, - SUM(total_tokens) AS total_tokens - FROM usage_events - WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef - GROUP BY provider, model - ORDER BY total_tokens DESC - `) - .all({ $sessionRef: safeSessionRef }), - ).map((row) => ({ - provider: row.provider, - model: row.model, - turnCount: coerceInt(row.turn_count), - inputTokens: row.inputTokens, - outputTokens: row.outputTokens, - cacheReadTokens: row.cacheReadTokens, - cacheWriteTokens: row.cacheWriteTokens, - totalTokens: row.totalTokens, - totalCost: row.totalCost, - inputCost: row.inputCost, - outputCost: row.outputCost, - cacheReadCost: row.cacheReadCost, - cacheWriteCost: row.cacheWriteCost, - pricingFound: row.pricingFound, - })); - - const toolRows = database - .prepare(` - SELECT - tool_name, - COUNT(*) AS call_count, - SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS error_count, - AVG(duration_ms) AS avg_duration_ms, - MIN(duration_ms) AS min_duration_ms, - MAX(duration_ms) AS max_duration_ms - FROM tool_events - WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef - GROUP BY tool_name - ORDER BY call_count DESC - `) - .all({ $sessionRef: safeSessionRef }) - .map((row) => { - const callCount = coerceInt(row.call_count); - const successCount = coerceInt(row.success_count); - const errorCount = coerceInt(row.error_count); - return { - toolName: row.tool_name, - callCount, - successCount, - errorCount, - errorRate: callCount > 0 ? errorCount / callCount : 0, - avgDurationMs: Number(row.avg_duration_ms || 0), - minDurationMs: coerceInt(row.min_duration_ms), - maxDurationMs: coerceInt(row.max_duration_ms), - }; - }); - - const firstActivityMs = coerceInt(summaryRow.first_activity_ms); - const lastActivityMs = coerceInt(summaryRow.last_activity_ms); - const totalCost = modelRows.reduce( - (sum, modelRow) => sum + Number(modelRow.totalCost || 0), - 0, - ); - - return { - sessionId: safeSessionRef, - sessionKey: String(summaryRow.session_key || ""), - rawSessionId: String(summaryRow.session_id || ""), - firstActivityMs, - lastActivityMs, - durationMs: Math.max(0, lastActivityMs - firstActivityMs), - turnCount: coerceInt(summaryRow.turn_count), - inputTokens: coerceInt(summaryRow.input_tokens), - outputTokens: coerceInt(summaryRow.output_tokens), - cacheReadTokens: coerceInt(summaryRow.cache_read_tokens), - cacheWriteTokens: coerceInt(summaryRow.cache_write_tokens), - totalTokens: coerceInt(summaryRow.total_tokens), - totalCost, - modelBreakdown: modelRows, - toolUsage: toolRows, - }; -}; - -const downsamplePoints = (points, maxPoints) => { - if (points.length <= maxPoints) return points; - const stride = Math.ceil(points.length / maxPoints); - const sampled = []; - for (let index = 0; index < points.length; index += stride) { - sampled.push(points[index]); - } - const lastPoint = points[points.length - 1]; - if (sampled[sampled.length - 1]?.timestamp !== lastPoint.timestamp) { - sampled.push(lastPoint); - } - return sampled; -}; - -const getSessionTimeSeries = ({ sessionId, maxPoints = kDefaultMaxPoints }) => { - const safeSessionRef = String(sessionId || "").trim(); - if (!safeSessionRef) return { sessionId: safeSessionRef, points: [] }; - const database = ensureDb(); - const rows = database - .prepare(` - SELECT - timestamp, - session_key, - session_id, - model, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - total_tokens - FROM usage_events - WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef - ORDER BY timestamp ASC - `) - .all({ $sessionRef: safeSessionRef }); - let cumulativeTokens = 0; - let cumulativeCost = 0; - const points = rows.map((row) => { - const inputTokens = coerceInt(row.input_tokens); - const outputTokens = coerceInt(row.output_tokens); - const cacheReadTokens = coerceInt(row.cache_read_tokens); - const cacheWriteTokens = coerceInt(row.cache_write_tokens); - const totalTokens = - coerceInt(row.total_tokens) || - inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; - const cost = deriveCostBreakdown({ - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - model: row.model, - }); - cumulativeTokens += totalTokens; - cumulativeCost += cost.totalCost; - return { - timestamp: coerceInt(row.timestamp), - sessionKey: String(row.session_key || ""), - rawSessionId: String(row.session_id || ""), - model: String(row.model || ""), - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens, - cost: cost.totalCost, - cumulativeTokens, - cumulativeCost, - }; - }); - const safeMaxPoints = clampInt(maxPoints, 10, kMaxMaxPoints, kDefaultMaxPoints); - return { - sessionId: safeSessionRef, - points: downsamplePoints(points, safeMaxPoints), - }; -}; - -module.exports = { - initUsageDb, - getDailySummary, - getSessionsList, - getSessionDetail, - getSessionTimeSeries, - kGlobalModelPricing, -}; diff --git a/tests/server/usage-db.test.js b/tests/server/usage-db.test.js index 41aa5b74..62033110 100644 --- a/tests/server/usage-db.test.js +++ b/tests/server/usage-db.test.js @@ -4,7 +4,7 @@ const path = require("path"); const { DatabaseSync } = require("node:sqlite"); const loadUsageDb = () => { - const modulePath = require.resolve("../../lib/server/usage-db"); + const modulePath = require.resolve("../../lib/server/db/usage"); delete require.cache[modulePath]; return require(modulePath); }; @@ -72,7 +72,7 @@ describe("server/usage-db", () => { }); const detail = getSessionDetail({ sessionId: "session-1" }); - const expectedCost = 2.5 + 75; + const expectedCost = 2.5 + 37.5; const summedBreakdownCost = detail.modelBreakdown.reduce( (sum, row) => sum + Number(row.totalCost || 0), 0, @@ -188,4 +188,75 @@ describe("server/usage-db", () => { fs.rmSync(rootDir, { recursive: true, force: true }); }); + + it("applies tiered pricing per event, not aggregated totals", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "usage-db-tiered-event-")); + const { initUsageDb, getSessionDetail } = loadUsageDb(); + const { path: dbPath } = initUsageDb({ rootDir }); + const database = new DatabaseSync(dbPath); + const now = Date.now(); + + const insertUsageEvent = database.prepare(` + INSERT INTO usage_events ( + timestamp, + session_id, + session_key, + run_id, + provider, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens + ) VALUES ( + $timestamp, + $session_id, + $session_key, + $run_id, + $provider, + $model, + $input_tokens, + $output_tokens, + $cache_read_tokens, + $cache_write_tokens, + $total_tokens + ) + `); + + // Each event stays below the 200k threshold, so both should use 25/M output rate. + insertUsageEvent.run({ + $timestamp: now - 1000, + $session_id: "raw-tier-1", + $session_key: "session-tier-1", + $run_id: "run-tier-1", + $provider: "anthropic", + $model: "claude-opus-4-6", + $input_tokens: 0, + $output_tokens: 150_000, + $cache_read_tokens: 0, + $cache_write_tokens: 0, + $total_tokens: 150_000, + }); + insertUsageEvent.run({ + $timestamp: now, + $session_id: "raw-tier-1", + $session_key: "session-tier-1", + $run_id: "run-tier-2", + $provider: "anthropic", + $model: "claude-opus-4-6", + $input_tokens: 0, + $output_tokens: 150_000, + $cache_read_tokens: 0, + $cache_write_tokens: 0, + $total_tokens: 150_000, + }); + + const detail = getSessionDetail({ sessionId: "session-tier-1" }); + + expect(detail).toBeTruthy(); + expect(detail.totalCost).toBeCloseTo(7.5, 8); + + fs.rmSync(rootDir, { recursive: true, force: true }); + }); }); diff --git a/tests/server/watchdog-db.test.js b/tests/server/watchdog-db.test.js index 13391179..f46773d7 100644 --- a/tests/server/watchdog-db.test.js +++ b/tests/server/watchdog-db.test.js @@ -4,7 +4,7 @@ const path = require("path"); const { DatabaseSync } = require("node:sqlite"); const loadWatchdogDb = () => { - const modulePath = require.resolve("../../lib/server/watchdog-db"); + const modulePath = require.resolve("../../lib/server/db/watchdog"); delete require.cache[modulePath]; return require(modulePath); }; From ba2114f1345037ce6afc3efe5f5508a433cf94df Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 08:26:07 -0800 Subject: [PATCH 092/417] Add file download action to explorer context menu. This adds a file-only Download option in the tree right-click menu, wires it to a new browse download endpoint, and covers both client and server behavior with tests. --- lib/public/js/components/file-tree.js | 45 ++++++++++++++++++++++----- lib/public/js/components/icons.js | 13 ++++++++ lib/public/js/lib/api.js | 31 ++++++++++++++++++ lib/server/routes/browse/index.js | 29 +++++++++++++++++ tests/frontend/api.test.js | 43 +++++++++++++++++++++++++ tests/server/routes-browse.test.js | 17 ++++++++++ 6 files changed, 170 insertions(+), 8 deletions(-) diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index ed85bdd8..662fa2b9 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -13,6 +13,7 @@ import { createBrowseFile, createBrowseFolder, moveBrowsePath, + downloadBrowseFile, } from "../lib/api.js"; import { kDraftIndexChangedEventName, @@ -40,6 +41,7 @@ import { FileAddLineIcon, FolderAddLineIcon, DeleteBinLineIcon, + DownloadLineIcon, } from "./icons.js"; import { LoadingSpinner } from "./loading-spinner.js"; import { ConfirmDialog } from "./confirm-dialog.js"; @@ -224,6 +226,7 @@ const TreeContextMenu = ({ isLocked, onNewFile, onNewFolder, + onDownload, onDelete, onClose, }) => { @@ -252,6 +255,7 @@ const TreeContextMenu = ({ const isRoot = targetType === "root"; const contextFolder = isFolder ? targetPath : ""; const canCreate = !isLocked && (isFolder || isRoot); + const canDownload = isFile && targetPath; const canDelete = !isLocked && (isFolder || isFile) && targetPath; return html` @@ -278,18 +282,33 @@ const TreeContextMenu = ({ ` : null} - ${canDelete + ${canDownload || canDelete ? html` ${canCreate ? html`
        ` : null} - + ${canDownload + ? html` + + ` + : null} + ${canDelete + ? html` + + ` + : null} ` : null} ${isLocked @@ -942,6 +961,15 @@ export const FileTree = ({ setContextMenu(null); }; + const requestDownload = async (targetPath) => { + try { + await downloadBrowseFile(targetPath); + showToast("Download started", "success"); + } catch (downloadError) { + showToast(downloadError.message || "Could not download file", "error"); + } + }; + const handleDragDrop = async (action, sourcePath, targetFolder) => { if (action === "start") { setDragSourcePath(sourcePath); @@ -1208,6 +1236,7 @@ export const FileTree = ({ isLocked=${!!contextMenu.isLocked} onNewFile=${(folder) => requestCreate(folder, "file")} onNewFolder=${(folder) => requestCreate(folder, "folder")} + onDownload=${requestDownload} onDelete=${requestDelete} onClose=${closeContextMenu} /> diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index b080dc82..d961fee0 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -288,6 +288,19 @@ export const FolderAddLineIcon = ({ className = "" }) => html` `; +export const DownloadLineIcon = ({ className = "" }) => html` + +`; + export const RestartLineIcon = ({ className = "" }) => html` { return parseJsonOrThrow(res, 'Could not delete file'); }; +export const downloadBrowseFile = async (filePath) => { + const params = new URLSearchParams({ path: String(filePath || "") }); + const res = await authFetch(`/api/browse/download?${params.toString()}`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText || "Could not download file"); + } + const fileBlob = await res.blob(); + const urlApi = window?.URL || URL; + if (!urlApi?.createObjectURL || !urlApi?.revokeObjectURL) { + throw new Error("Download is not supported in this browser"); + } + const downloadUrl = urlApi.createObjectURL(fileBlob); + const fileName = + String(filePath || "") + .split("/") + .filter(Boolean) + .pop() || "download"; + try { + const downloadLink = document.createElement("a"); + downloadLink.href = downloadUrl; + downloadLink.download = fileName; + document.body?.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + } finally { + urlApi.revokeObjectURL(downloadUrl); + } + return { ok: true }; +}; + export const restoreBrowseFile = async (filePath) => { const res = await authFetch('/api/browse/restore', { method: 'POST', diff --git a/lib/server/routes/browse/index.js b/lib/server/routes/browse/index.js index 7c9337ac..a1cbbc87 100644 --- a/lib/server/routes/browse/index.js +++ b/lib/server/routes/browse/index.js @@ -156,6 +156,35 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => { } }); + app.get("/api/browse/download", (req, res) => { + const resolvedPath = resolveSafePath( + req.query.path, + kRootResolved, + kRootWithSep, + kRootDisplayName, + ); + if (!resolvedPath.ok) { + return res.status(400).json({ ok: false, error: resolvedPath.error }); + } + try { + const stats = fs.statSync(resolvedPath.absolutePath); + if (!stats.isFile()) { + return res.status(400).json({ ok: false, error: "Path is not a file" }); + } + const fileName = path.basename(resolvedPath.relativePath || resolvedPath.absolutePath); + return res.download(resolvedPath.absolutePath, fileName, (error) => { + if (!error || res.headersSent) return; + return res + .status(500) + .json({ ok: false, error: error.message || "Could not download file" }); + }); + } catch (error) { + return res + .status(500) + .json({ ok: false, error: error.message || "Could not download file" }); + } + }); + app.get("/api/browse/git-summary", async (req, res) => { try { const envRepoSlug = parseGithubRepoSlug( diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 4446f42c..fd34e6fc 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -164,4 +164,47 @@ describe("frontend/api", () => { ); expect(result).toEqual({ ok: true, content: "diff --git" }); }); + + it("downloadBrowseFile calls download endpoint and triggers browser download", async () => { + const fileBlob = new Blob(["test"], { type: "text/plain" }); + const createObjectURL = vi.fn(() => "blob:test-url"); + const revokeObjectURL = vi.fn(); + global.window.URL = { createObjectURL, revokeObjectURL }; + const click = vi.fn(); + const remove = vi.fn(); + const appendChild = vi.fn(); + global.document = { + createElement: vi.fn((tagName) => + tagName === "a" + ? { + href: "", + download: "", + click, + remove, + } + : {}, + ), + body: { appendChild }, + }; + global.fetch.mockResolvedValue({ + status: 200, + ok: true, + blob: async () => fileBlob, + text: async () => "", + }); + const api = await loadApiModule(); + + const result = await api.downloadBrowseFile("workspace/file.txt"); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/browse/download?path=workspace%2Ffile.txt", + expect.objectContaining({ headers: expect.any(Headers) }), + ); + expect(createObjectURL).toHaveBeenCalledWith(fileBlob); + expect(appendChild).toHaveBeenCalledTimes(1); + expect(click).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url"); + expect(result).toEqual({ ok: true }); + }); }); diff --git a/tests/server/routes-browse.test.js b/tests/server/routes-browse.test.js index 6b8d191f..6a761860 100644 --- a/tests/server/routes-browse.test.js +++ b/tests/server/routes-browse.test.js @@ -217,6 +217,23 @@ describe("server/routes/browse", () => { expect(res.body.offset).toBe(1); }); + it("downloads files as attachments", async () => { + const rootDir = createTestRoot(); + const filePath = path.join(rootDir, "download-me.txt"); + fs.writeFileSync(filePath, "file payload\n", "utf8"); + const app = createApp(rootDir); + + const res = await request(app) + .get("/api/browse/download") + .query({ path: "download-me.txt" }); + + expect(res.status).toBe(200); + expect(String(res.headers["content-disposition"] || "")).toContain( + 'attachment; filename="download-me.txt"', + ); + expect(res.text).toBe("file payload\n"); + }); + it("writes file content and returns write result", async () => { const rootDir = createTestRoot(); const filePath = path.join(rootDir, "openclaw.json"); From 8d1d4f98d3aaf0c3f1d4c32129fb5631e43e24b9 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 08:33:13 -0800 Subject: [PATCH 093/417] Clarify usage cost breakdown as estimated. This updates the cost breakdown panel copy and adds a missing-pricing notice when model rates are unavailable for part of the selected range. --- .../components/usage-tab/overview-section.js | 126 ++++++++++++++---- 1 file changed, 100 insertions(+), 26 deletions(-) diff --git a/lib/public/js/components/usage-tab/overview-section.js b/lib/public/js/components/usage-tab/overview-section.js index 40bc61f0..2dc686ce 100644 --- a/lib/public/js/components/usage-tab/overview-section.js +++ b/lib/public/js/components/usage-tab/overview-section.js @@ -26,40 +26,78 @@ const SummaryCard = ({ title, tokens, cost }) => html` `; const AgentCostDistribution = ({ summary }) => { - const agents = Array.isArray(summary?.costByAgent?.agents) ? summary.costByAgent.agents : []; - const [selectedAgent, setSelectedAgent] = useState(() => String(agents[0]?.agent || "")); + const agents = Array.isArray(summary?.costByAgent?.agents) + ? summary.costByAgent.agents + : []; + const missingPricingModels = Array.from( + new Set( + (summary?.daily || []) + .flatMap((dayRow) => dayRow?.models || []) + .filter( + (modelRow) => + !modelRow?.pricingFound && Number(modelRow?.totalTokens || 0) > 0, + ) + .map( + (modelRow) => + String(modelRow?.model || "unknown").trim() || "unknown", + ), + ), + ).sort((leftValue, rightValue) => leftValue.localeCompare(rightValue)); + const missingPricingPreview = missingPricingModels.slice(0, 3).join(", "); + const hasMoreMissingPricingModels = missingPricingModels.length > 3; + const missingPricingLabel = missingPricingModels.length + ? hasMoreMissingPricingModels + ? `${missingPricingPreview}, +${missingPricingModels.length - 3} more` + : missingPricingPreview + : ""; + const [selectedAgent, setSelectedAgent] = useState(() => + String(agents[0]?.agent || ""), + ); useEffect(() => { if (agents.length === 0) { if (selectedAgent) setSelectedAgent(""); return; } - const hasSelectedAgent = agents.some((row) => String(row.agent || "") === selectedAgent); + const hasSelectedAgent = agents.some( + (row) => String(row.agent || "") === selectedAgent, + ); if (!hasSelectedAgent) setSelectedAgent(String(agents[0]?.agent || "")); }, [agents, selectedAgent]); const selectedAgentRow = - agents.find((row) => String(row.agent || "") === selectedAgent) || agents[0] || null; + agents.find((row) => String(row.agent || "") === selectedAgent) || + agents[0] || + null; return html`
        ${agents.length === 0 ? html` -
        -

        Cost breakdown

        +
        +

        Estimated cost breakdown

        -

        No agent usage recorded for this range.

        +

        + No agent usage recorded for this range. +

        ` : html`
        -
        -

        Cost breakdown

        -
        - ${formatUsd(selectedAgentRow?.totalCost)} - ${formatInteger(selectedAgentRow?.totalTokens)} tok -
        +

        Estimated cost breakdown

        +
        +
        +
        + <${ActionButton} + onClick=${onClose} + disabled=${sending} + tone="secondary" + size="md" + idleLabel="Cancel" + /> + <${ActionButton} + onClick=${handleSend} + disabled=${!selectedSession || loadingSessions || !!loadError} + loading=${sending} + tone="primary" + size="md" + idleLabel="Send fix request" + loadingLabel="Sending..." + /> +
        + + `; +}; diff --git a/lib/public/js/components/doctor/general-warning.js b/lib/public/js/components/doctor/general-warning.js new file mode 100644 index 00000000..fda7a13a --- /dev/null +++ b/lib/public/js/components/doctor/general-warning.js @@ -0,0 +1,37 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { ActionButton } from "../action-button.js"; +import { getDoctorWarningMessage, shouldShowDoctorWarning } from "./helpers.js"; + +const html = htm.bind(h); + +export const GeneralDoctorWarning = ({ + doctorStatus = null, + dismissedUntilMs = 0, + onOpenDoctor = () => {}, + onDismiss = () => {}, +}) => { + if (!shouldShowDoctorWarning(doctorStatus, dismissedUntilMs)) return null; + return html` +
        +
        +
        +

        Drift Doctor

        +

        ${getDoctorWarningMessage(doctorStatus)}

        +
        +
        + <${ActionButton} + onClick=${onDismiss} + tone="secondary" + idleLabel="Dismiss for 1 week" + /> + <${ActionButton} + onClick=${onOpenDoctor} + tone="warning" + idleLabel="Open Drift Doctor" + /> +
        +
        +
        + `; +}; diff --git a/lib/public/js/components/doctor/helpers.js b/lib/public/js/components/doctor/helpers.js new file mode 100644 index 00000000..f79d4c9f --- /dev/null +++ b/lib/public/js/components/doctor/helpers.js @@ -0,0 +1,169 @@ +export const getDoctorPriorityTone = (priority = "") => { + const normalized = String(priority || "").trim().toUpperCase(); + if (normalized === "P0") return "danger"; + if (normalized === "P1") return "warning"; + return "neutral"; +}; + +export const getDoctorStatusTone = (status = "") => { + const normalized = String(status || "").trim().toLowerCase(); + if (normalized === "fixed") return "success"; + if (normalized === "dismissed") return "neutral"; + return "warning"; +}; + +export const getDoctorCategoryTone = (category = "") => { + const normalized = String(category || "") + .trim() + .toLowerCase() + .replace(/[_-]+/g, " "); + if (normalized === "token efficiency") return "info"; + if (normalized === "redundancy") return "accent"; + if (normalized === "mixed concerns") return "cyan"; + if (normalized === "workspace state") return "secondary"; + return "info"; +}; + +export const formatDoctorCategory = (category = "") => { + const normalized = String(category || "") + .trim() + .replace(/[_-]+/g, " "); + if (!normalized) return "Workspace"; + return normalized.replace(/\b\w/g, (character) => character.toUpperCase()); +}; + +export const buildDoctorPriorityCounts = (cards = []) => + cards.reduce( + (totals, card) => { + const priority = String(card?.priority || "").trim().toUpperCase(); + if (priority === "P0" || priority === "P1" || priority === "P2") { + totals[priority] += 1; + } + return totals; + }, + { P0: 0, P1: 0, P2: 0 }, + ); + +export const groupDoctorCardsByStatus = (cards = []) => + cards.reduce( + (groups, card) => { + const status = String(card?.status || "open").trim().toLowerCase(); + if (status === "fixed") { + groups.fixed.push(card); + return groups; + } + if (status === "dismissed") { + groups.dismissed.push(card); + return groups; + } + groups.open.push(card); + return groups; + }, + { open: [], dismissed: [], fixed: [] }, + ); + +export const shouldShowDoctorWarning = ( + doctorStatus = null, + dismissedUntilMs = 0, +) => { + if (!doctorStatus || doctorStatus.runInProgress) return false; + if (doctorStatus.needsInitialRun || !doctorStatus.stale) return false; + if (!doctorStatus.changeSummary?.hasMeaningfulChanges) return false; + return Number(dismissedUntilMs || 0) <= Date.now(); +}; + +export const getDoctorWarningMessage = (doctorStatus = null) => { + if (!doctorStatus) return ""; + const changedFilesCount = Number(doctorStatus.changeSummary?.changedFilesCount || 0); + if (changedFilesCount > 0) { + return `Drift Doctor has not been run in the last week and ${changedFilesCount} file${changedFilesCount === 1 ? "" : "s"} changed since the last review.`; + } + return "Doctor has not been run in the last week."; +}; + +export const getDoctorDriftRisk = (changeSummary = null) => { + const deltaScore = Number(changeSummary?.deltaScore || 0); + const changedFilesCount = Number(changeSummary?.changedFilesCount || 0); + const hasBaseline = !!changeSummary?.hasBaseline; + const baselineSource = String(changeSummary?.baselineSource || "none"); + if (!hasBaseline) { + return { + label: "Unknown", + tone: "neutral", + detail: "No prior baseline yet", + }; + } + if (deltaScore >= 8 || changedFilesCount >= 8) { + return { + label: "High", + tone: "danger", + detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`, + }; + } + if (deltaScore >= 4 || changedFilesCount >= 4) { + return { + label: "Moderate", + tone: "warning", + detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`, + }; + } + return { + label: "Low", + tone: "info", + detail: changedFilesCount + ? `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}` + : baselineSource === "initial_install" + ? "Compared to initial install" + : "No detected changes", + }; +}; + +export const getDoctorRunPillDetail = (run = null) => { + if (!run || typeof run !== "object") return ""; + if (run.status === "running") return "Running"; + if (run.status === "failed") return "Failed"; + if ((run.cardCount || 0) === 0) return "No findings"; + return `${run.cardCount || 0} finding${run.cardCount === 1 ? "" : "s"}`; +}; + +export const buildDoctorRunMarkers = (run = null) => { + if (!run || typeof run !== "object") return []; + if (run.status === "running") { + return [{ tone: "cyan", count: 0, label: "Running" }]; + } + if (run.status === "failed") { + return [{ tone: "neutral", count: 0, label: "Failed" }]; + } + if ((run.cardCount || 0) === 0) { + return [{ tone: "success", count: 0, label: "No findings" }]; + } + const markers = []; + if (Number(run?.priorityCounts?.P0 || 0) > 0) { + markers.push({ + tone: "danger", + count: Number(run.priorityCounts.P0 || 0), + label: "P0", + }); + } + if (Number(run?.priorityCounts?.P1 || 0) > 0) { + markers.push({ + tone: "warning", + count: Number(run.priorityCounts.P1 || 0), + label: "P1", + }); + } + if (Number(run?.priorityCounts?.P2 || 0) > 0) { + markers.push({ + tone: "neutral", + count: Number(run.priorityCounts.P2 || 0), + label: "P2", + }); + } + return markers; +}; + +export const buildDoctorStatusFilterOptions = () => [ + { value: "open", label: "Open" }, + { value: "dismissed", label: "Dismissed" }, + { value: "fixed", label: "Fixed" }, +]; diff --git a/lib/public/js/components/doctor/index.js b/lib/public/js/components/doctor/index.js new file mode 100644 index 00000000..0b6546da --- /dev/null +++ b/lib/public/js/components/doctor/index.js @@ -0,0 +1,536 @@ +import { h } from "https://esm.sh/preact"; +import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { usePolling } from "../../hooks/usePolling.js"; +import { + fetchDoctorCards, + fetchDoctorStatus, + fetchDoctorRuns, + startDoctorRun, + updateDoctorCardStatus, +} from "../../lib/api.js"; +import { formatLocaleDateTime } from "../../lib/format.js"; +import { ActionButton } from "../action-button.js"; +import { Badge } from "../badge.js"; +import { LoadingSpinner } from "../loading-spinner.js"; +import { PageHeader } from "../page-header.js"; +import { showToast } from "../toast.js"; +import { DoctorSummaryCards } from "./summary-cards.js"; +import { DoctorFindingsList } from "./findings-list.js"; +import { DoctorFixCardModal } from "./fix-card-modal.js"; +import { + buildDoctorRunMarkers, + buildDoctorStatusFilterOptions, + getDoctorDriftRisk, + getDoctorRunPillDetail, + shouldShowDoctorWarning, +} from "./helpers.js"; + +const html = htm.bind(h); + +const kIdlePollMs = 15000; +const kActivePollMs = 2000; + +const DoctorEmptyStateIcon = () => html` + + + +`; + +export const DoctorTab = ({ isActive = false }) => { + const statusPoll = usePolling(fetchDoctorStatus, kIdlePollMs, { + enabled: isActive, + }); + const doctorStatus = statusPoll.data?.status || null; + const runPollIntervalMs = doctorStatus?.runInProgress + ? kActivePollMs + : kIdlePollMs; + const runsPoll = usePolling(() => fetchDoctorRuns(10), runPollIntervalMs, { + enabled: isActive, + }); + const [selectedRunFilter, setSelectedRunFilter] = useState("all"); + const [selectedStatusFilter, setSelectedStatusFilter] = useState("open"); + const [busyCardId, setBusyCardId] = useState(0); + const [fixCard, setFixCard] = useState(null); + const [pendingRunSelectionId, setPendingRunSelectionId] = useState(""); + + const runs = runsPoll.data?.runs || []; + const activeRunId = String(doctorStatus?.activeRunId || ""); + const selectedRunId = String(selectedRunFilter || ""); + const shouldRenderPendingRunTab = + selectedRunId !== "" && + selectedRunId !== "all" && + !runs.some((run) => String(run.id || "") === selectedRunId) && + (pendingRunSelectionId === selectedRunId || + (doctorStatus?.runInProgress && activeRunId === selectedRunId)); + const pendingRun = shouldRenderPendingRunTab + ? { + id: Number(selectedRunId || 0), + status: "running", + summary: "", + priorityCounts: { P0: 0, P1: 0, P2: 0 }, + statusCounts: { open: 0, dismissed: 0, fixed: 0 }, + } + : null; + const displayRuns = pendingRun ? [pendingRun, ...runs] : runs; + const selectedRunIsActiveRun = + selectedRunFilter !== "all" && + !!activeRunId && + String(selectedRunFilter || "") === activeRunId; + const selectedRun = + selectedRunFilter === "all" + ? null + : displayRuns.find( + (run) => String(run.id || "") === String(selectedRunFilter || ""), + ) || null; + const cardsPoll = usePolling( + () => fetchDoctorCards({ runId: selectedRunFilter || "all" }), + doctorStatus?.runInProgress || selectedRun?.status === "running" + ? kActivePollMs + : kIdlePollMs, + { enabled: isActive }, + ); + const allCards = cardsPoll.data?.cards || []; + + useEffect(() => { + if (!isActive) return; + statusPoll.refresh(); + runsPoll.refresh(); + }, [isActive]); + + useEffect(() => { + if (!runs.length) { + if (pendingRunSelectionId && selectedRunId === pendingRunSelectionId) + return; + if (selectedRunIsActiveRun && doctorStatus?.runInProgress) return; + if (selectedRunFilter !== "all") setSelectedRunFilter("all"); + return; + } + if (selectedRunFilter === "all") return; + const hasSelectedRun = runs.some( + (run) => String(run.id || "") === String(selectedRunFilter || ""), + ); + if (hasSelectedRun) return; + if (selectedRunIsActiveRun && doctorStatus?.runInProgress) return; + setSelectedRunFilter("all"); + }, [ + runs, + selectedRunId, + selectedRunFilter, + selectedRunIsActiveRun, + pendingRunSelectionId, + doctorStatus?.runInProgress, + ]); + + useEffect(() => { + if (!pendingRunSelectionId) return; + if (selectedRunFilter !== pendingRunSelectionId) { + setSelectedRunFilter(pendingRunSelectionId); + return; + } + const hasPendingRun = runs.some( + (run) => String(run.id || "") === String(pendingRunSelectionId || ""), + ); + const activePendingRun = + !!activeRunId && + activeRunId === pendingRunSelectionId && + !!doctorStatus?.runInProgress; + if (!hasPendingRun && !activePendingRun) return; + setPendingRunSelectionId(""); + }, [ + activeRunId, + doctorStatus?.runInProgress, + pendingRunSelectionId, + runs, + selectedRunFilter, + ]); + + useEffect(() => { + cardsPoll.refresh(); + }, [selectedRunFilter]); + + const selectedRunIsInProgress = + selectedRun?.status === "running" || + (selectedRunIsActiveRun && doctorStatus?.runInProgress); + const selectedRunSummary = useMemo( + () => (selectedRunIsInProgress ? "" : selectedRun?.summary || ""), + [selectedRun, selectedRunIsInProgress], + ); + const statusFilterOptions = useMemo( + () => buildDoctorStatusFilterOptions(), + [], + ); + const driftRisk = useMemo( + () => getDoctorDriftRisk(doctorStatus?.changeSummary || null), + [doctorStatus], + ); + const canRunDoctor = useMemo(() => { + if (doctorStatus?.runInProgress) return true; + if (doctorStatus?.needsInitialRun) return true; + return Number(doctorStatus?.changeSummary?.changedFilesCount || 0) > 0; + }, [doctorStatus]); + const runDoctorDisabledReason = canRunDoctor + ? "" + : "No workspace changes since the last completed Drift Doctor run."; + const showDoctorStaleBanner = useMemo( + () => shouldShowDoctorWarning(doctorStatus, 0), + [doctorStatus], + ); + const hasRuns = runs.length > 0; + const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null; + const hasLoadedCards = cardsPoll.data !== null || cardsPoll.error !== null; + const showInitialLoadingState = + !hasLoadedRuns || (hasRuns && !hasLoadedCards); + const cards = useMemo(() => { + if (selectedStatusFilter === "all") return allCards; + return allCards.filter( + (card) => + String(card?.status || "open") + .trim() + .toLowerCase() === selectedStatusFilter, + ); + }, [allCards, selectedStatusFilter]); + const openCards = useMemo( + () => + allCards.filter( + (card) => + String(card?.status || "open") + .trim() + .toLowerCase() === "open", + ), + [allCards], + ); + const visibleRuns = useMemo(() => displayRuns.slice(0, 2), [displayRuns]); + const overflowRuns = useMemo(() => displayRuns.slice(2), [displayRuns]); + const selectedOverflowRunValue = useMemo(() => { + if (selectedRunFilter === "all") return ""; + return overflowRuns.some( + (run) => String(run.id || "") === String(selectedRunFilter || ""), + ) + ? String(selectedRunFilter || "") + : ""; + }, [overflowRuns, selectedRunFilter]); + + const getRunTabClassName = (selected = false) => + [ + "inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors", + selected + ? "border-cyan-500/40 bg-cyan-500/10 text-cyan-200 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]" + : "border-border bg-black/20 text-gray-300 hover:border-gray-500 hover:text-gray-100", + ].join(" "); + + const getRunMarkerClassName = (tone = "neutral") => { + if (tone === "success") return "bg-green-400"; + if (tone === "warning") return "bg-yellow-400"; + if (tone === "danger") return "bg-red-400"; + if (tone === "cyan") return "ac-status-dot ac-status-dot--info"; + return "bg-gray-500"; + }; + const showRunLayout = + !showInitialLoadingState && + (runs.length > 0 || + !!pendingRunSelectionId || + !!activeRunId || + !!doctorStatus?.runInProgress); + + const handleRunDoctor = async () => { + try { + const result = await startDoctorRun(); + showToast( + result?.reusedPreviousRun + ? "No workspace changes since the last scan; reused previous findings" + : "Doctor run started", + "success", + ); + if (result?.runId) { + const runId = String(result.runId); + setPendingRunSelectionId(runId); + setSelectedRunFilter(runId); + } + statusPoll.refresh(); + runsPoll.refresh(); + cardsPoll.refresh(); + setTimeout(statusPoll.refresh, 1200); + setTimeout(runsPoll.refresh, 1200); + setTimeout(cardsPoll.refresh, 1200); + } catch (error) { + showToast(error.message || "Could not start Doctor run", "error"); + } + }; + + const handleUpdateStatus = async (card, status) => { + if (!card?.id || busyCardId) return; + try { + setBusyCardId(card.id); + await updateDoctorCardStatus({ cardId: card.id, status }); + showToast("Doctor card updated", "success"); + await cardsPoll.refresh(); + await runsPoll.refresh(); + await statusPoll.refresh(); + } catch (error) { + showToast(error.message || "Could not update Doctor card", "error"); + } finally { + setBusyCardId(0); + } + }; + + return html` +
        + ${showRunLayout + ? html` + <${PageHeader} + title="Drift Doctor" + actions=${html` + <${ActionButton} + onClick=${handleRunDoctor} + disabled=${!canRunDoctor} + loading=${!!doctorStatus?.runInProgress} + idleLabel="Run Drift Doctor" + loadingLabel="Running..." + title=${runDoctorDisabledReason} + /> + `} + /> + ` + : null} + ${showInitialLoadingState + ? html` +
        +
        + <${LoadingSpinner} className="h-4 w-4" /> + Loading Drift Doctor... +
        +
        + ` + : null} + ${!showInitialLoadingState && hasRuns + ? html` + <${DoctorSummaryCards} cards=${openCards} /> +
        +
        +
        + <${Badge} tone=${driftRisk.tone} + >${driftRisk.label} drift risk${ + driftRisk.detail ? ` · ${driftRisk.detail}` : "" + } +
        +
        + Last run + + ${formatLocaleDateTime(doctorStatus?.lastRunAt, { + fallback: "Never", + })} + +
        +
        + ${ + showDoctorStaleBanner + ? html` +
        + Doctor should be run again because the latest completed + run is older than one week and the workspace has + changed. +
        + ` + : null + } +
        + ` + : null} + ${showRunLayout + ? html` +
        +
        +

        Findings

        +
        +
        + + ${visibleRuns.map((run) => { + const selected = + String(selectedRunFilter || "") === String(run.id || ""); + const markers = buildDoctorRunMarkers(run); + return html` + + `; + })} + ${overflowRuns.length + ? html` + + ` + : null} + +
        + ${selectedRunSummary + ? html` +
        +
        + ${selectedRun?.id + ? `Run #${selectedRun.id} summary` + : "Run summary"} +
        +

        + ${selectedRunSummary} +

        +
        + ` + : null} + ${selectedRunIsInProgress + ? html` +
        +
        + <${LoadingSpinner} className="h-3.5 w-3.5" /> + Run in progress. Findings will appear when analysis + completes. +
        +
        + ` + : null} +
        + <${DoctorFindingsList} + cards=${cards} + busyCardId=${busyCardId} + onAskAgentFix=${setFixCard} + onUpdateStatus=${handleUpdateStatus} + showRunMeta=${selectedRunFilter === "all"} + hideEmptyState=${selectedRunIsInProgress} + /> +
        +
        + ` + : null} + ${!showInitialLoadingState && !showRunLayout + ? html` +
        +
        + <${DoctorEmptyStateIcon} /> +
        +

        + Workspace health review +

        +

        + Drift Doctor scans the workspace for guidance drift, + misplaced instructions, redundant docs, and cleanup + opportunities. +

        +
        +
        + <${ActionButton} + onClick=${handleRunDoctor} + disabled=${!canRunDoctor} + loading=${!!doctorStatus?.runInProgress} + size="lg" + idleLabel="Run Drift Doctor" + loadingLabel="Running..." + title=${runDoctorDisabledReason} + /> +

        + No changes will be made without your direct approval +

        +
        +
        +
        + ` + : null} + <${DoctorFixCardModal} + visible=${!!fixCard} + card=${fixCard} + onClose=${() => setFixCard(null)} + onComplete=${async () => { + await statusPoll.refresh(); + await runsPoll.refresh(); + await cardsPoll.refresh(); + }} + /> +
        + `; +}; diff --git a/lib/public/js/components/doctor/summary-cards.js b/lib/public/js/components/doctor/summary-cards.js new file mode 100644 index 00000000..276c24d2 --- /dev/null +++ b/lib/public/js/components/doctor/summary-cards.js @@ -0,0 +1,24 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { buildDoctorPriorityCounts } from "./helpers.js"; + +const html = htm.bind(h); + +const SummaryCard = ({ title = "", value = 0, toneClassName = "" }) => html` +
        +

        ${title}

        +
        ${value}
        +
        +`; + +export const DoctorSummaryCards = ({ cards = [] }) => { + const counts = buildDoctorPriorityCounts(cards); + return html` +
        + <${SummaryCard} title="Open Findings" value=${cards.length} /> + <${SummaryCard} title="P0" value=${counts.P0} toneClassName="text-red-400" /> + <${SummaryCard} title="P1" value=${counts.P1} toneClassName="text-yellow-400" /> + <${SummaryCard} title="P2" value=${counts.P2} toneClassName="text-gray-300" /> +
        + `; +}; diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index f9b3fd3e..b19989bb 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -198,6 +198,85 @@ export const fetchAgentSessions = async () => { return parseJsonOrThrow(res, "Could not load agent sessions"); }; +export const fetchDoctorStatus = async () => { + const res = await authFetch("/api/doctor/status"); + return parseJsonOrThrow(res, "Could not load Doctor status"); +}; + +export const startDoctorRun = async () => { + const res = await authFetch("/api/doctor/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + return parseJsonOrThrow(res, "Could not start Doctor run"); +}; + +export const importDoctorResult = async (rawOutput = "") => { + const res = await authFetch("/api/doctor/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rawOutput: String(rawOutput || "") }), + }); + return parseJsonOrThrow(res, "Could not import Doctor result"); +}; + +export const fetchDoctorRuns = async (limit = 10) => { + const params = new URLSearchParams({ limit: String(limit) }); + const res = await authFetch(`/api/doctor/runs?${params.toString()}`); + return parseJsonOrThrow(res, "Could not load Doctor runs"); +}; + +export const fetchDoctorCards = async ({ runId = "all" } = {}) => { + const params = new URLSearchParams(); + if (String(runId || "").trim()) params.set("runId", String(runId || "")); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const res = await authFetch(`/api/doctor/cards${suffix}`); + return parseJsonOrThrow(res, "Could not load Doctor findings"); +}; + +export const fetchDoctorRun = async (runId) => { + const res = await authFetch(`/api/doctor/runs/${encodeURIComponent(String(runId || ""))}`); + return parseJsonOrThrow(res, "Could not load Doctor run"); +}; + +export const fetchDoctorRunCards = async (runId) => { + const res = await authFetch( + `/api/doctor/runs/${encodeURIComponent(String(runId || ""))}/cards`, + ); + return parseJsonOrThrow(res, "Could not load Doctor cards"); +}; + +export const updateDoctorCardStatus = async ({ cardId, status }) => { + const res = await authFetch(`/api/doctor/cards/${encodeURIComponent(String(cardId || ""))}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: String(status || "") }), + }); + return parseJsonOrThrow(res, "Could not update Doctor card status"); +}; + +export const sendDoctorCardFix = async ({ + cardId, + sessionId = "", + replyChannel = "", + replyTo = "", +} = {}) => { + const res = await authFetch( + `/api/doctor/findings/${encodeURIComponent(String(cardId || ""))}/fix`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: String(sessionId || ""), + replyChannel: String(replyChannel || ""), + replyTo: String(replyTo || ""), + }), + }, + ); + return parseJsonOrThrow(res, "Could not send Doctor fix request"); +}; + export const sendAgentMessage = async ({ message = "", sessionKey = "", diff --git a/lib/server.js b/lib/server.js index c7894321..ee0dc2a2 100644 --- a/lib/server.js +++ b/lib/server.js @@ -43,6 +43,20 @@ const { getSessionDetail, getSessionTimeSeries, } = require("./server/db/usage"); +const { + initDoctorDb, + listDoctorRuns, + listDoctorCards, + getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline, + createDoctorRun, + completeDoctorRun, + insertDoctorCards, + getDoctorRun, + getDoctorCardsByRunId, + getDoctorCard, + updateDoctorCardStatus, +} = require("./server/db/doctor"); const { createWebhookMiddleware } = require("./server/webhook-middleware"); const { readEnvFile, @@ -84,6 +98,7 @@ const { createTelegramApi } = require("./server/telegram-api"); const { createDiscordApi } = require("./server/discord-api"); const { createWatchdogNotifier } = require("./server/watchdog-notify"); const { createWatchdog } = require("./server/watchdog"); +const { createDoctorService } = require("./server/doctor/service"); const { registerAuthRoutes } = require("./server/routes/auth"); const { registerPageRoutes } = require("./server/routes/pages"); @@ -100,6 +115,7 @@ const { registerWebhookRoutes } = require("./server/routes/webhooks"); const { registerWatchdogRoutes } = require("./server/routes/watchdog"); const { registerUsageRoutes } = require("./server/routes/usage"); const { registerGmailRoutes } = require("./server/routes/gmail"); +const { registerDoctorRoutes } = require("./server/routes/doctor"); const { PORT, GATEWAY_URL, kTrustProxyHops, SETUP_API_PREFIXES } = constants; @@ -170,6 +186,9 @@ initWatchdogDb({ initUsageDb({ rootDir: constants.kRootDir, }); +initDoctorDb({ + rootDir: constants.kRootDir, +}); const webhookMiddleware = createWebhookMiddleware({ gatewayUrl: constants.GATEWAY_URL, insertRequest, @@ -269,6 +288,24 @@ const watchdog = createWatchdog({ reloadEnv, resolveSetupUrl, }); +const doctorService = createDoctorService({ + clawCmd, + listDoctorRuns, + listDoctorCards, + getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline, + createDoctorRun, + completeDoctorRun, + insertDoctorCards, + getDoctorRun, + getDoctorCardsByRunId, + getDoctorCard, + updateDoctorCardStatus, + workspaceRoot: constants.WORKSPACE_DIR, + managedRoot: constants.OPENCLAW_DIR, + protectedPaths: Array.from(constants.kProtectedBrowsePaths || []), + lockedPaths: Array.from(constants.kLockedBrowsePaths || []), +}); setGatewayExitHandler((payload) => watchdog.onGatewayExit(payload)); setGatewayLaunchHandler((payload) => watchdog.onGatewayLaunch(payload)); const doSyncPromptFiles = () => { @@ -321,6 +358,11 @@ registerUsageRoutes({ getSessionDetail, getSessionTimeSeries, }); +registerDoctorRoutes({ + app, + requireAuth, + doctorService, +}); registerProxyRoutes({ app, proxy, diff --git a/lib/server/commands.js b/lib/server/commands.js index a756b78e..c9dd7f0b 100644 --- a/lib/server/commands.js +++ b/lib/server/commands.js @@ -4,7 +4,11 @@ const { OPENCLAW_DIR, GOG_KEYRING_PASSWORD } = require("./constants"); const createCommands = ({ gatewayEnv }) => { const shellCmd = (cmd, opts = {}) => new Promise((resolve, reject) => { - const { logStdout, ...execOpts } = opts; + const { + logStdout, + timeoutMs = 60000, + ...execOpts + } = opts; const shouldLogStdout = typeof logStdout === "boolean" ? logStdout : !cmd.includes("--json"); console.log( @@ -13,7 +17,7 @@ const createCommands = ({ gatewayEnv }) => { .replace(/sk-[^\s"]+/g, "***") .slice(0, 200)}`, ); - exec(cmd, { timeout: 60000, ...execOpts }, (err, stdout, stderr) => { + exec(cmd, { timeout: timeoutMs, ...execOpts }, (err, stdout, stderr) => { if (err) { console.error( `[onboard] Error: ${(stderr || err.message).slice(0, 300)}`, @@ -27,14 +31,14 @@ const createCommands = ({ gatewayEnv }) => { }); }); - const clawCmd = (cmd, { quiet = false } = {}) => + const clawCmd = (cmd, { quiet = false, timeoutMs = 15000 } = {}) => new Promise((resolve) => { if (!quiet) console.log(`[alphaclaw] Running: openclaw ${cmd}`); exec( `openclaw ${cmd}`, { env: gatewayEnv(), - timeout: 15000, + timeout: timeoutMs, }, (err, stdout, stderr) => { const result = { diff --git a/lib/server/db/doctor/index.js b/lib/server/db/doctor/index.js new file mode 100644 index 00000000..2c465a9e --- /dev/null +++ b/lib/server/db/doctor/index.js @@ -0,0 +1,529 @@ +const fs = require("fs"); +const path = require("path"); +const { DatabaseSync } = require("node:sqlite"); +const { createSchema } = require("./schema"); +const { + kDoctorCardStatus, + kDoctorDefaultRunsLimit, + kDoctorMaxRunsLimit, + kDoctorPriority, + kDoctorRunStatus, +} = require("../../doctor/constants"); + +let db = null; +const kDoctorInitialBaselineMetaKey = "initial_workspace_baseline"; + +const ensureDb = () => { + if (!db) throw new Error("Doctor DB not initialized"); + return db; +}; + +const parseJsonText = (value, fallbackValue) => { + if (typeof value !== "string" || !value) return fallbackValue; + try { + return JSON.parse(value); + } catch { + return fallbackValue; + } +}; + +const buildPriorityCounts = (cards = []) => ({ + P0: cards.filter((card) => card.priority === kDoctorPriority.P0).length, + P1: cards.filter((card) => card.priority === kDoctorPriority.P1).length, + P2: cards.filter((card) => card.priority === kDoctorPriority.P2).length, +}); + +const buildStatusCounts = (cards = []) => ({ + open: cards.filter((card) => card.status === kDoctorCardStatus.open).length, + dismissed: cards.filter((card) => card.status === kDoctorCardStatus.dismissed).length, + fixed: cards.filter((card) => card.status === kDoctorCardStatus.fixed).length, +}); + +const toCardModel = (row) => { + if (!row) return null; + return { + id: Number(row.id || 0), + runId: Number(row.run_id || 0), + createdAt: row.created_at || null, + updatedAt: row.updated_at || null, + priority: row.priority || kDoctorPriority.P2, + category: row.category || "workspace", + title: row.title || "", + summary: row.summary || "", + recommendation: row.recommendation || "", + evidence: parseJsonText(row.evidence_json, []), + targetPaths: parseJsonText(row.target_paths_json, []), + fixPrompt: row.fix_prompt || "", + status: row.status || kDoctorCardStatus.open, + }; +}; + +const attachRunCounts = (run, cards = []) => + run + ? { + ...run, + cardCount: cards.length, + priorityCounts: buildPriorityCounts(cards), + statusCounts: buildStatusCounts(cards), + } + : null; + +const getCardsByRunId = (runId) => { + const database = ensureDb(); + const rows = database + .prepare(` + SELECT + id, + run_id, + created_at, + updated_at, + priority, + category, + title, + summary, + recommendation, + evidence_json, + target_paths_json, + fix_prompt, + status + FROM doctor_cards + WHERE run_id = $run_id + ORDER BY + CASE priority + WHEN 'P0' THEN 0 + WHEN 'P1' THEN 1 + ELSE 2 + END ASC, + created_at DESC + `) + .all({ $run_id: Number(runId || 0) }); + return rows.map(toCardModel); +}; + +const listDoctorCards = ({ runId } = {}) => { + const database = ensureDb(); + const hasRunFilter = + runId !== undefined && + runId !== null && + String(runId || "").trim() !== "" && + String(runId || "").trim().toLowerCase() !== "all"; + const rows = database + .prepare(` + SELECT + c.id, + c.run_id, + c.created_at, + c.updated_at, + c.priority, + c.category, + c.title, + c.summary, + c.recommendation, + c.evidence_json, + c.target_paths_json, + c.fix_prompt, + c.status, + r.started_at AS run_started_at, + r.completed_at AS run_completed_at, + r.status AS run_status + FROM doctor_cards c + INNER JOIN doctor_runs r ON r.id = c.run_id + ${hasRunFilter ? "WHERE c.run_id = $run_id" : ""} + ORDER BY + CASE c.status + WHEN 'open' THEN 0 + WHEN 'dismissed' THEN 1 + ELSE 2 + END ASC, + CASE c.priority + WHEN 'P0' THEN 0 + WHEN 'P1' THEN 1 + ELSE 2 + END ASC, + c.created_at DESC + `) + .all(hasRunFilter ? { $run_id: Number(runId || 0) } : {}); + return rows.map((row) => ({ + ...toCardModel(row), + runStartedAt: row.run_started_at || null, + runCompletedAt: row.run_completed_at || null, + runStatus: row.run_status || kDoctorRunStatus.failed, + })); +}; + +const toRunModel = (row) => { + if (!row) return null; + return { + id: Number(row.id || 0), + startedAt: row.started_at || null, + completedAt: row.completed_at || null, + status: row.status || kDoctorRunStatus.failed, + engine: row.engine || "", + workspaceRoot: row.workspace_root || "", + workspaceFingerprint: row.workspace_fingerprint || "", + workspaceManifest: parseJsonText(row.workspace_manifest_json, null), + promptVersion: row.prompt_version || "", + summary: row.summary || "", + rawResult: parseJsonText(row.raw_result_json, null), + error: row.error || "", + reusedFromRunId: Number(row.reused_from_run_id || 0), + }; +}; + +const initDoctorDb = ({ rootDir }) => { + const dbDir = path.join(rootDir, "db"); + fs.mkdirSync(dbDir, { recursive: true }); + const dbPath = path.join(dbDir, "doctor.db"); + db = new DatabaseSync(dbPath); + createSchema(db); + markIncompleteRunsFailed(); + return { path: dbPath }; +}; + +const getDoctorMeta = (key) => { + const database = ensureDb(); + const row = database + .prepare(` + SELECT + key, + value_json, + updated_at + FROM doctor_meta + WHERE key = $key + LIMIT 1 + `) + .get({ $key: String(key || "") }); + if (!row) return null; + return { + key: row.key || "", + value: parseJsonText(row.value_json, null), + updatedAt: row.updated_at || null, + }; +}; + +const setDoctorMeta = ({ key, value = null }) => { + const database = ensureDb(); + database + .prepare(` + INSERT INTO doctor_meta ( + key, + value_json, + updated_at + ) VALUES ( + $key, + $value_json, + strftime('%Y-%m-%dT%H:%M:%fZ','now') + ) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + updated_at = excluded.updated_at + `) + .run({ + $key: String(key || ""), + $value_json: value == null ? null : JSON.stringify(value), + }); + return getDoctorMeta(key); +}; + +const getInitialWorkspaceBaseline = () => getDoctorMeta(kDoctorInitialBaselineMetaKey)?.value || null; + +const setInitialWorkspaceBaseline = (baseline) => + setDoctorMeta({ + key: kDoctorInitialBaselineMetaKey, + value: baseline, + })?.value || null; + +const markIncompleteRunsFailed = (errorMessage = "Doctor run interrupted before completion") => { + const database = ensureDb(); + const result = database + .prepare(` + UPDATE doctor_runs + SET + status = $status, + completed_at = COALESCE(completed_at, strftime('%Y-%m-%dT%H:%M:%fZ','now')), + error = COALESCE(NULLIF(error, ''), $error) + WHERE status = $running_status + `) + .run({ + $status: kDoctorRunStatus.failed, + $running_status: kDoctorRunStatus.running, + $error: String(errorMessage || ""), + }); + return Number(result.changes || 0); +}; + +const createDoctorRun = ({ + status = kDoctorRunStatus.running, + engine, + workspaceRoot, + workspaceFingerprint = "", + workspaceManifest = null, + promptVersion, + reusedFromRunId = 0, +}) => { + const database = ensureDb(); + const result = database + .prepare(` + INSERT INTO doctor_runs ( + status, + engine, + workspace_root, + workspace_fingerprint, + workspace_manifest_json, + prompt_version, + reused_from_run_id + ) VALUES ( + $status, + $engine, + $workspace_root, + $workspace_fingerprint, + $workspace_manifest_json, + $prompt_version, + $reused_from_run_id + ) + `) + .run({ + $status: String(status || kDoctorRunStatus.running), + $engine: String(engine || ""), + $workspace_root: String(workspaceRoot || ""), + $workspace_fingerprint: String(workspaceFingerprint || ""), + $workspace_manifest_json: workspaceManifest == null ? null : JSON.stringify(workspaceManifest), + $prompt_version: String(promptVersion || ""), + $reused_from_run_id: Number(reusedFromRunId || 0), + }); + return Number(result.lastInsertRowid || 0); +}; + +const completeDoctorRun = ({ + id, + status, + summary = "", + rawResult = null, + error = "", +}) => { + const database = ensureDb(); + const result = database + .prepare(` + UPDATE doctor_runs + SET + completed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), + status = $status, + summary = $summary, + raw_result_json = $raw_result_json, + error = $error + WHERE id = $id + `) + .run({ + $id: Number(id || 0), + $status: String(status || kDoctorRunStatus.failed), + $summary: String(summary || ""), + $raw_result_json: rawResult == null ? null : JSON.stringify(rawResult), + $error: String(error || ""), + }); + return Number(result.changes || 0); +}; + +const insertDoctorCards = ({ runId, cards = [] }) => { + const database = ensureDb(); + database.exec("BEGIN"); + try { + const stmt = database.prepare(` + INSERT INTO doctor_cards ( + run_id, + priority, + category, + title, + summary, + recommendation, + evidence_json, + target_paths_json, + fix_prompt, + status + ) VALUES ( + $run_id, + $priority, + $category, + $title, + $summary, + $recommendation, + $evidence_json, + $target_paths_json, + $fix_prompt, + $status + ) + `); + for (const card of cards) { + stmt.run({ + $run_id: Number(runId || 0), + $priority: String(card?.priority || kDoctorPriority.P2), + $category: String(card?.category || "workspace"), + $title: String(card?.title || ""), + $summary: String(card?.summary || ""), + $recommendation: String(card?.recommendation || ""), + $evidence_json: JSON.stringify(card?.evidence || []), + $target_paths_json: JSON.stringify(card?.targetPaths || []), + $fix_prompt: String(card?.fixPrompt || ""), + $status: String(card?.status || kDoctorCardStatus.open), + }); + } + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } +}; + +const getDoctorRun = (id) => { + const database = ensureDb(); + const row = database + .prepare(` + SELECT + id, + started_at, + completed_at, + status, + engine, + workspace_root, + workspace_fingerprint, + workspace_manifest_json, + prompt_version, + summary, + raw_result_json, + error, + reused_from_run_id + FROM doctor_runs + WHERE id = $id + LIMIT 1 + `) + .get({ $id: Number(id || 0) }); + const run = toRunModel(row); + if (!run) return null; + return attachRunCounts(run, getCardsByRunId(run.id)); +}; + +const getLatestDoctorRun = () => { + const database = ensureDb(); + const row = database + .prepare(` + SELECT + id, + started_at, + completed_at, + status, + engine, + workspace_root, + workspace_fingerprint, + workspace_manifest_json, + prompt_version, + summary, + raw_result_json, + error, + reused_from_run_id + FROM doctor_runs + ORDER BY started_at DESC + LIMIT 1 + `) + .get(); + const run = toRunModel(row); + if (!run) return null; + return attachRunCounts(run, getCardsByRunId(run.id)); +}; + +const listDoctorRuns = ({ limit = kDoctorDefaultRunsLimit } = {}) => { + const database = ensureDb(); + const safeLimit = Math.max( + 1, + Math.min(Number.parseInt(String(limit || kDoctorDefaultRunsLimit), 10) || kDoctorDefaultRunsLimit, kDoctorMaxRunsLimit), + ); + const rows = database + .prepare(` + SELECT + id, + started_at, + completed_at, + status, + engine, + workspace_root, + workspace_fingerprint, + workspace_manifest_json, + prompt_version, + summary, + raw_result_json, + error, + reused_from_run_id + FROM doctor_runs + ORDER BY started_at DESC + LIMIT $limit + `) + .all({ $limit: safeLimit }); + return rows.map((row) => { + const run = toRunModel(row); + return attachRunCounts(run, getCardsByRunId(run.id)); + }); +}; + +const getDoctorCard = (id) => { + const database = ensureDb(); + const row = database + .prepare(` + SELECT + id, + run_id, + created_at, + updated_at, + priority, + category, + title, + summary, + recommendation, + evidence_json, + target_paths_json, + fix_prompt, + status + FROM doctor_cards + WHERE id = $id + LIMIT 1 + `) + .get({ $id: Number(id || 0) }); + return toCardModel(row); +}; + +const updateDoctorCardStatus = ({ id, status }) => { + const database = ensureDb(); + const nextStatus = + status === kDoctorCardStatus.fixed || status === kDoctorCardStatus.dismissed + ? status + : kDoctorCardStatus.open; + const result = database + .prepare(` + UPDATE doctor_cards + SET + status = $status, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = $id + `) + .run({ + $id: Number(id || 0), + $status: nextStatus, + }); + return Number(result.changes || 0) > 0 ? getDoctorCard(id) : null; +}; + +module.exports = { + initDoctorDb, + markIncompleteRunsFailed, + getDoctorMeta, + setDoctorMeta, + getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline, + createDoctorRun, + completeDoctorRun, + insertDoctorCards, + getDoctorRun, + getLatestDoctorRun, + listDoctorRuns, + listDoctorCards, + getDoctorCardsByRunId: getCardsByRunId, + getDoctorCard, + updateDoctorCardStatus, +}; diff --git a/lib/server/db/doctor/schema.js b/lib/server/db/doctor/schema.js new file mode 100644 index 00000000..fb76b1c4 --- /dev/null +++ b/lib/server/db/doctor/schema.js @@ -0,0 +1,69 @@ +const hasColumn = (database, tableName, columnName) => { + const rows = database.prepare(`PRAGMA table_info(${tableName})`).all(); + return rows.some((row) => row.name === columnName); +}; + +const ensureColumn = (database, tableName, columnName, definition) => { + if (hasColumn(database, tableName, columnName)) return; + database.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition};`); +}; + +const createSchema = (database) => { + database.exec(` + CREATE TABLE IF NOT EXISTS doctor_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + completed_at TEXT, + status TEXT NOT NULL, + engine TEXT NOT NULL, + workspace_root TEXT NOT NULL, + workspace_fingerprint TEXT, + workspace_manifest_json TEXT, + prompt_version TEXT NOT NULL, + summary TEXT, + raw_result_json TEXT, + error TEXT, + reused_from_run_id INTEGER + ); + `); + database.exec(` + CREATE TABLE IF NOT EXISTS doctor_meta ( + key TEXT PRIMARY KEY, + value_json TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + `); + database.exec(` + CREATE TABLE IF NOT EXISTS doctor_cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + priority TEXT NOT NULL, + category TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT, + recommendation TEXT NOT NULL, + evidence_json TEXT, + target_paths_json TEXT, + fix_prompt TEXT NOT NULL, + status TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES doctor_runs(id) ON DELETE CASCADE + ); + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_doctor_runs_started_at + ON doctor_runs(started_at DESC); + `); + ensureColumn(database, "doctor_runs", "workspace_fingerprint", "TEXT"); + ensureColumn(database, "doctor_runs", "workspace_manifest_json", "TEXT"); + ensureColumn(database, "doctor_runs", "reused_from_run_id", "INTEGER"); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_doctor_cards_run_id + ON doctor_cards(run_id, created_at DESC); + `); +}; + +module.exports = { + createSchema, +}; diff --git a/lib/server/doctor/constants.js b/lib/server/doctor/constants.js new file mode 100644 index 00000000..2855452d --- /dev/null +++ b/lib/server/doctor/constants.js @@ -0,0 +1,43 @@ +const kDoctorPromptVersion = "doctor-v1"; +const kDoctorRunStatus = { + running: "running", + completed: "completed", + failed: "failed", +}; +const kDoctorCardStatus = { + open: "open", + dismissed: "dismissed", + fixed: "fixed", +}; +const kDoctorPriority = { + P0: "P0", + P1: "P1", + P2: "P2", +}; +const kDoctorEngine = { + gatewayAgent: "gateway_agent", + acpRuntime: "acp_runtime", + agentMessageFallback: "agent_message_fallback", + manualImport: "manual_import", + deterministicReuse: "deterministic_reuse", +}; +const kDoctorStaleThresholdMs = 7 * 24 * 60 * 60 * 1000; +const kDoctorMeaningfulChangeScoreThreshold = 4; +const kDoctorRunTimeoutMs = 10 * 60 * 1000; +const kDoctorDefaultRunsLimit = 10; +const kDoctorMaxRunsLimit = 50; +const kDoctorMaxCardsPerRun = 12; + +module.exports = { + kDoctorPromptVersion, + kDoctorRunStatus, + kDoctorCardStatus, + kDoctorPriority, + kDoctorEngine, + kDoctorStaleThresholdMs, + kDoctorMeaningfulChangeScoreThreshold, + kDoctorRunTimeoutMs, + kDoctorDefaultRunsLimit, + kDoctorMaxRunsLimit, + kDoctorMaxCardsPerRun, +}; diff --git a/lib/server/doctor/normalize.js b/lib/server/doctor/normalize.js new file mode 100644 index 00000000..39e54401 --- /dev/null +++ b/lib/server/doctor/normalize.js @@ -0,0 +1,214 @@ +const { + parseJsonSafe, + parseJsonValueFromNoisyOutput, +} = require("../utils/json"); +const { + kDoctorCardStatus, + kDoctorPriority, + kDoctorMaxCardsPerRun, +} = require("./constants"); + +const kCandidateArrayKeys = ["cards", "findings", "issues", "recommendations"]; +const kCandidateObjectKeys = [ + "result", + "data", + "output", + "response", + "message", + "content", + "text", + "payload", + "payloads", + "body", +]; + +const toTrimmedString = (value) => String(value ?? "").trim(); + +const parseJsonCandidate = (value) => { + if (value == null) return null; + if (typeof value === "object") return value; + if (typeof value !== "string") return null; + const direct = parseJsonSafe(value, null, { trim: true }); + if (direct) return direct; + const noisy = parseJsonValueFromNoisyOutput(value); + if (noisy) return noisy; + const fencedMatch = value.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (!fencedMatch) return null; + return parseJsonSafe(fencedMatch[1], null, { trim: true }); +}; + +const collectCandidatePayloads = (rootValue) => { + const queue = [rootValue]; + const seen = new Set(); + const candidates = []; + while (queue.length) { + const currentValue = queue.shift(); + if (currentValue == null) continue; + if (typeof currentValue === "string") { + const parsedValue = parseJsonCandidate(currentValue); + if (parsedValue && typeof parsedValue === "object") { + queue.push(parsedValue); + } + continue; + } + if (typeof currentValue !== "object") continue; + if (seen.has(currentValue)) continue; + seen.add(currentValue); + if (Array.isArray(currentValue)) { + for (const item of currentValue) { + if (item != null) queue.push(item); + } + continue; + } + candidates.push(currentValue); + for (const key of kCandidateObjectKeys) { + if (currentValue[key] != null) queue.push(currentValue[key]); + } + } + return candidates; +}; + +const normalizePriority = (value) => { + const normalized = toTrimmedString(value).toUpperCase(); + if (normalized === "P0" || normalized === "CRITICAL" || normalized === "HIGH") { + return kDoctorPriority.P0; + } + if (normalized === "P1" || normalized === "MEDIUM" || normalized === "MODERATE") { + return kDoctorPriority.P1; + } + if (normalized === "P2" || normalized === "LOW" || normalized === "NICE_TO_HAVE") { + return kDoctorPriority.P2; + } + return kDoctorPriority.P2; +}; + +const normalizeCardStatus = (value) => { + const normalized = toTrimmedString(value).toLowerCase(); + if (normalized === kDoctorCardStatus.fixed) return kDoctorCardStatus.fixed; + if (normalized === kDoctorCardStatus.dismissed) return kDoctorCardStatus.dismissed; + return kDoctorCardStatus.open; +}; + +const normalizeEvidence = (value) => { + if (Array.isArray(value)) { + return value + .map((item) => { + if (item == null) return null; + if (typeof item === "string") { + const text = toTrimmedString(item); + return text ? { type: "text", text } : null; + } + if (typeof item === "object") return item; + return { type: "text", text: String(item) }; + }) + .filter(Boolean); + } + if (typeof value === "string") { + const text = toTrimmedString(value); + return text ? [{ type: "text", text }] : []; + } + if (value && typeof value === "object") return [value]; + return []; +}; + +const normalizeTargetPaths = (value) => { + const values = Array.isArray(value) ? value : value == null ? [] : [value]; + return Array.from( + new Set( + values + .map((item) => toTrimmedString(item)) + .filter(Boolean), + ), + ); +}; + +const buildFallbackFixPrompt = ({ title, recommendation, targetPaths }) => { + const targetLine = targetPaths.length + ? `Focus on these paths if relevant: ${targetPaths.join(", ")}.` + : "Inspect the relevant workspace files before making changes."; + return ( + `Please address this Doctor finding safely.\n\n` + + `Finding: ${title}\n` + + `Recommendation: ${recommendation}\n` + + `${targetLine}\n` + + `Preserve existing behavior unless the change clearly improves workspace guidance organization.` + ); +}; + +const normalizeDoctorCard = (cardValue, index) => { + const title = + toTrimmedString(cardValue?.title) || + toTrimmedString(cardValue?.headline) || + toTrimmedString(cardValue?.name) || + `Doctor recommendation ${index + 1}`; + const summary = + toTrimmedString(cardValue?.summary) || + toTrimmedString(cardValue?.description) || + toTrimmedString(cardValue?.detail) || + ""; + const recommendation = + toTrimmedString(cardValue?.recommendation) || + toTrimmedString(cardValue?.recommendedAction) || + toTrimmedString(cardValue?.action) || + summary || + title; + const targetPaths = normalizeTargetPaths( + cardValue?.targetPaths ?? cardValue?.paths ?? cardValue?.files, + ); + return { + priority: normalizePriority(cardValue?.priority ?? cardValue?.severity), + category: toTrimmedString(cardValue?.category) || "workspace", + title, + summary, + recommendation, + evidence: normalizeEvidence(cardValue?.evidence), + targetPaths, + fixPrompt: + toTrimmedString(cardValue?.fixPrompt) || + toTrimmedString(cardValue?.fix_prompt) || + buildFallbackFixPrompt({ title, recommendation, targetPaths }), + status: normalizeCardStatus(cardValue?.status), + }; +}; + +const extractCardPayload = (payload) => { + if (!payload || typeof payload !== "object") return null; + for (const key of kCandidateArrayKeys) { + if (!Array.isArray(payload[key])) continue; + return { + summary: + toTrimmedString(payload.summary) || + toTrimmedString(payload.overview) || + toTrimmedString(payload.assessment) || + "", + cards: payload[key], + rawPayload: payload, + }; + } + return null; +}; + +const normalizeDoctorResult = (rawOutput) => { + const initialPayload = parseJsonCandidate(rawOutput); + const payloadCandidates = collectCandidatePayloads(initialPayload || rawOutput); + for (const candidate of payloadCandidates) { + const extracted = extractCardPayload(candidate); + if (!extracted) continue; + const cards = extracted.cards + .slice(0, kDoctorMaxCardsPerRun) + .map((cardValue, index) => normalizeDoctorCard(cardValue, index)); + return { + summary: extracted.summary, + cards, + rawPayload: extracted.rawPayload, + }; + } + throw new Error("Doctor response did not include a recognizable cards payload"); +}; + +module.exports = { + normalizePriority, + normalizeCardStatus, + normalizeDoctorResult, + normalizeDoctorCard, +}; diff --git a/lib/server/doctor/prompt.js b/lib/server/doctor/prompt.js new file mode 100644 index 00000000..ed8eed3f --- /dev/null +++ b/lib/server/doctor/prompt.js @@ -0,0 +1,89 @@ +const renderList = (items = []) => + items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)"; + +const buildDoctorPrompt = ({ + workspaceRoot = "", + managedRoot = "", + protectedPaths = [], + lockedPaths = [], + promptVersion = "doctor-v1", +}) => ` +You are AlphaClaw Doctor. Analyze this OpenClaw workspace for guidance drift, redundancy, misplacement, and cleanup opportunities. + +Important: +- Read the workspace and managed files as needed before deciding. +- This is advisory only. Do not make changes. +- Focus on organization and correctness of workspace guidance and setup-owned files. +- Prefer fewer, higher-signal findings. +- Avoid reporting issues that are already intentionally managed or locked by AlphaClaw. +- Evaluate files against intended OpenClaw defaults, not against an idealized minimal workspace. +- A fresh install can be healthy even if it includes broad default guidance. +- Return ONLY valid JSON. No markdown fences. No extra prose. + +OpenClaw default context: +- \`AGENTS.md\` is the workspace home file in the default OpenClaw template. It may intentionally include first-run instructions, session-startup guidance, memory conventions, safety rules, tool pointers, and optional behavioral guidance. +- Do not treat default-template content as drift just because it is broad or multi-purpose. +- Only flag \`AGENTS.md\` when there is clear workspace-specific drift, contradiction, substantial unnecessary local accretion, or guidance that no longer fits the file's intended role. + +AlphaClaw ownership rules: +- AlphaClaw-managed files and bootstrap files are product-owned constraints. +- Do not recommend splitting, renaming, relocating, or otherwise restructuring AlphaClaw-managed files solely for cleanliness or purity. +- Do not propose breaking changes to AlphaClaw's managed file layout, even if another structure might look cleaner. +- Only flag AlphaClaw-managed content when there is a concrete correctness issue, internal contradiction, broken ownership boundary, or behavior that is actively misleading. + +Workspace roots: +- Primary workspace root: ${workspaceRoot || "(unknown)"} +- Managed OpenClaw root: ${managedRoot || "(unknown)"} + +AlphaClaw protected paths: +${renderList(protectedPaths)} + +AlphaClaw locked/managed paths: +${renderList(lockedPaths)} + +Review priorities: +- Drift between workspace reality and AGENTS.md, TOOLS.md, SKILL.md, README, and setup-owned docs +- Redundant or scattered instructions that should be centralized +- Tool-specific guidance placed in the wrong file +- Workspace cleanup and consolidation opportunities +- Real contradictions or misleading guidance inside AlphaClaw-managed files + +Priority rubric: +- P0: dangerous drift, broken setup ownership, or issues likely to cause incorrect agent behavior +- P1: meaningful duplication, misplaced guidance, or organizational drift with clear cleanup value +- P2: nice-to-have consolidation and lower-risk cleanup opportunities + +Return exactly this JSON shape: +{ + "summary": "short overall assessment", + "cards": [ + { + "priority": "P0 | P1 | P2", + "category": "short category", + "title": "short title", + "summary": "what is wrong and why it matters", + "recommendation": "clear recommended action", + "evidence": [ + { "type": "path", "path": "relative/path" }, + { "type": "note", "text": "short supporting note" } + ], + "targetPaths": ["relative/path/one", "relative/path/two"], + "fixPrompt": "a concise message another agent can use to fix just this finding safely", + "status": "open" + } + ] +} + +Constraints: +- Maximum 12 cards +- Use relative paths in evidence and targetPaths +- Do not include duplicate cards +- Do not create cards for healthy default-template behavior +- Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure +- If there are no meaningful findings, return an empty cards array +- promptVersion: ${promptVersion} +`.trim(); + +module.exports = { + buildDoctorPrompt, +}; diff --git a/lib/server/doctor/service.js b/lib/server/doctor/service.js new file mode 100644 index 00000000..45d889a5 --- /dev/null +++ b/lib/server/doctor/service.js @@ -0,0 +1,392 @@ +const { buildDoctorPrompt } = require("./prompt"); +const { normalizeDoctorResult } = require("./normalize"); +const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint"); +const { + kDoctorEngine, + kDoctorMeaningfulChangeScoreThreshold, + kDoctorPromptVersion, + kDoctorRunStatus, + kDoctorRunTimeoutMs, + kDoctorStaleThresholdMs, +} = require("./constants"); + +const shellEscapeArg = (value) => { + const safeValue = String(value || ""); + return `'${safeValue.replace(/'/g, `'\\''`)}'`; +}; + +const hasValidIsoTime = (value) => { + const timestamp = Date.parse(String(value || "")); + return Number.isFinite(timestamp); +}; + +const formatElapsedSince = (isoTime) => { + if (!hasValidIsoTime(isoTime)) return "the last scan"; + const elapsedMs = Math.max(0, Date.now() - Date.parse(isoTime)); + const elapsedMinutes = Math.max(1, Math.round(elapsedMs / 60000)); + if (elapsedMinutes < 60) { + return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ago`; + } + const elapsedHours = Math.round(elapsedMinutes / 60); + if (elapsedHours < 24) { + return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ago`; + } + const elapsedDays = Math.round(elapsedHours / 24); + return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`; +}; + +const buildDoctorSessionKey = (runId) => `agent:main:doctor:${Number(runId || 0)}`; +const buildDoctorSessionId = (runId) => buildDoctorSessionKey(runId); +const buildDoctorIdempotencyKey = (runId) => `doctor-run-${Number(runId || 0)}`; + +const createDoctorService = ({ + clawCmd, + listDoctorRuns, + listDoctorCards, + getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline, + createDoctorRun, + completeDoctorRun, + insertDoctorCards, + getDoctorRun, + getDoctorCardsByRunId, + getDoctorCard, + updateDoctorCardStatus, + workspaceRoot, + managedRoot, + protectedPaths = [], + lockedPaths = [], +}) => { + const state = { + activeRunId: 0, + activeRunPromise: null, + snapshotCache: null, + }; + + const getLatestCompletedRun = () => + listDoctorRuns({ limit: 25 }).find((run) => run.status === kDoctorRunStatus.completed) || null; + + const getCurrentWorkspaceSnapshot = () => { + const now = Date.now(); + if (state.snapshotCache && now - state.snapshotCache.computedAt < 5000) { + return state.snapshotCache.snapshot; + } + const snapshot = computeWorkspaceSnapshot(workspaceRoot); + state.snapshotCache = { + computedAt: now, + snapshot, + }; + return snapshot; + }; + + const getOrCreateInitialBaseline = () => { + const existingBaseline = getInitialWorkspaceBaseline?.(); + if (existingBaseline?.fingerprint && existingBaseline?.manifest) { + return existingBaseline; + } + const snapshot = getCurrentWorkspaceSnapshot(); + const nextBaseline = { + fingerprint: snapshot.fingerprint, + manifest: snapshot.manifest, + capturedAt: new Date().toISOString(), + }; + return setInitialWorkspaceBaseline?.(nextBaseline) || nextBaseline; + }; + + const cloneRunCards = ({ sourceRunId, targetRunId }) => { + const sourceCards = getDoctorCardsByRunId(sourceRunId); + insertDoctorCards({ + runId: targetRunId, + cards: sourceCards, + }); + }; + + const buildStatus = () => { + const recentRuns = listDoctorRuns({ limit: 10 }); + const latestRun = recentRuns[0] || null; + const latestCompletedRun = + recentRuns.find((run) => run.status === kDoctorRunStatus.completed) || null; + const lastRunAt = + latestCompletedRun?.completedAt || latestCompletedRun?.startedAt || null; + const lastRunAgeMs = hasValidIsoTime(lastRunAt) ? Date.now() - Date.parse(lastRunAt) : null; + const stale = lastRunAgeMs == null || lastRunAgeMs >= kDoctorStaleThresholdMs; + const baselineRun = latestCompletedRun; + const initialBaseline = !baselineRun ? getOrCreateInitialBaseline() : null; + const currentSnapshot = baselineRun || initialBaseline ? getCurrentWorkspaceSnapshot() : null; + const baselineManifest = + baselineRun?.workspaceManifest && typeof baselineRun.workspaceManifest === "object" + ? baselineRun.workspaceManifest + : initialBaseline?.manifest && typeof initialBaseline.manifest === "object" + ? initialBaseline.manifest + : null; + const hasManifestBaseline = !!baselineManifest; + const delta = + hasManifestBaseline && currentSnapshot + ? calculateWorkspaceDelta({ + previousManifest: baselineManifest, + currentManifest: currentSnapshot.manifest, + }) + : { + addedFilesCount: 0, + removedFilesCount: 0, + modifiedFilesCount: 0, + changedFilesCount: 0, + deltaScore: 0, + changedPaths: [], + }; + const hasMeaningfulChanges = + !!latestCompletedRun && + delta.deltaScore >= kDoctorMeaningfulChangeScoreThreshold; + return { + activeRunId: state.activeRunId || 0, + runInProgress: !!state.activeRunPromise, + lastRunAt, + lastRunAgeMs, + needsInitialRun: !latestCompletedRun, + stale, + changeSummary: { + ...delta, + hasBaseline: hasManifestBaseline, + baselineSource: baselineRun ? "last_run" : initialBaseline ? "initial_install" : "none", + hasMeaningfulChanges, + }, + latestRun, + }; + }; + + const executeDoctorRun = async (runId) => { + try { + const prompt = buildDoctorPrompt({ + workspaceRoot, + managedRoot, + protectedPaths, + lockedPaths, + promptVersion: kDoctorPromptVersion, + }); + const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000; + const gatewayParams = { + agentId: "main", + idempotencyKey: buildDoctorIdempotencyKey(runId), + message: prompt, + sessionKey: buildDoctorSessionKey(runId), + thinking: "medium", + timeout: Math.round(kDoctorRunTimeoutMs / 1000), + }; + const result = await clawCmd( + `gateway call agent --expect-final --json --timeout ${gatewayTimeoutMs} --params ${shellEscapeArg( + JSON.stringify(gatewayParams), + )}`, + { + quiet: true, + timeoutMs: gatewayTimeoutMs, + }, + ); + if (!result?.ok) { + throw new Error(result?.stderr || "Doctor analysis command failed"); + } + const stdoutText = String(result.stdout || ""); + const stderrText = String(result.stderr || ""); + console.log( + `[doctor] run ${runId} command result ok=${result.ok} code=${result.code ?? 0} stdout_chars=${stdoutText.length} stderr_chars=${stderrText.length}`, + ); + let normalizedResult = null; + try { + normalizedResult = normalizeDoctorResult(stdoutText); + } catch (error) { + console.error( + `[doctor] run ${runId} normalize failed: ${error.message || "Unknown error"}`, + ); + console.error(`[doctor] run ${runId} stdout begin`); + console.error(stdoutText || "(empty)"); + console.error(`[doctor] run ${runId} stdout end`); + console.error(`[doctor] run ${runId} stderr begin`); + console.error(stderrText || "(empty)"); + console.error(`[doctor] run ${runId} stderr end`); + throw error; + } + insertDoctorCards({ + runId, + cards: normalizedResult.cards, + }); + completeDoctorRun({ + id: runId, + status: kDoctorRunStatus.completed, + summary: normalizedResult.summary, + rawResult: normalizedResult.rawPayload, + }); + } catch (error) { + completeDoctorRun({ + id: runId, + status: kDoctorRunStatus.failed, + error: error.message || "Doctor run failed", + }); + } finally { + state.activeRunId = 0; + state.activeRunPromise = null; + } + }; + + const runDoctor = () => { + if (state.activeRunPromise) { + return { + ok: false, + alreadyRunning: true, + runId: state.activeRunId || 0, + status: buildStatus(), + error: "Doctor run already in progress", + }; + } + const workspaceSnapshot = getCurrentWorkspaceSnapshot(); + const workspaceFingerprint = workspaceSnapshot.fingerprint; + const latestCompletedRun = getLatestCompletedRun(); + if ( + latestCompletedRun && + latestCompletedRun.workspaceFingerprint && + latestCompletedRun.workspaceFingerprint === workspaceFingerprint + ) { + const runId = createDoctorRun({ + status: kDoctorRunStatus.completed, + engine: kDoctorEngine.deterministicReuse, + workspaceRoot, + workspaceFingerprint, + workspaceManifest: workspaceSnapshot.manifest, + promptVersion: kDoctorPromptVersion, + reusedFromRunId: latestCompletedRun.id, + }); + cloneRunCards({ + sourceRunId: latestCompletedRun.id, + targetRunId: runId, + }); + const summary = `No workspace changes since last scan (${formatElapsedSince( + latestCompletedRun.completedAt || latestCompletedRun.startedAt, + )}). Same findings apply.`; + completeDoctorRun({ + id: runId, + status: kDoctorRunStatus.completed, + summary, + rawResult: latestCompletedRun.rawResult, + }); + return { + ok: true, + runId, + reusedPreviousRun: true, + sourceRunId: latestCompletedRun.id, + status: buildStatus(), + }; + } + const runId = createDoctorRun({ + status: kDoctorRunStatus.running, + engine: kDoctorEngine.gatewayAgent, + workspaceRoot, + workspaceFingerprint, + workspaceManifest: workspaceSnapshot.manifest, + promptVersion: kDoctorPromptVersion, + }); + state.activeRunId = runId; + state.activeRunPromise = executeDoctorRun(runId); + return { + ok: true, + runId, + status: buildStatus(), + }; + }; + + const importDoctorResult = ({ + rawOutput, + engine = kDoctorEngine.manualImport, + } = {}) => { + const normalizedRawOutput = String(rawOutput || ""); + if (!normalizedRawOutput.trim()) { + throw new Error("Doctor import requires raw output"); + } + const normalizedResult = normalizeDoctorResult(normalizedRawOutput); + const workspaceSnapshot = getCurrentWorkspaceSnapshot(); + const runId = createDoctorRun({ + status: kDoctorRunStatus.completed, + engine, + workspaceRoot, + workspaceFingerprint: workspaceSnapshot.fingerprint, + workspaceManifest: workspaceSnapshot.manifest, + promptVersion: kDoctorPromptVersion, + }); + insertDoctorCards({ + runId, + cards: normalizedResult.cards, + }); + completeDoctorRun({ + id: runId, + status: kDoctorRunStatus.completed, + summary: normalizedResult.summary, + rawResult: normalizedResult.rawPayload, + }); + return { + ok: true, + runId, + run: getDoctorRun(runId), + }; + }; + + const requestCardFix = async ({ + cardId, + sessionId = "", + replyChannel = "", + replyTo = "", + } = {}) => { + const card = getDoctorCard(cardId); + if (!card) throw new Error("Doctor card not found"); + const prompt = String(card.fixPrompt || "").trim(); + if (!prompt) throw new Error("Doctor card does not include a fix prompt"); + let command = `agent --agent main --message ${shellEscapeArg(prompt)}`; + const trimmedSessionId = String(sessionId || "").trim(); + const trimmedReplyChannel = String(replyChannel || "").trim(); + const trimmedReplyTo = String(replyTo || "").trim(); + if (trimmedReplyChannel && trimmedReplyTo) { + command += + ` --deliver --reply-channel ${shellEscapeArg(trimmedReplyChannel)}` + + ` --reply-to ${shellEscapeArg(trimmedReplyTo)}`; + } else if (trimmedSessionId) { + command += ` --session-id ${shellEscapeArg(trimmedSessionId)}`; + } + const result = await clawCmd(command, { + quiet: true, + timeoutMs: kDoctorRunTimeoutMs, + }); + if (!result?.ok) { + throw new Error(result?.stderr || "Could not send Doctor fix request"); + } + return { + ok: true, + stdout: result.stdout || "", + card, + }; + }; + + const setCardStatus = ({ cardId, status }) => { + const updatedCard = updateDoctorCardStatus({ + id: cardId, + status, + }); + if (!updatedCard) throw new Error("Doctor card not found"); + return updatedCard; + }; + + return { + buildStatus, + runDoctor, + importDoctorResult, + listDoctorRuns, + listDoctorCards, + getDoctorRun, + getDoctorCardsByRunId, + requestCardFix, + setCardStatus, + getDoctorCard, + }; +}; + +module.exports = { + buildDoctorIdempotencyKey, + buildDoctorSessionKey, + buildDoctorSessionId, + createDoctorService, +}; diff --git a/lib/server/doctor/workspace-fingerprint.js b/lib/server/doctor/workspace-fingerprint.js new file mode 100644 index 00000000..cefb45d7 --- /dev/null +++ b/lib/server/doctor/workspace-fingerprint.js @@ -0,0 +1,126 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const kIgnoredDirectoryNames = new Set([".git", "node_modules"]); + +const hashFile = (filePath) => { + const buffer = fs.readFileSync(filePath); + return crypto.createHash("sha256").update(buffer).digest("hex"); +}; + +const normalizeRelativePath = (rootDir, filePath) => + path.relative(rootDir, filePath).split(path.sep).join("/"); + +const walkFiles = (rootDir, currentDir = rootDir) => { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + const sortedEntries = [...entries].sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + + for (const entry of sortedEntries) { + if (entry.isDirectory()) { + if (kIgnoredDirectoryNames.has(entry.name)) continue; + files.push(...walkFiles(rootDir, path.join(currentDir, entry.name))); + continue; + } + if (!entry.isFile()) continue; + files.push(path.join(currentDir, entry.name)); + } + + return files; +}; + +const buildWorkspaceManifest = (rootDir) => { + const normalizedRootDir = path.resolve(String(rootDir || "")); + const files = walkFiles(normalizedRootDir); + return files.reduce((manifest, filePath) => { + manifest[normalizeRelativePath(normalizedRootDir, filePath)] = hashFile(filePath); + return manifest; + }, {}); +}; + +const computeWorkspaceFingerprintFromManifest = (manifest = {}) => { + const hash = crypto.createHash("sha256"); + const entries = Object.entries(manifest).sort(([leftPath], [rightPath]) => + leftPath.localeCompare(rightPath), + ); + + hash.update("workspace-fingerprint-v1"); + for (const [relativePath, fileHash] of entries) { + hash.update(relativePath); + hash.update("\0"); + hash.update(fileHash); + hash.update("\0"); + } + + return hash.digest("hex"); +}; + +const computeWorkspaceSnapshot = (rootDir) => { + const manifest = buildWorkspaceManifest(rootDir); + return { + fingerprint: computeWorkspaceFingerprintFromManifest(manifest), + manifest, + }; +}; + +const getPathChangeWeight = (relativePath = "") => { + const normalizedPath = String(relativePath || "").trim().toLowerCase(); + if (!normalizedPath) return 1; + if ( + normalizedPath === "agents.md" || + normalizedPath === "tools.md" || + normalizedPath === "readme.md" || + normalizedPath === "bootstrap.md" || + normalizedPath === "memory.md" || + normalizedPath === "user.md" || + normalizedPath === "identity.md" + ) { + return 4; + } + if (normalizedPath.startsWith("hooks/bootstrap/")) return 4; + if (normalizedPath.startsWith("skills/")) return 3; + if (normalizedPath.endsWith(".md")) return 2; + return 1; +}; + +const calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} } = {}) => { + const previousPaths = Object.keys(previousManifest); + const currentPaths = Object.keys(currentManifest); + const allPaths = Array.from(new Set([...previousPaths, ...currentPaths])).sort((left, right) => + left.localeCompare(right), + ); + const changeSummary = { + addedFilesCount: 0, + removedFilesCount: 0, + modifiedFilesCount: 0, + changedFilesCount: 0, + deltaScore: 0, + changedPaths: [], + }; + + for (const relativePath of allPaths) { + const previousHash = previousManifest[relativePath] || ""; + const currentHash = currentManifest[relativePath] || ""; + if (!previousHash && currentHash) { + changeSummary.addedFilesCount += 1; + } else if (previousHash && !currentHash) { + changeSummary.removedFilesCount += 1; + } else if (previousHash !== currentHash) { + changeSummary.modifiedFilesCount += 1; + } else { + continue; + } + changeSummary.changedFilesCount += 1; + changeSummary.deltaScore += getPathChangeWeight(relativePath); + changeSummary.changedPaths.push(relativePath); + } + + return changeSummary; +}; + +module.exports = { + calculateWorkspaceDelta, + computeWorkspaceFingerprintFromManifest, + computeWorkspaceSnapshot, +}; diff --git a/lib/server/routes/doctor.js b/lib/server/routes/doctor.js new file mode 100644 index 00000000..03b01e1c --- /dev/null +++ b/lib/server/routes/doctor.js @@ -0,0 +1,123 @@ +const { kDoctorCardStatus, kDoctorDefaultRunsLimit } = require("../doctor/constants"); + +const registerDoctorRoutes = ({ app, requireAuth, doctorService }) => { + app.get("/api/doctor/status", requireAuth, (req, res) => { + try { + res.json({ ok: true, status: doctorService.buildStatus() }); + } catch (error) { + res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.post("/api/doctor/run", requireAuth, (req, res) => { + try { + const result = doctorService.runDoctor(); + if (!result.ok && result.alreadyRunning) { + return res.status(409).json(result); + } + return res.status(result.reusedPreviousRun ? 200 : 202).json(result); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.post("/api/doctor/import", requireAuth, (req, res) => { + try { + const result = doctorService.importDoctorResult({ + rawOutput: req.body?.rawOutput, + }); + return res.status(201).json(result); + } catch (error) { + return res.status(400).json({ ok: false, error: error.message }); + } + }); + + app.get("/api/doctor/runs", requireAuth, (req, res) => { + try { + const limit = Number.parseInt(String(req.query.limit || kDoctorDefaultRunsLimit), 10); + const runs = doctorService.listDoctorRuns({ limit }); + res.json({ ok: true, runs }); + } catch (error) { + res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.get("/api/doctor/cards", requireAuth, (req, res) => { + try { + const runId = String(req.query.runId || "").trim(); + const cards = doctorService.listDoctorCards({ + runId: runId || "all", + }); + return res.json({ ok: true, cards }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.get("/api/doctor/runs/:id", requireAuth, (req, res) => { + try { + const run = doctorService.getDoctorRun(req.params.id); + if (!run) { + return res.status(404).json({ ok: false, error: "Doctor run not found" }); + } + return res.json({ ok: true, run }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.get("/api/doctor/runs/:id/cards", requireAuth, (req, res) => { + try { + const run = doctorService.getDoctorRun(req.params.id); + if (!run) { + return res.status(404).json({ ok: false, error: "Doctor run not found" }); + } + const cards = doctorService.getDoctorCardsByRunId(req.params.id); + return res.json({ ok: true, cards }); + } catch (error) { + return res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.post("/api/doctor/cards/:id/status", requireAuth, (req, res) => { + try { + const requestedStatus = String(req.body?.status || "").trim().toLowerCase(); + if ( + requestedStatus !== kDoctorCardStatus.open && + requestedStatus !== kDoctorCardStatus.dismissed && + requestedStatus !== kDoctorCardStatus.fixed + ) { + return res.status(400).json({ ok: false, error: "Invalid Doctor card status" }); + } + const card = doctorService.setCardStatus({ + cardId: req.params.id, + status: requestedStatus, + }); + return res.json({ ok: true, card }); + } catch (error) { + if (/not found/i.test(error.message || "")) { + return res.status(404).json({ ok: false, error: error.message }); + } + return res.status(400).json({ ok: false, error: error.message }); + } + }); + + app.post("/api/doctor/findings/:id/fix", requireAuth, async (req, res) => { + try { + const result = await doctorService.requestCardFix({ + cardId: req.params.id, + sessionId: req.body?.sessionId, + replyChannel: req.body?.replyChannel, + replyTo: req.body?.replyTo, + }); + return res.json(result); + } catch (error) { + if (/not found/i.test(error.message || "")) { + return res.status(404).json({ ok: false, error: error.message }); + } + return res.status(400).json({ ok: false, error: error.message }); + } + }); +}; + +module.exports = { registerDoctorRoutes }; diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index 43b346f8..c5e9a9a6 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -90,7 +90,13 @@ const registerSystemRoutes = ({ .filter((sessionRow) => { const key = String(sessionRow?.key || "").toLowerCase(); if (!key) return false; - if (key.includes(":hook:") || key.includes(":cron:")) return false; + if ( + key.includes(":hook:") || + key.includes(":cron:") || + key.includes(":doctor:") + ) { + return false; + } return true; }) .map((sessionRow) => { diff --git a/lib/server/utils/json.js b/lib/server/utils/json.js index 4049ecaa..4867ec37 100644 --- a/lib/server/utils/json.js +++ b/lib/server/utils/json.js @@ -11,21 +11,67 @@ const parseJsonSafe = (rawValue, fallbackValue = null, options = {}) => { } }; -const parseJsonObjectFromNoisyOutput = (rawValue) => { +const parseJsonValueFromNoisyOutput = (rawValue) => { const text = String(rawValue ?? ""); - const firstBrace = text.indexOf("{"); - const lastBrace = text.lastIndexOf("}"); - if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) { - return null; - } - try { - return JSON.parse(text.slice(firstBrace, lastBrace + 1)); - } catch { - return null; + const openingChars = new Set(["{", "["]); + const closingCharByOpeningChar = { + "{": "}", + "[": "]", + }; + for (let startIndex = 0; startIndex < text.length; startIndex += 1) { + const openingChar = text[startIndex]; + if (!openingChars.has(openingChar)) continue; + const expectedClosingChar = closingCharByOpeningChar[openingChar]; + const stack = [expectedClosingChar]; + let inString = false; + let escapeNextChar = false; + for (let currentIndex = startIndex + 1; currentIndex < text.length; currentIndex += 1) { + const currentChar = text[currentIndex]; + if (inString) { + if (escapeNextChar) { + escapeNextChar = false; + continue; + } + if (currentChar === "\\") { + escapeNextChar = true; + continue; + } + if (currentChar === "\"") { + inString = false; + } + continue; + } + if (currentChar === "\"") { + inString = true; + continue; + } + if (openingChars.has(currentChar)) { + stack.push(closingCharByOpeningChar[currentChar]); + continue; + } + if (currentChar !== stack[stack.length - 1]) continue; + stack.pop(); + if (stack.length > 0) continue; + const candidate = text.slice(startIndex, currentIndex + 1); + try { + return JSON.parse(candidate); + } catch { + break; + } + } } + return null; +}; + +const parseJsonObjectFromNoisyOutput = (rawValue) => { + const parsedValue = parseJsonValueFromNoisyOutput(rawValue); + return parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue) + ? parsedValue + : null; }; module.exports = { parseJsonSafe, + parseJsonValueFromNoisyOutput, parseJsonObjectFromNoisyOutput, }; diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index fd34e6fc..153068b6 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -121,6 +121,68 @@ describe("frontend/api", () => { expect(result).toEqual({ ok: true, sessions: [] }); }); + it("fetchDoctorStatus calls Doctor status endpoint", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, status: { stale: true } })); + const api = await loadApiModule(); + + const result = await api.fetchDoctorStatus(); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/doctor/status", + expect.objectContaining({ headers: expect.any(Headers) }), + ); + expect(result).toEqual({ ok: true, status: { stale: true } }); + }); + + it("fetchDoctorCards calls aggregated Doctor cards endpoint", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, cards: [] })); + const api = await loadApiModule(); + + const result = await api.fetchDoctorCards({ runId: "all" }); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/doctor/cards?runId=all", + expect.objectContaining({ headers: expect.any(Headers) }), + ); + expect(result).toEqual({ ok: true, cards: [] }); + }); + + it("startDoctorRun posts to the Doctor run endpoint", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(202, { ok: true, runId: 42 })); + const api = await loadApiModule(); + + const result = await api.startDoctorRun(); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/doctor/run", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({}), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); + expect(result).toEqual({ ok: true, runId: 42 }); + }); + + it("importDoctorResult posts raw Doctor output", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(201, { ok: true, runId: 43 })); + const api = await loadApiModule(); + + const result = await api.importDoctorResult('{"summary":"Imported","cards":[]}'); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/doctor/import", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ rawOutput: '{"summary":"Imported","cards":[]}' }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); + expect(result).toEqual({ ok: true, runId: 43 }); + }); + it("fetchUsageSessionDetail encodes session id in path", async () => { global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, detail: { sessionId: "x" } })); const api = await loadApiModule(); @@ -134,6 +196,33 @@ describe("frontend/api", () => { expect(result).toEqual({ ok: true, detail: { sessionId: "x" } }); }); + it("sendDoctorCardFix posts delivery fields", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, stdout: "sent" })); + const api = await loadApiModule(); + + const result = await api.sendDoctorCardFix({ + cardId: 7, + sessionId: "session-123", + replyChannel: "telegram", + replyTo: "1050", + }); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/doctor/findings/7/fix", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + sessionId: "session-123", + replyChannel: "telegram", + replyTo: "1050", + }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); + expect(result).toEqual({ ok: true, stdout: "sent" }); + }); + it("syncBrowseChanges posts commit message", async () => { global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, committed: true })); const api = await loadApiModule(); diff --git a/tests/frontend/doctor-helpers.test.js b/tests/frontend/doctor-helpers.test.js new file mode 100644 index 00000000..fa31078e --- /dev/null +++ b/tests/frontend/doctor-helpers.test.js @@ -0,0 +1,172 @@ +const loadDoctorHelpers = async () => + import("../../lib/public/js/components/doctor/helpers.js"); + +describe("frontend/doctor helpers", () => { + it("groups cards by status and counts priorities", async () => { + const helpers = await loadDoctorHelpers(); + const cards = [ + { id: 1, priority: "P0", status: "open" }, + { id: 2, priority: "P1", status: "dismissed" }, + { id: 3, priority: "P2", status: "fixed" }, + { id: 4, priority: "P2", status: "open" }, + ]; + + expect(helpers.buildDoctorPriorityCounts(cards)).toEqual({ + P0: 1, + P1: 1, + P2: 2, + }); + expect(helpers.groupDoctorCardsByStatus(cards)).toEqual({ + open: [ + { id: 1, priority: "P0", status: "open" }, + { id: 4, priority: "P2", status: "open" }, + ], + dismissed: [{ id: 2, priority: "P1", status: "dismissed" }], + fixed: [{ id: 3, priority: "P2", status: "fixed" }], + }); + }); + + it("only shows the warning for stale Doctor states with meaningful changes", async () => { + const helpers = await loadDoctorHelpers(); + + expect( + helpers.shouldShowDoctorWarning({ + needsInitialRun: true, + stale: true, + changeSummary: { hasMeaningfulChanges: true }, + }), + ).toBe(false); + expect( + helpers.shouldShowDoctorWarning({ + needsInitialRun: false, + stale: false, + changeSummary: { hasMeaningfulChanges: true }, + }), + ).toBe(false); + expect( + helpers.shouldShowDoctorWarning({ + needsInitialRun: false, + stale: true, + changeSummary: { hasMeaningfulChanges: false }, + }), + ).toBe(false); + expect( + helpers.shouldShowDoctorWarning( + { + needsInitialRun: false, + stale: true, + changeSummary: { hasMeaningfulChanges: true }, + }, + Date.now() + 1000, + ), + ).toBe(false); + expect( + helpers.shouldShowDoctorWarning({ + needsInitialRun: false, + stale: true, + changeSummary: { hasMeaningfulChanges: true }, + }), + ).toBe(true); + expect( + helpers.getDoctorWarningMessage({ + needsInitialRun: false, + stale: true, + changeSummary: { changedFilesCount: 3 }, + }), + ).toBe("Drift Doctor has not been run in the last week and 3 files changed since the last review."); + }); + + it("formats categories and run filter options", async () => { + const helpers = await loadDoctorHelpers(); + + expect(helpers.formatDoctorCategory("token_efficiency")).toBe("Token Efficiency"); + expect(helpers.getDoctorCategoryTone("token_efficiency")).toBe("info"); + expect(helpers.getDoctorCategoryTone("redundancy")).toBe("accent"); + expect(helpers.getDoctorCategoryTone("workspace_state")).toBe("secondary"); + expect( + helpers.buildDoctorRunMarkers({ + status: "completed", + cardCount: 0, + priorityCounts: { P0: 0, P1: 0, P2: 0 }, + }), + ).toEqual([{ tone: "success", count: 0, label: "No findings" }]); + expect( + helpers.buildDoctorRunMarkers({ + status: "completed", + cardCount: 3, + priorityCounts: { P0: 2, P1: 1, P2: 0 }, + }), + ).toEqual([ + { tone: "danger", count: 2, label: "P0" }, + { tone: "warning", count: 1, label: "P1" }, + ]); + expect( + helpers.buildDoctorRunMarkers({ + status: "running", + }), + ).toEqual([{ tone: "cyan", count: 0, label: "Running" }]); + expect(helpers.getDoctorRunPillDetail({ status: "failed" })).toBe("Failed"); + expect(helpers.getDoctorRunPillDetail({ status: "completed", cardCount: 0 })).toBe( + "No findings", + ); + expect(helpers.getDoctorDriftRisk({ hasBaseline: false })).toEqual({ + label: "Unknown", + tone: "neutral", + detail: "No prior baseline yet", + }); + expect( + helpers.getDoctorDriftRisk({ + hasBaseline: true, + baselineSource: "last_run", + deltaScore: 0, + changedFilesCount: 0, + }), + ).toEqual({ + label: "Low", + tone: "info", + detail: "No detected changes", + }); + expect( + helpers.getDoctorDriftRisk({ + hasBaseline: true, + baselineSource: "initial_install", + deltaScore: 0, + changedFilesCount: 0, + }), + ).toEqual({ + label: "Low", + tone: "info", + detail: "Compared to initial install", + }); + expect( + helpers.getDoctorDriftRisk({ + hasBaseline: true, + baselineSource: "last_run", + deltaScore: 5, + changedFilesCount: 3, + }), + ).toEqual({ + label: "Moderate", + tone: "warning", + detail: "3 changed files", + }); + expect( + helpers.getDoctorDriftRisk({ + hasBaseline: true, + baselineSource: "last_run", + deltaScore: 9, + changedFilesCount: 2, + }), + ).toEqual({ + label: "High", + tone: "danger", + detail: "2 changed files", + }); + expect(helpers.getDoctorStatusTone("fixed")).toBe("success"); + expect(helpers.buildDoctorStatusFilterOptions()).toEqual([ + { value: "open", label: "Open" }, + { value: "dismissed", label: "Dismissed" }, + { value: "fixed", label: "Fixed" }, + ]); + }); +}); diff --git a/tests/server/doctor-db.test.js b/tests/server/doctor-db.test.js new file mode 100644 index 00000000..3841911d --- /dev/null +++ b/tests/server/doctor-db.test.js @@ -0,0 +1,113 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { kDoctorCardStatus, kDoctorPriority, kDoctorRunStatus } = require("../../lib/server/doctor/constants"); + +const loadDoctorDb = () => { + const modulePath = require.resolve("../../lib/server/db/doctor"); + delete require.cache[modulePath]; + return require(modulePath); +}; + +describe("server/doctor-db", () => { + it("initializes doctor.db under root db directory", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-db-init-")); + const { initDoctorDb } = loadDoctorDb(); + + const result = initDoctorDb({ rootDir }); + + expect(result.path).toBe(path.join(rootDir, "db", "doctor.db")); + expect(fs.existsSync(result.path)).toBe(true); + }); + + it("stores runs and cards with aggregated counts", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-db-cards-")); + const { + initDoctorDb, + createDoctorRun, + insertDoctorCards, + completeDoctorRun, + getInitialWorkspaceBaseline, + getDoctorRun, + getLatestDoctorRun, + getDoctorCardsByRunId, + setInitialWorkspaceBaseline, + updateDoctorCardStatus, + } = loadDoctorDb(); + initDoctorDb({ rootDir }); + setInitialWorkspaceBaseline({ + fingerprint: "initial-fingerprint", + manifest: { "README.md": "hash-readme" }, + capturedAt: "2026-03-06T00:00:00.000Z", + }); + + const runId = createDoctorRun({ + engine: "gateway_agent", + workspaceRoot: "/tmp/workspace", + workspaceFingerprint: "fingerprint-123", + workspaceManifest: { "AGENTS.md": "hash-1" }, + promptVersion: "doctor-v1", + reusedFromRunId: 9, + }); + insertDoctorCards({ + runId, + cards: [ + { + priority: kDoctorPriority.P0, + category: "guidance", + title: "Misplaced tools guidance", + summary: "Tool guidance lives in the wrong file", + recommendation: "Move tool guidance into TOOLS.md", + evidence: [{ type: "path", path: "README.md" }], + targetPaths: ["README.md", "hooks/bootstrap/TOOLS.md"], + fixPrompt: "Move the tool guidance safely", + status: kDoctorCardStatus.open, + }, + { + priority: kDoctorPriority.P2, + category: "cleanup", + title: "Duplicate notes", + summary: "Low-value duplication", + recommendation: "Consolidate the duplicate notes", + evidence: [], + targetPaths: ["docs/notes.md"], + fixPrompt: "Consolidate the duplicate notes safely", + status: kDoctorCardStatus.dismissed, + }, + ], + }); + completeDoctorRun({ + id: runId, + status: kDoctorRunStatus.completed, + summary: "Found 2 recommendations", + rawResult: { cards: [] }, + }); + + const run = getDoctorRun(runId); + const latestRun = getLatestDoctorRun(); + const cards = getDoctorCardsByRunId(runId); + const initialBaseline = getInitialWorkspaceBaseline(); + + expect(run.status).toBe(kDoctorRunStatus.completed); + expect(initialBaseline).toEqual({ + fingerprint: "initial-fingerprint", + manifest: { "README.md": "hash-readme" }, + capturedAt: "2026-03-06T00:00:00.000Z", + }); + expect(run.workspaceFingerprint).toBe("fingerprint-123"); + expect(run.workspaceManifest).toEqual({ "AGENTS.md": "hash-1" }); + expect(run.reusedFromRunId).toBe(9); + expect(run.cardCount).toBe(2); + expect(run.priorityCounts).toEqual({ P0: 1, P1: 0, P2: 1 }); + expect(run.statusCounts).toEqual({ open: 1, dismissed: 1, fixed: 0 }); + expect(cards).toHaveLength(2); + expect(latestRun.id).toBe(runId); + + const updatedCard = updateDoctorCardStatus({ + id: cards[0].id, + status: kDoctorCardStatus.fixed, + }); + expect(updatedCard.status).toBe(kDoctorCardStatus.fixed); + }); +}); diff --git a/tests/server/doctor-normalize.test.js b/tests/server/doctor-normalize.test.js new file mode 100644 index 00000000..aca9b2cf --- /dev/null +++ b/tests/server/doctor-normalize.test.js @@ -0,0 +1,108 @@ +const { + normalizeDoctorResult, +} = require("../../lib/server/doctor/normalize"); + +describe("server/doctor-normalize", () => { + it("normalizes nested JSON output into AlphaClaw Doctor cards", () => { + const rawOutput = JSON.stringify({ + runId: "abc", + status: "ok", + result: JSON.stringify({ + summary: "Workspace guidance has drift", + findings: [ + { + severity: "high", + category: "guidance", + title: "Tools guidance drift", + description: "Tool guidance is duplicated in README.md", + recommendedAction: "Move tool guidance into TOOLS.md", + evidence: ["README.md duplicates TOOLS.md"], + paths: ["README.md", "hooks/bootstrap/TOOLS.md"], + }, + ], + }), + }); + + const result = normalizeDoctorResult(rawOutput); + + expect(result.summary).toBe("Workspace guidance has drift"); + expect(result.cards).toEqual([ + expect.objectContaining({ + priority: "P0", + category: "guidance", + title: "Tools guidance drift", + recommendation: "Move tool guidance into TOOLS.md", + targetPaths: ["README.md", "hooks/bootstrap/TOOLS.md"], + status: "open", + }), + ]); + expect(result.cards[0].fixPrompt).toContain("Move tool guidance into TOOLS.md"); + expect(result.cards[0].evidence).toEqual([ + { type: "text", text: "README.md duplicates TOOLS.md" }, + ]); + }); + + it("extracts Doctor JSON when prose surrounds the payload", () => { + const rawOutput = `Now I have a complete picture. Here's the analysis:\n\n${JSON.stringify({ + summary: "Fresh workspace with drift risk", + cards: [ + { + priority: "P1", + category: "redundancy", + title: "Duplicated UI guidance", + summary: "Two files repeat the same guidance", + recommendation: "Centralize the detailed guidance into one place", + evidence: [ + { type: "path", path: "hooks/bootstrap/TOOLS.md" }, + { type: "path", path: "skills/control-ui/SKILL.md" }, + ], + targetPaths: ["hooks/bootstrap/TOOLS.md"], + fixPrompt: "Reduce duplication safely", + status: "open", + }, + ], + })}\n\nThat is the full result.`; + + const result = normalizeDoctorResult(rawOutput); + + expect(result.summary).toBe("Fresh workspace with drift risk"); + expect(result.cards).toEqual([ + expect.objectContaining({ + priority: "P1", + category: "redundancy", + title: "Duplicated UI guidance", + recommendation: "Centralize the detailed guidance into one place", + targetPaths: ["hooks/bootstrap/TOOLS.md"], + }), + ]); + }); + + it("extracts Doctor JSON from agent payloads text wrappers", () => { + const rawOutput = JSON.stringify({ + runId: "6650ca1c-be0f-4c15-afb4-3d995c904e2e", + status: "ok", + summary: "completed", + result: { + payloads: [ + { + text: "No changes. All hashes identical to prior scan.\n\n{\"summary\":\"Healthy post-bootstrap workspace. No changes since last scan. No drift, contradictions, or misplaced guidance detected.\",\"cards\":[]}", + mediaUrl: null, + }, + ], + }, + }); + + const result = normalizeDoctorResult(rawOutput); + + expect(result.summary).toBe( + "Healthy post-bootstrap workspace. No changes since last scan. No drift, contradictions, or misplaced guidance detected.", + ); + expect(result.cards).toEqual([]); + }); + + it("throws when the payload does not include recognizable Doctor cards", () => { + expect(() => normalizeDoctorResult('{"ok":true,"summary":"no cards here"}')).toThrow( + "Doctor response did not include a recognizable cards payload", + ); + }); +}); diff --git a/tests/server/doctor-prompt.test.js b/tests/server/doctor-prompt.test.js new file mode 100644 index 00000000..60bbf704 --- /dev/null +++ b/tests/server/doctor-prompt.test.js @@ -0,0 +1,32 @@ +const { buildDoctorPrompt } = require("../../lib/server/doctor/prompt"); + +describe("server/doctor-prompt", () => { + it("includes OpenClaw default-template context for AGENTS.md", () => { + const prompt = buildDoctorPrompt({ + workspaceRoot: "/tmp/workspace", + managedRoot: "/tmp/managed", + promptVersion: "doctor-v1", + }); + + expect(prompt).toContain("OpenClaw default context:"); + expect(prompt).toContain("`AGENTS.md` is the workspace home file in the default OpenClaw template."); + expect(prompt).toContain("Do not treat default-template content as drift just because it is broad or multi-purpose."); + }); + + it("tells the analyzer not to propose structural changes to AlphaClaw-managed files", () => { + const prompt = buildDoctorPrompt({ + workspaceRoot: "/tmp/workspace", + managedRoot: "/tmp/managed", + lockedPaths: ["hooks/bootstrap/TOOLS.md"], + promptVersion: "doctor-v1", + }); + + expect(prompt).toContain("AlphaClaw ownership rules:"); + expect(prompt).toContain( + "Do not recommend splitting, renaming, relocating, or otherwise restructuring AlphaClaw-managed files solely for cleanliness or purity.", + ); + expect(prompt).toContain( + "Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure", + ); + }); +}); diff --git a/tests/server/doctor-service.test.js b/tests/server/doctor-service.test.js new file mode 100644 index 00000000..cf5290e3 --- /dev/null +++ b/tests/server/doctor-service.test.js @@ -0,0 +1,235 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const loadDoctorDb = () => { + const modulePath = require.resolve("../../lib/server/db/doctor"); + delete require.cache[modulePath]; + return require(modulePath); +}; + +const loadDoctorService = () => { + const modulePath = require.resolve("../../lib/server/doctor/service"); + delete require.cache[modulePath]; + return require(modulePath); +}; + +describe("server/doctor-service", () => { + it("reuses the previous completed run when the workspace fingerprint is unchanged", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-workspace-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-service-db-")); + fs.writeFileSync( + path.join(workspaceRoot, "AGENTS.md"), + "# Workspace Guidance\n\nKeep this concise.\n", + "utf8", + ); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const clawCmd = vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify({ + summary: "Should not be called", + cards: [], + }), + })); + const { createDoctorService } = loadDoctorService(); + const doctorService = createDoctorService({ + clawCmd, + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const imported = doctorService.importDoctorResult({ + rawOutput: JSON.stringify({ + summary: "Initial findings", + cards: [ + { + priority: "P1", + category: "redundancy", + title: "Duplicated UI guidance", + summary: "Two files describe the same flow", + recommendation: "Keep one file authoritative", + evidence: [{ type: "path", path: "AGENTS.md" }], + targetPaths: ["AGENTS.md"], + fixPrompt: "Consolidate the duplicated guidance safely.", + status: "open", + }, + ], + }), + }); + + const rerun = doctorService.runDoctor(); + const latestRun = doctorDb.getDoctorRun(rerun.runId); + + expect(imported.ok).toBe(true); + expect(rerun.ok).toBe(true); + expect(rerun.reusedPreviousRun).toBe(true); + expect(rerun.sourceRunId).toBe(imported.runId); + expect(clawCmd).not.toHaveBeenCalled(); + expect(latestRun.engine).toBe("deterministic_reuse"); + expect(latestRun.reusedFromRunId).toBe(imported.runId); + expect(latestRun.summary).toMatch(/^No workspace changes since last scan/); + expect(doctorDb.getDoctorCardsByRunId(rerun.runId)).toHaveLength(1); + }); + + it("runs Doctor analysis in a dedicated doctor session", async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-session-workspace-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-session-db-")); + fs.writeFileSync(path.join(workspaceRoot, "AGENTS.md"), "# Workspace Guidance\n", "utf8"); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const clawCmd = vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify({ + summary: "Healthy workspace", + cards: [], + }), + stderr: "", + code: 0, + })); + const { buildDoctorIdempotencyKey, buildDoctorSessionKey, createDoctorService } = + loadDoctorService(); + const doctorService = createDoctorService({ + clawCmd, + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const result = doctorService.runDoctor(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result.ok).toBe(true); + expect(clawCmd).toHaveBeenCalledTimes(1); + expect(clawCmd.mock.calls[0][0]).toContain("gateway call agent --expect-final --json"); + expect(clawCmd.mock.calls[0][0]).toContain( + `"idempotencyKey":"${buildDoctorIdempotencyKey(result.runId)}"`, + ); + expect(clawCmd.mock.calls[0][0]).toContain( + `"sessionKey":"${buildDoctorSessionKey(result.runId)}"`, + ); + }); + + it("reports meaningful workspace drift only after a stale completed run", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-drift-workspace-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-drift-db-")); + fs.writeFileSync(path.join(workspaceRoot, "AGENTS.md"), "# Guidance\n", "utf8"); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const listDoctorRuns = ({ limit } = {}) => + doctorDb.listDoctorRuns({ limit }).map((run) => ({ + ...run, + startedAt: "2000-01-01T00:00:00.000Z", + completedAt: "2000-01-01T00:00:00.000Z", + })); + + const { createDoctorService } = loadDoctorService(); + const buildDoctorService = () => + createDoctorService({ + clawCmd: vi.fn(), + listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const doctorService = buildDoctorService(); + + doctorService.importDoctorResult({ + rawOutput: JSON.stringify({ + summary: "Baseline findings", + cards: [], + }), + }); + + fs.writeFileSync(path.join(workspaceRoot, "README.md"), "# Updated docs\n", "utf8"); + fs.mkdirSync(path.join(workspaceRoot, "skills"), { recursive: true }); + fs.writeFileSync(path.join(workspaceRoot, "skills", "note.md"), "extra guidance\n", "utf8"); + + const refreshedDoctorService = buildDoctorService(); + const status = refreshedDoctorService.buildStatus(); + + expect(status.needsInitialRun).toBe(false); + expect(status.stale).toBe(true); + expect(status.changeSummary.hasBaseline).toBe(true); + expect(status.changeSummary.changedFilesCount).toBe(2); + expect(status.changeSummary.hasMeaningfulChanges).toBe(true); + expect(status.changeSummary.deltaScore).toBeGreaterThanOrEqual(4); + }); + + it("uses the persisted initial baseline before the first completed run", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-initial-baseline-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-initial-baseline-db-")); + fs.writeFileSync(path.join(workspaceRoot, "AGENTS.md"), "# Guidance\n", "utf8"); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const { createDoctorService } = loadDoctorService(); + const buildDoctorService = () => + createDoctorService({ + clawCmd: vi.fn(), + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const doctorService = buildDoctorService(); + + const initialStatus = doctorService.buildStatus(); + fs.writeFileSync(path.join(workspaceRoot, "README.md"), "# Added after baseline\n", "utf8"); + const nextStatus = buildDoctorService().buildStatus(); + + expect(initialStatus.needsInitialRun).toBe(true); + expect(initialStatus.changeSummary.hasBaseline).toBe(true); + expect(initialStatus.changeSummary.baselineSource).toBe("initial_install"); + expect(nextStatus.changeSummary.changedFilesCount).toBe(1); + expect(nextStatus.changeSummary.hasMeaningfulChanges).toBe(false); + }); +}); diff --git a/tests/server/routes-doctor.test.js b/tests/server/routes-doctor.test.js new file mode 100644 index 00000000..1b81b079 --- /dev/null +++ b/tests/server/routes-doctor.test.js @@ -0,0 +1,190 @@ +const express = require("express"); +const request = require("supertest"); + +const { registerDoctorRoutes } = require("../../lib/server/routes/doctor"); + +const createDoctorService = () => ({ + buildStatus: vi.fn(() => ({ + runInProgress: false, + stale: true, + needsInitialRun: true, + latestRun: null, + })), + runDoctor: vi.fn(() => ({ ok: true, runId: 42, status: { runInProgress: true } })), + importDoctorResult: vi.fn(({ rawOutput }) => ({ + ok: true, + runId: 43, + run: { id: 43, summary: rawOutput ? "Imported" : "" }, + })), + listDoctorRuns: vi.fn(() => [{ id: 42, status: "running", cardCount: 0 }]), + listDoctorCards: vi.fn(({ runId }) => + String(runId || "all") === "all" + ? [ + { id: 7, runId: 42, title: "Fix drift", status: "open" }, + { id: 8, runId: 41, title: "Cleanup docs", status: "dismissed" }, + ] + : [{ id: 7, runId: 42, title: "Fix drift", status: "open" }]), + getDoctorRun: vi.fn((id) => + String(id) === "42" + ? { id: 42, status: "completed", cardCount: 1 } + : null), + getDoctorCardsByRunId: vi.fn((id) => + String(id) === "42" + ? [{ id: 7, runId: 42, title: "Fix drift", status: "open" }] + : []), + setCardStatus: vi.fn(({ cardId, status }) => ({ + id: Number(cardId), + status, + })), + requestCardFix: vi.fn(async ({ cardId, sessionId, replyChannel, replyTo }) => ({ + ok: true, + stdout: "sent", + card: { id: Number(cardId), sessionId, replyChannel, replyTo }, + })), +}); + +const createApp = (doctorService) => { + const app = express(); + app.use(express.json()); + registerDoctorRoutes({ + app, + requireAuth: (req, res, next) => next(), + doctorService, + }); + return app; +}; + +describe("server/routes/doctor", () => { + it("returns Doctor status", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const res = await request(app).get("/api/doctor/status"); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(doctorService.buildStatus).toHaveBeenCalledTimes(1); + }); + + it("starts a Doctor run", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const res = await request(app).post("/api/doctor/run").send({}); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ + ok: true, + runId: 42, + status: { runInProgress: true }, + }); + }); + + it("returns 200 when a Doctor run reuses previous findings", async () => { + const doctorService = createDoctorService(); + doctorService.runDoctor.mockReturnValue({ + ok: true, + runId: 44, + reusedPreviousRun: true, + sourceRunId: 42, + status: { runInProgress: false }, + }); + const app = createApp(doctorService); + + const res = await request(app).post("/api/doctor/run").send({}); + + expect(res.status).toBe(200); + expect(res.body.reusedPreviousRun).toBe(true); + expect(res.body.sourceRunId).toBe(42); + }); + + it("returns 409 when a Doctor run is already in progress", async () => { + const doctorService = createDoctorService(); + doctorService.runDoctor.mockReturnValue({ + ok: false, + alreadyRunning: true, + runId: 42, + status: { runInProgress: true }, + error: "Doctor run already in progress", + }); + const app = createApp(doctorService); + + const res = await request(app).post("/api/doctor/run").send({}); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Doctor run already in progress"); + }); + + it("imports a Doctor result without rerunning analysis", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const res = await request(app).post("/api/doctor/import").send({ + rawOutput: '{"summary":"Imported","cards":[]}', + }); + + expect(res.status).toBe(201); + expect(doctorService.importDoctorResult).toHaveBeenCalledWith({ + rawOutput: '{"summary":"Imported","cards":[]}', + }); + expect(res.body.runId).toBe(43); + }); + + it("returns run cards for an existing run", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const res = await request(app).get("/api/doctor/runs/42/cards"); + + expect(res.status).toBe(200); + expect(res.body.cards).toEqual([ + { id: 7, runId: 42, title: "Fix drift", status: "open" }, + ]); + }); + + it("returns aggregated Doctor cards with optional run filter", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const allCardsResponse = await request(app).get("/api/doctor/cards"); + const runCardsResponse = await request(app).get("/api/doctor/cards?runId=42"); + + expect(allCardsResponse.status).toBe(200); + expect(allCardsResponse.body.cards).toHaveLength(2); + expect(doctorService.listDoctorCards).toHaveBeenNthCalledWith(1, { runId: "all" }); + expect(runCardsResponse.status).toBe(200); + expect(doctorService.listDoctorCards).toHaveBeenNthCalledWith(2, { runId: "42" }); + }); + + it("updates Doctor card status", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const res = await request(app).post("/api/doctor/cards/7/status").send({ + status: "fixed", + }); + + expect(res.status).toBe(200); + expect(res.body.card).toEqual({ id: 7, status: "fixed" }); + }); + + it("sends a Doctor fix request with delivery fields", async () => { + const doctorService = createDoctorService(); + const app = createApp(doctorService); + + const res = await request(app).post("/api/doctor/findings/7/fix").send({ + sessionId: "session-123", + replyChannel: "telegram", + replyTo: "1050", + }); + + expect(res.status).toBe(200); + expect(doctorService.requestCardFix).toHaveBeenCalledWith({ + cardId: "7", + sessionId: "session-123", + replyChannel: "telegram", + replyTo: "1050", + }); + expect(res.body.ok).toBe(true); + }); +}); diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index 7b35029e..400c612c 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -361,4 +361,48 @@ describe("server/routes/system", () => { error: "AlphaClaw update already in progress", }); }); + + it("hides internal hook, cron, and doctor sessions from GET /api/agent/sessions", async () => { + const deps = createSystemDeps(); + deps.clawCmd.mockResolvedValue({ + ok: true, + stdout: JSON.stringify({ + sessions: [ + { key: "agent:main:main", sessionId: "main-session", updatedAt: 10 }, + { key: "agent:main:hook:abc", sessionId: "hook-session", updatedAt: 9 }, + { key: "agent:main:cron:abc", sessionId: "cron-session", updatedAt: 8 }, + { key: "agent:main:doctor:42", sessionId: "doctor-session", updatedAt: 7 }, + { + key: "agent:main:telegram:direct:1050", + sessionId: "", + updatedAt: 6, + }, + ], + }), + }); + const app = createApp(deps); + + const res = await request(app).get("/api/agent/sessions"); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.sessions).toEqual([ + { + key: "agent:main:main", + sessionId: "main-session", + updatedAt: 10, + label: "Main agent thread", + replyChannel: "", + replyTo: "", + }, + { + key: "agent:main:telegram:direct:1050", + sessionId: "", + updatedAt: 6, + label: "Telegram 1050", + replyChannel: "telegram", + replyTo: "1050", + }, + ]); + }); }); From f3ab2b5ed298aba1149da59df8c06f88a7f885d0 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 17:44:42 -0800 Subject: [PATCH 099/417] 0.4.6-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26d60247..2160693b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.5", + "version": "0.4.6-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.5", + "version": "0.4.6-beta.0", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 2eff69d9..d0429d99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.5", + "version": "0.4.6-beta.0", "publishConfig": { "access": "public" }, From 46f7b24125b08d9a4b420100195f500cd8713ee7 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 19:00:15 -0800 Subject: [PATCH 100/417] Refine Doctor UI: simplify drift bar, editable fix prompts, auto-mark fixed, friendly topic names. Replace drift risk badges with factual change count and yellow highlight for meaningful changes. Add editable prompt textarea in fix-card modal, auto-mark findings fixed after sending, resolve Telegram topic friendly names in session picker, move priority badge left of title. --- .../js/components/doctor/findings-list.js | 6 +- .../js/components/doctor/fix-card-modal.js | 60 ++++++++++++++++--- lib/public/js/components/doctor/helpers.js | 37 ++---------- lib/public/js/components/doctor/index.js | 50 ++++++++-------- lib/public/js/lib/api.js | 2 + lib/server.js | 2 + lib/server/doctor/service.js | 7 ++- lib/server/routes/doctor.js | 1 + lib/server/routes/system.js | 14 +++++ tests/frontend/api.test.js | 2 + tests/frontend/doctor-helpers.test.js | 56 ++++++----------- tests/server/routes-doctor.test.js | 2 + tests/server/routes-system.test.js | 26 ++++++++ 13 files changed, 158 insertions(+), 107 deletions(-) diff --git a/lib/public/js/components/doctor/findings-list.js b/lib/public/js/components/doctor/findings-list.js index f09f346c..c1ef6b12 100644 --- a/lib/public/js/components/doctor/findings-list.js +++ b/lib/public/js/components/doctor/findings-list.js @@ -35,11 +35,15 @@ export const DoctorFindingsList = ({
        -

        ${card.title}

        <${Badge} tone=${getDoctorPriorityTone(card.priority)}> ${card.priority} +

        + ${card.title} +

        +
        +
        <${Badge} tone=${getDoctorCategoryTone(card.category)}> ${formatDoctorCategory(card.category)} diff --git a/lib/public/js/components/doctor/fix-card-modal.js b/lib/public/js/components/doctor/fix-card-modal.js index b2dd03ea..115f4b5c 100644 --- a/lib/public/js/components/doctor/fix-card-modal.js +++ b/lib/public/js/components/doctor/fix-card-modal.js @@ -3,8 +3,14 @@ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { ModalShell } from "../modal-shell.js"; import { ActionButton } from "../action-button.js"; -import { fetchAgentSessions, sendDoctorCardFix } from "../../lib/api.js"; +import { Badge } from "../badge.js"; +import { + fetchAgentSessions, + sendDoctorCardFix, + updateDoctorCardStatus, +} from "../../lib/api.js"; import { showToast } from "../toast.js"; +import { getDoctorPriorityTone } from "./helpers.js"; const html = htm.bind(h); @@ -19,6 +25,12 @@ export const DoctorFixCardModal = ({ const [loadingSessions, setLoadingSessions] = useState(false); const [sending, setSending] = useState(false); const [loadError, setLoadError] = useState(""); + const [promptText, setPromptText] = useState(""); + + useEffect(() => { + if (!visible) return; + setPromptText(String(card?.fixPrompt || "")); + }, [visible, card?.fixPrompt, card?.id]); useEffect(() => { if (!visible) return; @@ -59,7 +71,10 @@ export const DoctorFixCardModal = ({ }, [visible, card?.id]); const selectedSession = useMemo( - () => sessions.find((sessionRow) => String(sessionRow?.key || "") === selectedSessionKey) || null, + () => + sessions.find( + (sessionRow) => String(sessionRow?.key || "") === selectedSessionKey, + ) || null, [sessions, selectedSessionKey], ); @@ -72,8 +87,17 @@ export const DoctorFixCardModal = ({ sessionId: selectedSession?.sessionId || "", replyChannel: selectedSession?.replyChannel || "", replyTo: selectedSession?.replyTo || "", + prompt: promptText, }); - showToast("Doctor fix request sent to your agent", "success"); + try { + await updateDoctorCardStatus({ cardId: card.id, status: "fixed" }); + showToast("Doctor fix request sent and finding marked fixed", "success"); + } catch (statusError) { + showToast( + statusError.message || "Doctor fix request sent, but could not mark the finding fixed", + "warning", + ); + } onComplete(); onClose(); } catch (error) { @@ -96,8 +120,14 @@ export const DoctorFixCardModal = ({

        -
        ${card?.priority || "P2"}
        -
        ${card?.title || "Doctor finding"}
        +
        + <${Badge} tone=${getDoctorPriorityTone(card?.priority || "P2")}> + ${card?.priority || "P2"} + +
        + ${card?.title || "Doctor finding"} +
        +
        ${card?.recommendation || ""}
        @@ -116,11 +146,23 @@ export const DoctorFixCardModal = ({ `, )} - ${loadingSessions - ? html`
        Loading sessions...
        ` - : null} + ${ + loadingSessions + ? html`
        Loading sessions...
        ` + : null + } ${loadError ? html`
        ${loadError}
        ` : null}
        +
        + + +
        <${ActionButton} onClick=${onClose} @@ -131,7 +173,7 @@ export const DoctorFixCardModal = ({ /> <${ActionButton} onClick=${handleSend} - disabled=${!selectedSession || loadingSessions || !!loadError} + disabled=${!selectedSession || loadingSessions || !!loadError || !String(promptText || "").trim()} loading=${sending} tone="primary" size="md" diff --git a/lib/public/js/components/doctor/helpers.js b/lib/public/js/components/doctor/helpers.js index f79d4c9f..ae873eb4 100644 --- a/lib/public/js/components/doctor/helpers.js +++ b/lib/public/js/components/doctor/helpers.js @@ -81,40 +81,15 @@ export const getDoctorWarningMessage = (doctorStatus = null) => { return "Doctor has not been run in the last week."; }; -export const getDoctorDriftRisk = (changeSummary = null) => { - const deltaScore = Number(changeSummary?.deltaScore || 0); +export const getDoctorChangeLabel = (changeSummary = null) => { const changedFilesCount = Number(changeSummary?.changedFilesCount || 0); - const hasBaseline = !!changeSummary?.hasBaseline; - const baselineSource = String(changeSummary?.baselineSource || "none"); - if (!hasBaseline) { - return { - label: "Unknown", - tone: "neutral", - detail: "No prior baseline yet", - }; - } - if (deltaScore >= 8 || changedFilesCount >= 8) { - return { - label: "High", - tone: "danger", - detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`, - }; - } - if (deltaScore >= 4 || changedFilesCount >= 4) { - return { - label: "Moderate", - tone: "warning", - detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`, - }; + const hasMeaningfulChanges = !!changeSummary?.hasMeaningfulChanges; + if (changedFilesCount === 0) { + return { text: "No changes since last run", meaningful: false }; } return { - label: "Low", - tone: "info", - detail: changedFilesCount - ? `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}` - : baselineSource === "initial_install" - ? "Compared to initial install" - : "No detected changes", + text: `${changedFilesCount} change${changedFilesCount === 1 ? "" : "s"} since last run`, + meaningful: hasMeaningfulChanges, }; }; diff --git a/lib/public/js/components/doctor/index.js b/lib/public/js/components/doctor/index.js index 0b6546da..242e3f2f 100644 --- a/lib/public/js/components/doctor/index.js +++ b/lib/public/js/components/doctor/index.js @@ -11,7 +11,6 @@ import { } from "../../lib/api.js"; import { formatLocaleDateTime } from "../../lib/format.js"; import { ActionButton } from "../action-button.js"; -import { Badge } from "../badge.js"; import { LoadingSpinner } from "../loading-spinner.js"; import { PageHeader } from "../page-header.js"; import { showToast } from "../toast.js"; @@ -21,7 +20,7 @@ import { DoctorFixCardModal } from "./fix-card-modal.js"; import { buildDoctorRunMarkers, buildDoctorStatusFilterOptions, - getDoctorDriftRisk, + getDoctorChangeLabel, getDoctorRunPillDetail, shouldShowDoctorWarning, } from "./helpers.js"; @@ -167,8 +166,8 @@ export const DoctorTab = ({ isActive = false }) => { () => buildDoctorStatusFilterOptions(), [], ); - const driftRisk = useMemo( - () => getDoctorDriftRisk(doctorStatus?.changeSummary || null), + const changeLabel = useMemo( + () => getDoctorChangeLabel(doctorStatus?.changeSummary || null), [doctorStatus], ); const canRunDoctor = useMemo(() => { @@ -183,6 +182,7 @@ export const DoctorTab = ({ isActive = false }) => { () => shouldShowDoctorWarning(doctorStatus, 0), [doctorStatus], ); + const hasCompletedDoctorRun = !!doctorStatus?.lastRunAt; const hasRuns = runs.length > 0; const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null; const hasLoadedCards = cardsPoll.data !== null || cardsPoll.error !== null; @@ -314,25 +314,27 @@ export const DoctorTab = ({ isActive = false }) => { ? html` <${DoctorSummaryCards} cards=${openCards} />
        -
        -
        - <${Badge} tone=${driftRisk.tone} - >${driftRisk.label} drift risk${ - driftRisk.detail ? ` · ${driftRisk.detail}` : "" - } -
        -
        - Last run - - ${formatLocaleDateTime(doctorStatus?.lastRunAt, { - fallback: "Never", - })} - -
        -
        + ${hasCompletedDoctorRun + ? html` +
        + + Last run ·${" "} + + ${formatLocaleDateTime(doctorStatus?.lastRunAt, { + fallback: "Never", + })} + + + + ${changeLabel.text} + +
        + ` + : null} ${ showDoctorStaleBanner ? html` @@ -376,7 +378,7 @@ export const DoctorTab = ({ isActive = false }) => { setSelectedRunFilter(String(run.id || ""))} > Run #${run.id} - + ${markers.map( (marker) => html` { const res = await authFetch( `/api/doctor/findings/${encodeURIComponent(String(cardId || ""))}/fix`, @@ -271,6 +272,7 @@ export const sendDoctorCardFix = async ({ sessionId: String(sessionId || ""), replyChannel: String(replyChannel || ""), replyTo: String(replyTo || ""), + prompt: String(prompt || ""), }), }, ); diff --git a/lib/server.js b/lib/server.js index ee0dc2a2..2f91c930 100644 --- a/lib/server.js +++ b/lib/server.js @@ -43,6 +43,7 @@ const { getSessionDetail, getSessionTimeSeries, } = require("./server/db/usage"); +const topicRegistry = require("./server/topic-registry"); const { initDoctorDb, listDoctorRuns, @@ -239,6 +240,7 @@ registerSystemRoutes({ onExpectedGatewayRestart: () => watchdog.onExpectedRestart(), OPENCLAW_DIR: constants.OPENCLAW_DIR, restartRequiredState, + topicRegistry, }); registerBrowseRoutes({ app, diff --git a/lib/server/doctor/service.js b/lib/server/doctor/service.js index 45d889a5..6f102a7c 100644 --- a/lib/server/doctor/service.js +++ b/lib/server/doctor/service.js @@ -331,12 +331,13 @@ const createDoctorService = ({ sessionId = "", replyChannel = "", replyTo = "", + prompt = "", } = {}) => { const card = getDoctorCard(cardId); if (!card) throw new Error("Doctor card not found"); - const prompt = String(card.fixPrompt || "").trim(); - if (!prompt) throw new Error("Doctor card does not include a fix prompt"); - let command = `agent --agent main --message ${shellEscapeArg(prompt)}`; + const resolvedPrompt = String(prompt || card.fixPrompt || "").trim(); + if (!resolvedPrompt) throw new Error("Doctor card does not include a fix prompt"); + let command = `agent --agent main --message ${shellEscapeArg(resolvedPrompt)}`; const trimmedSessionId = String(sessionId || "").trim(); const trimmedReplyChannel = String(replyChannel || "").trim(); const trimmedReplyTo = String(replyTo || "").trim(); diff --git a/lib/server/routes/doctor.js b/lib/server/routes/doctor.js index 03b01e1c..08532ef3 100644 --- a/lib/server/routes/doctor.js +++ b/lib/server/routes/doctor.js @@ -109,6 +109,7 @@ const registerDoctorRoutes = ({ app, requireAuth, doctorService }) => { sessionId: req.body?.sessionId, replyChannel: req.body?.replyChannel, replyTo: req.body?.replyTo, + prompt: req.body?.prompt, }); return res.json(result); } catch (error) { diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index c5e9a9a6..e3e961b6 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -20,6 +20,7 @@ const registerSystemRoutes = ({ onExpectedGatewayRestart, OPENCLAW_DIR, restartRequiredState, + topicRegistry, }) => { let envRestartPending = false; const kEnvVarsReservedForUserInput = new Set([ @@ -73,6 +74,19 @@ const registerSystemRoutes = ({ if (telegramMatch) { return `Telegram ${telegramMatch[1]}`; } + const telegramTopicMatch = key.match(/:telegram:group:([^:]+):topic:([^:]+)$/); + if (telegramTopicMatch) { + const [, groupId, topicId] = telegramTopicMatch; + let groupEntry = null; + try { + groupEntry = topicRegistry?.getGroup?.(groupId) || null; + } catch {} + const groupName = String(groupEntry?.name || "").trim(); + const topicName = String(groupEntry?.topics?.[topicId]?.name || "").trim(); + if (groupName && topicName) return `Telegram ${groupName} · ${topicName}`; + if (topicName) return `Telegram Topic ${topicName}`; + return `Telegram Topic ${topicId}`; + } const directMatch = key.match(/:direct:([^:]+)$/); if (directMatch) { return `Direct ${directMatch[1]}`; diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 153068b6..6cfbbcb6 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -205,6 +205,7 @@ describe("frontend/api", () => { sessionId: "session-123", replyChannel: "telegram", replyTo: "1050", + prompt: "Use a more focused fix request", }); expect(global.fetch).toHaveBeenCalledWith( @@ -215,6 +216,7 @@ describe("frontend/api", () => { sessionId: "session-123", replyChannel: "telegram", replyTo: "1050", + prompt: "Use a more focused fix request", }), headers: expect.any(Headers), }), diff --git a/tests/frontend/doctor-helpers.test.js b/tests/frontend/doctor-helpers.test.js index fa31078e..d50943ba 100644 --- a/tests/frontend/doctor-helpers.test.js +++ b/tests/frontend/doctor-helpers.test.js @@ -109,58 +109,36 @@ describe("frontend/doctor helpers", () => { expect(helpers.getDoctorRunPillDetail({ status: "completed", cardCount: 0 })).toBe( "No findings", ); - expect(helpers.getDoctorDriftRisk({ hasBaseline: false })).toEqual({ - label: "Unknown", - tone: "neutral", - detail: "No prior baseline yet", + expect(helpers.getDoctorChangeLabel({ changedFilesCount: 0 })).toEqual({ + text: "No changes since last run", + meaningful: false, }); expect( - helpers.getDoctorDriftRisk({ - hasBaseline: true, - baselineSource: "last_run", - deltaScore: 0, - changedFilesCount: 0, - }), - ).toEqual({ - label: "Low", - tone: "info", - detail: "No detected changes", - }); - expect( - helpers.getDoctorDriftRisk({ - hasBaseline: true, - baselineSource: "initial_install", - deltaScore: 0, - changedFilesCount: 0, + helpers.getDoctorChangeLabel({ + changedFilesCount: 2, + hasMeaningfulChanges: false, }), ).toEqual({ - label: "Low", - tone: "info", - detail: "Compared to initial install", + text: "2 changes since last run", + meaningful: false, }); expect( - helpers.getDoctorDriftRisk({ - hasBaseline: true, - baselineSource: "last_run", - deltaScore: 5, + helpers.getDoctorChangeLabel({ changedFilesCount: 3, + hasMeaningfulChanges: true, }), ).toEqual({ - label: "Moderate", - tone: "warning", - detail: "3 changed files", + text: "3 changes since last run", + meaningful: true, }); expect( - helpers.getDoctorDriftRisk({ - hasBaseline: true, - baselineSource: "last_run", - deltaScore: 9, - changedFilesCount: 2, + helpers.getDoctorChangeLabel({ + changedFilesCount: 1, + hasMeaningfulChanges: false, }), ).toEqual({ - label: "High", - tone: "danger", - detail: "2 changed files", + text: "1 change since last run", + meaningful: false, }); expect(helpers.getDoctorStatusTone("fixed")).toBe("success"); expect(helpers.buildDoctorStatusFilterOptions()).toEqual([ diff --git a/tests/server/routes-doctor.test.js b/tests/server/routes-doctor.test.js index 1b81b079..221ffc07 100644 --- a/tests/server/routes-doctor.test.js +++ b/tests/server/routes-doctor.test.js @@ -176,6 +176,7 @@ describe("server/routes/doctor", () => { sessionId: "session-123", replyChannel: "telegram", replyTo: "1050", + prompt: "Use the safer prompt", }); expect(res.status).toBe(200); @@ -184,6 +185,7 @@ describe("server/routes/doctor", () => { sessionId: "session-123", replyChannel: "telegram", replyTo: "1050", + prompt: "Use the safer prompt", }); expect(res.body.ok).toBe(true); }); diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index 400c612c..1556abe3 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -58,6 +58,9 @@ const createSystemDeps = () => { clearRequired: vi.fn(), markRestartComplete: vi.fn(), }, + topicRegistry: { + getGroup: vi.fn(() => null), + }, OPENCLAW_DIR: "/tmp/openclaw", }; return deps; @@ -364,6 +367,16 @@ describe("server/routes/system", () => { it("hides internal hook, cron, and doctor sessions from GET /api/agent/sessions", async () => { const deps = createSystemDeps(); + deps.topicRegistry.getGroup.mockImplementation((groupId) => + String(groupId) === "-1003709908795" + ? { + name: "AlphaClaw", + topics: { + "4011": { name: "Rosebud" }, + }, + } + : null, + ); deps.clawCmd.mockResolvedValue({ ok: true, stdout: JSON.stringify({ @@ -377,6 +390,11 @@ describe("server/routes/system", () => { sessionId: "", updatedAt: 6, }, + { + key: "agent:main:telegram:group:-1003709908795:topic:4011", + sessionId: "topic-session", + updatedAt: 5, + }, ], }), }); @@ -403,6 +421,14 @@ describe("server/routes/system", () => { replyChannel: "telegram", replyTo: "1050", }, + { + key: "agent:main:telegram:group:-1003709908795:topic:4011", + sessionId: "topic-session", + updatedAt: 5, + label: "Telegram AlphaClaw · Rosebud", + replyChannel: "", + replyTo: "", + }, ]); }); }); From 29021d748439e0ab4df664ee29db664860ef3b5f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 19:00:21 -0800 Subject: [PATCH 101/417] 0.4.6-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2160693b..a3e2ac09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.0", + "version": "0.4.6-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.0", + "version": "0.4.6-beta.1", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index d0429d99..40bb04eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.0", + "version": "0.4.6-beta.1", "publishConfig": { "access": "public" }, From 03fcd28aa26e39311905d375fe85f54fd052baea Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 5 Mar 2026 19:56:06 -0800 Subject: [PATCH 102/417] Improve Doctor heuristics, centralize localStorage keys, share session picker - Workspace fingerprint now stores file sizes and scores modified content files by byte delta (small fix = low score, major rewrite = full weight), reducing false "meaningful changes" signals from Doctor-applied fixes. - Drop yellow highlight from change label; always neutral gray. - Centralize all localStorage keys in lib/public/js/lib/storage-keys.js with consistent alphaclaw.. naming; remove legacy keys. - Extract useAgentSessions hook with caching and last-selection memory, shared by Doctor fix modal and Gmail setup wizard. - Pass previously dismissed/fixed findings to the Doctor prompt so the model avoids re-suggesting resolved issues. - Fix missing await on onComplete in fix modal that prevented the card list from reflecting the "fixed" status before the modal closed. --- AGENTS.md | 6 + .../js/components/doctor/fix-card-modal.js | 65 ++------- lib/public/js/components/doctor/helpers.js | 10 +- lib/public/js/components/doctor/index.js | 6 +- lib/public/js/components/file-tree.js | 2 +- .../js/components/file-viewer/constants.js | 7 +- .../js/components/file-viewer/storage.js | 5 +- .../components/google/gmail-setup-wizard.js | 69 +++------- .../components/google/gmail-watch-toggle.js | 5 +- .../onboarding/use-welcome-storage.js | 3 +- .../js/components/telegram-workspace/index.js | 7 +- lib/public/js/hooks/useAgentSessions.js | 128 ++++++++++++++++++ lib/public/js/lib/browse-draft-state.js | 22 ++- lib/public/js/lib/storage-keys.js | 28 ++++ lib/public/js/lib/ui-settings.js | 4 +- lib/server/doctor/prompt.js | 18 ++- lib/server/doctor/service.js | 9 ++ lib/server/doctor/workspace-fingerprint.js | 52 ++++++- tests/frontend/browse-draft-state.test.js | 12 +- tests/frontend/doctor-helpers.test.js | 40 ++---- 20 files changed, 312 insertions(+), 186 deletions(-) create mode 100644 lib/public/js/hooks/useAgentSessions.js create mode 100644 lib/public/js/lib/storage-keys.js diff --git a/AGENTS.md b/AGENTS.md index 916fc21b..c6a894c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,4 +183,10 @@ Use these conventions for all UI work under `lib/public/js` and `lib/public/css` - Add new formatter helpers to `lib/public/js/lib/format.js` when the behavior is cross-feature and likely to be reused; keep feature-specific transforms local to the feature folder. - Avoid wrapper pass-through helpers that only rename a global formatter without adding feature-specific behavior. +### localStorage keys + +- All standalone `localStorage` keys are defined in `lib/public/js/lib/storage-keys.js`. Import keys from this file — never define raw localStorage key strings inline in components. +- Use the naming convention `alphaclaw..` for new keys (e.g. `alphaclaw.doctor.lastSessionKey`). +- Keys that live inside the `alphaclaw.ui.settings` JSON blob (e.g. `browseLastPath`, `doctorWarningDismissedUntilMs`) are sub-keys, not standalone localStorage entries — those stay in their consuming file. + For inconsistencies tracking and DRY opportunities, see `lib/setup/core-prompts/UI-DRY-OPPORTUNITIES.md`. diff --git a/lib/public/js/components/doctor/fix-card-modal.js b/lib/public/js/components/doctor/fix-card-modal.js index 115f4b5c..9251909f 100644 --- a/lib/public/js/components/doctor/fix-card-modal.js +++ b/lib/public/js/components/doctor/fix-card-modal.js @@ -1,16 +1,16 @@ import { h } from "https://esm.sh/preact"; -import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import { useEffect, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { ModalShell } from "../modal-shell.js"; import { ActionButton } from "../action-button.js"; import { Badge } from "../badge.js"; import { - fetchAgentSessions, sendDoctorCardFix, updateDoctorCardStatus, } from "../../lib/api.js"; import { showToast } from "../toast.js"; import { getDoctorPriorityTone } from "./helpers.js"; +import { useAgentSessions } from "../../hooks/useAgentSessions.js"; const html = htm.bind(h); @@ -20,11 +20,16 @@ export const DoctorFixCardModal = ({ onClose = () => {}, onComplete = () => {}, }) => { - const [sessions, setSessions] = useState([]); - const [selectedSessionKey, setSelectedSessionKey] = useState(""); - const [loadingSessions, setLoadingSessions] = useState(false); + const { + sessions, + selectedSessionKey, + setSelectedSessionKey, + selectedSession, + loading: loadingSessions, + error: loadError, + } = useAgentSessions({ enabled: visible }); + const [sending, setSending] = useState(false); - const [loadError, setLoadError] = useState(""); const [promptText, setPromptText] = useState(""); useEffect(() => { @@ -32,52 +37,6 @@ export const DoctorFixCardModal = ({ setPromptText(String(card?.fixPrompt || "")); }, [visible, card?.fixPrompt, card?.id]); - useEffect(() => { - if (!visible) return; - let active = true; - const loadSessions = async () => { - try { - setLoadingSessions(true); - setLoadError(""); - const data = await fetchAgentSessions(); - if (!active) return; - const nextSessions = Array.isArray(data?.sessions) ? data.sessions : []; - setSessions(nextSessions); - const preferredSession = - nextSessions.find((sessionRow) => { - const key = String(sessionRow?.key || "").toLowerCase(); - return key === "agent:main:main"; - }) || - nextSessions.find((sessionRow) => { - const key = String(sessionRow?.key || "").toLowerCase(); - return key.includes(":direct:") || key.includes(":group:"); - }) || - nextSessions[0] || - null; - setSelectedSessionKey(String(preferredSession?.key || "")); - } catch (error) { - if (!active) return; - setSessions([]); - setSelectedSessionKey(""); - setLoadError(error.message || "Could not load agent sessions"); - } finally { - if (active) setLoadingSessions(false); - } - }; - loadSessions(); - return () => { - active = false; - }; - }, [visible, card?.id]); - - const selectedSession = useMemo( - () => - sessions.find( - (sessionRow) => String(sessionRow?.key || "") === selectedSessionKey, - ) || null, - [sessions, selectedSessionKey], - ); - const handleSend = async () => { if (!card?.id || sending) return; try { @@ -98,7 +57,7 @@ export const DoctorFixCardModal = ({ "warning", ); } - onComplete(); + await onComplete(); onClose(); } catch (error) { showToast(error.message || "Could not send Doctor fix request", "error"); diff --git a/lib/public/js/components/doctor/helpers.js b/lib/public/js/components/doctor/helpers.js index ae873eb4..10549fbe 100644 --- a/lib/public/js/components/doctor/helpers.js +++ b/lib/public/js/components/doctor/helpers.js @@ -83,14 +83,8 @@ export const getDoctorWarningMessage = (doctorStatus = null) => { export const getDoctorChangeLabel = (changeSummary = null) => { const changedFilesCount = Number(changeSummary?.changedFilesCount || 0); - const hasMeaningfulChanges = !!changeSummary?.hasMeaningfulChanges; - if (changedFilesCount === 0) { - return { text: "No changes since last run", meaningful: false }; - } - return { - text: `${changedFilesCount} change${changedFilesCount === 1 ? "" : "s"} since last run`, - meaningful: hasMeaningfulChanges, - }; + if (changedFilesCount === 0) return "No changes since last run"; + return `${changedFilesCount} change${changedFilesCount === 1 ? "" : "s"} since last run`; }; export const getDoctorRunPillDetail = (run = null) => { diff --git a/lib/public/js/components/doctor/index.js b/lib/public/js/components/doctor/index.js index 242e3f2f..79c2b1d3 100644 --- a/lib/public/js/components/doctor/index.js +++ b/lib/public/js/components/doctor/index.js @@ -327,10 +327,8 @@ export const DoctorTab = ({ isActive = false }) => { })} - - ${changeLabel.text} + + ${changeLabel}
        ` diff --git a/lib/public/js/components/file-tree.js b/lib/public/js/components/file-tree.js index 662fa2b9..8c47d8d2 100644 --- a/lib/public/js/components/file-tree.js +++ b/lib/public/js/components/file-tree.js @@ -52,7 +52,7 @@ const kTreeIndentPx = 9; const kFolderBasePaddingPx = 10; const kFileBasePaddingPx = 14; const kTreeRefreshIntervalMs = 5000; -const kExpandedFoldersStorageKey = "alphaclaw.browse.expandedFolders"; +import { kExpandedFoldersStorageKey } from "../lib/storage-keys.js"; const readStoredExpandedPaths = () => { try { diff --git a/lib/public/js/components/file-viewer/constants.js b/lib/public/js/components/file-viewer/constants.js index 96cbcb65..705804d0 100644 --- a/lib/public/js/components/file-viewer/constants.js +++ b/lib/public/js/components/file-viewer/constants.js @@ -1,6 +1,7 @@ -export const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode"; -export const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode"; -export const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath"; +export { + kFileViewerModeStorageKey, + kEditorSelectionStorageKey, +} from "../../lib/storage-keys.js"; export const kLoadingIndicatorDelayMs = 1000; export const kFileRefreshIntervalMs = 5000; export const kSqlitePageSize = 50; diff --git a/lib/public/js/components/file-viewer/storage.js b/lib/public/js/components/file-viewer/storage.js index da32b0fb..17b8cc6d 100644 --- a/lib/public/js/components/file-viewer/storage.js +++ b/lib/public/js/components/file-viewer/storage.js @@ -1,15 +1,12 @@ import { kEditorSelectionStorageKey, kFileViewerModeStorageKey, - kLegacyFileViewerModeStorageKey, } from "./constants.js"; export const readStoredFileViewerMode = () => { try { const storedMode = String( - window.localStorage.getItem(kFileViewerModeStorageKey) || - window.localStorage.getItem(kLegacyFileViewerModeStorageKey) || - "", + window.localStorage.getItem(kFileViewerModeStorageKey) || "", ).trim(); return storedMode === "preview" ? "preview" : "edit"; } catch { diff --git a/lib/public/js/components/google/gmail-setup-wizard.js b/lib/public/js/components/google/gmail-setup-wizard.js index b4bab3ca..5a322efd 100644 --- a/lib/public/js/components/google/gmail-setup-wizard.js +++ b/lib/public/js/components/google/gmail-setup-wizard.js @@ -1,12 +1,13 @@ import { h } from "https://esm.sh/preact"; -import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; +import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; import { CloseIcon } from "../icons.js"; import { ActionButton } from "../action-button.js"; -import { fetchAgentSessions, sendAgentMessage } from "../../lib/api.js"; +import { sendAgentMessage } from "../../lib/api.js"; import { showToast } from "../toast.js"; +import { useAgentSessions } from "../../hooks/useAgentSessions.js"; const html = htm.bind(h); @@ -45,6 +46,11 @@ const kStepTitles = [ const kTotalSteps = kStepTitles.length; const kNoSessionSelectedValue = "__none__"; +const kDirectOrGroupFilter = (sessionRow) => { + const key = String(sessionRow?.key || "").toLowerCase(); + return key.includes(":direct:") || key.includes(":group:"); +}; + const renderCommandBlock = (command = "", onCopy = () => {}) => html`
         {
             if (!visible) return;
        @@ -94,41 +104,6 @@ export const GmailSetupWizard = ({
             setWatchEnabled(false);
             setSendingToAgent(false);
             setAgentMessageSent(false);
        -    setAgentSessions([]);
        -    setSelectedSessionKey("");
        -    setLoadingAgentSessions(false);
        -    setAgentSessionsError("");
        -  }, [visible, account?.id]);
        -
        -  useEffect(() => {
        -    if (!visible) return;
        -    let active = true;
        -    const loadAgentSessions = async () => {
        -      try {
        -        setLoadingAgentSessions(true);
        -        setAgentSessionsError("");
        -        const data = await fetchAgentSessions();
        -        if (!active) return;
        -        const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
        -        setAgentSessions(sessions);
        -        const defaultSession = sessions.find((sessionRow) => {
        -          const key = String(sessionRow?.key || "").toLowerCase();
        -          return key.includes(":direct:") || key.includes(":group:");
        -        });
        -        setSelectedSessionKey((currentKey) => currentKey || String(defaultSession?.key || ""));
        -      } catch (err) {
        -        if (!active) return;
        -        setAgentSessions([]);
        -        setSelectedSessionKey("");
        -        setAgentSessionsError(err.message || "Could not load sessions");
        -      } finally {
        -        if (active) setLoadingAgentSessions(false);
        -      }
        -    };
        -    loadAgentSessions();
        -    return () => {
        -      active = false;
        -    };
           }, [visible, account?.id]);
         
           const commands = clientConfig?.commands || null;
        @@ -150,23 +125,15 @@ export const GmailSetupWizard = ({
             }
             return true;
           }, [needsProjectId, projectIdInput]);
        -  const selectableAgentSessions = useMemo(
        -    () =>
        -      agentSessions.filter((sessionRow) => {
        -        const key = String(sessionRow?.key || "").toLowerCase();
        -        return key.includes(":direct:") || key.includes(":group:");
        -      }),
        -    [agentSessions],
        -  );
         
        -  const handleCopy = async (value) => {
        +  const handleCopy = useCallback(async (value) => {
             const ok = await copyText(value);
             if (ok) {
               showToast("Copied to clipboard", "success");
               return;
             }
             showToast("Could not copy text", "error");
        -  };
        +  }, []);
         
           const handleFinish = async () => {
             try {
        diff --git a/lib/public/js/components/google/gmail-watch-toggle.js b/lib/public/js/components/google/gmail-watch-toggle.js
        index d2d27685..39a720d0 100644
        --- a/lib/public/js/components/google/gmail-watch-toggle.js
        +++ b/lib/public/js/components/google/gmail-watch-toggle.js
        @@ -7,7 +7,10 @@ import { InfoTooltip } from "../info-tooltip.js";
         const html = htm.bind(h);
         
         const resolveWatchState = ({ watchStatus, busy = false }) => {
        -  if (busy) return { label: "Starting", tone: "warning" };
        +  if (busy) {
        +    const label = watchStatus?.enabled ? "Stopping" : "Starting";
        +    return { label, tone: "warning" };
        +  }
           if (!watchStatus?.enabled) return { label: "Stopped", tone: "neutral" };
           if (watchStatus.enabled && !watchStatus.running)
             return { label: "Error", tone: "danger" };
        diff --git a/lib/public/js/components/onboarding/use-welcome-storage.js b/lib/public/js/components/onboarding/use-welcome-storage.js
        index b831dac9..8d97f42d 100644
        --- a/lib/public/js/components/onboarding/use-welcome-storage.js
        +++ b/lib/public/js/components/onboarding/use-welcome-storage.js
        @@ -1,6 +1,7 @@
         import { useEffect, useState } from "https://esm.sh/preact/hooks";
         
        -export const kOnboardingStorageKey = "openclaw_setup";
        +import { kOnboardingStorageKey } from "../../lib/storage-keys.js";
        +export { kOnboardingStorageKey };
         export const kOnboardingStepKey = "_step";
         export const kPairingChannelKey = "_pairingChannel";
         export const kOnboardingSetupErrorKey = "_lastSetupError";
        diff --git a/lib/public/js/components/telegram-workspace/index.js b/lib/public/js/components/telegram-workspace/index.js
        index dbd3d1d9..a07bc0b7 100644
        --- a/lib/public/js/components/telegram-workspace/index.js
        +++ b/lib/public/js/components/telegram-workspace/index.js
        @@ -23,8 +23,11 @@ const kSteps = [
           { id: "summary", label: "Summary" },
         ];
         
        -const kTelegramWorkspaceStorageKey = "telegram-workspace-state-v1";
        -const kTelegramWorkspaceCacheKey = "telegram-workspace-cache-v1";
        +import {
        +  kTelegramWorkspaceStorageKey,
        +  kTelegramWorkspaceCacheKey,
        +} from "../../lib/storage-keys.js";
        +
         const loadTelegramWorkspaceState = () => {
           try {
             const raw = window.localStorage.getItem(kTelegramWorkspaceStorageKey);
        diff --git a/lib/public/js/hooks/useAgentSessions.js b/lib/public/js/hooks/useAgentSessions.js
        new file mode 100644
        index 00000000..6dae8dfa
        --- /dev/null
        +++ b/lib/public/js/hooks/useAgentSessions.js
        @@ -0,0 +1,128 @@
        +import { useState, useEffect, useMemo, useCallback } from "https://esm.sh/preact/hooks";
        +import { fetchAgentSessions } from "../lib/api.js";
        +import {
        +  kAgentSessionsCacheKey,
        +  kAgentLastSessionKey,
        +} from "../lib/storage-keys.js";
        +
        +const readCachedSessions = () => {
        +  try {
        +    const raw = localStorage.getItem(kAgentSessionsCacheKey);
        +    if (!raw) return [];
        +    const parsed = JSON.parse(raw);
        +    return Array.isArray(parsed) ? parsed : [];
        +  } catch {
        +    return [];
        +  }
        +};
        +
        +const writeCachedSessions = (sessions) => {
        +  try {
        +    localStorage.setItem(kAgentSessionsCacheKey, JSON.stringify(sessions));
        +  } catch {}
        +};
        +
        +const readLastSessionKey = () => {
        +  try {
        +    return localStorage.getItem(kAgentLastSessionKey) || "";
        +  } catch {
        +    return "";
        +  }
        +};
        +
        +const writeLastSessionKey = (key) => {
        +  try {
        +    localStorage.setItem(kAgentLastSessionKey, String(key || ""));
        +  } catch {}
        +};
        +
        +const pickPreferredSession = (sessions, lastKey) => {
        +  if (lastKey) {
        +    const lastMatch = sessions.find((row) => String(row?.key || "") === lastKey);
        +    if (lastMatch) return lastMatch;
        +  }
        +  return (
        +    sessions.find((row) => String(row?.key || "").toLowerCase() === "agent:main:main") ||
        +    sessions.find((row) => {
        +      const key = String(row?.key || "").toLowerCase();
        +      return key.includes(":direct:") || key.includes(":group:");
        +    }) ||
        +    sessions[0] ||
        +    null
        +  );
        +};
        +
        +/**
        + * Shared hook for agent session selection with localStorage caching.
        + *
        + * @param {object} options
        + * @param {boolean} options.enabled - Whether to load sessions (tie to modal visibility, etc.)
        + * @param {(sessions: Array) => Array} [options.filter] - Optional filter applied to the session list before exposing it.
        + * @returns {{ sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading, error }}
        + */
        +export const useAgentSessions = ({ enabled = false, filter } = {}) => {
        +  const [allSessions, setAllSessions] = useState([]);
        +  const [selectedSessionKey, setSelectedSessionKeyState] = useState("");
        +  const [loading, setLoading] = useState(false);
        +  const [error, setError] = useState("");
        +
        +  const setSelectedSessionKey = useCallback((key) => {
        +    const normalized = String(key || "");
        +    setSelectedSessionKeyState(normalized);
        +    writeLastSessionKey(normalized);
        +  }, []);
        +
        +  useEffect(() => {
        +    if (!enabled) return;
        +    let active = true;
        +
        +    const cached = readCachedSessions();
        +    const lastKey = readLastSessionKey();
        +    if (cached.length > 0) {
        +      setAllSessions(cached);
        +      const preferred = pickPreferredSession(cached, lastKey);
        +      setSelectedSessionKeyState(String(preferred?.key || ""));
        +    }
        +
        +    const load = async () => {
        +      try {
        +        if (cached.length === 0) setLoading(true);
        +        setError("");
        +        const data = await fetchAgentSessions();
        +        if (!active) return;
        +        const nextSessions = Array.isArray(data?.sessions) ? data.sessions : [];
        +        setAllSessions(nextSessions);
        +        writeCachedSessions(nextSessions);
        +        if (cached.length === 0 || !lastKey) {
        +          const preferred = pickPreferredSession(nextSessions, lastKey);
        +          setSelectedSessionKeyState(String(preferred?.key || ""));
        +        }
        +      } catch (err) {
        +        if (!active) return;
        +        if (cached.length === 0) {
        +          setAllSessions([]);
        +          setSelectedSessionKeyState("");
        +          setError(err.message || "Could not load agent sessions");
        +        }
        +      } finally {
        +        if (active) setLoading(false);
        +      }
        +    };
        +    load();
        +    return () => {
        +      active = false;
        +    };
        +  }, [enabled]);
        +
        +  const sessions = useMemo(
        +    () => (filter ? allSessions.filter(filter) : allSessions),
        +    [allSessions, filter],
        +  );
        +
        +  const selectedSession = useMemo(
        +    () => sessions.find((row) => String(row?.key || "") === selectedSessionKey) || null,
        +    [sessions, selectedSessionKey],
        +  );
        +
        +  return { sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading, error };
        +};
        diff --git a/lib/public/js/lib/browse-draft-state.js b/lib/public/js/lib/browse-draft-state.js
        index 7dd28062..935c8821 100644
        --- a/lib/public/js/lib/browse-draft-state.js
        +++ b/lib/public/js/lib/browse-draft-state.js
        @@ -1,20 +1,21 @@
        -export const kFileDraftStorageKeyPrefix = "alphaclaw.browse.draft.";
        -export const kLegacyFileDraftStorageKeyPrefix = "alphaclawBrowseDraft:";
        -export const kDraftIndexStorageKey = "alphaclaw.draftIndex";
        +import {
        +  kFileDraftStorageKeyPrefix,
        +  kDraftIndexStorageKey,
        +} from "./storage-keys.js";
        +
        +export { kFileDraftStorageKeyPrefix, kDraftIndexStorageKey };
         export const kDraftIndexChangedEventName = "alphaclaw:browse-draft-index-changed";
         
         const getStorage = (storage) => storage || window.localStorage;
         
        -export const getFileDraftStorageKey = (filePath, useLegacyPrefix = false) =>
        -  `${useLegacyPrefix ? kLegacyFileDraftStorageKeyPrefix : kFileDraftStorageKeyPrefix}${String(filePath || "").trim()}`;
        +export const getFileDraftStorageKey = (filePath) =>
        +  `${kFileDraftStorageKeyPrefix}${String(filePath || "").trim()}`;
         
         export const readStoredFileDraft = (filePath, storage) => {
           try {
             if (!filePath) return "";
             const localStorage = getStorage(storage);
        -    const draft =
        -      localStorage.getItem(getFileDraftStorageKey(filePath)) ||
        -      localStorage.getItem(getFileDraftStorageKey(filePath, true));
        +    const draft = localStorage.getItem(getFileDraftStorageKey(filePath));
             return typeof draft === "string" ? draft : "";
           } catch {
             return "";
        @@ -34,7 +35,6 @@ export const clearStoredFileDraft = (filePath, storage) => {
             if (!filePath) return;
             const localStorage = getStorage(storage);
             localStorage.removeItem(getFileDraftStorageKey(filePath));
        -    localStorage.removeItem(getFileDraftStorageKey(filePath, true));
           } catch {}
         };
         
        @@ -94,10 +94,6 @@ export const readStoredDraftPaths = (storage) => {
                 const path = key.slice(kFileDraftStorageKeyPrefix.length).trim();
                 if (path) nextDraftPaths.add(path);
               }
        -      if (key.startsWith(kLegacyFileDraftStorageKeyPrefix)) {
        -        const path = key.slice(kLegacyFileDraftStorageKeyPrefix.length).trim();
        -        if (path) nextDraftPaths.add(path);
        -      }
             }
             if (nextDraftPaths.size > 0) {
               writeDraftIndex(nextDraftPaths, { storage: localStorage });
        diff --git a/lib/public/js/lib/storage-keys.js b/lib/public/js/lib/storage-keys.js
        new file mode 100644
        index 00000000..c854f94c
        --- /dev/null
        +++ b/lib/public/js/lib/storage-keys.js
        @@ -0,0 +1,28 @@
        +// Centralized localStorage key registry.
        +// All standalone localStorage keys used by the Setup UI should be defined here
        +// so they stay discoverable, consistently prefixed, and free of collisions.
        +//
        +// Naming convention: "alphaclaw.."
        +
        +// --- UI settings (single JSON blob containing sub-keys) ---
        +export const kUiSettingsStorageKey = "alphaclaw.ui.settings";
        +
        +// --- Browse / file viewer ---
        +export const kFileViewerModeStorageKey = "alphaclaw.browse.viewerMode";
        +export const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelection";
        +export const kExpandedFoldersStorageKey = "alphaclaw.browse.expandedFolders";
        +
        +// --- Browse / drafts ---
        +export const kFileDraftStorageKeyPrefix = "alphaclaw.browse.draft.";
        +export const kDraftIndexStorageKey = "alphaclaw.browse.draftIndex";
        +
        +// --- Onboarding ---
        +export const kOnboardingStorageKey = "alphaclaw.onboarding.state";
        +
        +// --- Telegram workspace ---
        +export const kTelegramWorkspaceStorageKey = "alphaclaw.telegram.workspaceState";
        +export const kTelegramWorkspaceCacheKey = "alphaclaw.telegram.workspaceCache";
        +
        +// --- Agent sessions (shared across session pickers) ---
        +export const kAgentSessionsCacheKey = "alphaclaw.agent.sessionsCache";
        +export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey";
        diff --git a/lib/public/js/lib/ui-settings.js b/lib/public/js/lib/ui-settings.js
        index 6004a862..cae51c81 100644
        --- a/lib/public/js/lib/ui-settings.js
        +++ b/lib/public/js/lib/ui-settings.js
        @@ -1,4 +1,6 @@
        -export const kUiSettingsStorageKey = "alphaclaw.uiSettings";
        +import { kUiSettingsStorageKey } from "./storage-keys.js";
        +
        +export { kUiSettingsStorageKey };
         
         const parseSettings = (rawValue) => {
           if (!rawValue) return {};
        diff --git a/lib/server/doctor/prompt.js b/lib/server/doctor/prompt.js
        index ed8eed3f..341d0f9a 100644
        --- a/lib/server/doctor/prompt.js
        +++ b/lib/server/doctor/prompt.js
        @@ -1,11 +1,26 @@
         const renderList = (items = []) =>
           items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)";
         
        +const renderResolvedCards = (cards = []) => {
        +  if (!cards.length) return "";
        +  const lines = cards.map(
        +    (card) =>
        +      `- [${card.status}] ${card.title}` +
        +      (card.category ? ` (${card.category})` : ""),
        +  );
        +  return `
        +
        +Previously resolved findings (do not re-suggest these):
        +${lines.join("\n")}
        +`;
        +};
        +
         const buildDoctorPrompt = ({
           workspaceRoot = "",
           managedRoot = "",
           protectedPaths = [],
           lockedPaths = [],
        +  resolvedCards = [],
           promptVersion = "doctor-v1",
         }) => `
         You are AlphaClaw Doctor. Analyze this OpenClaw workspace for guidance drift, redundancy, misplacement, and cleanup opportunities.
        @@ -74,10 +89,11 @@ Return exactly this JSON shape:
           ]
         }
         
        -Constraints:
        +${renderResolvedCards(resolvedCards)}Constraints:
         - Maximum 12 cards
         - Use relative paths in evidence and targetPaths
         - Do not include duplicate cards
        +- Do not re-suggest findings that appear in the "Previously resolved" list above
         - Do not create cards for healthy default-template behavior
         - Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure
         - If there are no meaningful findings, return an empty cards array
        diff --git a/lib/server/doctor/service.js b/lib/server/doctor/service.js
        index 6f102a7c..f0dd31c1 100644
        --- a/lib/server/doctor/service.js
        +++ b/lib/server/doctor/service.js
        @@ -156,11 +156,20 @@ const createDoctorService = ({
         
           const executeDoctorRun = async (runId) => {
             try {
        +      const allCards = listDoctorCards();
        +      const resolvedCards = allCards
        +        .filter((card) => card.status === "dismissed" || card.status === "fixed")
        +        .map((card) => ({
        +          status: card.status,
        +          title: card.title || "",
        +          category: card.category || "",
        +        }));
               const prompt = buildDoctorPrompt({
                 workspaceRoot,
                 managedRoot,
                 protectedPaths,
                 lockedPaths,
        +        resolvedCards,
                 promptVersion: kDoctorPromptVersion,
               });
               const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000;
        diff --git a/lib/server/doctor/workspace-fingerprint.js b/lib/server/doctor/workspace-fingerprint.js
        index cefb45d7..7f68395a 100644
        --- a/lib/server/doctor/workspace-fingerprint.js
        +++ b/lib/server/doctor/workspace-fingerprint.js
        @@ -4,6 +4,17 @@ const crypto = require("crypto");
         
         const kIgnoredDirectoryNames = new Set([".git", "node_modules"]);
         
        +const kContentFileExtensions = new Set([
        +  ".md", ".json", ".js", ".ts", ".jsx", ".tsx", ".yaml", ".yml",
        +  ".txt", ".sh", ".css", ".html", ".xml", ".toml", ".ini", ".cfg",
        +  ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h",
        +]);
        +
        +const isContentFile = (relativePath = "") => {
        +  const ext = path.extname(String(relativePath || "")).toLowerCase();
        +  return kContentFileExtensions.has(ext);
        +};
        +
         const hashFile = (filePath) => {
           const buffer = fs.readFileSync(filePath);
           return crypto.createHash("sha256").update(buffer).digest("hex");
        @@ -34,11 +45,21 @@ const buildWorkspaceManifest = (rootDir) => {
           const normalizedRootDir = path.resolve(String(rootDir || ""));
           const files = walkFiles(normalizedRootDir);
           return files.reduce((manifest, filePath) => {
        -    manifest[normalizeRelativePath(normalizedRootDir, filePath)] = hashFile(filePath);
        +    const stat = fs.statSync(filePath);
        +    manifest[normalizeRelativePath(normalizedRootDir, filePath)] = {
        +      hash: hashFile(filePath),
        +      size: stat.size,
        +    };
             return manifest;
           }, {});
         };
         
        +const getManifestEntryHash = (entry) =>
        +  typeof entry === "object" && entry !== null ? String(entry.hash || "") : String(entry || "");
        +
        +const getManifestEntrySize = (entry) =>
        +  typeof entry === "object" && entry !== null ? Number(entry.size || 0) : 0;
        +
         const computeWorkspaceFingerprintFromManifest = (manifest = {}) => {
           const hash = crypto.createHash("sha256");
           const entries = Object.entries(manifest).sort(([leftPath], [rightPath]) =>
        @@ -46,10 +67,10 @@ const computeWorkspaceFingerprintFromManifest = (manifest = {}) => {
           );
         
           hash.update("workspace-fingerprint-v1");
        -  for (const [relativePath, fileHash] of entries) {
        +  for (const [relativePath, entry] of entries) {
             hash.update(relativePath);
             hash.update("\0");
        -    hash.update(fileHash);
        +    hash.update(getManifestEntryHash(entry));
             hash.update("\0");
           }
         
        @@ -84,6 +105,20 @@ const getPathChangeWeight = (relativePath = "") => {
           return 1;
         };
         
        +const kByteDeltaSmallThreshold = 100;
        +const kByteDeltaSignificantThreshold = 500;
        +
        +const getModifiedFileScore = (relativePath, previousEntry, currentEntry) => {
        +  if (!isContentFile(relativePath)) return 1;
        +  const previousSize = getManifestEntrySize(previousEntry);
        +  const currentSize = getManifestEntrySize(currentEntry);
        +  if (!previousSize && !currentSize) return getPathChangeWeight(relativePath);
        +  const byteDelta = Math.abs(currentSize - previousSize);
        +  if (byteDelta < kByteDeltaSmallThreshold) return 1;
        +  if (byteDelta < kByteDeltaSignificantThreshold) return 2;
        +  return getPathChangeWeight(relativePath);
        +};
        +
         const calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} } = {}) => {
           const previousPaths = Object.keys(previousManifest);
           const currentPaths = Object.keys(currentManifest);
        @@ -100,19 +135,23 @@ const calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} }
           };
         
           for (const relativePath of allPaths) {
        -    const previousHash = previousManifest[relativePath] || "";
        -    const currentHash = currentManifest[relativePath] || "";
        +    const previousEntry = previousManifest[relativePath];
        +    const currentEntry = currentManifest[relativePath];
        +    const previousHash = getManifestEntryHash(previousEntry);
        +    const currentHash = getManifestEntryHash(currentEntry);
             if (!previousHash && currentHash) {
               changeSummary.addedFilesCount += 1;
        +      changeSummary.deltaScore += getPathChangeWeight(relativePath);
             } else if (previousHash && !currentHash) {
               changeSummary.removedFilesCount += 1;
        +      changeSummary.deltaScore += getPathChangeWeight(relativePath);
             } else if (previousHash !== currentHash) {
               changeSummary.modifiedFilesCount += 1;
        +      changeSummary.deltaScore += getModifiedFileScore(relativePath, previousEntry, currentEntry);
             } else {
               continue;
             }
             changeSummary.changedFilesCount += 1;
        -    changeSummary.deltaScore += getPathChangeWeight(relativePath);
             changeSummary.changedPaths.push(relativePath);
           }
         
        @@ -123,4 +162,5 @@ module.exports = {
           calculateWorkspaceDelta,
           computeWorkspaceFingerprintFromManifest,
           computeWorkspaceSnapshot,
        +  isContentFile,
         };
        diff --git a/tests/frontend/browse-draft-state.test.js b/tests/frontend/browse-draft-state.test.js
        index 0cf29e82..6fbf4225 100644
        --- a/tests/frontend/browse-draft-state.test.js
        +++ b/tests/frontend/browse-draft-state.test.js
        @@ -43,17 +43,17 @@ describe("frontend/browse-draft-state", () => {
             expect(dispatchEvent).toHaveBeenCalledTimes(3);
           });
         
        -  it("builds draft index from legacy per-file keys", async () => {
        +  it("builds draft index from per-file keys when no index exists", async () => {
             const storage = createStorage();
             const draftState = await loadDraftState();
        -    storage.setItem("alphaclawBrowseDraft:legacy/a.txt", "a");
        -    storage.setItem("alphaclaw.browse.draft.current/b.txt", "b");
        +    storage.setItem("alphaclaw.browse.draft.docs/a.txt", "a");
        +    storage.setItem("alphaclaw.browse.draft.src/b.txt", "b");
         
             const draftPaths = draftState.readStoredDraftPaths(storage);
         
        -    expect(Array.from(draftPaths).sort()).toEqual(["current/b.txt", "legacy/a.txt"]);
        -    const storedIndexRaw = storage.getItem("alphaclaw.draftIndex");
        +    expect(Array.from(draftPaths).sort()).toEqual(["docs/a.txt", "src/b.txt"]);
        +    const storedIndexRaw = storage.getItem("alphaclaw.browse.draftIndex");
             expect(storedIndexRaw).toBeTruthy();
        -    expect(JSON.parse(storedIndexRaw)).toEqual(["current/b.txt", "legacy/a.txt"]);
        +    expect(JSON.parse(storedIndexRaw)).toEqual(["docs/a.txt", "src/b.txt"]);
           });
         });
        diff --git a/tests/frontend/doctor-helpers.test.js b/tests/frontend/doctor-helpers.test.js
        index d50943ba..b82704ec 100644
        --- a/tests/frontend/doctor-helpers.test.js
        +++ b/tests/frontend/doctor-helpers.test.js
        @@ -109,37 +109,15 @@ describe("frontend/doctor helpers", () => {
             expect(helpers.getDoctorRunPillDetail({ status: "completed", cardCount: 0 })).toBe(
               "No findings",
             );
        -    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 0 })).toEqual({
        -      text: "No changes since last run",
        -      meaningful: false,
        -    });
        -    expect(
        -      helpers.getDoctorChangeLabel({
        -        changedFilesCount: 2,
        -        hasMeaningfulChanges: false,
        -      }),
        -    ).toEqual({
        -      text: "2 changes since last run",
        -      meaningful: false,
        -    });
        -    expect(
        -      helpers.getDoctorChangeLabel({
        -        changedFilesCount: 3,
        -        hasMeaningfulChanges: true,
        -      }),
        -    ).toEqual({
        -      text: "3 changes since last run",
        -      meaningful: true,
        -    });
        -    expect(
        -      helpers.getDoctorChangeLabel({
        -        changedFilesCount: 1,
        -        hasMeaningfulChanges: false,
        -      }),
        -    ).toEqual({
        -      text: "1 change since last run",
        -      meaningful: false,
        -    });
        +    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 0 })).toBe(
        +      "No changes since last run",
        +    );
        +    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 2 })).toBe(
        +      "2 changes since last run",
        +    );
        +    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 1 })).toBe(
        +      "1 change since last run",
        +    );
             expect(helpers.getDoctorStatusTone("fixed")).toBe("success");
             expect(helpers.buildDoctorStatusFilterOptions()).toEqual([
               { value: "open", label: "Open" },
        
        From d240e0a628e4c0e2fc4edd95e7d327847d480ecf Mon Sep 17 00:00:00 2001
        From: Chrys Bader 
        Date: Thu, 5 Mar 2026 19:56:10 -0800
        Subject: [PATCH 103/417] 0.4.6-beta.2
        
        ---
         package-lock.json | 4 ++--
         package.json      | 2 +-
         2 files changed, 3 insertions(+), 3 deletions(-)
        
        diff --git a/package-lock.json b/package-lock.json
        index a3e2ac09..97d2691b 100644
        --- a/package-lock.json
        +++ b/package-lock.json
        @@ -1,12 +1,12 @@
         {
           "name": "@chrysb/alphaclaw",
        -  "version": "0.4.6-beta.1",
        +  "version": "0.4.6-beta.2",
           "lockfileVersion": 3,
           "requires": true,
           "packages": {
             "": {
               "name": "@chrysb/alphaclaw",
        -      "version": "0.4.6-beta.1",
        +      "version": "0.4.6-beta.2",
               "license": "MIT",
               "dependencies": {
                 "express": "^4.21.0",
        diff --git a/package.json b/package.json
        index 40bb04eb..2cd3905c 100644
        --- a/package.json
        +++ b/package.json
        @@ -1,6 +1,6 @@
         {
           "name": "@chrysb/alphaclaw",
        -  "version": "0.4.6-beta.1",
        +  "version": "0.4.6-beta.2",
           "publishConfig": {
             "access": "public"
           },
        
        From 6d18d138af31ac8b4bc0cea5bc6e6bfda851ae87 Mon Sep 17 00:00:00 2001
        From: Chrys Bader 
        Date: Thu, 5 Mar 2026 20:57:43 -0800
        Subject: [PATCH 104/417] Add file-linked findings with inline previews,
         collapsible snippets, and independent scroll panes
        
        Doctor findings now link to specific file lines in the viewer with
        auto-scroll and highlight. Evidence sections render captured code
        snippets with staleness detection, collapsible beyond 5 lines with a
        gradient fade. The fix modal is simplified to just session picker and
        instructions. The Doctor prompt now explains OpenClaw's auto-injection
        of root .md files to prevent false orphan findings. App layout uses
        independent scroll panes so Doctor scroll position is preserved when
        navigating to the file viewer.
        ---
         lib/public/css/explorer.css                   |  11 +-
         lib/public/css/shell.css                      |  16 +-
         lib/public/css/theme.css                      |   4 +
         lib/public/js/app.js                          | 100 +++++----
         .../js/components/doctor/findings-list.js     | 202 ++++++++++++++++--
         .../js/components/doctor/fix-card-modal.js    |  28 +--
         lib/public/js/components/doctor/helpers.js    |  24 +--
         lib/public/js/components/doctor/index.js      |   4 +-
         .../components/file-viewer/editor-surface.js  |   1 +
         lib/public/js/components/file-viewer/index.js |   4 +
         .../use-editor-selection-restore.js           | 147 +++++++++++--
         .../components/file-viewer/use-file-viewer.js |   4 +
         lib/server/doctor/normalize.js                |  80 +++++--
         lib/server/doctor/prompt.js                   |  16 +-
         lib/server/doctor/service.js                  |  37 ++++
         tests/frontend/doctor-helpers.test.js         |   4 +-
         tests/server/doctor-normalize.test.js         |   4 +-
         17 files changed, 540 insertions(+), 146 deletions(-)
        
        diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css
        index 4a0a2fda..758a04f4 100644
        --- a/lib/public/css/explorer.css
        +++ b/lib/public/css/explorer.css
        @@ -1,9 +1,5 @@
         /* ── Browse/Explorer mode ─────────────────────── */
         
        -.app-content.browse-mode {
        -  padding: 0;
        -}
        -
         .sidebar-tabs {
           display: flex;
           align-items: center;
        @@ -875,6 +871,10 @@
           align-items: flex-start;
         }
         
        +.line-highlight-flash {
        +  color: #4ade80 !important;
        +}
        +
         .file-viewer-editor {
           width: 100%;
           min-height: 0;
        @@ -1608,7 +1608,4 @@
             display: none;
           }
         
        -  .app-content.browse-mode {
        -    padding: 0;
        -  }
         }
        diff --git a/lib/public/css/shell.css b/lib/public/css/shell.css
        index dbb6821d..52539380 100644
        --- a/lib/public/css/shell.css
        +++ b/lib/public/css/shell.css
        @@ -47,12 +47,22 @@
         .app-content {
           grid-column: 3;
           grid-row: 2;
        -  overflow-y: auto;
        -  padding: 24px 32px;
        +  overflow: hidden;
           position: relative;
           z-index: 1;
         }
         
        +.app-content-pane {
        +  position: absolute;
        +  inset: 0;
        +  overflow-y: auto;
        +  padding: 24px 32px;
        +}
        +
        +.app-content-pane.browse-pane {
        +  padding: 0;
        +}
        +
         /* ── Sidebar ───────────────────────────────────── */
         
         .app-sidebar {
        @@ -312,6 +322,8 @@
           .app-content {
             grid-column: 1;
             grid-row: 2;
        +  }
        +  .app-content-pane {
             padding: 0 14px 12px;
           }
         
        diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css
        index cf230526..bd74cfd1 100644
        --- a/lib/public/css/theme.css
        +++ b/lib/public/css/theme.css
        @@ -74,6 +74,10 @@ body::before {
           background: rgba(0, 0, 0, 0.12);
         }
         
        +.snippet-collapse-fade {
        +  background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.75) 70%);
        +}
        +
         /* Shared inset panel for "surface on surface" layouts. */
         .ac-surface-inset {
           border: 1px solid var(--panel-border-contrast);
        diff --git a/lib/public/js/app.js b/lib/public/js/app.js
        index 69c6bb1d..de5ff808 100644
        --- a/lib/public/js/app.js
        +++ b/lib/public/js/app.js
        @@ -477,6 +477,7 @@ const App = () => {
           const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
           const menuRef = useRef(null);
           const routeHistoryRef = useRef([]);
        +  const menuPaneRef = useRef(null);
           const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
             enabled: onboarded === true,
           });
        @@ -709,8 +710,12 @@ const App = () => {
               .map((segment) => encodeURIComponent(segment))
               .join("/");
             const baseRoute = encodedPath ? `/browse/${encodedPath}` : "/browse";
        -    if (view === "diff" && encodedPath) return `${baseRoute}?view=diff`;
        -    return baseRoute;
        +    const params = new URLSearchParams();
        +    if (view === "diff" && encodedPath) params.set("view", "diff");
        +    if (options.line) params.set("line", String(options.line));
        +    if (options.lineEnd) params.set("lineEnd", String(options.lineEnd));
        +    const query = params.toString();
        +    return query ? `${baseRoute}?${query}` : baseRoute;
           };
           const navigateToSubScreen = (screen) => {
             setLocation(`/${screen}`);
        @@ -767,7 +772,7 @@ const App = () => {
             setLocation(`/${kDefaultUiTab}`);
             setMobileSidebarOpen(false);
           };
        -  const handleAppContentScroll = (e) => {
        +  const handlePaneScroll = (e) => {
             const nextScrolled = e.currentTarget.scrollTop > 0;
             setMobileTopbarScrolled((currentScrolled) =>
               currentScrolled === nextScrolled ? currentScrolled : nextScrolled,
        @@ -820,11 +825,13 @@ const App = () => {
                 .join("/")
             : "";
           const activeBrowsePath = browsePreviewPath || selectedBrowsePath;
        +  const browseQueryParams = isBrowseRoute ? new URLSearchParams(browseRouteQuery) : null;
           const browseViewerMode =
        -    !browsePreviewPath &&
        -    new URLSearchParams(browseRouteQuery).get("view") === "diff"
        +    !browsePreviewPath && browseQueryParams?.get("view") === "diff"
               ? "diff"
               : "edit";
        +  const browseLineTarget = Number.parseInt(browseQueryParams?.get("line") || "", 10) || 0;
        +  const browseLineEndTarget = Number.parseInt(browseQueryParams?.get("lineEnd") || "", 10) || 0;
           const selectedNavId = isBrowseRoute
             ? "browse"
             : location === "/telegram"
        @@ -1017,33 +1024,18 @@ const App = () => {
                 onclick=${() => setMobileSidebarOpen(false)}
               />
         
        -      
        -
        - - - alphaclaw - -
        -
        -
        +
        +
        +
        <${FileViewer} filePath=${activeBrowsePath} isPreviewOnly=${false} browseView=${browseViewerMode} + lineTarget=${browseLineTarget} + lineEndTarget=${browseLineEndTarget} onRequestEdit=${(targetPath) => { const normalizedTargetPath = String(targetPath || ""); if ( @@ -1061,7 +1053,31 @@ const App = () => { }} />
        -
        +
        +
        +
        + + + alphaclaw + +
        +
        <${GeneralTab} @@ -1086,7 +1102,22 @@ const App = () => { />
        - ${!isBrowseRoute && location !== "/general" +
        +
        + <${DoctorTab} + isActive=${location === "/doctor"} + onOpenFile=${(relativePath, options = {}) => { + const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`; + navigateToBrowseFile(browsePath, { + view: "edit", + ...(options.line ? { line: options.line } : {}), + ...(options.lineEnd ? { lineEnd: options.lineEnd } : {}), + }); + }} + /> +
        +
        + ${!isBrowseRoute && location !== "/general" && location !== "/doctor" ? html` <${Switch}> <${Route} path="/telegram"> @@ -1136,13 +1167,6 @@ const App = () => { />
        - <${Route} path="/doctor"> -
        - <${DoctorTab} - isActive=${location === "/doctor"} - /> -
        - <${Route} path="/envars">
        <${Envars} onRestartRequired=${setRestartRequired} /> @@ -1164,7 +1188,7 @@ const App = () => {
        <${ToastContainer} - className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none" + className="fixed top-4 right-4 z-[60] space-y-2 pointer-events-none" />
        diff --git a/lib/public/js/components/doctor/findings-list.js b/lib/public/js/components/doctor/findings-list.js index c1ef6b12..3cbf0b02 100644 --- a/lib/public/js/components/doctor/findings-list.js +++ b/lib/public/js/components/doctor/findings-list.js @@ -1,4 +1,5 @@ import { h } from "https://esm.sh/preact"; +import { useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { Badge } from "../badge.js"; import { ActionButton } from "../action-button.js"; @@ -10,8 +11,159 @@ import { const html = htm.bind(h); -const renderEvidenceLine = (item = {}) => { - if (item?.path) return item.path; +const resolveTargetPath = (item) => { + if (!item) return null; + if (typeof item === "string") return { path: item }; + if (typeof item === "object" && item.path) return item; + return null; +}; + +const formatPathLabel = (filePath, startLine, endLine) => { + if (startLine && endLine && endLine > startLine) + return `${filePath}:${startLine}-${endLine}`; + if (startLine) return `${filePath}:${startLine}`; + return filePath; +}; + +const buildLineOptions = (startLine, endLine) => { + const options = {}; + if (startLine) options.line = startLine; + if (endLine && endLine > startLine) options.lineEnd = endLine; + return options; +}; + +const renderPathLink = (filePath, onOpenFile, { startLine, endLine } = {}) => { + const label = formatPathLabel(filePath, startLine, endLine); + return html` + + `; +}; + +const kSnippetCollapseThreshold = 7; + +const SnippetBlock = ({ item, onOpenFile, isOutdated }) => { + const snippet = item.snippet; + const allLines = String(snippet.text || "").split("\n"); + const isCollapsible = allLines.length > kSnippetCollapseThreshold; + const [expanded, setExpanded] = useState(!isCollapsible); + const visibleLines = expanded + ? allLines + : allLines.slice(0, kSnippetCollapseThreshold); + const gutterWidth = String(snippet.endLine || snippet.startLine || 1).length; + return html` +
        +
        + + ${isOutdated + ? html`file changed since scan` + : html`snapshot`} +
        +
        +
        + ${visibleLines.map( + (line, index) => html` +
        + ${snippet.startLine + index}${line || " "} +
        + `, + )} + ${expanded && snippet.truncated + ? html`
        ... truncated
        ` + : ""} +
        + ${isCollapsible && !expanded + ? html` + + ` + : null} + ${isCollapsible && expanded + ? html` + + ` + : null} +
        +
        + `; +}; + +const renderEvidenceLine = (item = {}, onOpenFile, changedPathsSet) => { + if (item?.path && item?.snippet) { + const isOutdated = changedPathsSet.has(item.path); + return html`<${SnippetBlock} + item=${item} + onOpenFile=${onOpenFile} + isOutdated=${isOutdated} + />`; + } + if (item?.path) + return renderPathLink(item.path, onOpenFile, { + startLine: item.startLine, + endLine: item.endLine, + }); if (item?.text) return item.text; return JSON.stringify(item); }; @@ -21,9 +173,12 @@ export const DoctorFindingsList = ({ busyCardId = 0, onAskAgentFix = () => {}, onUpdateStatus = () => {}, + onOpenFile = () => {}, + changedPaths = [], showRunMeta = false, hideEmptyState = false, }) => { + const changedPathsSet = new Set(changedPaths); return html`
        ${cards.length @@ -98,15 +253,34 @@ export const DoctorFindingsList = ({ Target paths
        - ${card.targetPaths.map( - (targetPath) => html` - { + const resolved = + resolveTargetPath(rawItem); + if (!resolved) return null; + const label = formatPathLabel( + resolved.path, + resolved.startLine, + resolved.endLine, + ); + return html` + + `; + })}
        ` @@ -117,11 +291,15 @@ export const DoctorFindingsList = ({ ? html`
        Evidence
        -
        +
        ${card.evidence.map( (item) => html`
        - ${renderEvidenceLine(item)} + ${renderEvidenceLine( + item, + onOpenFile, + changedPathsSet, + )}
        `, )} diff --git a/lib/public/js/components/doctor/fix-card-modal.js b/lib/public/js/components/doctor/fix-card-modal.js index 9251909f..9b55de38 100644 --- a/lib/public/js/components/doctor/fix-card-modal.js +++ b/lib/public/js/components/doctor/fix-card-modal.js @@ -3,13 +3,8 @@ import { useEffect, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { ModalShell } from "../modal-shell.js"; import { ActionButton } from "../action-button.js"; -import { Badge } from "../badge.js"; -import { - sendDoctorCardFix, - updateDoctorCardStatus, -} from "../../lib/api.js"; +import { sendDoctorCardFix, updateDoctorCardStatus } from "../../lib/api.js"; import { showToast } from "../toast.js"; -import { getDoctorPriorityTone } from "./helpers.js"; import { useAgentSessions } from "../../hooks/useAgentSessions.js"; const html = htm.bind(h); @@ -50,10 +45,14 @@ export const DoctorFixCardModal = ({ }); try { await updateDoctorCardStatus({ cardId: card.id, status: "fixed" }); - showToast("Doctor fix request sent and finding marked fixed", "success"); + showToast( + "Doctor fix request sent and finding marked fixed", + "success", + ); } catch (statusError) { showToast( - statusError.message || "Doctor fix request sent, but could not mark the finding fixed", + statusError.message || + "Doctor fix request sent, but could not mark the finding fixed", "warning", ); } @@ -74,21 +73,10 @@ export const DoctorFixCardModal = ({ >

        Ask agent to fix

        -

        +

        Send this Doctor finding to one of your agent sessions as a focused fix request.

        -
        -
        - <${Badge} tone=${getDoctorPriorityTone(card?.priority || "P2")}> - ${card?.priority || "P2"} - -
        - ${card?.title || "Doctor finding"} -
        -
        -
        ${card?.recommendation || ""}
        -
        { - const nextChoice = e.target.value; - setSyncCronChoice(nextChoice); - const nextEnabled = nextChoice !== "disabled"; - const nextSchedule = nextEnabled - ? nextChoice - : syncCronSchedule; - setSyncCronEnabled(nextEnabled); - setSyncCronSchedule(nextSchedule); - saveSyncCronSettings({ - enabled: nextEnabled, - schedule: nextSchedule, - }); - }} - disabled=${savingSyncCron} - class="appearance-none bg-black/30 border border-border rounded-lg pl-2.5 pr-9 py-1.5 text-xs text-gray-300 ${savingSyncCron - ? "opacity-50 cursor-not-allowed" - : ""}" - title=${syncCron?.installed === false - ? "Not Installed Yet" - : syncCronStatusText} - > - - - - - - <${ChevronDownIcon} - className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500" - /> -
        -
        -
        -
        - `} - -
        -
        -
        -

        OpenClaw Gateway Dashboard

        -
        - <${UpdateActionButton} - onClick=${async () => { - if (dashboardLoading) return; - setDashboardLoading(true); - try { - const data = await fetchDashboardUrl(); - console.log("[dashboard] response:", JSON.stringify(data)); - window.open(data.url || "/openclaw", "_blank"); - } catch (err) { - console.error("[dashboard] error:", err); - window.open("/openclaw", "_blank"); - } - setDashboardLoading(false); - }} - loading=${dashboardLoading} - warning=${false} - idleLabel="Open" - loadingLabel="Opening..." - /> -
        - <${DevicePairings} - pending=${devicePending} - onApprove=${handleDeviceApprove} - onReject=${handleDeviceReject} - /> -
        -
        - `; -}; const App = () => { - const appShellRef = useRef(null); - const [onboarded, setOnboarded] = useState(null); const [location, setLocation] = useLocation(); - const [acVersion, setAcVersion] = useState(null); - const [acLatest, setAcLatest] = useState(null); - const [acHasUpdate, setAcHasUpdate] = useState(false); - const [acUpdating, setAcUpdating] = useState(false); - const [acDismissed, setAcDismissed] = useState(false); - const [authEnabled, setAuthEnabled] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); - const [sidebarTab, setSidebarTab] = useState(() => - location.startsWith("/browse") ? "browse" : "menu", - ); - const [sidebarWidthPx, setSidebarWidthPx] = useState(() => { - const settings = readUiSettings(); - if (!Number.isFinite(settings.sidebarWidthPx)) return kDefaultSidebarWidthPx; - return clampSidebarWidth(settings.sidebarWidthPx); - }); - const [lastBrowsePath, setLastBrowsePath] = useState(() => { - const settings = readUiSettings(); - return typeof settings[kBrowseLastPathUiSettingKey] === "string" - ? settings[kBrowseLastPathUiSettingKey] - : ""; - }); - const [lastMenuRoute, setLastMenuRoute] = useState(() => { - const settings = readUiSettings(); - const storedRoute = settings[kLastMenuRouteUiSettingKey]; - if ( - typeof storedRoute === "string" && - storedRoute.startsWith("/") && - !storedRoute.startsWith("/browse") - ) { - return storedRoute; - } - return `/${kDefaultUiTab}`; - }); const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] = useState(() => { const settings = readUiSettings(); return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0); }); - const [isResizingSidebar, setIsResizingSidebar] = useState(false); - const [browsePreviewPath, setBrowsePreviewPath] = useState(""); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false); - const [restartRequired, setRestartRequired] = useState(false); - const [browseRestartRequired, setBrowseRestartRequired] = useState(false); - const [restartingGateway, setRestartingGateway] = useState(false); - const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0); - const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000); - const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false); - const menuRef = useRef(null); - const routeHistoryRef = useRef([]); - const menuPaneRef = useRef(null); - const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, { - enabled: onboarded === true, - }); - const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, { - enabled: onboarded === true, - }); - const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, { - enabled: onboarded === true, - }); - const sharedStatus = sharedStatusPoll.data || null; - const sharedWatchdogStatus = sharedWatchdogPoll.data?.status || null; - const sharedDoctorStatus = sharedDoctorPoll.data?.status || null; - const isAnyRestartRequired = restartRequired || browseRestartRequired; - const refreshSharedStatuses = useCallback(() => { - sharedStatusPoll.refresh(); - sharedWatchdogPoll.refresh(); - sharedDoctorPoll.refresh(); - }, [sharedStatusPoll.refresh, sharedWatchdogPoll.refresh, sharedDoctorPoll.refresh]); - - const closeMenu = useCallback((e) => { - if (menuRef.current && !menuRef.current.contains(e.target)) { - setMenuOpen(false); - } - }, []); - - useEffect(() => { - if (menuOpen) { - document.addEventListener("click", closeMenu, true); - return () => document.removeEventListener("click", closeMenu, true); - } - }, [menuOpen, closeMenu]); - useEffect(() => { - fetchOnboardStatus() - .then((data) => setOnboarded(data.onboarded)) - .catch(() => setOnboarded(false)); - fetchAuthStatus() - .then((data) => setAuthEnabled(!!data.authEnabled)) - .catch(() => {}); - }, []); - - useEffect(() => { - if (!mobileSidebarOpen) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = previousOverflow; - }; - }, [mobileSidebarOpen]); - - useEffect(() => { - if (!onboarded) return; - let active = true; - const check = async (refresh = false) => { - try { - const data = await fetchAlphaclawVersion(refresh); - if (!active) return; - setAcVersion(data.currentVersion || null); - setAcLatest(data.latestVersion || null); - setAcHasUpdate(!!data.hasUpdate); - } catch {} - }; - check(true); - const id = setInterval(() => check(false), 5 * 60 * 1000); - return () => { - active = false; - clearInterval(id); - }; - }, [onboarded]); - - const refreshRestartStatus = useCallback(async () => { - if (!onboarded) return; - try { - const data = await fetchRestartStatus(); - setRestartRequired(!!data.restartRequired); - setRestartingGateway(!!data.restartInProgress); - } catch {} - }, [onboarded]); - - useEffect(() => { - if (!onboarded) return; - refreshRestartStatus(); - }, [onboarded, refreshRestartStatus]); - - useEffect(() => { - if (onboarded !== true) return; - const inStatusView = - location.startsWith("/general") || location.startsWith("/watchdog"); - const gatewayStatus = sharedStatus?.gateway ?? null; - const watchdogHealth = String(sharedWatchdogStatus?.health || "").toLowerCase(); - const watchdogLifecycle = String(sharedWatchdogStatus?.lifecycle || "").toLowerCase(); - const shouldFastPollWatchdog = - watchdogHealth === "unknown" || - watchdogLifecycle === "restarting" || - watchdogLifecycle === "stopped" || - !!sharedWatchdogStatus?.operationInProgress; - const shouldFastPollGateway = !gatewayStatus || gatewayStatus !== "running"; - const nextCadenceMs = - inStatusView && (shouldFastPollWatchdog || shouldFastPollGateway) ? 2000 : 15000; - setStatusPollCadenceMs((currentCadenceMs) => - currentCadenceMs === nextCadenceMs ? currentCadenceMs : nextCadenceMs, - ); - }, [ - onboarded, + const { state: controllerState, actions: controllerActions } = useAppShellController({ location, - sharedStatus?.gateway, - sharedWatchdogStatus?.health, - sharedWatchdogStatus?.lifecycle, - sharedWatchdogStatus?.operationInProgress, - ]); - - useEffect(() => { - if (!onboarded || (!restartRequired && !restartingGateway)) return; - const id = setInterval(refreshRestartStatus, 2000); - return () => clearInterval(id); - }, [onboarded, restartRequired, restartingGateway, refreshRestartStatus]); + }); + const { refs: shellRefs, state: shellState, actions: shellActions } = useAppShellUi(); + const { state: browseState, actions: browseActions, constants: browseConstants } = + useBrowseNavigation({ + location, + setLocation, + onCloseMobileSidebar: shellActions.closeMobileSidebar, + }); useEffect(() => { - const handleBrowseFileSaved = (event) => { - const savedPath = String(event?.detail?.path || ""); - if (!shouldRequireRestartForBrowsePath(savedPath)) return; - setBrowseRestartRequired(true); - }; - window.addEventListener("alphaclaw:browse-file-saved", handleBrowseFileSaved); - return () => { - window.removeEventListener("alphaclaw:browse-file-saved", handleBrowseFileSaved); - }; - }, []); - - const handleGatewayRestart = useCallback(async () => { - if (restartingGateway) return; - setRestartingGateway(true); - try { - const data = await restartGateway(); - if (!data?.ok) throw new Error(data?.error || "Gateway restart failed"); - setRestartRequired(!!data.restartRequired); - setBrowseRestartRequired(false); - setGatewayRestartSignal(Date.now()); - refreshSharedStatuses(); - showToast("Gateway restarted", "success"); - setTimeout(refreshRestartStatus, 800); - } catch (err) { - showToast(err.message || "Restart failed", "error"); - setTimeout(refreshRestartStatus, 800); - } finally { - setRestartingGateway(false); - } - }, [restartingGateway, refreshRestartStatus, refreshSharedStatuses]); - - const handleOpenclawUpdate = useCallback(async () => { - if (openclawUpdateInProgress) { - return { ok: false, error: "OpenClaw update already in progress" }; - } - setOpenclawUpdateInProgress(true); - try { - const data = await updateOpenclaw(); - return data; - } finally { - setOpenclawUpdateInProgress(false); - refreshSharedStatuses(); - setTimeout(refreshSharedStatuses, 1200); - setTimeout(refreshSharedStatuses, 3500); - setTimeout(refreshRestartStatus, 1200); - } - }, [ - openclawUpdateInProgress, - refreshRestartStatus, - refreshSharedStatuses, - ]); - - const handleOpenclawVersionActionComplete = useCallback( - ({ type }) => { - if (type !== "update") return; - refreshSharedStatuses(); - setTimeout(refreshSharedStatuses, 1200); - }, - [refreshSharedStatuses], - ); + const settings = readUiSettings(); + settings[kDoctorWarningDismissedUntilUiSettingKey] = doctorWarningDismissedUntilMs; + writeUiSettings(settings); + }, [doctorWarningDismissedUntilMs]); - const handleAcUpdate = async () => { - if (acUpdating) return; - setAcUpdating(true); + const handleSidebarLogout = async () => { + shellActions.setMenuOpen(false); + await logout(); try { - const data = await updateAlphaclaw(); - if (data.ok) { - showToast("AlphaClaw updated — restarting...", "success"); - setTimeout(() => window.location.reload(), 5000); - } else { - showToast(data.error || "AlphaClaw update failed", "error"); - setAcUpdating(false); - } - } catch (err) { - showToast(err.message || "Could not update AlphaClaw", "error"); - setAcUpdating(false); - } + window.localStorage.clear(); + window.sessionStorage.clear(); + } catch {} + window.location.href = "/login.html"; }; - // Still loading onboard status - if (onboarded === null) { + + if (controllerState.onboarded === null) { return html`
        { `; } - if (!onboarded) { + if (!controllerState.onboarded) { return html`
        - <${Welcome} onComplete=${() => setOnboarded(true)} /> + <${Welcome} onComplete=${controllerActions.handleOnboardingComplete} />
        <${ToastContainer} /> `; } - const buildBrowseRoute = (relativePath, options = {}) => { - const view = String(options?.view || "edit"); - const encodedPath = String(relativePath || "") - .split("/") - .filter(Boolean) - .map((segment) => encodeURIComponent(segment)) - .join("/"); - const baseRoute = encodedPath ? `/browse/${encodedPath}` : "/browse"; - const params = new URLSearchParams(); - if (view === "diff" && encodedPath) params.set("view", "diff"); - if (options.line) params.set("line", String(options.line)); - if (options.lineEnd) params.set("lineEnd", String(options.lineEnd)); - const query = params.toString(); - return query ? `${baseRoute}?${query}` : baseRoute; - }; - const navigateToSubScreen = (screen) => { - setLocation(`/${screen}`); - setMobileSidebarOpen(false); - }; - const handleBrowsePreviewFile = useCallback((nextPreviewPath) => { - const normalizedPreviewPath = normalizeBrowsePath(nextPreviewPath); - setBrowsePreviewPath(normalizedPreviewPath); - }, []); - const navigateToBrowseFile = (relativePath, options = {}) => { - const normalizedTargetPath = normalizeBrowsePath(relativePath); - const selectingDirectory = - !!options.directory || String(relativePath || "").trim().endsWith("/"); - const shouldPreservePreview = selectingDirectory && !!options.preservePreview; - const activePath = normalizeBrowsePath( - browsePreviewPath || selectedBrowsePath || "", - ); - const nextPreviewPath = - shouldPreservePreview && activePath && activePath !== normalizedTargetPath - ? activePath - : ""; - setBrowsePreviewPath(nextPreviewPath); - const routeOptions = selectingDirectory - ? { ...options, view: "edit" } - : options; - setLocation(buildBrowseRoute(normalizedTargetPath, routeOptions)); - setMobileSidebarOpen(false); - }; - const handleSidebarLogout = async () => { - setMenuOpen(false); - await logout(); - try { - window.localStorage.clear(); - window.sessionStorage.clear(); - } catch {} - window.location.href = "/login.html"; - }; - const handleSelectSidebarTab = (nextTab) => { - setSidebarTab(nextTab); - if (nextTab === "menu" && location.startsWith("/browse")) { - setBrowsePreviewPath(""); - setLocation(lastMenuRoute || `/${kDefaultUiTab}`); - return; - } - if (nextTab === "browse" && !location.startsWith("/browse")) { - setLocation(buildBrowseRoute(lastBrowsePath)); - } - }; - const handleSelectNavItem = (itemId) => { - setLocation(`/${itemId}`); - setMobileSidebarOpen(false); - }; - const exitSubScreen = () => { - setLocation(`/${kDefaultUiTab}`); - setMobileSidebarOpen(false); - }; - const handlePaneScroll = (e) => { - const nextScrolled = e.currentTarget.scrollTop > 0; - setMobileTopbarScrolled((currentScrolled) => - currentScrolled === nextScrolled ? currentScrolled : nextScrolled, - ); - }; - - const kNavSections = [ - { - label: "Setup", - items: [ - { id: "general", label: "General" }, - ], - }, - { - label: "Monitoring", - items: [ - { id: "watchdog", label: "Watchdog" }, - { id: "usage", label: "Usage" }, - { id: "doctor", label: "Doctor" }, - ], - }, - { - label: "Config", - items: [ - { id: "providers", label: "Providers" }, - { id: "envars", label: "Envars" }, - { id: "webhooks", label: "Webhooks" }, - ], - }, - ]; - - const isBrowseRoute = location.startsWith("/browse"); - const browseRoutePath = isBrowseRoute ? String(location || "").split("?")[0] : ""; - const browseRouteQuery = - isBrowseRoute && String(location || "").includes("?") - ? String(location || "").split("?").slice(1).join("?") - : ""; - const selectedBrowsePath = isBrowseRoute - ? browseRoutePath - .replace(/^\/browse\/?/, "") - .split("/") - .filter(Boolean) - .map((segment) => { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } - }) - .join("/") - : ""; - const activeBrowsePath = browsePreviewPath || selectedBrowsePath; - const browseQueryParams = isBrowseRoute ? new URLSearchParams(browseRouteQuery) : null; - const browseViewerMode = - !browsePreviewPath && browseQueryParams?.get("view") === "diff" - ? "diff" - : "edit"; - const browseLineTarget = Number.parseInt(browseQueryParams?.get("line") || "", 10) || 0; - const browseLineEndTarget = Number.parseInt(browseQueryParams?.get("lineEnd") || "", 10) || 0; - const selectedNavId = isBrowseRoute - ? "browse" - : location === "/telegram" - ? "" - : location.startsWith("/providers") - ? "providers" - : location.startsWith("/watchdog") - ? "watchdog" - : location.startsWith("/usage") - ? "usage" - : location.startsWith("/doctor") - ? "doctor" - : location.startsWith("/envars") - ? "envars" - : location.startsWith("/webhooks") - ? "webhooks" - : "general"; - - useEffect(() => { - setSidebarTab((currentTab) => { - if (location.startsWith("/browse")) return "browse"; - if (currentTab === "browse") return "menu"; - return currentTab; - }); - }, [location]); - - useEffect(() => { - if (location.startsWith("/browse")) return; - setBrowsePreviewPath(""); - }, [location]); - - useEffect(() => { - const historyStack = routeHistoryRef.current; - const lastEntry = historyStack[historyStack.length - 1]; - if (lastEntry === location) return; - historyStack.push(location); - if (historyStack.length > 100) { - historyStack.shift(); - } - }, [location]); - - useEffect(() => { - if (location.startsWith("/browse")) return; - if (location === "/telegram") return; - setLastMenuRoute((currentRoute) => - currentRoute === location ? currentRoute : location, - ); - }, [location]); - - useEffect(() => { - if (!isBrowseRoute) return; - if (!selectedBrowsePath) return; - setLastBrowsePath((currentPath) => - currentPath === selectedBrowsePath ? currentPath : selectedBrowsePath, - ); - }, [isBrowseRoute, selectedBrowsePath]); - - useEffect(() => { - const handleBrowseGitSynced = () => { - if (!isBrowseRoute || browseViewerMode !== "diff") return; - const activePath = String(selectedBrowsePath || "").trim(); - if (!activePath) return; - setLocation(buildBrowseRoute(activePath, { view: "edit" })); - }; - window.addEventListener("alphaclaw:browse-git-synced", handleBrowseGitSynced); - return () => { - window.removeEventListener( - "alphaclaw:browse-git-synced", - handleBrowseGitSynced, - ); - }; - }, [ - isBrowseRoute, - browseViewerMode, - selectedBrowsePath, - setLocation, - buildBrowseRoute, - ]); - - useEffect(() => { - const settings = readUiSettings(); - settings.sidebarWidthPx = sidebarWidthPx; - settings[kBrowseLastPathUiSettingKey] = lastBrowsePath; - settings[kLastMenuRouteUiSettingKey] = lastMenuRoute; - settings[kDoctorWarningDismissedUntilUiSettingKey] = doctorWarningDismissedUntilMs; - writeUiSettings(settings); - }, [sidebarWidthPx, lastBrowsePath, lastMenuRoute, doctorWarningDismissedUntilMs]); - - const resizeSidebarWithClientX = useCallback((clientX) => { - const shellElement = appShellRef.current; - if (!shellElement) return; - const shellBounds = shellElement.getBoundingClientRect(); - const nextWidth = clampSidebarWidth(Math.round(clientX - shellBounds.left)); - setSidebarWidthPx(nextWidth); - }, []); - - const onSidebarResizerPointerDown = (event) => { - event.preventDefault(); - setIsResizingSidebar(true); - resizeSidebarWithClientX(event.clientX); - }; - - useEffect(() => { - if (!isResizingSidebar) return () => {}; - const onPointerMove = (event) => resizeSidebarWithClientX(event.clientX); - const onPointerUp = () => setIsResizingSidebar(false); - window.addEventListener("pointermove", onPointerMove); - window.addEventListener("pointerup", onPointerUp); - const previousUserSelect = document.body.style.userSelect; - const previousCursor = document.body.style.cursor; - document.body.style.userSelect = "none"; - document.body.style.cursor = "col-resize"; - return () => { - window.removeEventListener("pointermove", onPointerMove); - window.removeEventListener("pointerup", onPointerUp); - document.body.style.userSelect = previousUserSelect; - document.body.style.cursor = previousCursor; - }; - }, [isResizingSidebar, resizeSidebarWithClientX]); - - const renderWebhooks = (hookName = "") => html` -
        - <${Webhooks} - selectedHookName=${hookName} - onSelectHook=${(name) => setLocation(`/webhooks/${encodeURIComponent(name)}`)} - onBackToList=${() => { - const historyStack = routeHistoryRef.current; - const hasPreviousRoute = historyStack.length > 1; - if (!hasPreviousRoute) { - setLocation("/webhooks"); - return; - } - const currentPath = getHashPath(); - window.history.back(); - window.setTimeout(() => { - if (getHashPath() === currentPath) { - setLocation("/webhooks"); - } - }, 180); - }} - onRestartRequired=${setRestartRequired} - onOpenFile=${(relativePath) => - navigateToBrowseFile(String(relativePath || "").trim(), { view: "edit" })} - /> -
        - `; - return html`
        <${GlobalRestartBanner} - visible=${isAnyRestartRequired} - restarting=${restartingGateway} - onRestart=${handleGatewayRestart} + visible=${controllerState.isAnyRestartRequired} + restarting=${controllerState.restartingGateway} + onRestart=${controllerActions.handleGatewayRestart} /> <${AppSidebar} - mobileSidebarOpen=${mobileSidebarOpen} - authEnabled=${authEnabled} - menuRef=${menuRef} - menuOpen=${menuOpen} - onToggleMenu=${() => setMenuOpen((open) => !open)} + mobileSidebarOpen=${shellState.mobileSidebarOpen} + authEnabled=${controllerState.authEnabled} + menuRef=${shellRefs.menuRef} + menuOpen=${shellState.menuOpen} + onToggleMenu=${shellActions.onToggleMenu} onLogout=${handleSidebarLogout} - sidebarTab=${sidebarTab} - onSelectSidebarTab=${handleSelectSidebarTab} - navSections=${kNavSections} - selectedNavId=${selectedNavId} - onSelectNavItem=${handleSelectNavItem} - selectedBrowsePath=${selectedBrowsePath} - onSelectBrowseFile=${navigateToBrowseFile} - onPreviewBrowseFile=${handleBrowsePreviewFile} - acHasUpdate=${acHasUpdate} - acLatest=${acLatest} - acDismissed=${acDismissed} - acUpdating=${acUpdating} - onAcUpdate=${handleAcUpdate} + sidebarTab=${browseState.sidebarTab} + onSelectSidebarTab=${browseActions.handleSelectSidebarTab} + navSections=${browseConstants.kNavSections} + selectedNavId=${browseState.selectedNavId} + onSelectNavItem=${browseActions.handleSelectNavItem} + selectedBrowsePath=${browseState.selectedBrowsePath} + onSelectBrowseFile=${browseActions.navigateToBrowseFile} + onPreviewBrowseFile=${browseActions.handleBrowsePreviewFile} + acHasUpdate=${controllerState.acHasUpdate} + acLatest=${controllerState.acLatest} + acUpdating=${controllerState.acUpdating} + onAcUpdate=${controllerActions.handleAcUpdate} />
        setMobileSidebarOpen(false)} + class=${`mobile-sidebar-overlay ${shellState.mobileSidebarOpen ? "active" : ""}`} + onclick=${shellActions.closeMobileSidebar} />
        -
        - <${FileViewer} - filePath=${activeBrowsePath} - isPreviewOnly=${false} - browseView=${browseViewerMode} - lineTarget=${browseLineTarget} - lineEndTarget=${browseLineEndTarget} - onRequestEdit=${(targetPath) => { - const normalizedTargetPath = String(targetPath || ""); - if ( - normalizedTargetPath && - normalizedTargetPath !== selectedBrowsePath - ) { - navigateToBrowseFile(normalizedTargetPath, { view: "edit" }); - return; - } - setLocation(buildBrowseRoute(selectedBrowsePath, { view: "edit" })); - }} - onRequestClearSelection=${() => { - setBrowsePreviewPath(""); - setLocation("/browse"); - }} - /> -
        + <${BrowseRoute} + activeBrowsePath=${browseState.activeBrowsePath} + browseView=${browseState.browseViewerMode} + lineTarget=${browseState.browseLineTarget} + lineEndTarget=${browseState.browseLineEndTarget} + selectedBrowsePath=${browseState.selectedBrowsePath} + onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} + onEditSelectedBrowseFile=${() => + setLocation( + browseActions.buildBrowseRoute(browseState.selectedBrowsePath, { view: "edit" }), + )} + onClearSelection=${() => { + browseActions.clearBrowsePreview(); + setLocation("/browse"); + }} + />
        -
        +
        -
        -
        - <${GeneralTab} - statusData=${sharedStatus} - watchdogData=${sharedWatchdogStatus} - doctorStatusData=${sharedDoctorStatus} - doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs} - onRefreshStatuses=${refreshSharedStatuses} - onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)} - onNavigate=${navigateToSubScreen} - onOpenGmailWebhook=${() => setLocation("/webhooks/gmail")} - isActive=${location === "/general"} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} - openclawUpdateInProgress=${openclawUpdateInProgress} - onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} - onOpenclawUpdate=${handleOpenclawUpdate} - onRestartRequired=${setRestartRequired} - onDismissDoctorWarning=${() => - setDoctorWarningDismissedUntilMs(Date.now() + kOneWeekMs)} - /> -
        -
        -
        -
        - <${DoctorTab} - isActive=${location === "/doctor"} - onOpenFile=${(relativePath, options = {}) => { - const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`; - navigateToBrowseFile(browsePath, { - view: "edit", - ...(options.line ? { line: options.line } : {}), - ...(options.lineEnd ? { lineEnd: options.lineEnd } : {}), - }); - }} - /> -
        -
        - ${!isBrowseRoute && location !== "/general" && location !== "/doctor" + ${!browseState.isBrowseRoute ? html` <${Switch}> + <${Route} path="/general"> + <${GeneralRoute} + statusData=${controllerState.sharedStatus} + watchdogData=${controllerState.sharedWatchdogStatus} + doctorStatusData=${controllerState.sharedDoctorStatus} + doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs} + onRefreshStatuses=${controllerActions.refreshSharedStatuses} + onSetLocation=${setLocation} + onNavigate=${browseActions.navigateToSubScreen} + restartingGateway=${controllerState.restartingGateway} + onRestartGateway=${controllerActions.handleGatewayRestart} + restartSignal=${controllerState.gatewayRestartSignal} + openclawUpdateInProgress=${controllerState.openclawUpdateInProgress} + onOpenclawVersionActionComplete=${controllerActions.handleOpenclawVersionActionComplete} + onOpenclawUpdate=${controllerActions.handleOpenclawUpdate} + onRestartRequired=${controllerActions.setRestartRequired} + onDismissDoctorWarning=${() => + setDoctorWarningDismissedUntilMs(Date.now() + kOneWeekMs)} + /> + + <${Route} path="/doctor"> + <${DoctorRoute} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} /> + <${Route} path="/telegram"> -
        - <${TelegramWorkspace} onBack=${exitSubScreen} /> -
        + <${TelegramRoute} onBack=${browseActions.exitSubScreen} /> <${Route} path="/providers"> -
        - <${Providers} onRestartRequired=${setRestartRequired} /> -
        + <${ProvidersRoute} onRestartRequired=${controllerActions.setRestartRequired} /> <${Route} path="/watchdog"> -
        - <${WatchdogTab} - gatewayStatus=${sharedStatus?.gateway || null} - openclawVersion=${sharedStatus?.openclawVersion || null} - watchdogStatus=${sharedWatchdogStatus} - onRefreshStatuses=${refreshSharedStatuses} - restartingGateway=${restartingGateway} - onRestartGateway=${handleGatewayRestart} - restartSignal=${gatewayRestartSignal} - openclawUpdateInProgress=${openclawUpdateInProgress} - onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete} - onOpenclawUpdate=${handleOpenclawUpdate} - /> -
        + <${WatchdogRoute} + statusData=${controllerState.sharedStatus} + watchdogStatus=${controllerState.sharedWatchdogStatus} + onRefreshStatuses=${controllerActions.refreshSharedStatuses} + restartingGateway=${controllerState.restartingGateway} + onRestartGateway=${controllerActions.handleGatewayRestart} + restartSignal=${controllerState.gatewayRestartSignal} + openclawUpdateInProgress=${controllerState.openclawUpdateInProgress} + onOpenclawVersionActionComplete=${controllerActions.handleOpenclawVersionActionComplete} + onOpenclawUpdate=${controllerActions.handleOpenclawUpdate} + /> <${Route} path="/usage/:sessionId"> ${(params) => html` -
        - <${UsageTab} - sessionId=${decodeURIComponent(params.sessionId || "")} - onSelectSession=${(id) => - setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} - onBackToSessions=${() => setLocation("/usage")} - /> -
        + <${UsageRoute} + sessionId=${decodeURIComponent(params.sessionId || "")} + onSetLocation=${setLocation} + /> `} <${Route} path="/usage"> -
        - <${UsageTab} - onSelectSession=${(id) => - setLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} - onBackToSessions=${() => setLocation("/usage")} - /> -
        + <${UsageRoute} onSetLocation=${setLocation} /> <${Route} path="/envars"> -
        - <${Envars} onRestartRequired=${setRestartRequired} /> -
        + <${EnvarsRoute} onRestartRequired=${controllerActions.setRestartRequired} /> <${Route} path="/webhooks/:hookName"> - ${(params) => - renderWebhooks(decodeURIComponent(params.hookName || ""))} + ${(params) => html` + <${WebhooksRoute} + hookName=${decodeURIComponent(params.hookName || "")} + routeHistoryRef=${browseState.routeHistoryRef} + getCurrentPath=${getHashRouterPath} + onSetLocation=${setLocation} + onRestartRequired=${controllerActions.setRestartRequired} + onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} + /> + `} <${Route} path="/webhooks"> - ${() => renderWebhooks("")} + <${WebhooksRoute} + routeHistoryRef=${browseState.routeHistoryRef} + getCurrentPath=${getHashRouterPath} + onSetLocation=${setLocation} + onRestartRequired=${controllerActions.setRestartRequired} + onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} + /> <${Route}> <${RouteRedirect} to="/general" /> @@ -1194,8 +275,8 @@ const App = () => {
        - ${acVersion - ? html`v${acVersion}` + ${controllerState.acVersion + ? html`v${controllerState.acVersion}` : null}
        diff --git a/lib/public/js/components/general/index.js b/lib/public/js/components/general/index.js new file mode 100644 index 00000000..4da9348d --- /dev/null +++ b/lib/public/js/components/general/index.js @@ -0,0 +1,155 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { Gateway } from "../gateway.js"; +import { Channels } from "../channels.js"; +import { Pairings } from "../pairings.js"; +import { DevicePairings } from "../device-pairings.js"; +import { Google } from "../google/index.js"; +import { Features } from "../features.js"; +import { GeneralDoctorWarning } from "../doctor/general-warning.js"; +import { ChevronDownIcon } from "../icons.js"; +import { UpdateActionButton } from "../update-action-button.js"; +import { useGeneralTab } from "./use-general-tab.js"; + +const html = htm.bind(h); + +export const GeneralTab = ({ + statusData = null, + watchdogData = null, + doctorStatusData = null, + doctorWarningDismissedUntilMs = 0, + onRefreshStatuses = () => {}, + onSwitchTab = () => {}, + onNavigate = () => {}, + onOpenGmailWebhook = () => {}, + isActive = false, + restartingGateway = false, + onRestartGateway = () => {}, + restartSignal = 0, + openclawUpdateInProgress = false, + onOpenclawVersionActionComplete = () => {}, + onOpenclawUpdate = () => {}, + onRestartRequired = () => {}, + onDismissDoctorWarning = () => {}, +}) => { + const { state, actions } = useGeneralTab({ + statusData, + watchdogData, + doctorStatusData, + onRefreshStatuses, + isActive, + restartSignal, + }); + + return html` +
        + <${Gateway} + status=${state.gatewayStatus} + openclawVersion=${state.openclawVersion} + restarting=${restartingGateway} + onRestart=${onRestartGateway} + watchdogStatus=${state.watchdogStatus} + onOpenWatchdog=${() => onSwitchTab("watchdog")} + onRepair=${actions.handleWatchdogRepair} + repairing=${state.repairingWatchdog} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} + onOpenclawUpdate=${onOpenclawUpdate} + /> + <${GeneralDoctorWarning} + doctorStatus=${state.doctorStatus} + dismissedUntilMs=${doctorWarningDismissedUntilMs} + onOpenDoctor=${() => onSwitchTab("doctor")} + onDismiss=${onDismissDoctorWarning} + /> + <${Channels} + channels=${state.channels} + onSwitchTab=${onSwitchTab} + onNavigate=${onNavigate} + /> + <${Pairings} + pending=${state.pending} + channels=${state.channels} + visible=${state.hasUnpaired} + onApprove=${actions.handleApprove} + onReject=${actions.handleReject} + /> + <${Features} onSwitchTab=${onSwitchTab} /> + <${Google} + gatewayStatus=${state.gatewayStatus} + onRestartRequired=${onRestartRequired} + onOpenGmailWebhook=${onOpenGmailWebhook} + /> + + ${state.repo && + html` +
        +
        +
        + + + + ${state.repo} +
        +
        + Auto-sync +
        + + <${ChevronDownIcon} + className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500" + /> +
        +
        +
        +
        + `} + +
        +
        +
        +

        OpenClaw Gateway Dashboard

        +
        + <${UpdateActionButton} + onClick=${actions.handleOpenDashboard} + loading=${state.dashboardLoading} + warning=${false} + idleLabel="Open" + loadingLabel="Opening..." + /> +
        + <${DevicePairings} + pending=${state.devicePending} + onApprove=${actions.handleDeviceApprove} + onReject=${actions.handleDeviceReject} + /> +
        +
        + `; +}; diff --git a/lib/public/js/components/general/use-general-tab.js b/lib/public/js/components/general/use-general-tab.js new file mode 100644 index 00000000..71811a5c --- /dev/null +++ b/lib/public/js/components/general/use-general-tab.js @@ -0,0 +1,233 @@ +import { useEffect, useState } from "https://esm.sh/preact/hooks"; +import { + approveDevice, + approvePairing, + fetchDashboardUrl, + fetchDevicePairings, + fetchPairings, + rejectDevice, + rejectPairing, + triggerWatchdogRepair, + updateSyncCron, +} from "../../lib/api.js"; +import { usePolling } from "../../hooks/usePolling.js"; +import { showToast } from "../toast.js"; +import { ALL_CHANNELS } from "../channels.js"; + +const kDefaultSyncCronSchedule = "0 * * * *"; + +export const useGeneralTab = ({ + statusData = null, + watchdogData = null, + doctorStatusData = null, + onRefreshStatuses = () => {}, + isActive = false, + restartSignal = 0, +} = {}) => { + const [dashboardLoading, setDashboardLoading] = useState(false); + const [repairingWatchdog, setRepairingWatchdog] = useState(false); + const [syncCronEnabled, setSyncCronEnabled] = useState(true); + const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule); + const [savingSyncCron, setSavingSyncCron] = useState(false); + const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule); + + const status = statusData; + const watchdogStatus = watchdogData; + const doctorStatus = doctorStatusData; + const gatewayStatus = status?.gateway ?? null; + const channels = status?.channels ?? null; + const repo = status?.repo || null; + const syncCron = status?.syncCron || null; + const openclawVersion = status?.openclawVersion || null; + + const hasUnpaired = ALL_CHANNELS.some((channel) => { + const info = channels?.[channel]; + return info && info.status !== "paired"; + }); + + const pairingsPoll = usePolling( + async () => { + const data = await fetchPairings(); + return data.pending || []; + }, + 1000, + { enabled: hasUnpaired && gatewayStatus === "running" }, + ); + const pending = pairingsPoll.data || []; + + const devicePoll = usePolling( + async () => { + const data = await fetchDevicePairings(); + return data.pending || []; + }, + 2000, + { enabled: gatewayStatus === "running" }, + ); + const devicePending = devicePoll.data || []; + + useEffect(() => { + if (!isActive) return; + onRefreshStatuses(); + pairingsPoll.refresh(); + devicePoll.refresh(); + }, [devicePoll.refresh, isActive, onRefreshStatuses, pairingsPoll.refresh]); + + useEffect(() => { + if (!restartSignal || !isActive) return; + onRefreshStatuses(); + pairingsPoll.refresh(); + devicePoll.refresh(); + const t1 = setTimeout(() => { + onRefreshStatuses(); + pairingsPoll.refresh(); + devicePoll.refresh(); + }, 1200); + const t2 = setTimeout(() => { + onRefreshStatuses(); + pairingsPoll.refresh(); + devicePoll.refresh(); + }, 3500); + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + }, [ + devicePoll.refresh, + isActive, + onRefreshStatuses, + pairingsPoll.refresh, + restartSignal, + ]); + + useEffect(() => { + if (!syncCron) return; + setSyncCronEnabled(syncCron.enabled !== false); + setSyncCronSchedule(syncCron.schedule || kDefaultSyncCronSchedule); + setSyncCronChoice( + syncCron.enabled === false ? "disabled" : syncCron.schedule || kDefaultSyncCronSchedule, + ); + }, [syncCron?.enabled, syncCron?.schedule]); + + const refreshAfterPairingAction = () => { + setTimeout(pairingsPoll.refresh, 500); + setTimeout(pairingsPoll.refresh, 2000); + setTimeout(onRefreshStatuses, 3000); + }; + + const saveSyncCronSettings = async ({ + enabled = syncCronEnabled, + schedule = syncCronSchedule, + } = {}) => { + if (savingSyncCron) return; + setSavingSyncCron(true); + try { + const data = await updateSyncCron({ enabled, schedule }); + if (!data.ok) { + throw new Error(data.error || "Could not save sync settings"); + } + showToast("Sync schedule updated", "success"); + onRefreshStatuses(); + } catch (err) { + showToast(err.message || "Could not save sync settings", "error"); + } finally { + setSavingSyncCron(false); + } + }; + + const handleSyncCronChoiceChange = async (nextChoice) => { + setSyncCronChoice(nextChoice); + const nextEnabled = nextChoice !== "disabled"; + const nextSchedule = nextEnabled ? nextChoice : syncCronSchedule; + setSyncCronEnabled(nextEnabled); + setSyncCronSchedule(nextSchedule); + await saveSyncCronSettings({ + enabled: nextEnabled, + schedule: nextSchedule, + }); + }; + + const handleApprove = async (id, channel) => { + await approvePairing(id, channel); + refreshAfterPairingAction(); + }; + + const handleReject = async (id, channel) => { + await rejectPairing(id, channel); + refreshAfterPairingAction(); + }; + + const handleDeviceApprove = async (id) => { + await approveDevice(id); + setTimeout(devicePoll.refresh, 500); + setTimeout(devicePoll.refresh, 2000); + }; + + const handleDeviceReject = async (id) => { + await rejectDevice(id); + setTimeout(devicePoll.refresh, 500); + setTimeout(devicePoll.refresh, 2000); + }; + + const handleWatchdogRepair = async () => { + if (repairingWatchdog) return; + setRepairingWatchdog(true); + try { + const data = await triggerWatchdogRepair(); + if (!data.ok) throw new Error(data.error || "Repair failed"); + showToast("Repair triggered", "success"); + setTimeout(() => { + onRefreshStatuses(); + }, 800); + } catch (err) { + showToast(err.message || "Could not run repair", "error"); + } finally { + setRepairingWatchdog(false); + } + }; + + const handleOpenDashboard = async () => { + if (dashboardLoading) return; + setDashboardLoading(true); + try { + const data = await fetchDashboardUrl(); + console.log("[dashboard] response:", JSON.stringify(data)); + window.open(data.url || "/openclaw", "_blank"); + } catch (err) { + console.error("[dashboard] error:", err); + window.open("/openclaw", "_blank"); + } finally { + setDashboardLoading(false); + } + }; + + return { + state: { + channels, + dashboardLoading, + devicePending, + doctorStatus, + gatewayStatus, + hasUnpaired, + openclawVersion, + pending, + repairingWatchdog, + repo, + savingSyncCron, + syncCron, + syncCronChoice, + syncCronEnabled, + syncCronSchedule, + syncCronStatusText: syncCronEnabled ? "Enabled" : "Disabled", + watchdogStatus, + }, + actions: { + handleApprove, + handleDeviceApprove, + handleDeviceReject, + handleOpenDashboard, + handleReject, + handleSyncCronChoiceChange, + handleWatchdogRepair, + }, + }; +}; diff --git a/lib/public/js/components/routes/browse-route.js b/lib/public/js/components/routes/browse-route.js new file mode 100644 index 00000000..2a36c716 --- /dev/null +++ b/lib/public/js/components/routes/browse-route.js @@ -0,0 +1,35 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { FileViewer } from "../file-viewer/index.js"; + +const html = htm.bind(h); + +export const BrowseRoute = ({ + activeBrowsePath = "", + browseView = "edit", + lineTarget = 0, + lineEndTarget = 0, + selectedBrowsePath = "", + onNavigateToBrowseFile = () => {}, + onEditSelectedBrowseFile = () => {}, + onClearSelection = () => {}, +}) => html` +
        + <${FileViewer} + filePath=${activeBrowsePath} + isPreviewOnly=${false} + browseView=${browseView} + lineTarget=${lineTarget} + lineEndTarget=${lineEndTarget} + onRequestEdit=${(targetPath) => { + const normalizedTargetPath = String(targetPath || ""); + if (normalizedTargetPath && normalizedTargetPath !== selectedBrowsePath) { + onNavigateToBrowseFile(normalizedTargetPath, { view: "edit" }); + return; + } + onEditSelectedBrowseFile(); + }} + onRequestClearSelection=${onClearSelection} + /> +
        +`; diff --git a/lib/public/js/components/routes/doctor-route.js b/lib/public/js/components/routes/doctor-route.js new file mode 100644 index 00000000..e92bed59 --- /dev/null +++ b/lib/public/js/components/routes/doctor-route.js @@ -0,0 +1,21 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { DoctorTab } from "../doctor/index.js"; + +const html = htm.bind(h); + +export const DoctorRoute = ({ onNavigateToBrowseFile = () => {} }) => html` +
        + <${DoctorTab} + isActive=${true} + onOpenFile=${(relativePath, options = {}) => { + const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`; + onNavigateToBrowseFile(browsePath, { + view: "edit", + ...(options.line ? { line: options.line } : {}), + ...(options.lineEnd ? { lineEnd: options.lineEnd } : {}), + }); + }} + /> +
        +`; diff --git a/lib/public/js/components/routes/envars-route.js b/lib/public/js/components/routes/envars-route.js new file mode 100644 index 00000000..089dab14 --- /dev/null +++ b/lib/public/js/components/routes/envars-route.js @@ -0,0 +1,11 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { Envars } from "../envars.js"; + +const html = htm.bind(h); + +export const EnvarsRoute = ({ onRestartRequired = () => {} }) => html` +
        + <${Envars} onRestartRequired=${onRestartRequired} /> +
        +`; diff --git a/lib/public/js/components/routes/general-route.js b/lib/public/js/components/routes/general-route.js new file mode 100644 index 00000000..0231a0cb --- /dev/null +++ b/lib/public/js/components/routes/general-route.js @@ -0,0 +1,45 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { GeneralTab } from "../general/index.js"; + +const html = htm.bind(h); + +export const GeneralRoute = ({ + statusData = null, + watchdogData = null, + doctorStatusData = null, + doctorWarningDismissedUntilMs = 0, + onRefreshStatuses = () => {}, + onSetLocation = () => {}, + onNavigate = () => {}, + restartingGateway = false, + onRestartGateway = () => {}, + restartSignal = 0, + openclawUpdateInProgress = false, + onOpenclawVersionActionComplete = () => {}, + onOpenclawUpdate = () => {}, + onRestartRequired = () => {}, + onDismissDoctorWarning = () => {}, +}) => html` +
        + <${GeneralTab} + statusData=${statusData} + watchdogData=${watchdogData} + doctorStatusData=${doctorStatusData} + doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs} + onRefreshStatuses=${onRefreshStatuses} + onSwitchTab=${(nextTab) => onSetLocation(`/${nextTab}`)} + onNavigate=${onNavigate} + onOpenGmailWebhook=${() => onSetLocation("/webhooks/gmail")} + isActive=${true} + restartingGateway=${restartingGateway} + onRestartGateway=${onRestartGateway} + restartSignal=${restartSignal} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} + onOpenclawUpdate=${onOpenclawUpdate} + onRestartRequired=${onRestartRequired} + onDismissDoctorWarning=${onDismissDoctorWarning} + /> +
        +`; diff --git a/lib/public/js/components/routes/index.js b/lib/public/js/components/routes/index.js new file mode 100644 index 00000000..91df833d --- /dev/null +++ b/lib/public/js/components/routes/index.js @@ -0,0 +1,10 @@ +export { BrowseRoute } from "./browse-route.js"; +export { DoctorRoute } from "./doctor-route.js"; +export { EnvarsRoute } from "./envars-route.js"; +export { GeneralRoute } from "./general-route.js"; +export { ProvidersRoute } from "./providers-route.js"; +export { RouteRedirect } from "./route-redirect.js"; +export { TelegramRoute } from "./telegram-route.js"; +export { UsageRoute } from "./usage-route.js"; +export { WatchdogRoute } from "./watchdog-route.js"; +export { WebhooksRoute } from "./webhooks-route.js"; diff --git a/lib/public/js/components/routes/providers-route.js b/lib/public/js/components/routes/providers-route.js new file mode 100644 index 00000000..792a8a90 --- /dev/null +++ b/lib/public/js/components/routes/providers-route.js @@ -0,0 +1,11 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { Providers } from "../providers.js"; + +const html = htm.bind(h); + +export const ProvidersRoute = ({ onRestartRequired = () => {} }) => html` +
        + <${Providers} onRestartRequired=${onRestartRequired} /> +
        +`; diff --git a/lib/public/js/components/routes/route-redirect.js b/lib/public/js/components/routes/route-redirect.js new file mode 100644 index 00000000..60df9960 --- /dev/null +++ b/lib/public/js/components/routes/route-redirect.js @@ -0,0 +1,10 @@ +import { useEffect } from "https://esm.sh/preact/hooks"; +import { useLocation } from "https://esm.sh/wouter-preact"; + +export const RouteRedirect = ({ to }) => { + const [, setLocation] = useLocation(); + useEffect(() => { + setLocation(to); + }, [to, setLocation]); + return null; +}; diff --git a/lib/public/js/components/routes/telegram-route.js b/lib/public/js/components/routes/telegram-route.js new file mode 100644 index 00000000..b0bfb834 --- /dev/null +++ b/lib/public/js/components/routes/telegram-route.js @@ -0,0 +1,11 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { TelegramWorkspace } from "../telegram-workspace/index.js"; + +const html = htm.bind(h); + +export const TelegramRoute = ({ onBack = () => {} }) => html` +
        + <${TelegramWorkspace} onBack=${onBack} /> +
        +`; diff --git a/lib/public/js/components/routes/usage-route.js b/lib/public/js/components/routes/usage-route.js new file mode 100644 index 00000000..39b578bc --- /dev/null +++ b/lib/public/js/components/routes/usage-route.js @@ -0,0 +1,15 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { UsageTab } from "../usage-tab/index.js"; + +const html = htm.bind(h); + +export const UsageRoute = ({ sessionId = "", onSetLocation = () => {} }) => html` +
        + <${UsageTab} + sessionId=${sessionId} + onSelectSession=${(id) => onSetLocation(`/usage/${encodeURIComponent(String(id || ""))}`)} + onBackToSessions=${() => onSetLocation("/usage")} + /> +
        +`; diff --git a/lib/public/js/components/routes/watchdog-route.js b/lib/public/js/components/routes/watchdog-route.js new file mode 100644 index 00000000..d7a3768f --- /dev/null +++ b/lib/public/js/components/routes/watchdog-route.js @@ -0,0 +1,32 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { WatchdogTab } from "../watchdog-tab.js"; + +const html = htm.bind(h); + +export const WatchdogRoute = ({ + statusData = null, + watchdogStatus = null, + onRefreshStatuses = () => {}, + restartingGateway = false, + onRestartGateway = () => {}, + restartSignal = 0, + openclawUpdateInProgress = false, + onOpenclawVersionActionComplete = () => {}, + onOpenclawUpdate = () => {}, +}) => html` +
        + <${WatchdogTab} + gatewayStatus=${statusData?.gateway || null} + openclawVersion=${statusData?.openclawVersion || null} + watchdogStatus=${watchdogStatus} + onRefreshStatuses=${onRefreshStatuses} + restartingGateway=${restartingGateway} + onRestartGateway=${onRestartGateway} + restartSignal=${restartSignal} + openclawUpdateInProgress=${openclawUpdateInProgress} + onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} + onOpenclawUpdate=${onOpenclawUpdate} + /> +
        +`; diff --git a/lib/public/js/components/routes/webhooks-route.js b/lib/public/js/components/routes/webhooks-route.js new file mode 100644 index 00000000..2cee435b --- /dev/null +++ b/lib/public/js/components/routes/webhooks-route.js @@ -0,0 +1,43 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { Webhooks } from "../webhooks.js"; + +const html = htm.bind(h); + +export const WebhooksRoute = ({ + hookName = "", + routeHistoryRef = null, + getCurrentPath = () => "", + onSetLocation = () => {}, + onRestartRequired = () => {}, + onNavigateToBrowseFile = () => {}, +}) => { + const handleBackToList = () => { + const historyStack = routeHistoryRef?.current || []; + const hasPreviousRoute = historyStack.length > 1; + if (!hasPreviousRoute) { + onSetLocation("/webhooks"); + return; + } + const currentPath = getCurrentPath(); + window.history.back(); + window.setTimeout(() => { + if (getCurrentPath() === currentPath) { + onSetLocation("/webhooks"); + } + }, 180); + }; + + return html` +
        + <${Webhooks} + selectedHookName=${hookName} + onSelectHook=${(name) => onSetLocation(`/webhooks/${encodeURIComponent(name)}`)} + onBackToList=${handleBackToList} + onRestartRequired=${onRestartRequired} + onOpenFile=${(relativePath) => + onNavigateToBrowseFile(String(relativePath || "").trim(), { view: "edit" })} + /> +
        + `; +}; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index e17cf137..90453f04 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -47,7 +47,6 @@ export const AppSidebar = ({ onPreviewBrowseFile = () => {}, acHasUpdate = false, acLatest = "", - acDismissed = false, acUpdating = false, onAcUpdate = () => {}, }) => { @@ -200,7 +199,7 @@ export const AppSidebar = ({ `, )}
        -
        +
        + ${item.status === "error" + ? html`<${ActionButton} + onClick=${() => + handleAskAgentToDebug(item)} + loading=${debugLoadingRequestId === + item.id} + tone="primary" + size="sm" + idleLabel="Ask agent to debug" + loadingLabel="Loading..." + className="h-7 px-2.5" + />` + : null}
        @@ -1058,6 +1135,30 @@ ${jsonPretty(item.gatewayBody)} + <${AgentSendModal} + visible=${!!debugRequest} + title="Ask agent to debug" + messageLabel="Debug request" + messageRows=${12} + initialMessage=${debugAgentMessage} + resetKey=${String(debugRequest?.id || "")} + submitLabel="Send debug request" + loadingLabel="Sending..." + onClose=${() => setDebugRequest(null)} + onSubmit=${async ({ selectedSessionKey, message }) => { + try { + await sendAgentMessage({ + message, + sessionKey: selectedSessionKey, + }); + showToast("Debug request sent to agent", "success"); + return true; + } catch (err) { + showToast(err.message || "Could not send debug request", "error"); + return false; + } + }} + />
        `; }; diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index cbe5208d..e877682f 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -58,7 +58,9 @@ const registerSystemRoutes = ({ const parseJsonFromStdout = (stdout) => { const raw = String(stdout || "").trim(); if (!raw) return null; - const candidateStarts = [raw.indexOf("{"), raw.indexOf("[")].filter((idx) => idx >= 0); + const candidateStarts = [raw.indexOf("{"), raw.indexOf("[")].filter( + (idx) => idx >= 0, + ); for (const start of candidateStarts) { const candidate = raw.slice(start); try { @@ -74,7 +76,9 @@ const registerSystemRoutes = ({ if (telegramMatch) { return `Telegram ${telegramMatch[1]}`; } - const telegramTopicMatch = key.match(/:telegram:group:([^:]+):topic:([^:]+)$/); + const telegramTopicMatch = key.match( + /:telegram:group:([^:]+):topic:([^:]+)$/, + ); if (telegramTopicMatch) { const [, groupId, topicId] = telegramTopicMatch; let groupEntry = null; @@ -82,7 +86,9 @@ const registerSystemRoutes = ({ groupEntry = topicRegistry?.getGroup?.(groupId) || null; } catch {} const groupName = String(groupEntry?.name || "").trim(); - const topicName = String(groupEntry?.topics?.[topicId]?.name || "").trim(); + const topicName = String( + groupEntry?.topics?.[topicId]?.name || "", + ).trim(); if (groupName && topicName) return `Telegram ${groupName} · ${topicName}`; if (topicName) return `Telegram Topic ${topicName}`; return `Telegram Topic ${topicId}`; @@ -265,13 +271,19 @@ const registerSystemRoutes = ({ ); const hiddenKnownVarKeys = new Set( kKnownVars - .filter((def) => !isReservedUserEnvVar(def.key) && !isVisibleInEnvars(def)) + .filter( + (def) => !isReservedUserEnvVar(def.key) && !isVisibleInEnvars(def), + ) .map((def) => def.key), ); const existingHiddenKnownVars = readEnvFile().filter((v) => hiddenKnownVarKeys.has(v.key), ); - const nextEnvVars = [...filtered, ...existingHiddenKnownVars, ...existingLockedVars]; + const nextEnvVars = [ + ...filtered, + ...existingHiddenKnownVars, + ...existingLockedVars, + ]; syncChannelConfig(nextEnvVars, "remove"); writeEnvFile(nextEnvVars); const changed = reloadEnv(); @@ -401,12 +413,15 @@ const registerSystemRoutes = ({ let selectedSession = null; try { const sessions = await listSendableAgentSessions(); - selectedSession = sessions.find((sessionRow) => sessionRow.key === sessionKey) || null; + selectedSession = + sessions.find((sessionRow) => sessionRow.key === sessionKey) || null; } catch (err) { return res.status(502).json({ ok: false, error: err.message }); } if (!selectedSession) { - return res.status(400).json({ ok: false, error: "Selected session was not found" }); + return res + .status(400) + .json({ ok: false, error: "Selected session was not found" }); } if (selectedSession.replyChannel && selectedSession.replyTo) { command += @@ -420,7 +435,10 @@ const registerSystemRoutes = ({ if (!result.ok) { return res .status(502) - .json({ ok: false, error: result.stderr || "Could not send message to agent" }); + .json({ + ok: false, + error: result.stderr || "Could not send message to agent", + }); } return res.json({ ok: true, stdout: result.stdout || "" }); }); From 2323cb1781e0a3e3a85a0f0d55e561a36f0b424d Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Fri, 6 Mar 2026 17:01:57 -0800 Subject: [PATCH 118/417] 0.4.6-beta.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f10f6e5b..3e618e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.6", + "version": "0.4.6-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.6", + "version": "0.4.6-beta.7", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 1b9c74f4..822236cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.6", + "version": "0.4.6-beta.7", "publishConfig": { "access": "public" }, From f29334212fc5bdd473770b55164e98807808beda Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 09:31:14 -0800 Subject: [PATCH 119/417] Add Drift Doctor Project Context truncation warnings. Surface persistent truncation and near-limit warnings for injected workspace files so users can catch hidden instruction loss before it causes agent drift. --- lib/public/js/components/doctor/helpers.js | 76 +++++++- lib/public/js/components/doctor/index.js | 117 ++++++++++--- lib/server/doctor/bootstrap-context.js | 191 ++++++++++++++++++++ lib/server/doctor/prompt.js | 24 ++- lib/server/doctor/service.js | 22 ++- tests/frontend/doctor-helpers.test.js | 117 ++++++++++++- tests/server/doctor-prompt.test.js | 12 ++ tests/server/doctor-service.test.js | 195 +++++++++++++++++++++ 8 files changed, 708 insertions(+), 46 deletions(-) create mode 100644 lib/server/doctor/bootstrap-context.js diff --git a/lib/public/js/components/doctor/helpers.js b/lib/public/js/components/doctor/helpers.js index 43c0996d..cba0c097 100644 --- a/lib/public/js/components/doctor/helpers.js +++ b/lib/public/js/components/doctor/helpers.js @@ -1,12 +1,16 @@ export const getDoctorPriorityTone = (priority = "") => { - const normalized = String(priority || "").trim().toUpperCase(); + const normalized = String(priority || "") + .trim() + .toUpperCase(); if (normalized === "P0") return "danger"; if (normalized === "P1") return "warning"; return "neutral"; }; export const getDoctorStatusTone = (status = "") => { - const normalized = String(status || "").trim().toLowerCase(); + const normalized = String(status || "") + .trim() + .toLowerCase(); if (normalized === "fixed") return "success"; if (normalized === "dismissed") return "neutral"; return "warning"; @@ -35,7 +39,9 @@ export const formatDoctorCategory = (category = "") => { export const buildDoctorPriorityCounts = (cards = []) => cards.reduce( (totals, card) => { - const priority = String(card?.priority || "").trim().toUpperCase(); + const priority = String(card?.priority || "") + .trim() + .toUpperCase(); if (priority === "P0" || priority === "P1" || priority === "P2") { totals[priority] += 1; } @@ -47,7 +53,9 @@ export const buildDoctorPriorityCounts = (cards = []) => export const groupDoctorCardsByStatus = (cards = []) => cards.reduce( (groups, card) => { - const status = String(card?.status || "open").trim().toLowerCase(); + const status = String(card?.status || "open") + .trim() + .toLowerCase(); if (status === "fixed") { groups.fixed.push(card); return groups; @@ -74,13 +82,71 @@ export const shouldShowDoctorWarning = ( export const getDoctorWarningMessage = (doctorStatus = null) => { if (!doctorStatus) return ""; - const changedFilesCount = Number(doctorStatus.changeSummary?.changedFilesCount || 0); + const changedFilesCount = Number( + doctorStatus.changeSummary?.changedFilesCount || 0, + ); if (changedFilesCount > 0) { return `Drift Doctor has not been run in the last week and ${changedFilesCount} file${changedFilesCount === 1 ? "" : "s"} changed since the last review.`; } return "Doctor has not been run in the last week."; }; +export const formatDoctorCharCount = (value = 0) => + `${Number(value || 0).toLocaleString()} chars`; + +const isManagedBootstrapContextPath = (filePath = "") => + String(filePath || "").startsWith("hooks/bootstrap/"); + +export const getDoctorBootstrapTruncationItems = (doctorStatus = null) => { + const bootstrapContext = doctorStatus?.bootstrapContext; + const truncatedFiles = (bootstrapContext?.activeTruncatedFiles || []).filter( + (file) => !isManagedBootstrapContextPath(file?.path), + ); + const nearLimitFiles = (bootstrapContext?.activeNearLimitFiles || []).filter( + (file) => !isManagedBootstrapContextPath(file?.path), + ); + return [ + ...truncatedFiles.map((file) => ({ + path: file.path, + size: formatDoctorCharCount(file.rawChars), + statusText: `-${Number( + Math.max( + 0, + Number(file.rawChars || 0) - Number(file.injectedChars || 0), + ), + ).toLocaleString()} cut`, + statusTone: "danger", + })), + ...nearLimitFiles.map((file) => ({ + path: file.path, + size: formatDoctorCharCount(file.rawChars), + statusText: "Near limit", + statusTone: "warning", + })), + ]; +}; + +export const hasDoctorBootstrapWarnings = (doctorStatus = null) => + getDoctorBootstrapTruncationItems(doctorStatus).length > 0; + +export const getDoctorBootstrapWarningTitle = (doctorStatus = null) => { + const items = getDoctorBootstrapTruncationItems(doctorStatus); + if (!items.length) return ""; + const hasTruncatedItems = items.some((item) => item.statusTone === "danger"); + const hasNearLimitItems = items.some((item) => item.statusTone === "warning"); + if (hasTruncatedItems && hasNearLimitItems) { + return "Some of your main files are being truncated or nearing the limit:"; + } + if (hasNearLimitItems) { + return items.length === 1 + ? "One of your main files is nearing the limit:" + : "Some of your main files are nearing the limit:"; + } + return items.length === 1 + ? "One of your main files is being truncated:" + : "Some of your main files are being truncated:"; +}; + export const getDoctorChangeLabel = (changeSummary = null) => { const changedFilesCount = Number(changeSummary?.changedFilesCount || 0); if (changedFilesCount === 0) return "No changes since last run"; diff --git a/lib/public/js/components/doctor/index.js b/lib/public/js/components/doctor/index.js index 86c5c485..ab9d0e64 100644 --- a/lib/public/js/components/doctor/index.js +++ b/lib/public/js/components/doctor/index.js @@ -20,8 +20,11 @@ import { DoctorFixCardModal } from "./fix-card-modal.js"; import { buildDoctorRunMarkers, buildDoctorStatusFilterOptions, + getDoctorBootstrapTruncationItems, + getDoctorBootstrapWarningTitle, getDoctorChangeLabel, getDoctorRunPillDetail, + hasDoctorBootstrapWarnings, shouldShowDoctorWarning, } from "./helpers.js"; @@ -182,6 +185,18 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => { () => shouldShowDoctorWarning(doctorStatus, 0), [doctorStatus], ); + const showBootstrapTruncationBanner = useMemo( + () => hasDoctorBootstrapWarnings(doctorStatus), + [doctorStatus], + ); + const bootstrapTruncationMessage = useMemo( + () => getDoctorBootstrapWarningTitle(doctorStatus), + [doctorStatus], + ); + const bootstrapTruncationItems = useMemo( + () => getDoctorBootstrapTruncationItems(doctorStatus), + [doctorStatus], + ); const hasCompletedDoctorRun = !!doctorStatus?.lastRunAt; const hasRuns = runs.length > 0; const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null; @@ -312,29 +327,76 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => { : null} ${!showInitialLoadingState && hasRuns ? html` - <${DoctorSummaryCards} cards=${openCards} /> -
        - ${hasCompletedDoctorRun - ? html` -
        - - Last run ·${" "} - - ${formatLocaleDateTime(doctorStatus?.lastRunAt, { - fallback: "Never", - })} +
        + <${DoctorSummaryCards} cards=${openCards} /> +
        + ${hasCompletedDoctorRun + ? html` +
        + + Last run ·${" "} + + ${formatLocaleDateTime(doctorStatus?.lastRunAt, { + fallback: "Never", + })} + - - - ${changeLabel} - -
        - ` - : null} - ${ - showDoctorStaleBanner + + ${changeLabel} + +
        + ${showBootstrapTruncationBanner + ? html` +
        +
        + ⚠️ ${bootstrapTruncationMessage} +
        +
        + ${bootstrapTruncationItems.map( + (item) => html` +
        + + + + ${item.size} + + + ${item.statusText} + + +
        + `, + )} +
        +
        +

        + Truncated files become partially hidden from + your agent and could cause drift. +

        +
        + ` + : null} + ` + : null} + ${showDoctorStaleBanner ? html`
        {} }) => { changed.
        ` - : null - } + : null} +
        ` : null} @@ -461,9 +523,7 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => { ${selectedRunIsInProgress ? html`
        -
        +
        Run in progress. Findings will appear when analysis completes. {} }) => { onAskAgentFix=${setFixCard} onUpdateStatus=${handleUpdateStatus} onOpenFile=${onOpenFile} - changedPaths=${doctorStatus?.changeSummary?.changedPaths || []} + changedPaths=${doctorStatus?.changeSummary?.changedPaths || + []} showRunMeta=${selectedRunFilter === "all"} hideEmptyState=${selectedRunIsInProgress} /> diff --git a/lib/server/doctor/bootstrap-context.js b/lib/server/doctor/bootstrap-context.js new file mode 100644 index 00000000..4cea8b70 --- /dev/null +++ b/lib/server/doctor/bootstrap-context.js @@ -0,0 +1,191 @@ +const fs = require("fs"); +const path = require("path"); + +const kDoctorBootstrapMaxChars = 20000; +const kDoctorBootstrapTotalMaxChars = 150000; +const kDoctorBootstrapNearLimitRatio = 0.9; +const kDoctorContextTruncationGuidance = + "OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning."; + +const kDoctorRootContextFiles = [ + { path: "AGENTS.md", injectMode: "always" }, + { path: "SOUL.md", injectMode: "always" }, + { path: "TOOLS.md", injectMode: "always" }, + { path: "IDENTITY.md", injectMode: "always" }, + { path: "USER.md", injectMode: "always" }, + { path: "HEARTBEAT.md", injectMode: "always" }, + { path: "BOOTSTRAP.md", injectMode: "first_run_only" }, +]; + +const kDoctorBootstrapExtraFiles = [ + { path: "hooks/bootstrap/AGENTS.md", injectMode: "always" }, + { path: "hooks/bootstrap/TOOLS.md", injectMode: "always" }, +]; + +const kDoctorBootstrapContextFiles = [...kDoctorRootContextFiles, ...kDoctorBootstrapExtraFiles]; + +const readWorkspaceFileChars = (workspaceRoot, relativePath) => { + const fullPath = path.join(workspaceRoot, relativePath); + try { + const content = fs.readFileSync(fullPath, "utf8"); + return { + exists: true, + chars: content.length, + }; + } catch { + return { + exists: false, + chars: 0, + }; + } +}; + +const analyzeBootstrapContext = ({ + workspaceRoot = "", + bootstrapMaxChars = kDoctorBootstrapMaxChars, + bootstrapTotalMaxChars = kDoctorBootstrapTotalMaxChars, +} = {}) => { + const files = kDoctorBootstrapContextFiles.map((spec) => { + const fileState = readWorkspaceFileChars(workspaceRoot, spec.path); + const rawChars = fileState.chars; + const fileLimitChars = Math.min(rawChars, bootstrapMaxChars); + const nearFileLimit = rawChars > 0 && rawChars >= Math.floor(bootstrapMaxChars * kDoctorBootstrapNearLimitRatio); + return { + ...spec, + exists: fileState.exists, + rawChars, + fileLimitChars, + injectedChars: 0, + truncatedByFileLimit: rawChars > bootstrapMaxChars, + truncatedByTotalLimit: false, + truncated: rawChars > bootstrapMaxChars, + nearFileLimit: nearFileLimit && rawChars <= bootstrapMaxChars, + active: spec.injectMode === "always", + reason: rawChars > bootstrapMaxChars ? "file_limit" : "", + }; + }); + + let injectedTotalChars = 0; + for (const file of files) { + if (!file.active || !file.exists) continue; + const remainingChars = Math.max(0, bootstrapTotalMaxChars - injectedTotalChars); + file.injectedChars = Math.min(file.fileLimitChars, remainingChars); + file.truncatedByTotalLimit = file.fileLimitChars > file.injectedChars; + file.truncated = file.truncatedByFileLimit || file.truncatedByTotalLimit; + if (file.truncatedByFileLimit && file.truncatedByTotalLimit) { + file.reason = "file_and_total_limit"; + } else if (file.truncatedByFileLimit) { + file.reason = "file_limit"; + } else if (file.truncatedByTotalLimit) { + file.reason = "total_limit"; + } + injectedTotalChars += file.injectedChars; + } + + const activeFiles = files.filter((file) => file.active && file.exists); + const activeTruncatedFiles = activeFiles.filter((file) => file.truncated); + const activeNearLimitFiles = activeFiles.filter((file) => file.nearFileLimit && !file.truncated); + const inactiveTruncatedFiles = files.filter((file) => !file.active && file.exists && file.truncated); + const hasTotalLimitTruncation = activeTruncatedFiles.some( + (file) => file.reason === "total_limit" || file.reason === "file_and_total_limit", + ); + + return { + bootstrapMaxChars, + bootstrapTotalMaxChars, + truncationGuidance: kDoctorContextTruncationGuidance, + files, + activeFiles, + activeRawChars: activeFiles.reduce((sum, file) => sum + file.rawChars, 0), + activeInjectedChars: activeFiles.reduce((sum, file) => sum + file.injectedChars, 0), + hasActiveTruncation: activeTruncatedFiles.length > 0, + hasActiveNearLimitFiles: activeNearLimitFiles.length > 0, + hasActiveWarnings: activeTruncatedFiles.length > 0 || activeNearLimitFiles.length > 0, + hasAnyTruncation: activeTruncatedFiles.length > 0 || inactiveTruncatedFiles.length > 0, + activeTruncatedFiles, + activeNearLimitFiles, + inactiveTruncatedFiles, + hasTotalLimitTruncation, + totalLimitReached: injectedTotalChars >= bootstrapTotalMaxChars, + }; +}; + +const formatChars = (value = 0) => `${Number(value || 0).toLocaleString()} chars`; + +const buildBootstrapTruncationCards = (bootstrapContext = null) => { + if (!bootstrapContext?.hasActiveTruncation) return []; + + const cards = bootstrapContext.activeTruncatedFiles + .filter((file) => file.reason === "file_limit") + .map((file) => ({ + priority: "P0", + category: "project context", + title: `${file.path} is being truncated in Project Context`, + summary: + `${file.path} is ${formatChars(file.rawChars)}, above the per-file Project Context limit ` + + `of ${formatChars(bootstrapContext.bootstrapMaxChars)}. The agent is not seeing the full file.`, + recommendation: + `Move the most important rules to the top of ${file.path}, shorten or split low-priority content, ` + + `and increase OpenClaw's bootstrap limits if this file legitimately needs more room. ` + + kDoctorContextTruncationGuidance, + evidence: [ + { type: "path", path: file.path }, + { + type: "text", + text: + `Raw size: ${formatChars(file.rawChars)}. ` + + `Per-file limit: ${formatChars(bootstrapContext.bootstrapMaxChars)}.`, + }, + ], + targetPaths: [{ path: file.path }], + fixPrompt: + `Reorganize ${file.path} so the most important instructions appear at the top and reduce unnecessary length. ` + + `Do not change unrelated behavior.`, + status: "open", + })); + + const totalLimitedFiles = bootstrapContext.activeTruncatedFiles.filter( + (file) => file.reason === "total_limit" || file.reason === "file_and_total_limit", + ); + if (totalLimitedFiles.length > 0) { + cards.unshift({ + priority: "P0", + category: "project context", + title: "Project Context total bootstrap limit is truncating injected files", + summary: + `Injected workspace guidance needs ${formatChars(bootstrapContext.activeRawChars)} raw across active ` + + `Project Context files, exceeding the total bootstrap budget of ` + + `${formatChars(bootstrapContext.bootstrapTotalMaxChars)}.`, + recommendation: + `Reduce total Project Context size across injected guidance files, keep critical instructions near the top, ` + + `and raise OpenClaw's total bootstrap budget if the workspace legitimately needs more injected guidance. ` + + kDoctorContextTruncationGuidance, + evidence: totalLimitedFiles.map((file) => ({ + type: "text", + text: + `${file.path}: raw ${formatChars(file.rawChars)}, injected ${formatChars(file.injectedChars)} ` + + `before the total limit stopped more content from being included.`, + })), + targetPaths: totalLimitedFiles.map((file) => ({ path: file.path })), + fixPrompt: + `Reduce the combined size of the affected Project Context files and keep the most important instructions near the top. ` + + `Only edit the files listed in the finding.`, + status: "open", + }); + } + + return cards; +}; + +module.exports = { + analyzeBootstrapContext, + buildBootstrapTruncationCards, + formatChars, + kDoctorBootstrapContextFiles, + kDoctorBootstrapExtraFiles, + kDoctorBootstrapMaxChars, + kDoctorBootstrapNearLimitRatio, + kDoctorBootstrapTotalMaxChars, + kDoctorContextTruncationGuidance, + kDoctorRootContextFiles, +}; diff --git a/lib/server/doctor/prompt.js b/lib/server/doctor/prompt.js index 5014b819..25e3a583 100644 --- a/lib/server/doctor/prompt.js +++ b/lib/server/doctor/prompt.js @@ -1,6 +1,17 @@ +const { + kDoctorBootstrapExtraFiles, + kDoctorBootstrapMaxChars, + kDoctorBootstrapTotalMaxChars, + kDoctorContextTruncationGuidance, + kDoctorRootContextFiles, +} = require("./bootstrap-context"); + const renderList = (items = []) => items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)"; +const renderContextFileList = (files = []) => + files.map((file) => `\`${file.path}\``).join(", "); + const renderResolvedCards = (cards = []) => { if (!cards.length) return ""; const lines = cards.map( @@ -37,10 +48,15 @@ Important: - Return ONLY valid JSON. No markdown fences. No extra prose. OpenClaw context injection: -- OpenClaw automatically injects a fixed set of named workspace files into the agent's context window ("Project Context") on every turn. The exact set is: \`AGENTS.md\`, \`SOUL.md\`, \`TOOLS.md\`, \`IDENTITY.md\`, \`USER.md\`, \`HEARTBEAT.md\`, and \`BOOTSTRAP.md\` (first-run only). -- Only these specific files are auto-injected. Other \`.md\` files at the workspace root (e.g. INTERESTS.md, MEMORY.md, README.md) are NOT injected and must be explicitly read by the agent. -- Do not flag auto-injected files as orphaned or unreferenced — they are loaded by the runtime, not by explicit file references in AGENTS.md. -- Additionally, AlphaClaw injects bootstrap files from \`hooks/bootstrap/\` (e.g. AGENTS.md, TOOLS.md) as extra context on every turn. +- OpenClaw automatically injects these workspace files into the agent's Project Context: ${renderContextFileList( + kDoctorRootContextFiles, + )}. +- \`BOOTSTRAP.md\` is first-run only; the others above are injected on normal turns when present. +- Additionally, AlphaClaw injects these extra bootstrap files on normal turns when present: ${renderContextFileList( + kDoctorBootstrapExtraFiles, + )}. +- Large injected files are truncated per-file at ${kDoctorBootstrapMaxChars} chars by default, and total bootstrap injection across files is capped at ${kDoctorBootstrapTotalMaxChars} chars by default. +- ${kDoctorContextTruncationGuidance} OpenClaw default context: - \`AGENTS.md\` is the workspace home file in the default OpenClaw template. It may intentionally include first-run instructions, session-startup guidance, memory conventions, safety rules, tool pointers, and optional behavioral guidance. diff --git a/lib/server/doctor/service.js b/lib/server/doctor/service.js index 73f7e6fb..678004e2 100644 --- a/lib/server/doctor/service.js +++ b/lib/server/doctor/service.js @@ -1,5 +1,9 @@ const fs = require("fs"); const path = require("path"); +const { + analyzeBootstrapContext, + buildBootstrapTruncationCards, +} = require("./bootstrap-context"); const { buildDoctorPrompt } = require("./prompt"); const { normalizeDoctorResult } = require("./normalize"); const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint"); @@ -137,6 +141,7 @@ const createDoctorService = ({ }; const buildStatus = () => { + const bootstrapContext = analyzeBootstrapContext({ workspaceRoot }); const recentRuns = listDoctorRuns({ limit: 10 }); const latestRun = recentRuns[0] || null; const latestCompletedRun = @@ -179,6 +184,7 @@ const createDoctorService = ({ lastRunAgeMs, needsInitialRun: !latestCompletedRun, stale, + bootstrapContext, changeSummary: { ...delta, hasBaseline: hasManifestBaseline, @@ -245,10 +251,14 @@ const createDoctorService = ({ console.error(`[doctor] run ${runId} stderr end`); throw error; } - captureEvidenceSnippets(normalizedResult.cards, workspaceRoot); + const bootstrapTruncationCards = buildBootstrapTruncationCards( + analyzeBootstrapContext({ workspaceRoot }), + ); + const cards = [...bootstrapTruncationCards, ...normalizedResult.cards]; + captureEvidenceSnippets(cards, workspaceRoot); insertDoctorCards({ runId, - cards: normalizedResult.cards, + cards, }); completeDoctorRun({ id: runId, @@ -342,7 +352,11 @@ const createDoctorService = ({ throw new Error("Doctor import requires raw output"); } const normalizedResult = normalizeDoctorResult(normalizedRawOutput); - captureEvidenceSnippets(normalizedResult.cards, workspaceRoot); + const bootstrapTruncationCards = buildBootstrapTruncationCards( + analyzeBootstrapContext({ workspaceRoot }), + ); + const cards = [...bootstrapTruncationCards, ...normalizedResult.cards]; + captureEvidenceSnippets(cards, workspaceRoot); const workspaceSnapshot = getCurrentWorkspaceSnapshot(); const runId = createDoctorRun({ status: kDoctorRunStatus.completed, @@ -354,7 +368,7 @@ const createDoctorService = ({ }); insertDoctorCards({ runId, - cards: normalizedResult.cards, + cards, }); completeDoctorRun({ id: runId, diff --git a/tests/frontend/doctor-helpers.test.js b/tests/frontend/doctor-helpers.test.js index 531cbe5c..b0e3f611 100644 --- a/tests/frontend/doctor-helpers.test.js +++ b/tests/frontend/doctor-helpers.test.js @@ -73,13 +73,17 @@ describe("frontend/doctor helpers", () => { stale: true, changeSummary: { changedFilesCount: 3 }, }), - ).toBe("Drift Doctor has not been run in the last week and 3 files changed since the last review."); + ).toBe( + "Drift Doctor has not been run in the last week and 3 files changed since the last review.", + ); }); it("formats categories and run filter options", async () => { const helpers = await loadDoctorHelpers(); - expect(helpers.formatDoctorCategory("token_efficiency")).toBe("Token Efficiency"); + expect(helpers.formatDoctorCategory("token_efficiency")).toBe( + "Token Efficiency", + ); expect(helpers.getDoctorCategoryTone("token_efficiency")).toBe("info"); expect(helpers.getDoctorCategoryTone("redundancy")).toBe("accent"); expect(helpers.getDoctorCategoryTone("workspace_state")).toBe("secondary"); @@ -106,9 +110,9 @@ describe("frontend/doctor helpers", () => { }), ).toEqual([{ tone: "cyan", count: 0, label: "Running" }]); expect(helpers.getDoctorRunPillDetail({ status: "failed" })).toBe("Failed"); - expect(helpers.getDoctorRunPillDetail({ status: "completed", cardCount: 0 })).toBe( - "No findings", - ); + expect( + helpers.getDoctorRunPillDetail({ status: "completed", cardCount: 0 }), + ).toBe("No findings"); expect(helpers.getDoctorChangeLabel({ changedFilesCount: 0 })).toBe( "No changes since last run", ); @@ -125,4 +129,107 @@ describe("frontend/doctor helpers", () => { { value: "fixed", label: "Fixed" }, ]); }); + + it("formats persistent Project Context truncation warnings", async () => { + const helpers = await loadDoctorHelpers(); + const fileLimitStatus = { + bootstrapContext: { + hasActiveTruncation: true, + hasActiveNearLimitFiles: false, + hasActiveWarnings: true, + hasTotalLimitTruncation: false, + bootstrapMaxChars: 20000, + bootstrapTotalMaxChars: 150000, + truncationGuidance: + "OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.", + activeTruncatedFiles: [ + { path: "AGENTS.md", rawChars: 24500, injectedChars: 20000 }, + ], + activeNearLimitFiles: [], + }, + }; + const multiFileStatus = { + bootstrapContext: { + hasActiveTruncation: true, + hasActiveNearLimitFiles: true, + hasActiveWarnings: true, + hasTotalLimitTruncation: true, + bootstrapMaxChars: 20000, + bootstrapTotalMaxChars: 150000, + truncationGuidance: + "OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.", + activeTruncatedFiles: [ + { path: "AGENTS.md", rawChars: 24500, injectedChars: 20000 }, + { + path: "hooks/bootstrap/AGENTS.md", + rawChars: 1800, + injectedChars: 1000, + }, + ], + activeNearLimitFiles: [ + { path: "TOOLS.md", rawChars: 20000 }, + { + path: "hooks/bootstrap/TOOLS.md", + rawChars: 19500, + injectedChars: 19500, + }, + ], + }, + }; + + const nearLimitStatus = { + bootstrapContext: { + hasActiveTruncation: false, + hasActiveNearLimitFiles: true, + hasActiveWarnings: true, + bootstrapMaxChars: 20000, + bootstrapTotalMaxChars: 150000, + activeTruncatedFiles: [], + activeNearLimitFiles: [ + { path: "TOOLS.md", rawChars: 19000, injectedChars: 19000 }, + ], + }, + }; + + expect(helpers.hasDoctorBootstrapWarnings(fileLimitStatus)).toBe(true); + expect(helpers.getDoctorBootstrapWarningTitle(fileLimitStatus)).toBe( + "One of your main files is being truncated:", + ); + expect(helpers.getDoctorBootstrapWarningTitle(multiFileStatus)).toBe( + "Some of your main files are being truncated or nearing the limit:", + ); + expect(helpers.getDoctorBootstrapTruncationItems(multiFileStatus)).toEqual([ + { + path: "AGENTS.md", + size: "24,500 chars", + statusText: "-4,500 cut", + statusTone: "danger", + }, + { + path: "TOOLS.md", + size: "20,000 chars", + statusText: "Near limit", + statusTone: "warning", + }, + ]); + expect(helpers.getDoctorBootstrapWarningTitle(nearLimitStatus)).toBe( + "One of your main files is nearing the limit:", + ); + expect(helpers.getDoctorBootstrapTruncationItems(fileLimitStatus)).toEqual([ + { + path: "AGENTS.md", + size: "24,500 chars", + statusText: "-4,500 cut", + statusTone: "danger", + }, + ]); + expect(helpers.getDoctorBootstrapTruncationItems(nearLimitStatus)).toEqual([ + { + path: "TOOLS.md", + size: "19,000 chars", + statusText: "Near limit", + statusTone: "warning", + }, + ]); + }); }); diff --git a/tests/server/doctor-prompt.test.js b/tests/server/doctor-prompt.test.js index 60bbf704..aa155fed 100644 --- a/tests/server/doctor-prompt.test.js +++ b/tests/server/doctor-prompt.test.js @@ -13,6 +13,18 @@ describe("server/doctor-prompt", () => { expect(prompt).toContain("Do not treat default-template content as drift just because it is broad or multi-purpose."); }); + it("includes Project Context truncation guidance", () => { + const prompt = buildDoctorPrompt({ + workspaceRoot: "/tmp/workspace", + managedRoot: "/tmp/managed", + promptVersion: "doctor-v1", + }); + + expect(prompt).toContain("Large injected files are truncated per-file at 20000 chars by default"); + expect(prompt).toContain("OpenClaw trims oversized injected files by keeping the first 70%"); + expect(prompt).toContain("`BOOTSTRAP.md` is first-run only"); + }); + it("tells the analyzer not to propose structural changes to AlphaClaw-managed files", () => { const prompt = buildDoctorPrompt({ workspaceRoot: "/tmp/workspace", diff --git a/tests/server/doctor-service.test.js b/tests/server/doctor-service.test.js index cf5290e3..da9c51e8 100644 --- a/tests/server/doctor-service.test.js +++ b/tests/server/doctor-service.test.js @@ -14,6 +14,8 @@ const loadDoctorService = () => { return require(modulePath); }; +const repeatText = (length, character = "A") => character.repeat(length); + describe("server/doctor-service", () => { it("reuses the previous completed run when the workspace fingerprint is unchanged", () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-workspace-")); @@ -232,4 +234,197 @@ describe("server/doctor-service", () => { expect(nextStatus.changeSummary.changedFilesCount).toBe(1); expect(nextStatus.changeSummary.hasMeaningfulChanges).toBe(false); }); + + it("reports healthy Project Context files without truncation", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-healthy-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-healthy-db-")); + fs.writeFileSync(path.join(workspaceRoot, "AGENTS.md"), "# Guidance\nKeep it short.\n", "utf8"); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const { createDoctorService } = loadDoctorService(); + const doctorService = createDoctorService({ + clawCmd: vi.fn(), + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const status = doctorService.buildStatus(); + + expect(status.bootstrapContext.hasActiveTruncation).toBe(false); + expect(status.bootstrapContext.activeTruncatedFiles).toEqual([]); + }); + + it("reports per-file Project Context truncation in Doctor status", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-file-limit-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-file-limit-db-")); + fs.writeFileSync(path.join(workspaceRoot, "AGENTS.md"), repeatText(20001), "utf8"); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const { createDoctorService } = loadDoctorService(); + const doctorService = createDoctorService({ + clawCmd: vi.fn(), + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const status = doctorService.buildStatus(); + + expect(status.bootstrapContext.hasActiveTruncation).toBe(true); + expect(status.bootstrapContext.hasTotalLimitTruncation).toBe(false); + expect(status.bootstrapContext.activeTruncatedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "AGENTS.md", + rawChars: 20001, + truncatedByFileLimit: true, + truncatedByTotalLimit: false, + reason: "file_limit", + }), + ]), + ); + }); + + it("reports total Project Context truncation when active injected files exceed the total cap", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-total-limit-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-total-limit-db-")); + const activeProjectContextFiles = [ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "hooks/bootstrap/AGENTS.md", + "hooks/bootstrap/TOOLS.md", + ]; + fs.mkdirSync(path.join(workspaceRoot, "hooks", "bootstrap"), { recursive: true }); + for (const filePath of activeProjectContextFiles) { + fs.writeFileSync(path.join(workspaceRoot, filePath), repeatText(20000), "utf8"); + } + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const { createDoctorService } = loadDoctorService(); + const doctorService = createDoctorService({ + clawCmd: vi.fn(), + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const status = doctorService.buildStatus(); + + expect(status.bootstrapContext.hasActiveTruncation).toBe(true); + expect(status.bootstrapContext.hasTotalLimitTruncation).toBe(true); + expect(status.bootstrapContext.activeInjectedChars).toBe( + status.bootstrapContext.bootstrapTotalMaxChars, + ); + expect(status.bootstrapContext.activeTruncatedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "hooks/bootstrap/TOOLS.md", + truncatedByTotalLimit: true, + }), + ]), + ); + }); + + it("adds deterministic truncation cards alongside imported Doctor findings", () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-import-")); + const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bootstrap-import-db-")); + fs.writeFileSync(path.join(workspaceRoot, "AGENTS.md"), repeatText(20001), "utf8"); + + const doctorDb = loadDoctorDb(); + doctorDb.initDoctorDb({ rootDir: dbRoot }); + + const { createDoctorService } = loadDoctorService(); + const doctorService = createDoctorService({ + clawCmd: vi.fn(), + listDoctorRuns: doctorDb.listDoctorRuns, + listDoctorCards: doctorDb.listDoctorCards, + getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline, + setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline, + createDoctorRun: doctorDb.createDoctorRun, + completeDoctorRun: doctorDb.completeDoctorRun, + insertDoctorCards: doctorDb.insertDoctorCards, + getDoctorRun: doctorDb.getDoctorRun, + getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId, + getDoctorCard: doctorDb.getDoctorCard, + updateDoctorCardStatus: doctorDb.updateDoctorCardStatus, + workspaceRoot, + managedRoot: workspaceRoot, + }); + + const imported = doctorService.importDoctorResult({ + rawOutput: JSON.stringify({ + summary: "Imported findings", + cards: [ + { + priority: "P2", + category: "workspace", + title: "Small cleanup", + summary: "Minor cleanup item", + recommendation: "Tidy the note", + evidence: [{ type: "path", path: "AGENTS.md" }], + targetPaths: ["AGENTS.md"], + fixPrompt: "Tidy the note safely.", + status: "open", + }, + ], + }), + }); + + const cards = doctorDb.getDoctorCardsByRunId(imported.runId); + + expect(cards).toHaveLength(2); + expect(cards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + priority: "P0", + title: "AGENTS.md is being truncated in Project Context", + }), + expect.objectContaining({ + priority: "P2", + title: "Small cleanup", + }), + ]), + ); + }); }); From 351deb4f9836e865ee72703c05379c2f1a9adc83 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 09:33:11 -0800 Subject: [PATCH 120/417] Increase JSON body parser limit to 5mb for large file saves. --- lib/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server.js b/lib/server.js index 3a396859..82a2b120 100644 --- a/lib/server.js +++ b/lib/server.js @@ -131,7 +131,7 @@ const app = express(); app.set("trust proxy", kTrustProxyHops); app.use(["/webhook", "/hooks"], express.raw({ type: "*/*", limit: "5mb" })); app.use("/gmail-pubsub", express.raw({ type: "*/*", limit: "5mb" })); -app.use(express.json()); +app.use(express.json({ limit: "5mb" })); const proxy = httpProxy.createProxyServer({ target: GATEWAY_URL, From dbb0a3ba1c180001a6a900dc3f5261135f2c3b7b Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 09:42:16 -0800 Subject: [PATCH 121/417] 0.4.6-beta.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e618e0c..00d17dca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.7", + "version": "0.4.6-beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.7", + "version": "0.4.6-beta.8", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 822236cb..7233d9bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.7", + "version": "0.4.6-beta.8", "publishConfig": { "access": "public" }, From 6239ef57b1a95587e62866d4aa8ecdeda52f5101 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 12:17:41 -0800 Subject: [PATCH 122/417] Improve onboarding import flow for existing repos. Split GitHub import into source and target repos, normalize imported OpenClaw config for AlphaClaw runtime expectations, and keep deployment-managed env/git behavior from mutating the source workspace unexpectedly. --- AGENTS.md | 2 +- bin/alphaclaw.js | 34 +- lib/public/css/theme.css | 19 + lib/public/js/components/envars.js | 1 - .../components/onboarding/welcome-config.js | 56 ++- .../onboarding/welcome-form-step.js | 186 ++++++-- .../onboarding/welcome-import-step.js | 306 ++++++++++++++ .../onboarding/welcome-secret-review-step.js | 190 +++++++++ lib/public/js/components/segmented-control.js | 8 +- lib/public/js/components/welcome.js | 341 +++++++++++---- lib/public/js/lib/api.js | 38 +- lib/server.js | 9 +- lib/server/constants.js | 1 - lib/server/gateway.js | 52 +-- lib/server/onboarding/github.js | 108 ++++- lib/server/onboarding/import-applier.js | 279 ++++++++++++ lib/server/onboarding/import-scanner.js | 398 ++++++++++++++++++ lib/server/onboarding/index.js | 288 +++++++++++-- lib/server/onboarding/secret-detector.js | 289 +++++++++++++ lib/server/onboarding/workspace.js | 32 +- lib/server/routes/onboarding.js | 185 +++++++- tests/frontend/api.test.js | 2 +- tests/server/import-applier.test.js | 124 ++++++ tests/server/import-scanner.test.js | 261 ++++++++++++ tests/server/routes-onboarding.test.js | 171 ++++++-- tests/server/secret-detector.test.js | 271 ++++++++++++ 26 files changed, 3344 insertions(+), 307 deletions(-) create mode 100644 lib/public/js/components/onboarding/welcome-import-step.js create mode 100644 lib/public/js/components/onboarding/welcome-secret-review-step.js create mode 100644 lib/server/onboarding/import-applier.js create mode 100644 lib/server/onboarding/import-scanner.js create mode 100644 lib/server/onboarding/secret-detector.js create mode 100644 tests/server/import-applier.test.js create mode 100644 tests/server/import-scanner.test.js create mode 100644 tests/server/secret-detector.test.js diff --git a/AGENTS.md b/AGENTS.md index 9f39afde..95e2402f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Use this release flow when promoting tested beta builds to production: - `npm version prerelease --preid=beta` - `git push && git push --tags` - `npm publish --tag beta` -3. For beta template testing, pin exact beta in template `package.json` (for example `0.3.2-beta.4`) to avoid Docker layer cache reusing old installs. +3. For beta template testing (~/Projects/openclaw-railway template, beta branch), pin exact beta in template `package.json` (for example `0.3.2-beta.4`) to avoid Docker layer cache reusing old installs. 4. When ready for production, publish a stable release version (for example `0.3.2`): - `npm version 0.3.2` - `git push && git push --tags` diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 1ac3d58e..f5abd3ed 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -216,21 +216,7 @@ try { } // --------------------------------------------------------------------------- -// 5. Symlink /.openclaw/.env -> /.env -// --------------------------------------------------------------------------- - -const openclawEnvLink = path.join(openclawDir, ".env"); -try { - if (!fs.existsSync(openclawEnvLink)) { - fs.symlinkSync(envFilePath, openclawEnvLink); - console.log(`[alphaclaw] Symlinked ${openclawEnvLink} -> ${envFilePath}`); - } -} catch (e) { - console.log(`[alphaclaw] .env symlink skipped: ${e.message}`); -} - -// --------------------------------------------------------------------------- -// 6. Load .env into process.env +// 5. Load .env into process.env // --------------------------------------------------------------------------- if (fs.existsSync(envFilePath)) { @@ -530,23 +516,7 @@ if (!gogInstalled) { } // --------------------------------------------------------------------------- -// 7. Configure gog keyring (file backend for headless environments) -// --------------------------------------------------------------------------- - -process.env.GOG_KEYRING_PASSWORD = - process.env.GOG_KEYRING_PASSWORD || "alphaclaw"; -const gogConfigFile = path.join(openclawDir, "gogcli", "config.json"); - -if (!fs.existsSync(gogConfigFile)) { - fs.mkdirSync(path.join(openclawDir, "gogcli"), { recursive: true }); - try { - execSync("gog auth keyring file", { stdio: "ignore" }); - console.log("[alphaclaw] gog keyring configured (file backend)"); - } catch {} -} - -// --------------------------------------------------------------------------- -// 8. Install/reconcile system cron entry +// 7. Install/reconcile system cron entry // --------------------------------------------------------------------------- const packagedHourlyGitSyncPath = path.join(setupDir, "hourly-git-sync.sh"); diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css index bd74cfd1..c4dae6be 100644 --- a/lib/public/css/theme.css +++ b/lib/public/css/theme.css @@ -513,6 +513,11 @@ textarea:focus { height: 28px; } +.ac-segmented-control-full { + display: flex; + width: 100%; +} + .ac-segmented-control-button { border: 0; background: transparent; @@ -527,6 +532,20 @@ textarea:focus { transition: color 0.12s, background 0.12s; } +.ac-segmented-control-full .ac-segmented-control-button { + flex: 1 1 0%; +} + +.ac-segmented-control-lg { + height: 36px; + border-radius: 12px; +} + +.ac-segmented-control-lg .ac-segmented-control-button { + font-size: 14px; + padding: 0 16px; +} + .ac-segmented-control-button:hover { color: var(--text); background: rgba(255, 255, 255, 0.03); diff --git a/lib/public/js/components/envars.js b/lib/public/js/components/envars.js index 200a92d9..4bb00010 100644 --- a/lib/public/js/components/envars.js +++ b/lib/public/js/components/envars.js @@ -86,7 +86,6 @@ const kHintByKey = { OPENAI_API_KEY: html`from platform.openai.com`, GEMINI_API_KEY: html`from aistudio.google.com`, ELEVENLABS_API_KEY: html`from elevenlabs.io · XI_API_KEY also supported`, - GITHUB_TOKEN: html`classic PAT · repo scope · github settings`, GITHUB_WORKSPACE_REPO: html`use owner/repo or https://github.com/owner/repo`, TELEGRAM_BOT_TOKEN: html`from @BotFather · full guide`, DISCORD_BOT_TOKEN: html`from developer portal · full guide`, diff --git a/lib/public/js/components/onboarding/welcome-config.js b/lib/public/js/components/onboarding/welcome-config.js index 0be58a1f..533190d8 100644 --- a/lib/public/js/components/onboarding/welcome-config.js +++ b/lib/public/js/components/onboarding/welcome-config.js @@ -4,6 +4,13 @@ import { kAllAiAuthFields } from "../../lib/model-config.js"; const html = htm.bind(h); +export const kRepoModeNew = "new"; +export const kRepoModeExisting = "existing"; +export const kGithubFlowFresh = "fresh"; +export const kGithubFlowImport = "import"; +export const kGithubTargetRepoModeCreate = "create"; +export const kGithubTargetRepoModeExistingEmpty = "existing-empty"; + export const normalizeGithubRepoInput = (repoInput) => String(repoInput || "") .trim() @@ -19,44 +26,59 @@ export const isValidGithubRepoInput = (repoInput) => { }; export const kWelcomeGroups = [ - { - id: "ai", - title: "Primary Agent Model", - description: "Choose your main model and authenticate its provider", - fields: kAllAiAuthFields, - validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi), - }, { id: "github", title: "GitHub", description: "Backs up your agent's config and workspace", fields: [ + { + key: "_GITHUB_SOURCE_REPO", + label: "Source Repo", + placeholder: "username/existing-openclaw", + isText: true, + }, { key: "GITHUB_WORKSPACE_REPO", - label: "Workspace Repo", - hint: "A new private repo will be created for you", + label: "New Workspace Repo", placeholder: "username/my-agent", isText: true, }, { key: "GITHUB_TOKEN", label: "Personal Access Token", - hint: html`Create a classic PAT on${" "}GitHub settingsclassic PAT${" "}with${" "}repo${" "}scope`, - placeholder: "ghp_...", + >${" "}scope, or a${" "}fine-grained token${" "}with Contents + Metadata access`, + placeholder: "ghp_... or github_pat_...", }, ], - validate: (vals) => - !!( - vals.GITHUB_TOKEN && isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO) - ), + validate: (vals) => { + const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; + const hasTarget = isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO); + const hasSource = + githubFlow !== kGithubFlowImport || + isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO); + return !!(vals.GITHUB_TOKEN && hasTarget && hasSource); + }, + }, + { + id: "ai", + title: "Primary Agent Model", + description: "Choose your main model and authenticate its provider", + fields: kAllAiAuthFields, + validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi), }, { id: "channels", diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index 98dccace..0364688d 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -4,7 +4,14 @@ import htm from "https://esm.sh/htm"; import { SecretInput } from "../secret-input.js"; import { ActionButton } from "../action-button.js"; import { Badge } from "../badge.js"; -import { isValidGithubRepoInput } from "./welcome-config.js"; +import { SegmentedControl } from "../segmented-control.js"; +import { + isValidGithubRepoInput, + kGithubFlowFresh, + kGithubFlowImport, + kGithubTargetRepoModeCreate, + kGithubTargetRepoModeExistingEmpty, +} from "./welcome-config.js"; const html = htm.bind(h); @@ -43,12 +50,19 @@ export const WelcomeFormStep = ({ handleSubmit, }) => { const [repoTouched, setRepoTouched] = useState(false); + const [sourceRepoTouched, setSourceRepoTouched] = useState(false); const [showOptionalOpenai, setShowOptionalOpenai] = useState(false); const [showOptionalGemini, setShowOptionalGemini] = useState(false); + const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; + const freshRepoMode = + githubFlow === kGithubFlowImport + ? kGithubTargetRepoModeCreate + : vals._GITHUB_TARGET_REPO_MODE || kGithubTargetRepoModeCreate; useEffect(() => { if (activeGroup.id !== "github") { setRepoTouched(false); + setSourceRepoTouched(false); } }, [activeGroup.id]); @@ -183,9 +197,58 @@ export const WelcomeFormStep = ({ `}
        `} + ${activeGroup.id === "github" && + html` +
        +
        + <${SegmentedControl} + options=${[ + { label: "Start fresh", value: kGithubFlowFresh }, + { label: "Import existing setup", value: kGithubFlowImport }, + ]} + value=${githubFlow} + onChange=${(value) => setValue("_GITHUB_FLOW", value)} + size="lg" + fullWidth=${true} + /> +
        + ${githubFlow === kGithubFlowFresh + ? html` +
        +
        + +
        + <${SegmentedControl} + options=${[ + { + label: "Create new repo", + value: kGithubTargetRepoModeCreate, + }, + { + label: "Use existing empty repo", + value: kGithubTargetRepoModeExistingEmpty, + }, + ]} + value=${freshRepoMode} + onChange=${(value) => + setValue("_GITHUB_TARGET_REPO_MODE", value)} + /> +
        + ` + : null} +
        + `} ${(activeGroup.id === "ai" ? activeGroup.fields.filter((field) => visibleAiFieldKeys.has(field.key)) - : activeGroup.fields + : activeGroup.id === "github" + ? activeGroup.fields.filter((field) => + githubFlow === kGithubFlowImport + ? true + : field.key !== "_GITHUB_SOURCE_REPO", + ) + : activeGroup.fields ).map( (field) => html`
        @@ -198,12 +261,51 @@ export const WelcomeFormStep = ({ onInput=${(e) => setValue(field.key, e.target.value)} onBlur=${field.key === "GITHUB_WORKSPACE_REPO" ? () => setRepoTouched(true) - : undefined} + : field.key === "_GITHUB_SOURCE_REPO" + ? () => setSourceRepoTouched(true) + : undefined} placeholder=${field.placeholder || ""} isSecret=${!field.isText} inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono" /> -

        ${field.hint}

        +

        + ${activeGroup.id === "github" && + field.key === "GITHUB_WORKSPACE_REPO" + ? githubFlow === kGithubFlowImport + ? "Your new project will live here" + : freshRepoMode === kGithubTargetRepoModeExistingEmpty + ? "Enter the owner/repo of an existing empty repository" + : "A new private repo will be created for you" + : activeGroup.id === "github" && + field.key === "_GITHUB_SOURCE_REPO" + ? "The repo to import from" + : activeGroup.id === "github" && field.key === "GITHUB_TOKEN" + ? githubFlow === kGithubFlowImport + ? freshRepoMode === kGithubTargetRepoModeCreate + ? html`Use a classic PAT with${" "}repo${" "}scope to create the target repo. Fine-grained + works if the target already exists and can access both + repos.` + : html`Use a classic PAT with${" "}repo${" "}scope, or a fine-grained token with Contents + + Metadata access to both the source repo and target + repo` + : freshRepoMode === kGithubTargetRepoModeExistingEmpty + ? html`Use a classic PAT with${" "}repo${" "}scope, or a fine-grained token with Contents + + Metadata access to this repo` + : html`Use a classic PAT with${" "}repo${" "}scope to create a new private repository` + : field.hint} +

        `, )} @@ -212,7 +314,18 @@ export const WelcomeFormStep = ({ vals.GITHUB_WORKSPACE_REPO && !isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO) ? html`
        - Workspace Repo must be in + Target Repo must be in + owner/repo + format. +
        ` + : null} + ${activeGroup.id === "github" && + githubFlow === kGithubFlowImport && + sourceRepoTouched && + vals._GITHUB_SOURCE_REPO && + !isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO) + ? html`
        + Source Repo must be in owner/repo format.
        ` @@ -281,39 +394,48 @@ export const WelcomeFormStep = ({ ${step < totalGroups - 1 ? html` ${step > 0 - ? html`` + ? html`<${ActionButton} + onClick=${goBack} + tone="secondary" + size="md" + idleLabel="Back" + className="w-full" + />` : html`
        `} - + loadingLabel="Checking..." + className="w-full" + /> ` : html` ${step > 0 - ? html`` + ? html`<${ActionButton} + onClick=${goBack} + tone="secondary" + size="md" + idleLabel="Back" + className="w-full" + />` : html`
        `} - + <${ActionButton} + onClick=${handleSubmit} + disabled=${!allValid} + loading=${loading} + tone="primary" + size="md" + idleLabel="Next" + loadingLabel="Starting..." + className="w-full" + /> `}
        `; diff --git a/lib/public/js/components/onboarding/welcome-import-step.js b/lib/public/js/components/onboarding/welcome-import-step.js new file mode 100644 index 00000000..64e7cdd5 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-import-step.js @@ -0,0 +1,306 @@ +import { h } from "https://esm.sh/preact"; +import { useState } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { ActionButton } from "../action-button.js"; +import { LoadingSpinner } from "../loading-spinner.js"; + +const html = htm.bind(h); + +const kCategories = [ + { + key: "gatewayConfig", + label: "Gateway Config", + icon: "⚙️", + description: "openclaw.json configuration", + showFiles: true, + }, + { + key: "envFiles", + label: "Environment Files", + icon: "🔐", + description: ".env files with variables", + showFiles: true, + }, + { + key: "workspaceFiles", + label: "Workspace Files", + icon: "📄", + description: "Prompt files (AGENTS.md, SOUL.md, etc.)", + showFiles: true, + }, + { + key: "skills", + label: "Skills", + icon: "🛠", + description: "Custom skill definitions", + showFiles: true, + }, + { + key: "cronJobs", + label: "Cron Jobs", + icon: "⏰", + description: "Scheduled tasks", + showFiles: true, + }, + { + key: "webhooks", + label: "Hooks", + icon: "🔗", + description: "Webhook mappings and internal hooks", + showDirs: true, + }, + { + key: "memory", + label: "Memory", + icon: "🧠", + description: "Agent memory and embeddings", + showDirs: true, + }, +]; + +const CategoryCard = ({ category, data }) => { + const [expanded, setExpanded] = useState(false); + if (!data?.found) return null; + const isHooksCategory = category.key === "webhooks"; + const warningItems = Array.isArray(data.transformWarnings) + ? data.transformWarnings + : []; + const warningPathPrefixes = new Set( + warningItems + .map((warning) => String(warning.actualPath || "").trim()) + .filter(Boolean) + .map((pathValue) => pathValue.split("/").slice(0, -2).join("/")), + ); + + const items = [ + ...(data.jobNames || []), + ...(data.hookNames || []), + ...(data.files || []), + ...(data.dirs || []).filter((dir) => !warningPathPrefixes.has(dir)), + ...(data.extraMarkdown || []), + ]; + const count = + typeof data.jobCount === "number" && data.jobCount > 0 + ? data.jobCount + : typeof data.hookCount === "number" && data.hookCount > 0 + ? data.hookCount + : items.length; + const warningCount = + typeof data.warningCount === "number" + ? data.warningCount + : warningItems.length; + + return html` +
        + + ${expanded && + items.length > 0 && + html` +
        + ${items.map( + (item) => html` +
        + ${item} +
        + `, + )} + ${isHooksCategory + ? warningItems.map( + (warning) => html` +
        + ${warning.actualPath} +
        + `, + ) + : null} +
        + `} +
        + `; +}; + +export const WelcomeImportStep = ({ + scanResult, + scanning, + error, + onApprove, + onShowSecretReview, + onBack, +}) => { + if (scanning) { + return html` +
        + <${LoadingSpinner} /> +

        Scanning repository...

        +
        + `; + } + + if (error) { + return html` +
        +
        + ${error} +
        + +
        + `; + } + + if (!scanResult) return null; + + const secretCount = (scanResult.secrets || []).length; + const hasConflicts = scanResult.managedConflicts?.found; + + return html` +
        +
        +

        Import Summary

        +

        + ${scanResult.hasOpenclawSetup + ? "Found an existing OpenClaw setup" + : "No OpenClaw config detected — we'll set up fresh after import"} +

        +
        + +
        + ${kCategories.map( + (cat) => html` + <${CategoryCard} + key=${cat.key} + category=${cat} + data=${scanResult[cat.key]} + /> + `, + )} +
        + + ${scanResult.credentials?.found && + html` +
        + Deployment-specific files found (credentials, device identity) — these + will not be imported. +
        + `} + ${hasConflicts && + html` +
        + AlphaClaw-managed files detected + (${(scanResult.managedConflicts.files || []).join(", ")}). These will + be overwritten with AlphaClaw defaults. +
        + `} + ${scanResult.managedEnvConflicts?.found + ? html` +
        + AlphaClaw controls deployment env vars + (${(scanResult.managedEnvConflicts.vars || []).join(", ")}). + Imported values for these will be normalized during import. +
        + ` + : null} + ${scanResult.webhooks?.warningCount > 0 + ? html` +
        + AlphaClaw expects hook transforms at + hooks/transforms/name/name-transform.mjs. We found some that do not match and will try to patch them + during import. The originals will be backed up under + hooks/transforms/_backup. +
        + ` + : null} + ${secretCount > 0 && + html` +
        +
        + + ${secretCount} possible secret${secretCount === 1 ? "" : "s"} + detected + +

        + Review and extract to environment variables +

        +
        + <${ActionButton} + onClick=${onShowSecretReview} + tone="primary" + size="sm" + idleLabel="Review" + className="font-medium" + /> +
        + `} + +
        + <${ActionButton} + onClick=${onBack} + tone="secondary" + size="md" + idleLabel="Back" + className="w-full" + /> + <${ActionButton} + onClick=${() => onApprove([])} + loading=${scanning} + tone="primary" + size="md" + idleLabel="Import" + loadingLabel="Importing..." + className="w-full" + /> +
        +
        + `; +}; diff --git a/lib/public/js/components/onboarding/welcome-secret-review-step.js b/lib/public/js/components/onboarding/welcome-secret-review-step.js new file mode 100644 index 00000000..299bd39a --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-secret-review-step.js @@ -0,0 +1,190 @@ +import { h } from "https://esm.sh/preact"; +import { useState, useCallback } from "https://esm.sh/preact/hooks"; +import htm from "https://esm.sh/htm"; +import { LoadingSpinner } from "../loading-spinner.js"; + +const html = htm.bind(h); + +const SecretRow = ({ secret, selected, onToggle, envVarName, onEnvVarChange }) => + html` +
        +
        + +
        +
        + ${secret.maskedValue} + ${secret.confidence === "high" + ? html`high confidence` + : html`possible`} +
        +
        + Found in${" "} + ${secret.file || "config"} + ${secret.configPath + ? html` at ${secret.configPath}` + : null} +
        + ${secret.duplicateIn && + html` +
        + Also found in${" "}${secret.duplicateIn} +
        + `} +
        +
        + ${selected && + html` +
        + + onEnvVarChange(e.target.value)} + class="w-full mt-1 bg-black/30 border border-border rounded-lg px-3 py-1.5 text-xs text-gray-200 outline-none focus:border-gray-500 font-mono" + /> +
        + `} +
        + `; + +export const WelcomeSecretReviewStep = ({ + secrets = [], + onApprove, + onBack, + loading, + error, +}) => { + const [selections, setSelections] = useState(() => { + const initial = {}; + for (const secret of secrets) { + initial[secret.configPath] = { + selected: secret.confidence === "high", + envVarName: secret.suggestedEnvVar || "", + }; + } + return initial; + }); + + const toggleSecret = useCallback( + (configPath) => { + setSelections((prev) => ({ + ...prev, + [configPath]: { + ...prev[configPath], + selected: !prev[configPath]?.selected, + }, + })); + }, + [], + ); + + const updateEnvVarName = useCallback( + (configPath, name) => { + setSelections((prev) => ({ + ...prev, + [configPath]: { + ...prev[configPath], + envVarName: name, + }, + })); + }, + [], + ); + + const selectedCount = Object.values(selections).filter( + (s) => s.selected, + ).length; + + const handleExtract = () => { + const approved = secrets + .filter((s) => selections[s.configPath]?.selected) + .map((s) => ({ + ...s, + suggestedEnvVar: + selections[s.configPath]?.envVarName || s.suggestedEnvVar, + })); + onApprove(approved); + }; + + if (loading) { + return html` +
        + <${LoadingSpinner} /> +

        Applying import...

        +
        + `; + } + + return html` +
        +
        +

        Review Secrets

        +

        + Select secrets to extract into environment variables. Inline values in + config will be replaced with ${"`"}${"${"}ENV_VAR_NAME${"}"}${"`"} references. +

        +
        + + ${error && + html` +
        + ${error} +
        + `} + +
        + ${secrets.map( + (secret) => html` + <${SecretRow} + key=${secret.configPath} + secret=${secret} + selected=${selections[secret.configPath]?.selected || false} + envVarName=${selections[secret.configPath]?.envVarName || ""} + onToggle=${() => toggleSecret(secret.configPath)} + onEnvVarChange=${(name) => + updateEnvVarName(secret.configPath, name)} + /> + `, + )} +
        + +
        + + +
        +
        + `; +}; diff --git a/lib/public/js/components/segmented-control.js b/lib/public/js/components/segmented-control.js index 18776398..e2ba2d9c 100644 --- a/lib/public/js/components/segmented-control.js +++ b/lib/public/js/components/segmented-control.js @@ -11,14 +11,20 @@ const html = htm.bind(h); * @param {*} props.value Currently selected value. * @param {Function} props.onChange Called with the new value on click. * @param {string} [props.className] Extra classes on the wrapper. + * @param {"sm"|"lg"} [props.size] Visual size variant. + * @param {boolean} [props.fullWidth] Stretch wrapper and options to 100%. */ export const SegmentedControl = ({ options = [], value, onChange = () => {}, className = "", + size = "sm", + fullWidth = false, }) => html` -
        +
        ${options.map( (option) => html` - + className="w-full" + />
        `; diff --git a/lib/public/js/components/welcome/index.js b/lib/public/js/components/welcome/index.js new file mode 100644 index 00000000..85bee1da --- /dev/null +++ b/lib/public/js/components/welcome/index.js @@ -0,0 +1,112 @@ +import { h } from "https://esm.sh/preact"; +import htm from "https://esm.sh/htm"; +import { kWelcomeGroups } from "../onboarding/welcome-config.js"; +import { WelcomeImportStep } from "../onboarding/welcome-import-step.js"; +import { WelcomePlaceholderReviewStep } from "../onboarding/welcome-placeholder-review-step.js"; +import { WelcomeSecretReviewStep } from "../onboarding/welcome-secret-review-step.js"; +import { WelcomeHeader } from "../onboarding/welcome-header.js"; +import { WelcomeSetupStep } from "../onboarding/welcome-setup-step.js"; +import { WelcomeFormStep } from "../onboarding/welcome-form-step.js"; +import { WelcomePairingStep } from "../onboarding/welcome-pairing-step.js"; +import { useWelcome } from "./use-welcome.js"; + +const html = htm.bind(h); + +export const Welcome = ({ onComplete }) => { + const { state, actions } = useWelcome({ onComplete }); + + return html` +
        + <${WelcomeHeader} + groups=${kWelcomeGroups} + step=${state.step} + isSetupStep=${state.isSetupStep} + isPairingStep=${state.isPairingStep} + stepNumber=${state.stepNumber} + activeStepLabel=${state.activeStepLabel} + /> + +
        + ${state.isImportStep + ? html`<${WelcomeImportStep} + scanResult=${state.importScanResult} + scanning=${state.importScanning} + error=${state.importError} + onApprove=${actions.handleImportApprove} + onShowSecretReview=${actions.handleShowSecretReview} + onBack=${actions.handleImportBack} + />` + : state.isSecretReviewStep + ? html`<${WelcomeSecretReviewStep} + secrets=${state.importScanResult?.secrets || []} + onApprove=${actions.handleImportApprove} + onBack=${actions.handleSecretReviewBack} + loading=${state.importScanning} + error=${state.importError} + />` + : state.isPlaceholderReviewStep + ? html`<${WelcomePlaceholderReviewStep} + placeholderReview=${state.placeholderReview} + vals=${state.vals} + setValue=${actions.setValue} + onContinue=${actions.handlePlaceholderReviewContinue} + />` + : state.isSetupStep + ? html`<${WelcomeSetupStep} + error=${state.setupError} + loading=${state.loading} + onRetry=${actions.handleSubmit} + onBack=${actions.goBackFromSetupError} + />` + : state.isPairingStep + ? html`<${WelcomePairingStep} + channel=${state.selectedPairingChannel} + pairings=${state.pairingRequestsPoll.data || []} + channels=${state.pairingChannels} + loading=${!state.pairingStatusPoll.data} + error=${state.pairingError} + onApprove=${actions.handlePairingApprove} + onReject=${actions.handlePairingReject} + canFinish=${state.pairingComplete || state.canFinishPairing} + onContinue=${actions.finishOnboarding} + />` + : html` + <${WelcomeFormStep} + activeGroup=${state.activeGroup} + vals=${state.vals} + hasAi=${state.hasAi} + setValue=${actions.setValue} + modelOptions=${state.modelOptions} + modelsLoading=${state.modelsLoading} + modelsError=${state.modelsError} + canToggleFullCatalog=${state.canToggleFullCatalog} + showAllModels=${state.showAllModels} + setShowAllModels=${actions.setShowAllModels} + selectedProvider=${state.selectedProvider} + codexLoading=${state.codexLoading} + codexStatus=${state.codexStatus} + startCodexAuth=${actions.startCodexAuth} + handleCodexDisconnect=${actions.handleCodexDisconnect} + codexAuthStarted=${state.codexAuthStarted} + codexAuthWaiting=${state.codexAuthWaiting} + codexManualInput=${state.codexManualInput} + setCodexManualInput=${actions.setCodexManualInput} + completeCodexAuth=${actions.completeCodexAuth} + codexExchanging=${state.codexExchanging} + visibleAiFieldKeys=${state.visibleAiFieldKeys} + error=${state.formError} + step=${state.step} + totalGroups=${kWelcomeGroups.length} + currentGroupValid=${state.currentGroupValid} + goBack=${actions.goBack} + goNext=${actions.goNext} + loading=${state.loading} + githubStepLoading=${state.githubStepLoading} + allValid=${state.allValid} + handleSubmit=${actions.handleSubmit} + /> + `} +
        +
        + `; +}; diff --git a/lib/public/js/components/welcome.js b/lib/public/js/components/welcome/use-welcome.js similarity index 70% rename from lib/public/js/components/welcome.js rename to lib/public/js/components/welcome/use-welcome.js index fc13c747..2b175d9c 100644 --- a/lib/public/js/components/welcome.js +++ b/lib/public/js/components/welcome/use-welcome.js @@ -1,18 +1,16 @@ -import { h } from "https://esm.sh/preact"; -import { useState, useEffect } from "https://esm.sh/preact/hooks"; -import htm from "https://esm.sh/htm"; +import { useEffect, useState } from "https://esm.sh/preact/hooks"; import { runOnboard, verifyGithubOnboardingRepo, scanImportRepo, applyImport, fetchModels, -} from "../lib/api.js"; +} from "../../lib/api.js"; import { getModelProvider, getFeaturedModels, getVisibleAiFieldKeys, -} from "../lib/model-config.js"; +} from "../../lib/model-config.js"; import { kWelcomeGroups, isValidGithubRepoInput, @@ -22,29 +20,44 @@ import { kGithubTargetRepoModeExistingEmpty, kRepoModeNew, kRepoModeExisting, -} from "./onboarding/welcome-config.js"; -import { WelcomeImportStep } from "./onboarding/welcome-import-step.js"; -import { WelcomeSecretReviewStep } from "./onboarding/welcome-secret-review-step.js"; -import { WelcomeHeader } from "./onboarding/welcome-header.js"; -import { WelcomeSetupStep } from "./onboarding/welcome-setup-step.js"; -import { WelcomeFormStep } from "./onboarding/welcome-form-step.js"; -import { WelcomePairingStep } from "./onboarding/welcome-pairing-step.js"; -import { getPreferredPairingChannel } from "./onboarding/pairing-utils.js"; +} from "../onboarding/welcome-config.js"; +import { getPreferredPairingChannel } from "../onboarding/pairing-utils.js"; import { kOnboardingStorageKey, kPairingChannelKey, useWelcomeStorage, -} from "./onboarding/use-welcome-storage.js"; -import { useWelcomeCodex } from "./onboarding/use-welcome-codex.js"; -import { useWelcomePairing } from "./onboarding/use-welcome-pairing.js"; -const html = htm.bind(h); +} from "../onboarding/use-welcome-storage.js"; +import { useWelcomeCodex } from "../onboarding/use-welcome-codex.js"; +import { useWelcomePairing } from "../onboarding/use-welcome-pairing.js"; + const kMaxOnboardingVars = 64; const kMaxEnvKeyLength = 128; const kMaxEnvValueLength = 4096; -const kImportStepId = "import"; -const kSecretReviewStepId = "secret-review"; +export const kImportStepId = "import"; +export const kSecretReviewStepId = "secret-review"; +export const kPlaceholderReviewStepId = "placeholder-review"; +const kImportSubstepKey = "_IMPORT_SUBSTEP"; +const kImportPlaceholderReviewKey = "_IMPORT_PLACEHOLDER_REVIEW"; +const kImportPlaceholderSkipConfirmedKey = "_IMPORT_PLACEHOLDER_SKIP_CONFIRMED"; + +const normalizePlaceholderReview = (review) => { + if (!review || !Array.isArray(review.vars) || review.vars.length === 0) { + return { found: false, count: 0, vars: [] }; + } + return { + found: true, + count: + typeof review.count === "number" ? review.count : review.vars.length, + vars: review.vars + .map((item) => ({ + key: String(item?.key || "").trim(), + status: String(item?.status || "missing").trim() || "missing", + })) + .filter((item) => item.key), + }; +}; -export const Welcome = ({ onComplete }) => { +export const useWelcome = ({ onComplete }) => { const kSetupStepIndex = kWelcomeGroups.length; const kPairingStepIndex = kSetupStepIndex + 1; const { vals, setVals, setValue, step, setStep, setupError, setSetupError } = @@ -71,12 +84,34 @@ export const Welcome = ({ onComplete }) => { completeCodexAuth, handleCodexDisconnect, } = useWelcomeCodex({ setFormError }); - const [importStep, setImportStep] = useState(null); + const [importStep, setImportStepState] = useState(() => { + const storedStep = String(vals[kImportSubstepKey] || "").trim(); + return storedStep === kPlaceholderReviewStepId + ? storedStep + : null; + }); const [importTempDir, setImportTempDir] = useState(null); const [importScanResult, setImportScanResult] = useState(null); const [importScanning, setImportScanning] = useState(false); const [importError, setImportError] = useState(null); + const setImportStep = (nextStep) => { + setImportStepState(nextStep); + setVals((prev) => ({ + ...prev, + [kImportSubstepKey]: + nextStep === kPlaceholderReviewStepId ? nextStep : "", + })); + }; + + const clearPlaceholderReview = () => { + setVals((prev) => ({ + ...prev, + [kImportPlaceholderReviewKey]: null, + [kImportPlaceholderSkipConfirmedKey]: false, + })); + }; + useEffect(() => { fetchModels() .then((result) => { @@ -93,6 +128,9 @@ export const Welcome = ({ onComplete }) => { }, []); const selectedProvider = getModelProvider(vals.MODEL_KEY); + const placeholderReview = normalizePlaceholderReview( + vals[kImportPlaceholderReviewKey], + ); const featuredModels = getFeaturedModels(models); const baseModelOptions = showAllModels ? models @@ -121,7 +159,7 @@ export const Welcome = ({ onComplete }) => { ? !!codexStatus.connected : false; - const allValid = kWelcomeGroups.every((g) => g.validate(vals, { hasAi })); + const allValid = kWelcomeGroups.every((group) => group.validate(vals, { hasAi })); const isSetupStep = step === kSetupStepIndex; const isPairingStep = step === kPairingStepIndex; const activeGroup = step < kSetupStepIndex ? kWelcomeGroups[step] : null; @@ -152,7 +190,7 @@ export const Welcome = ({ onComplete }) => { .filter( ([key]) => key !== "MODEL_KEY" && !String(key || "").startsWith("_"), ) - .filter(([, v]) => v) + .filter(([, value]) => value) .map(([key, value]) => ({ key, value })); const preflightError = (() => { if (!vals.MODEL_KEY || !String(vals.MODEL_KEY).includes("/")) { @@ -192,7 +230,7 @@ export const Welcome = ({ onComplete }) => { setStep( Math.max( 0, - kWelcomeGroups.findIndex((g) => g.id === "github"), + kWelcomeGroups.findIndex((group) => group.id === "github"), ), ); return; @@ -241,6 +279,7 @@ export const Welcome = ({ onComplete }) => { setFormError(null); setStep((prev) => Math.max(0, prev - 1)); }; + const goBackFromSetupError = () => { setLoading(false); setSetupError(null); @@ -256,11 +295,16 @@ export const Welcome = ({ onComplete }) => { githubFlow === kGithubFlowImport ? kGithubTargetRepoModeCreate : vals._GITHUB_TARGET_REPO_MODE || kGithubTargetRepoModeCreate; + const targetVerifyMode = + targetRepoMode === kGithubTargetRepoModeExistingEmpty + ? kRepoModeExisting + : kRepoModeNew; const sourceRepo = githubFlow === kGithubFlowImport ? vals._GITHUB_SOURCE_REPO : vals.GITHUB_WORKSPACE_REPO; setGithubStepLoading(true); + clearPlaceholderReview(); try { if (githubFlow === kGithubFlowImport) { const sourceResult = await verifyGithubOnboardingRepo( @@ -329,7 +373,7 @@ export const Welcome = ({ onComplete }) => { const targetResult = await verifyGithubOnboardingRepo( vals.GITHUB_WORKSPACE_REPO, vals.GITHUB_TOKEN, - kRepoModeNew, + targetVerifyMode, ); if (!targetResult?.ok) { setFormError(targetResult?.error || "GitHub verification failed"); @@ -380,9 +424,20 @@ export const Welcome = ({ onComplete }) => { setImportScanning(false); return; } - if (result.preFill) { - setVals((prev) => ({ ...prev, ...result.preFill })); + const nextPlaceholderReview = normalizePlaceholderReview( + result.placeholderReview, + ); + setVals((prev) => ({ + ...prev, + ...(result.preFill || {}), + [kImportPlaceholderReviewKey]: nextPlaceholderReview, + [kImportPlaceholderSkipConfirmedKey]: false, + })); + if (nextPlaceholderReview.found) { + setImportStep(kPlaceholderReviewStepId); + return; } + clearPlaceholderReview(); setImportStep(null); setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1)); } catch (err) { @@ -405,21 +460,31 @@ export const Welcome = ({ onComplete }) => { setImportTempDir(null); setImportScanResult(null); setImportError(null); + clearPlaceholderReview(); + }; + + const handlePlaceholderReviewContinue = () => { + clearPlaceholderReview(); + setImportStep(null); + setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1)); }; const isImportStep = importStep === kImportStepId; const isSecretReviewStep = importStep === kSecretReviewStepId; + const isPlaceholderReviewStep = importStep === kPlaceholderReviewStepId; const activeStepLabel = isImportStep ? "Import" : isSecretReviewStep ? "Review Secrets" - : isSetupStep - ? "Initializing" - : isPairingStep - ? "Pairing" - : activeGroup?.title || "Setup"; + : isPlaceholderReviewStep + ? "Review Env Vars" + : isSetupStep + ? "Initializing" + : isPairingStep + ? "Pairing" + : activeGroup?.title || "Setup"; const stepNumber = - isImportStep || isSecretReviewStep + isImportStep || isSecretReviewStep || isPlaceholderReviewStep ? step + 1 : isSetupStep ? kWelcomeGroups.length + 1 @@ -427,91 +492,70 @@ export const Welcome = ({ onComplete }) => { ? kWelcomeGroups.length + 2 : step + 1; - return html` -
        - <${WelcomeHeader} - groups=${kWelcomeGroups} - step=${step} - isSetupStep=${isSetupStep} - isPairingStep=${isPairingStep} - stepNumber=${stepNumber} - activeStepLabel=${activeStepLabel} - /> - -
        - ${isImportStep - ? html`<${WelcomeImportStep} - scanResult=${importScanResult} - scanning=${importScanning} - error=${importError} - onApprove=${handleImportApprove} - onShowSecretReview=${handleShowSecretReview} - onBack=${handleImportBack} - />` - : isSecretReviewStep - ? html`<${WelcomeSecretReviewStep} - secrets=${importScanResult?.secrets || []} - onApprove=${handleImportApprove} - onBack=${handleSecretReviewBack} - loading=${importScanning} - error=${importError} - />` - : isSetupStep - ? html`<${WelcomeSetupStep} - error=${setupError} - loading=${loading} - onRetry=${handleSubmit} - onBack=${goBackFromSetupError} - />` - : isPairingStep - ? html`<${WelcomePairingStep} - channel=${selectedPairingChannel} - pairings=${pairingRequestsPoll.data || []} - channels=${pairingChannels} - loading=${!pairingStatusPoll.data} - error=${pairingError} - onApprove=${handlePairingApprove} - onReject=${handlePairingReject} - canFinish=${pairingComplete || canFinishPairing} - onContinue=${finishOnboarding} - />` - : html` - <${WelcomeFormStep} - activeGroup=${activeGroup} - vals=${vals} - hasAi=${hasAi} - setValue=${setValue} - modelOptions=${modelOptions} - modelsLoading=${modelsLoading} - modelsError=${modelsError} - canToggleFullCatalog=${canToggleFullCatalog} - showAllModels=${showAllModels} - setShowAllModels=${setShowAllModels} - selectedProvider=${selectedProvider} - codexLoading=${codexLoading} - codexStatus=${codexStatus} - startCodexAuth=${startCodexAuth} - handleCodexDisconnect=${handleCodexDisconnect} - codexAuthStarted=${codexAuthStarted} - codexAuthWaiting=${codexAuthWaiting} - codexManualInput=${codexManualInput} - setCodexManualInput=${setCodexManualInput} - completeCodexAuth=${completeCodexAuth} - codexExchanging=${codexExchanging} - visibleAiFieldKeys=${visibleAiFieldKeys} - error=${formError} - step=${step} - totalGroups=${kWelcomeGroups.length} - currentGroupValid=${currentGroupValid} - goBack=${goBack} - goNext=${goNext} - loading=${loading} - githubStepLoading=${githubStepLoading} - allValid=${allValid} - handleSubmit=${handleSubmit} - /> - `} -
        -
        - `; + return { + state: { + vals, + step, + setupError, + modelsLoading, + modelsError, + showAllModels, + loading, + githubStepLoading, + formError, + importScanResult, + importScanning, + importError, + selectedProvider, + modelOptions, + canToggleFullCatalog, + visibleAiFieldKeys, + hasAi, + allValid, + isSetupStep, + isPairingStep, + activeGroup, + currentGroupValid, + selectedPairingChannel, + placeholderReview, + isImportStep, + isSecretReviewStep, + isPlaceholderReviewStep, + activeStepLabel, + stepNumber, + codexStatus, + codexLoading, + codexManualInput, + codexExchanging, + codexAuthStarted, + codexAuthWaiting, + pairingStatusPoll, + pairingRequestsPoll, + pairingChannels, + canFinishPairing, + pairingError, + pairingComplete, + }, + actions: { + setVals, + setValue, + setShowAllModels, + setCodexManualInput, + startCodexAuth, + completeCodexAuth, + handleCodexDisconnect, + handleSubmit, + finishOnboarding, + goBack, + goBackFromSetupError, + goNext, + handleImportApprove, + handleShowSecretReview, + handleSecretReviewBack, + handleImportBack, + handlePlaceholderReviewContinue, + handlePairingApprove, + handlePairingReject, + }, + }; }; diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index f82eb78f..d14c0b6e 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -23,26 +23,26 @@ export const authFetch = async (url, opts = {}) => { try { window.localStorage?.clear?.(); } catch {} - window.location.href = '/setup'; - throw new Error('Unauthorized'); + window.location.href = "/setup"; + throw new Error("Unauthorized"); } return res; }; export async function fetchStatus() { - const res = await authFetch('/api/status'); + const res = await authFetch("/api/status"); return res.json(); } export async function fetchPairings() { - const res = await authFetch('/api/pairings'); + const res = await authFetch("/api/pairings"); return res.json(); } export async function approvePairing(id, channel) { const res = await authFetch(`/api/pairings/${id}/approve`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel }), }); return res.json(); @@ -50,15 +50,15 @@ export async function approvePairing(id, channel) { export async function rejectPairing(id, channel) { const res = await authFetch(`/api/pairings/${id}/reject`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel }), }); return res.json(); } export async function fetchGoogleAccounts() { - const res = await authFetch('/api/google/accounts'); + const res = await authFetch("/api/google/accounts"); return res.json(); } @@ -99,9 +99,9 @@ export async function saveGoogleCredentials({ personal = false, accountId = "", }) { - const res = await authFetch('/api/google/credentials', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/google/credentials", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId, clientSecret, @@ -122,18 +122,18 @@ export async function saveGoogleAccount({ personal = false, accountId = "", }) { - const res = await authFetch('/api/google/accounts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/google/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, services, client, personal, accountId }), }); return res.json(); } export async function disconnectGoogle(accountId = "") { - const res = await authFetch('/api/google/disconnect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/google/disconnect", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accountId }), }); return res.json(); @@ -188,7 +188,10 @@ export const renewGmailWatch = async ({ const res = await authFetch("/api/gmail/watch/renew", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId: String(accountId || ""), force: Boolean(force) }), + body: JSON.stringify({ + accountId: String(accountId || ""), + force: Boolean(force), + }), }); return parseJsonOrThrow(res, "Could not renew Gmail watch"); }; @@ -236,7 +239,9 @@ export const fetchDoctorCards = async ({ runId = "all" } = {}) => { }; export const fetchDoctorRun = async (runId) => { - const res = await authFetch(`/api/doctor/runs/${encodeURIComponent(String(runId || ""))}`); + const res = await authFetch( + `/api/doctor/runs/${encodeURIComponent(String(runId || ""))}`, + ); return parseJsonOrThrow(res, "Could not load Doctor run"); }; @@ -248,11 +253,14 @@ export const fetchDoctorRunCards = async (runId) => { }; export const updateDoctorCardStatus = async ({ cardId, status }) => { - const res = await authFetch(`/api/doctor/cards/${encodeURIComponent(String(cardId || ""))}/status`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: String(status || "") }), - }); + const res = await authFetch( + `/api/doctor/cards/${encodeURIComponent(String(cardId || ""))}/status`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: String(status || "") }), + }, + ); return parseJsonOrThrow(res, "Could not update Doctor card status"); }; @@ -295,114 +303,122 @@ export const sendAgentMessage = async ({ }; export async function restartGateway() { - const res = await authFetch('/api/gateway/restart', { method: 'POST' }); - return parseJsonOrThrow(res, 'Could not restart gateway'); + const res = await authFetch("/api/gateway/restart", { method: "POST" }); + return parseJsonOrThrow(res, "Could not restart gateway"); } export async function fetchRestartStatus() { - const res = await authFetch('/api/restart-status'); - return parseJsonOrThrow(res, 'Could not load restart status'); + const res = await authFetch("/api/restart-status"); + return parseJsonOrThrow(res, "Could not load restart status"); } export async function fetchWatchdogStatus() { - const res = await authFetch('/api/watchdog/status'); - return parseJsonOrThrow(res, 'Could not load watchdog status'); + const res = await authFetch("/api/watchdog/status"); + return parseJsonOrThrow(res, "Could not load watchdog status"); } export async function fetchUsageSummary(days = 30) { const params = new URLSearchParams({ days: String(days) }); const res = await authFetch(`/api/usage/summary?${params.toString()}`); - return parseJsonOrThrow(res, 'Could not load usage summary'); + return parseJsonOrThrow(res, "Could not load usage summary"); } export async function fetchUsageSessions(limit = 50) { const params = new URLSearchParams({ limit: String(limit) }); const res = await authFetch(`/api/usage/sessions?${params.toString()}`); - return parseJsonOrThrow(res, 'Could not load usage sessions'); + return parseJsonOrThrow(res, "Could not load usage sessions"); } export async function fetchUsageSessionDetail(sessionId) { - const res = await authFetch(`/api/usage/sessions/${encodeURIComponent(String(sessionId || ''))}`); - return parseJsonOrThrow(res, 'Could not load usage session detail'); + const res = await authFetch( + `/api/usage/sessions/${encodeURIComponent(String(sessionId || ""))}`, + ); + return parseJsonOrThrow(res, "Could not load usage session detail"); } export async function fetchUsageSessionTimeSeries(sessionId, maxPoints = 100) { const params = new URLSearchParams({ maxPoints: String(maxPoints) }); - const safeSessionId = encodeURIComponent(String(sessionId || '')); - const res = await authFetch(`/api/usage/sessions/${safeSessionId}/timeseries?${params.toString()}`); - return parseJsonOrThrow(res, 'Could not load usage time series'); + const safeSessionId = encodeURIComponent(String(sessionId || "")); + const res = await authFetch( + `/api/usage/sessions/${safeSessionId}/timeseries?${params.toString()}`, + ); + return parseJsonOrThrow(res, "Could not load usage time series"); } export async function fetchWatchdogEvents(limit = 20) { - const res = await authFetch(`/api/watchdog/events?limit=${encodeURIComponent(String(limit))}`); - return parseJsonOrThrow(res, 'Could not load watchdog events'); + const res = await authFetch( + `/api/watchdog/events?limit=${encodeURIComponent(String(limit))}`, + ); + return parseJsonOrThrow(res, "Could not load watchdog events"); } export async function fetchWatchdogLogs(tail = 65536) { - const res = await authFetch(`/api/watchdog/logs?tail=${encodeURIComponent(String(tail))}`); - if (!res.ok) throw new Error('Could not load watchdog logs'); + const res = await authFetch( + `/api/watchdog/logs?tail=${encodeURIComponent(String(tail))}`, + ); + if (!res.ok) throw new Error("Could not load watchdog logs"); return res.text(); } export async function triggerWatchdogRepair() { - const res = await authFetch('/api/watchdog/repair', { method: 'POST' }); - return parseJsonOrThrow(res, 'Could not trigger watchdog repair'); + const res = await authFetch("/api/watchdog/repair", { method: "POST" }); + return parseJsonOrThrow(res, "Could not trigger watchdog repair"); } export async function fetchWatchdogResources() { - const res = await authFetch('/api/watchdog/resources'); - return parseJsonOrThrow(res, 'Could not load system resources'); + const res = await authFetch("/api/watchdog/resources"); + return parseJsonOrThrow(res, "Could not load system resources"); } export async function fetchWatchdogSettings() { - const res = await authFetch('/api/watchdog/settings'); - return parseJsonOrThrow(res, 'Could not load watchdog settings'); + const res = await authFetch("/api/watchdog/settings"); + return parseJsonOrThrow(res, "Could not load watchdog settings"); } export async function updateWatchdogSettings(settings) { - const res = await authFetch('/api/watchdog/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/watchdog/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings || {}), }); - return parseJsonOrThrow(res, 'Could not update watchdog settings'); + return parseJsonOrThrow(res, "Could not update watchdog settings"); } export async function fetchDashboardUrl() { - const res = await authFetch('/api/gateway/dashboard'); + const res = await authFetch("/api/gateway/dashboard"); return res.json(); } export async function fetchOpenclawVersion(refresh = false) { - const query = refresh ? '?refresh=1' : ''; + const query = refresh ? "?refresh=1" : ""; const res = await authFetch(`/api/openclaw/version${query}`); return res.json(); } export async function updateOpenclaw() { - const res = await authFetch('/api/openclaw/update', { method: 'POST' }); + const res = await authFetch("/api/openclaw/update", { method: "POST" }); return res.json(); } export async function fetchAlphaclawVersion(refresh = false) { - const query = refresh ? '?refresh=1' : ''; + const query = refresh ? "?refresh=1" : ""; const res = await authFetch(`/api/alphaclaw/version${query}`); return res.json(); } export async function updateAlphaclaw() { - const res = await authFetch('/api/alphaclaw/update', { method: 'POST' }); + const res = await authFetch("/api/alphaclaw/update", { method: "POST" }); return res.json(); } export async function fetchSyncCron() { - const res = await authFetch('/api/sync-cron'); + const res = await authFetch("/api/sync-cron"); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : {}; } catch { - throw new Error(text || 'Could not parse sync cron response'); + throw new Error(text || "Could not parse sync cron response"); } if (!res.ok) { throw new Error(data.error || text || `HTTP ${res.status}`); @@ -411,9 +427,9 @@ export async function fetchSyncCron() { } export async function updateSyncCron(payload) { - const res = await authFetch('/api/sync-cron', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/sync-cron", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const text = await res.text(); @@ -421,7 +437,7 @@ export async function updateSyncCron(payload) { try { data = text ? JSON.parse(text) : {}; } catch { - throw new Error(text || 'Could not parse sync cron response'); + throw new Error(text || "Could not parse sync cron response"); } if (!res.ok) { throw new Error(data.error || text || `HTTP ${res.status}`); @@ -430,57 +446,57 @@ export async function updateSyncCron(payload) { } export async function fetchDevicePairings() { - const res = await authFetch('/api/devices'); + const res = await authFetch("/api/devices"); return res.json(); } export async function approveDevice(id) { - const res = await authFetch(`/api/devices/${id}/approve`, { method: 'POST' }); + const res = await authFetch(`/api/devices/${id}/approve`, { method: "POST" }); return res.json(); } export async function rejectDevice(id) { - const res = await authFetch(`/api/devices/${id}/reject`, { method: 'POST' }); + const res = await authFetch(`/api/devices/${id}/reject`, { method: "POST" }); return res.json(); } export const fetchAuthStatus = async () => { - const res = await authFetch('/api/auth/status'); + const res = await authFetch("/api/auth/status"); return res.json(); }; export const logout = async () => { - const res = await authFetch('/api/auth/logout', { method: 'POST' }); + const res = await authFetch("/api/auth/logout", { method: "POST" }); return res.json(); }; export async function fetchOnboardStatus() { - const res = await authFetch('/api/onboard/status'); + const res = await authFetch("/api/onboard/status"); return res.json(); } export async function runOnboard(vars, modelKey, { importMode = false } = {}) { - const res = await authFetch('/api/onboard', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/onboard", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vars, modelKey, importMode }), }); return res.json(); } export async function verifyGithubOnboardingRepo(repo, token, mode = "new") { - const res = await authFetch('/api/onboard/github/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/onboard/github/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo, token, mode }), }); return res.json(); } export async function scanImportRepo(tempDir) { - const res = await authFetch('/api/onboard/import/scan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/onboard/import/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tempDir }), }); return res.json(); @@ -493,9 +509,9 @@ export async function applyImport({ githubRepo = "", githubToken = "", }) { - const res = await authFetch('/api/onboard/import/apply', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/onboard/import/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tempDir, approvedSecrets, @@ -508,87 +524,98 @@ export async function applyImport({ } export const fetchModels = async () => { - const res = await authFetch('/api/models'); + const res = await authFetch("/api/models"); return res.json(); }; export const fetchModelStatus = async () => { - const res = await authFetch('/api/models/status'); + const res = await authFetch("/api/models/status"); return res.json(); }; export const setPrimaryModel = async (modelKey) => { - const res = await authFetch('/api/models/set', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/models/set", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ modelKey }), }); return res.json(); }; export const fetchModelsConfig = async () => { - const res = await authFetch('/api/models/config'); + const res = await authFetch("/api/models/config"); return res.json(); }; -export const saveModelsConfig = async ({ primary, configuredModels, profiles, authOrder }) => { - const res = await authFetch('/api/models/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, +export const saveModelsConfig = async ({ + primary, + configuredModels, + profiles, + authOrder, +}) => { + const res = await authFetch("/api/models/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ primary, configuredModels, profiles, authOrder }), }); return res.json(); }; export const fetchAuthProfiles = async () => { - const res = await authFetch('/api/models/auth'); + const res = await authFetch("/api/models/auth"); return res.json(); }; export const upsertAuthProfile = async (profileId, credential) => { - const res = await authFetch(`/api/models/auth/${encodeURIComponent(profileId)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credential), - }); + const res = await authFetch( + `/api/models/auth/${encodeURIComponent(profileId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(credential), + }, + ); return res.json(); }; export const deleteAuthProfile = async (profileId) => { - const res = await authFetch(`/api/models/auth/${encodeURIComponent(profileId)}`, { - method: 'DELETE', - }); + const res = await authFetch( + `/api/models/auth/${encodeURIComponent(profileId)}`, + { + method: "DELETE", + }, + ); return res.json(); }; export const fetchCodexStatus = async () => { - const res = await authFetch('/api/codex/status'); + const res = await authFetch("/api/codex/status"); return res.json(); }; export const disconnectCodex = async () => { - const res = await authFetch('/api/codex/disconnect', { method: 'POST' }); + const res = await authFetch("/api/codex/disconnect", { method: "POST" }); return res.json(); }; export const exchangeCodexOAuth = async (input) => { - const res = await authFetch('/api/codex/exchange', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/codex/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input }), }); return res.json(); }; export async function fetchEnvVars() { - const res = await authFetch('/api/env'); + const res = await authFetch("/api/env"); return res.json(); } export async function saveEnvVars(vars) { - const res = await authFetch('/api/env', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/env", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vars }), }); const text = await res.text(); @@ -596,7 +623,7 @@ export async function saveEnvVars(vars) { try { data = text ? JSON.parse(text) : {}; } catch { - throw new Error(text || 'Could not parse env save response'); + throw new Error(text || "Could not parse env save response"); } if (!res.ok) { throw new Error(data.error || text || `HTTP ${res.status}`); @@ -619,107 +646,110 @@ const parseJsonOrThrow = async (res, fallbackError) => { }; export async function fetchWebhooks() { - const res = await authFetch('/api/webhooks'); - return parseJsonOrThrow(res, 'Could not load webhooks'); + const res = await authFetch("/api/webhooks"); + return parseJsonOrThrow(res, "Could not load webhooks"); } export async function fetchWebhookDetail(name) { const res = await authFetch(`/api/webhooks/${encodeURIComponent(name)}`); - return parseJsonOrThrow(res, 'Could not load webhook detail'); + return parseJsonOrThrow(res, "Could not load webhook detail"); } export async function createWebhook(name) { - const res = await authFetch('/api/webhooks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); - return parseJsonOrThrow(res, 'Could not create webhook'); + return parseJsonOrThrow(res, "Could not create webhook"); } export async function deleteWebhook(name, { deleteTransformDir = false } = {}) { const res = await authFetch(`/api/webhooks/${encodeURIComponent(name)}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + method: "DELETE", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ deleteTransformDir: !!deleteTransformDir }), }); - return parseJsonOrThrow(res, 'Could not delete webhook'); + return parseJsonOrThrow(res, "Could not delete webhook"); } -export async function fetchWebhookRequests(name, { limit = 50, offset = 0, status = 'all' } = {}) { +export async function fetchWebhookRequests( + name, + { limit = 50, offset = 0, status = "all" } = {}, +) { const params = new URLSearchParams({ limit: String(limit), offset: String(offset), - status: String(status || 'all'), + status: String(status || "all"), }); const res = await authFetch( `/api/webhooks/${encodeURIComponent(name)}/requests?${params.toString()}`, ); - return parseJsonOrThrow(res, 'Could not load webhook requests'); + return parseJsonOrThrow(res, "Could not load webhook requests"); } export async function fetchWebhookRequest(name, id) { const res = await authFetch( `/api/webhooks/${encodeURIComponent(name)}/requests/${encodeURIComponent(String(id))}`, ); - return parseJsonOrThrow(res, 'Could not load webhook request'); + return parseJsonOrThrow(res, "Could not load webhook request"); } export const fetchBrowseTree = async (depth = 10) => { const params = new URLSearchParams({ depth: String(depth) }); const res = await authFetch(`/api/browse/tree?${params.toString()}`); - return parseJsonOrThrow(res, 'Could not load file tree'); + return parseJsonOrThrow(res, "Could not load file tree"); }; export const fetchFileContent = async (filePath) => { - const params = new URLSearchParams({ path: String(filePath || '') }); + const params = new URLSearchParams({ path: String(filePath || "") }); const res = await authFetch(`/api/browse/read?${params.toString()}`); - return parseJsonOrThrow(res, 'Could not load file content'); + return parseJsonOrThrow(res, "Could not load file content"); }; export const saveFileContent = async (filePath, content) => { - const res = await authFetch('/api/browse/write', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/browse/write", { + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: filePath, content }), }); - return parseJsonOrThrow(res, 'Could not save file'); + return parseJsonOrThrow(res, "Could not save file"); }; export const createBrowseFile = async (filePath) => { - const res = await authFetch('/api/browse/create-file', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: String(filePath || '') }), + const res = await authFetch("/api/browse/create-file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: String(filePath || "") }), }); - return parseJsonOrThrow(res, 'Could not create file'); + return parseJsonOrThrow(res, "Could not create file"); }; export const createBrowseFolder = async (folderPath) => { - const res = await authFetch('/api/browse/create-folder', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: String(folderPath || '') }), + const res = await authFetch("/api/browse/create-folder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: String(folderPath || "") }), }); - return parseJsonOrThrow(res, 'Could not create folder'); + return parseJsonOrThrow(res, "Could not create folder"); }; export const moveBrowsePath = async (from, to) => { - const res = await authFetch('/api/browse/move', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from: String(from || ''), to: String(to || '') }), + const res = await authFetch("/api/browse/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from: String(from || ""), to: String(to || "") }), }); - return parseJsonOrThrow(res, 'Could not move path'); + return parseJsonOrThrow(res, "Could not move path"); }; export const deleteBrowseFile = async (filePath) => { - const res = await authFetch('/api/browse/delete', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: String(filePath || '') }), + const res = await authFetch("/api/browse/delete", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: String(filePath || "") }), }); - return parseJsonOrThrow(res, 'Could not delete file'); + return parseJsonOrThrow(res, "Could not delete file"); }; export const downloadBrowseFile = async (filePath) => { @@ -754,23 +784,23 @@ export const downloadBrowseFile = async (filePath) => { }; export const restoreBrowseFile = async (filePath) => { - const res = await authFetch('/api/browse/restore', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: String(filePath || '') }), + const res = await authFetch("/api/browse/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: String(filePath || "") }), }); - return parseJsonOrThrow(res, 'Could not restore file'); + return parseJsonOrThrow(res, "Could not restore file"); }; export const fetchBrowseGitSummary = async () => { - const res = await authFetch('/api/browse/git-summary'); - return parseJsonOrThrow(res, 'Could not load git summary'); + const res = await authFetch("/api/browse/git-summary"); + return parseJsonOrThrow(res, "Could not load git summary"); }; export const fetchBrowseFileDiff = async (filePath) => { const params = new URLSearchParams({ path: String(filePath || "") }); const res = await authFetch(`/api/browse/git-diff?${params.toString()}`); - return parseJsonOrThrow(res, 'Could not load file diff'); + return parseJsonOrThrow(res, "Could not load file diff"); }; export const fetchBrowseSqliteTable = async ({ @@ -790,10 +820,10 @@ export const fetchBrowseSqliteTable = async ({ }; export const syncBrowseChanges = async (message = "") => { - const res = await authFetch('/api/browse/git-sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await authFetch("/api/browse/git-sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: String(message || "") }), }); - return parseJsonOrThrow(res, 'Could not sync changes'); + return parseJsonOrThrow(res, "Could not sync changes"); }; diff --git a/lib/server.js b/lib/server.js index c836db64..e2d70976 100644 --- a/lib/server.js +++ b/lib/server.js @@ -92,6 +92,9 @@ const { installControlUiSkill, syncBootstrapPromptFiles, } = require("./server/onboarding/workspace"); +const { + cleanupStaleImportTempDirs, +} = require("./server/onboarding/import/import-temp"); const { migrateManagedInternalFiles, } = require("./server/internal-files-migration"); @@ -123,6 +126,7 @@ const { PORT, GATEWAY_URL, kTrustProxyHops, SETUP_API_PREFIXES } = constants; startEnvWatcher(); attachGatewaySignalHandlers(); +cleanupStaleImportTempDirs(); migrateManagedInternalFiles({ fs, openclawDir: constants.OPENCLAW_DIR, diff --git a/lib/server/commands.js b/lib/server/commands.js index c9dd7f0b..4b1e2af3 100644 --- a/lib/server/commands.js +++ b/lib/server/commands.js @@ -14,6 +14,7 @@ const createCommands = ({ gatewayEnv }) => { console.log( `[onboard] Running: ${cmd .replace(/ghp_[^\s"]+/g, "***") + .replace(/github_pat_[^\s"]+/g, "***") .replace(/sk-[^\s"]+/g, "***") .slice(0, 200)}`, ); diff --git a/lib/server/gateway.js b/lib/server/gateway.js index 6e2b4c4e..31e68569 100644 --- a/lib/server/gateway.js +++ b/lib/server/gateway.js @@ -3,6 +3,7 @@ const { spawn, execSync } = require("child_process"); const fs = require("fs"); const net = require("net"); const { + ALPHACLAW_DIR, OPENCLAW_DIR, GATEWAY_HOST, GATEWAY_PORT, @@ -48,8 +49,30 @@ const gatewayEnv = () => ({ XDG_CONFIG_HOME: OPENCLAW_DIR, }); -const isOnboarded = () => - fs.existsSync(kOnboardingMarkerPath) || fs.existsSync(kControlUiSkillPath); +const writeOnboardingMarker = (reason) => { + fs.mkdirSync(ALPHACLAW_DIR, { recursive: true }); + fs.writeFileSync( + kOnboardingMarkerPath, + JSON.stringify( + { + onboarded: true, + reason, + markedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); +}; + +const isOnboarded = () => { + if (fs.existsSync(kOnboardingMarkerPath)) return true; + if (fs.existsSync(kControlUiSkillPath)) { + writeOnboardingMarker("legacy_artifact_backfill"); + return true; + } + return false; +}; const isGatewayRunning = () => new Promise((resolve) => { diff --git a/lib/server/onboarding/github.js b/lib/server/onboarding/github.js index 02e1e724..f2ff3f64 100644 --- a/lib/server/onboarding/github.js +++ b/lib/server/onboarding/github.js @@ -1,6 +1,11 @@ +const fs = require("fs"); const os = require("os"); const path = require("path"); const crypto = require("crypto"); +const { + kImportTempPrefix, + isValidImportTempDir, +} = require("./import/import-temp"); const buildGithubHeaders = (githubToken) => ({ Authorization: `token ${githubToken}`, @@ -64,6 +69,7 @@ const verifyGithubRepoForOnboarding = async ({ const authedUser = await userRes.json().catch(() => ({})); const authedLogin = String(authedUser?.login || "").trim(); if ( + !isExisting && repoOwner && authedLogin && repoOwner.toLowerCase() !== authedLogin.toLowerCase() @@ -71,7 +77,7 @@ const verifyGithubRepoForOnboarding = async ({ return { ok: false, status: 400, - error: `Workspace repo owner must match your token user "${authedLogin}"`, + error: `New workspace repo owner must match your token user "${authedLogin}"`, }; } @@ -184,13 +190,37 @@ const ensureGithubRepoAccessible = async ({ const cloneRepoToTemp = async ({ repoUrl, githubToken, shellCmd }) => { const tempId = crypto.randomUUID().slice(0, 8); - const tempDir = path.join(os.tmpdir(), `alphaclaw-import-${tempId}`); - const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoUrl}.git`; + const tempDir = path.join(os.tmpdir(), `${kImportTempPrefix}${tempId}`); + const askPassPath = path.join( + os.tmpdir(), + `alphaclaw-import-askpass-${tempId}.sh`, + ); try { - await shellCmd(`git clone --depth=1 "${remoteUrl}" "${tempDir}"`, { - timeout: 60000, - }); + fs.writeFileSync( + askPassPath, + [ + "#!/bin/sh", + 'case "$1" in', + ' *Username*) printf "%s\\n" "x-access-token" ;;', + ' *) printf "%s\\n" "$ALPHACLAW_GITHUB_TOKEN" ;;', + "esac", + "", + ].join("\n"), + { mode: 0o700 }, + ); + await shellCmd( + `git clone --depth=1 "https://github.com/${repoUrl}.git" "${tempDir}"`, + { + timeout: 60000, + env: { + ...process.env, + GIT_ASKPASS: askPassPath, + GIT_TERMINAL_PROMPT: "0", + ALPHACLAW_GITHUB_TOKEN: githubToken, + }, + }, + ); console.log(`[onboard] Cloned ${repoUrl} to ${tempDir}`); return { ok: true, tempDir }; } catch (e) { @@ -198,17 +228,16 @@ const cloneRepoToTemp = async ({ repoUrl, githubToken, shellCmd }) => { ok: false, error: `Failed to clone repo: ${e.message}`, }; + } finally { + try { + fs.rmSync(askPassPath, { force: true }); + } catch {} } }; const cleanupTempClone = (tempDir) => { try { - const fs = require("fs"); - if ( - tempDir && - tempDir.startsWith(os.tmpdir()) && - tempDir.includes("alphaclaw-import-") - ) { + if (isValidImportTempDir(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); console.log(`[onboard] Cleaned up temp clone ${tempDir}`); } diff --git a/lib/server/onboarding/import-applier.js b/lib/server/onboarding/import/import-applier.js similarity index 78% rename from lib/server/onboarding/import-applier.js rename to lib/server/onboarding/import/import-applier.js index 3b12a31c..b3ff0ff9 100644 --- a/lib/server/onboarding/import-applier.js +++ b/lib/server/onboarding/import/import-applier.js @@ -1,10 +1,11 @@ const path = require("path"); -const os = require("os"); +const { isValidImportTempDir } = require("./import-temp"); +const { + normalizeHookPath, + normalizeTransformModulePath, +} = require("./import-config"); -const isValidTempDir = (tempDir) => - typeof tempDir === "string" && - tempDir.startsWith(os.tmpdir()) && - tempDir.includes("alphaclaw-import-"); +const isValidTempDir = (tempDir) => isValidImportTempDir(tempDir); const kTransformsRoot = path.join("hooks", "transforms"); const kTransformsBackupRoot = path.join(kTransformsRoot, "_backup"); @@ -48,34 +49,52 @@ const cleanupBootstrapArtifacts = (fs, openclawDir) => { } }; -const promoteCloneToWorkspace = ({ fs, tempDir, openclawDir }) => { +const promoteCloneToTarget = ({ + fs, + tempDir, + targetDir, + sourceSubdir = "", + cleanupBootstrap = false, +}) => { if (!isValidTempDir(tempDir)) { return { ok: false, error: "Invalid temp directory" }; } + const sourceDir = sourceSubdir ? path.join(tempDir, sourceSubdir) : tempDir; + try { - if (fs.existsSync(openclawDir)) { - cleanupBootstrapArtifacts(fs, openclawDir); - const existingEntries = fs.readdirSync(openclawDir); + if (!fs.existsSync(sourceDir)) { + return { ok: false, error: "Import source directory not found" }; + } + if (fs.existsSync(targetDir)) { + if (cleanupBootstrap) { + cleanupBootstrapArtifacts(fs, targetDir); + } + const existingEntries = fs.readdirSync(targetDir); if (existingEntries.length > 0) { return { ok: false, - error: "OpenClaw directory already exists and is not empty", + error: "Import target directory already exists and is not empty", }; } - fs.rmSync(openclawDir, { recursive: true, force: true }); + fs.rmSync(targetDir, { recursive: true, force: true }); + } + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.renameSync(sourceDir, targetDir); + if (sourceDir !== tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); } - fs.mkdirSync(path.dirname(openclawDir), { recursive: true }); - fs.renameSync(tempDir, openclawDir); - console.log(`[import] Promoted ${tempDir} to ${openclawDir}`); + console.log(`[import] Promoted ${sourceDir} to ${targetDir}`); return { ok: true }; } catch (e) { // Cross-device rename falls back to copy if (e.code === "EXDEV") { try { - copyDirRecursive(fs, tempDir, openclawDir); + copyDirRecursive(fs, sourceDir, targetDir); fs.rmSync(tempDir, { recursive: true, force: true }); - console.log(`[import] Copied ${tempDir} to ${openclawDir} (cross-device)`); + console.log( + `[import] Copied ${sourceDir} to ${targetDir} (cross-device)`, + ); return { ok: true }; } catch (copyErr) { return { ok: false, error: `Failed to copy clone: ${copyErr.message}` }; @@ -99,12 +118,6 @@ const copyDirRecursive = (fs, src, dest) => { } }; -const normalizeHookPath = (value) => String(value || "").trim().replace(/^\/+/, ""); -const normalizeTransformModulePath = (value) => - String(value || "") - .trim() - .replace(/^\/+/, "") - .replace(/^hooks\/transforms\/+/, ""); const toPosixPath = (value) => String(value || "").replace(/\\/g, "/"); const ensureRelativeImportPath = (value) => { const normalized = toPosixPath(value); @@ -154,12 +167,16 @@ const alignHookTransforms = ({ fs, baseDir, configFiles = [] }) => { } catch { continue; } - const mappings = Array.isArray(cfg?.hooks?.mappings) ? cfg.hooks.mappings : []; + const mappings = Array.isArray(cfg?.hooks?.mappings) + ? cfg.hooks.mappings + : []; let changed = false; mappings.forEach((mapping, index) => { const hookPath = normalizeHookPath(mapping?.match?.path); - const actualModule = normalizeTransformModulePath(mapping?.transform?.module); + const actualModule = normalizeTransformModulePath( + mapping?.transform?.module, + ); if (!hookPath || !actualModule) return; const expectedModule = `${hookPath}/${hookPath}-transform.mjs`; @@ -184,7 +201,10 @@ const alignHookTransforms = ({ fs, baseDir, configFiles = [] }) => { const backupRootAbsolutePath = path.join(baseDir, backupRootRelativePath); if (!movedRoots.has(sourceRootAbsolutePath)) { - if (!pathExists(fs, backupRootAbsolutePath) && pathExists(fs, sourceRootAbsolutePath)) { + if ( + !pathExists(fs, backupRootAbsolutePath) && + pathExists(fs, sourceRootAbsolutePath) + ) { movePath(fs, sourceRootAbsolutePath, backupRootAbsolutePath); } movedRoots.set(sourceRootAbsolutePath, backupRootAbsolutePath); @@ -197,9 +217,15 @@ const alignHookTransforms = ({ fs, baseDir, configFiles = [] }) => { fs.mkdirSync(path.dirname(expectedAbsolutePath), { recursive: true }); const shimImportPath = ensureRelativeImportPath( - path.relative(path.dirname(expectedAbsolutePath), backupActualAbsolutePath), + path.relative( + path.dirname(expectedAbsolutePath), + backupActualAbsolutePath, + ), + ); + fs.writeFileSync( + expectedAbsolutePath, + buildTransformShim(shimImportPath), ); - fs.writeFileSync(expectedAbsolutePath, buildTransformShim(shimImportPath)); mappings[index] = { ...mapping, @@ -220,11 +246,7 @@ const alignHookTransforms = ({ fs, baseDir, configFiles = [] }) => { return { alignedCount }; }; -const applySecretExtraction = ({ - fs, - baseDir, - approvedSecrets, -}) => { +const applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => { const envVars = []; const rewriteMap = new Map(); @@ -254,10 +276,7 @@ const applySecretExtraction = ({ const secretJson = JSON.stringify(value); const envRefJson = JSON.stringify(envRef); content = content.replace( - new RegExp( - secretJson.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"), - "g", - ), + new RegExp(secretJson.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"), "g"), envRefJson, ); } @@ -272,7 +291,7 @@ const applySecretExtraction = ({ }; module.exports = { - promoteCloneToWorkspace, + promoteCloneToTarget, alignHookTransforms, applySecretExtraction, isValidTempDir, diff --git a/lib/server/onboarding/import/import-config.js b/lib/server/onboarding/import/import-config.js new file mode 100644 index 00000000..10fb1ada --- /dev/null +++ b/lib/server/onboarding/import/import-config.js @@ -0,0 +1,69 @@ +const path = require("path"); + +const normalizeHookPath = (value) => + String(value || "") + .trim() + .replace(/^\/+/, ""); + +const normalizeTransformModulePath = (value) => + String(value || "") + .trim() + .replace(/^\/+/, "") + .replace(/^hooks\/transforms\/+/, ""); + +const resolveConfigIncludes = ({ fs, absoluteConfigPath }) => { + const includes = []; + try { + const raw = fs.readFileSync(absoluteConfigPath, "utf8"); + const cfg = JSON.parse(raw); + const walk = (entry) => { + if (!entry || typeof entry !== "object") return; + for (const [key, value] of Object.entries(entry)) { + if (key === "$include" && typeof value === "string" && value.trim()) { + includes.push(value.trim()); + continue; + } + walk(value); + } + }; + walk(cfg); + } catch {} + return includes; +}; + +const resolveImportedConfigPaths = ({ fs, openclawDir }) => { + const discovered = new Set(); + const queue = [path.join(openclawDir, "openclaw.json")].filter((configPath) => + fs.existsSync(configPath), + ); + + while (queue.length > 0) { + const absoluteConfigPath = queue.shift(); + if (!absoluteConfigPath || discovered.has(absoluteConfigPath)) continue; + if (!fs.existsSync(absoluteConfigPath)) continue; + discovered.add(absoluteConfigPath); + + const includes = resolveConfigIncludes({ fs, absoluteConfigPath }); + for (const includePath of includes) { + const candidatePaths = [ + path.join(openclawDir, includePath), + path.join(path.dirname(absoluteConfigPath), includePath), + ]; + for (const candidatePath of candidatePaths) { + if (fs.existsSync(candidatePath) && !discovered.has(candidatePath)) { + queue.push(candidatePath); + break; + } + } + } + } + + return [...discovered]; +}; + +module.exports = { + normalizeHookPath, + normalizeTransformModulePath, + resolveConfigIncludes, + resolveImportedConfigPaths, +}; diff --git a/lib/server/onboarding/import-scanner.js b/lib/server/onboarding/import/import-scanner.js similarity index 75% rename from lib/server/onboarding/import-scanner.js rename to lib/server/onboarding/import/import-scanner.js index 3f92708c..f9544566 100644 --- a/lib/server/onboarding/import-scanner.js +++ b/lib/server/onboarding/import/import-scanner.js @@ -1,5 +1,10 @@ const path = require("path"); -const { kSystemVars } = require("../constants"); +const { kSystemVars } = require("../../constants"); +const { + normalizeHookPath, + normalizeTransformModulePath, + resolveConfigIncludes, +} = require("./import-config"); const kWorkspaceFiles = [ "AGENTS.md", @@ -10,22 +15,19 @@ const kWorkspaceFiles = [ "IDENTITY.md", "HEARTBEAT.md", "BOOTSTRAP.md", - "IDEAS.md", - "INTERESTS.md", ]; -const kConfigLocations = [ - "openclaw.json", - ".openclaw/openclaw.json", - "openclaw.json5", - ".openclaw/openclaw.json5", -]; +const kConfigLocations = ["openclaw.json"]; const kEnvFileLocations = [ ".env", ".env.local", ".env.production", ".env.development", +]; + +const kUnsupportedNestedLocations = [ + ".openclaw/openclaw.json", ".openclaw/.env", ]; @@ -132,12 +134,6 @@ const parseHookDefinitions = (fs, baseDir, configFiles) => { const hookNames = []; const transformWarnings = []; const seen = new Set(); - const normalizeHookPath = (value) => String(value || "").trim().replace(/^\/+/, ""); - const normalizeTransformModulePath = (value) => - String(value || "") - .trim() - .replace(/^\/+/, "") - .replace(/^hooks\/transforms\/+/, ""); const addHookName = (value) => { const name = String(value || "").trim(); @@ -151,27 +147,32 @@ const parseHookDefinitions = (fs, baseDir, configFiles) => { try { const raw = fs.readFileSync(path.join(baseDir, configFile), "utf8"); const cfg = JSON.parse(raw); - const mappings = Array.isArray(cfg?.hooks?.mappings) ? cfg.hooks.mappings : []; + const mappings = Array.isArray(cfg?.hooks?.mappings) + ? cfg.hooks.mappings + : []; mappings.forEach((mapping, index) => { const name = String( - mapping?.name || mapping?.id || mapping?.match?.path || `Hook ${index + 1}`, + mapping?.name || + mapping?.id || + mapping?.match?.path || + `Hook ${index + 1}`, ).trim(); const matchPath = normalizeHookPath(mapping?.match?.path); - const hookLabel = addHookName(matchPath ? `${name} (${matchPath})` : name); + const hookLabel = addHookName( + matchPath ? `${name} (${matchPath})` : name, + ); if (matchPath) { const actualModule = normalizeTransformModulePath( mapping?.transform?.module, ); const expectedModule = `${matchPath}/${matchPath}-transform.mjs`; if (hookLabel && actualModule && actualModule !== expectedModule) { - transformWarnings.push( - { - hookLabel, - actualPath: `hooks/transforms/${actualModule}`, - expectedPath: `hooks/transforms/${expectedModule}`, - message: `Uses hooks/transforms/${actualModule}; expected hooks/transforms/${expectedModule}`, - }, - ); + transformWarnings.push({ + hookLabel, + actualPath: `hooks/transforms/${actualModule}`, + expectedPath: `hooks/transforms/${expectedModule}`, + message: `Uses hooks/transforms/${actualModule}; expected hooks/transforms/${expectedModule}`, + }); } } }); @@ -181,7 +182,9 @@ const parseHookDefinitions = (fs, baseDir, configFiles) => { for (const [key, entry] of Object.entries(internalEntries)) { const enabled = entry?.enabled; addHookName( - enabled === false ? `internal:${key} (disabled)` : `internal:${key}`, + enabled === false + ? `internal:${key} (disabled)` + : `internal:${key}`, ); } } @@ -191,26 +194,6 @@ const parseHookDefinitions = (fs, baseDir, configFiles) => { return { hookNames, transformWarnings }; }; -const resolveConfigIncludes = (fs, baseDir, configPath) => { - const extras = []; - try { - const raw = fs.readFileSync(path.join(baseDir, configPath), "utf8"); - const cfg = JSON.parse(raw); - const walk = (obj) => { - if (!obj || typeof obj !== "object") return; - for (const [key, val] of Object.entries(obj)) { - if (key === "$include" && typeof val === "string") { - extras.push(val); - } else if (typeof val === "object") { - walk(val); - } - } - }; - walk(cfg); - } catch {} - return extras; -}; - const kEnvRefPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g; const collectManagedEnvRefs = (value, found) => { @@ -228,7 +211,9 @@ const collectManagedEnvRefs = (value, found) => { return; } if (value && typeof value === "object") { - Object.values(value).forEach((entry) => collectManagedEnvRefs(entry, found)); + Object.values(value).forEach((entry) => + collectManagedEnvRefs(entry, found), + ); } }; @@ -272,12 +257,73 @@ const collectManagedEnvConflicts = (fs, baseDir, configFiles, envFiles) => { }; }; +const detectImportSourceLayout = ({ + gatewayConfig, + workspaceFiles, + skills, + cronJobs, + webhooks, + memory, + credentials, + unsupportedNested, +}) => { + const configFiles = Array.isArray(gatewayConfig?.files) + ? gatewayConfig.files + : []; + const hasRootConfig = configFiles.some( + (filePath) => filePath === "openclaw.json", + ); + const hasUnsupportedNested = !!unsupportedNested?.found; + const hasWorkspaceOnlyContent = !!( + workspaceFiles?.found || + skills?.found || + cronJobs?.found || + webhooks?.found || + memory?.found || + credentials?.found + ); + + if (hasUnsupportedNested) { + return { + kind: "unsupported-nested-openclaw", + supported: false, + error: + "This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.", + }; + } + if (hasRootConfig) { + return { + kind: "full-openclaw-root", + supported: true, + promoteSourceSubdir: "", + }; + } + if (hasWorkspaceOnlyContent) { + return { + kind: "workspace-only", + supported: true, + promoteSourceSubdir: "", + }; + } + return { + kind: "empty", + supported: true, + promoteSourceSubdir: "", + }; +}; + const scanWorkspace = ({ fs, baseDir }) => { const gatewayConfig = scanCategory(fs, baseDir, kConfigLocations); for (const cfgFile of gatewayConfig.files) { - const includes = resolveConfigIncludes(fs, baseDir, cfgFile); + const includes = resolveConfigIncludes({ + fs, + absoluteConfigPath: path.join(baseDir, cfgFile), + }); for (const inc of includes) { - if (fileExists(fs, path.join(baseDir, inc)) && !gatewayConfig.files.includes(inc)) { + if ( + fileExists(fs, path.join(baseDir, inc)) && + !gatewayConfig.files.includes(inc) + ) { gatewayConfig.files.push(inc); } } @@ -291,6 +337,11 @@ const scanWorkspace = ({ fs, baseDir }) => { } const envFiles = scanCategory(fs, baseDir, kEnvFileLocations); + const unsupportedNested = scanCategory( + fs, + baseDir, + kUnsupportedNestedLocations, + ); const workspaceFilesScan = scanCategory(fs, baseDir, kWorkspaceFiles); const extraMarkdown = listRootMarkdown(fs, baseDir); @@ -378,12 +429,32 @@ const scanWorkspace = ({ fs, baseDir }) => { !envFiles.found && !workspaceFiles.found && !skills.found; + const sourceLayout = detectImportSourceLayout({ + gatewayConfig, + workspaceFiles, + skills, + cronJobs, + webhooks, + memory, + credentials, + unsupportedNested, + }); + if (sourceLayout.kind === "workspace-only") { + sourceLayout.promoteSourceSubdir = dirExists( + fs, + path.join(baseDir, "workspace"), + ) + ? "workspace" + : ""; + } return { hasOpenclawSetup, isEmpty, + sourceLayout, gatewayConfig, envFiles, + unsupportedNested, workspaceFiles, skills, cronJobs, @@ -395,4 +466,4 @@ const scanWorkspace = ({ fs, baseDir }) => { }; }; -module.exports = { scanWorkspace }; +module.exports = { scanWorkspace, detectImportSourceLayout }; diff --git a/lib/server/onboarding/import/import-temp.js b/lib/server/onboarding/import/import-temp.js new file mode 100644 index 00000000..86155abd --- /dev/null +++ b/lib/server/onboarding/import/import-temp.js @@ -0,0 +1,63 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const kImportTempPrefix = "alphaclaw-import-"; +const kImportTempTtlMs = 24 * 60 * 60 * 1000; + +const resolveImportTempDir = (tempDir) => { + const value = String(tempDir || "").trim(); + if (!value) return ""; + return path.resolve(value); +}; + +const isValidImportTempDir = (tempDir) => { + const resolved = resolveImportTempDir(tempDir); + if (!resolved) return false; + const tempRoot = path.resolve(os.tmpdir()); + const relative = path.relative(tempRoot, resolved); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return false; + } + return path.basename(resolved).startsWith(kImportTempPrefix); +}; + +const cleanupStaleImportTempDirs = ({ + fsModule = fs, + maxAgeMs = kImportTempTtlMs, + nowMs = Date.now(), +} = {}) => { + const tempRoot = path.resolve(os.tmpdir()); + let removedCount = 0; + + let entries = []; + try { + entries = fsModule.readdirSync(tempRoot, { withFileTypes: true }); + } catch { + return { removedCount }; + } + + for (const entry of entries) { + if (!entry?.isDirectory?.()) continue; + if (!String(entry.name || "").startsWith(kImportTempPrefix)) continue; + const candidate = path.join(tempRoot, entry.name); + if (!isValidImportTempDir(candidate)) continue; + try { + const stats = fsModule.statSync(candidate); + const ageMs = nowMs - Number(stats?.mtimeMs || 0); + if (ageMs < maxAgeMs) continue; + fsModule.rmSync(candidate, { recursive: true, force: true }); + removedCount += 1; + } catch {} + } + + return { removedCount }; +}; + +module.exports = { + kImportTempPrefix, + kImportTempTtlMs, + resolveImportTempDir, + isValidImportTempDir, + cleanupStaleImportTempDirs, +}; diff --git a/lib/server/onboarding/secret-detector.js b/lib/server/onboarding/import/secret-detector.js similarity index 100% rename from lib/server/onboarding/secret-detector.js rename to lib/server/onboarding/import/secret-detector.js diff --git a/lib/server/onboarding/index.js b/lib/server/onboarding/index.js index aa2e5e17..7fb90f64 100644 --- a/lib/server/onboarding/index.js +++ b/lib/server/onboarding/index.js @@ -1,5 +1,9 @@ const path = require("path"); const { kSetupDir, kRootDir } = require("../constants"); +const { + resolveConfigIncludes, + resolveImportedConfigPaths, +} = require("./import/import-config"); const { validateOnboardingInput } = require("./validation"); const { ensureGithubRepoAccessible, @@ -55,55 +59,37 @@ const collectEnvRefs = (value, found = new Set()) => { return found; }; -const resolveConfigIncludes = ({ fs, absoluteConfigPath }) => { - const includes = []; - try { - const raw = fs.readFileSync(absoluteConfigPath, "utf8"); - const cfg = JSON.parse(raw); - const walk = (value) => { - if (!value || typeof value !== "object") return; - for (const [key, entry] of Object.entries(value)) { - if (key === "$include" && typeof entry === "string" && entry.trim()) { - includes.push(entry.trim()); - continue; - } - walk(entry); - } - }; - walk(cfg); - } catch {} - return includes; -}; - -const resolveImportedConfigPaths = ({ fs, openclawDir }) => { - const discovered = new Set(); - const queue = [ - path.join(openclawDir, "openclaw.json"), - path.join(openclawDir, ".openclaw", "openclaw.json"), - ].filter((configPath) => fs.existsSync(configPath)); - - while (queue.length > 0) { - const absoluteConfigPath = queue.shift(); - if (!absoluteConfigPath || discovered.has(absoluteConfigPath)) continue; - if (!fs.existsSync(absoluteConfigPath)) continue; - discovered.add(absoluteConfigPath); +const getEnvVarValue = (items, key) => + items.find((entry) => entry.key === key)?.value || ""; - const includes = resolveConfigIncludes({ fs, absoluteConfigPath }); - for (const includePath of includes) { - const candidatePaths = [ - path.join(openclawDir, includePath), - path.join(path.dirname(absoluteConfigPath), includePath), - ]; - for (const candidatePath of candidatePaths) { - if (fs.existsSync(candidatePath) && !discovered.has(candidatePath)) { - queue.push(candidatePath); - break; - } - } - } - } - - return [...discovered]; +const buildPlaceholderReview = ({ + referencedEnvVars, + envVars = [], + systemVars = new Set(), +}) => { + const vars = Array.from(referencedEnvVars) + .filter((envKey) => !systemVars.has(envKey)) + .sort() + .map((envKey) => { + const currentValue = String(getEnvVarValue(envVars, envKey) || "").trim(); + const status = + currentValue === kPlaceholderEnvValue + ? "placeholder" + : currentValue + ? "resolved" + : "missing"; + if (status === "resolved") return null; + return { + key: envKey, + status, + }; + }) + .filter(Boolean); + return { + found: vars.length > 0, + count: vars.length, + vars, + }; }; const normalizeImportedConfig = ({ fs, openclawDir }) => { @@ -129,7 +115,10 @@ const normalizeImportedConfig = ({ fs, openclawDir }) => { }; changed = true; } - if (cfg.hooks && Object.prototype.hasOwnProperty.call(cfg.hooks, "transformsDir")) { + if ( + cfg.hooks && + Object.prototype.hasOwnProperty.call(cfg.hooks, "transformsDir") + ) { const { transformsDir, ...nextHooks } = cfg.hooks; void transformsDir; cfg.hooks = nextHooks; @@ -153,6 +142,24 @@ const getImportedConfigEnvRefs = ({ fs, openclawDir }) => { return refs; }; +const getImportedPlaceholderReview = ({ + fs, + openclawDir, + envVars = [], + systemVars = new Set(), + normalizeConfig = false, +}) => { + if (normalizeConfig) { + normalizeImportedConfig({ fs, openclawDir }); + } + const referencedEnvVars = getImportedConfigEnvRefs({ fs, openclawDir }); + return buildPlaceholderReview({ + referencedEnvVars, + envVars, + systemVars, + }); +}; + const createOnboardingService = ({ fs, constants, @@ -204,7 +211,12 @@ const createOnboardingService = ({ return verification; }; - const completeOnboarding = async ({ req, vars, modelKey, importMode = false }) => { + const completeOnboarding = async ({ + req, + vars, + modelKey, + importMode = false, + }) => { const validation = validateOnboardingInput({ vars, modelKey, @@ -229,9 +241,7 @@ const createOnboardingService = ({ const repoUrl = resolveGithubRepoUrl(githubRepoInput); const remoteUrl = `https://github.com/${repoUrl}.git`; const existingConfigPresent = - importMode && - (fs.existsSync(`${OPENCLAW_DIR}/openclaw.json`) || - fs.existsSync(`${OPENCLAW_DIR}/.openclaw/openclaw.json`)); + importMode && fs.existsSync(`${OPENCLAW_DIR}/openclaw.json`); const existingEnvVars = typeof readEnvFile === "function" ? readEnvFile() : []; const varsToSave = [...existingEnvVars]; @@ -242,41 +252,35 @@ const createOnboardingService = ({ } upsertEnvVar(varsToSave, "GITHUB_WORKSPACE_REPO", repoUrl); if (importMode && existingConfigPresent) { - normalizeImportedConfig({ - fs, - openclawDir: OPENCLAW_DIR, - }); const systemVars = - constants.kSystemVars instanceof Set ? constants.kSystemVars : new Set(); - const referencedEnvVars = getImportedConfigEnvRefs({ + constants.kSystemVars instanceof Set + ? constants.kSystemVars + : new Set(); + const placeholderReview = getImportedPlaceholderReview({ fs, openclawDir: OPENCLAW_DIR, + envVars: varsToSave, + systemVars, + normalizeConfig: true, }); - for (const envKey of referencedEnvVars) { - if (systemVars.has(envKey)) continue; - const existingValue = String( - varsToSave.find((entry) => entry.key === envKey)?.value || "", - ).trim(); - if (existingValue) continue; - upsertEnvVar(varsToSave, envKey, kPlaceholderEnvValue); + for (const placeholderVar of placeholderReview.vars) { + upsertEnvVar(varsToSave, placeholderVar.key, kPlaceholderEnvValue); } } writeEnvFile(varsToSave); reloadEnv(); - if (!importMode) { - const [, repoName] = repoUrl.split("/"); - const repoCheck = await ensureGithubRepoAccessible({ - repoUrl, - repoName, - githubToken, - }); - if (!repoCheck.ok) { - return { - status: repoCheck.status, - body: { ok: false, error: repoCheck.error }, - }; - } + const [, repoName] = repoUrl.split("/"); + const repoCheck = await ensureGithubRepoAccessible({ + repoUrl, + repoName, + githubToken, + }); + if (!repoCheck.ok) { + return { + status: repoCheck.status, + body: { ok: false, error: repoCheck.error }, + }; } fs.mkdirSync(OPENCLAW_DIR, { recursive: true }); @@ -289,14 +293,20 @@ const createOnboardingService = ({ fs, workspaceDir: WORKSPACE_DIR, baseUrl: getBaseUrl(req), - skipExisting: importMode, }); ensureOpenclawRuntimeArtifacts({ fs, openclawDir: OPENCLAW_DIR, }); - if (!fs.existsSync(`${OPENCLAW_DIR}/.git`)) { + const hadImportedGit = importMode && fs.existsSync(`${OPENCLAW_DIR}/.git`); + if (hadImportedGit) { + try { + fs.rmSync(`${OPENCLAW_DIR}/.git`, { recursive: true, force: true }); + } catch {} + } + + if (hadImportedGit || !fs.existsSync(`${OPENCLAW_DIR}/.git`)) { await shellCmd( `cd ${OPENCLAW_DIR} && git init -b main && git remote add origin "${remoteUrl}" && git config user.email "agent@alphaclaw.md" && git config user.name "AlphaClaw Agent"`, ); @@ -337,7 +347,9 @@ const createOnboardingService = ({ ); console.log("[onboard] Onboard complete"); } else { - console.log("[onboard] Skipped openclaw onboard (existing config present)"); + console.log( + "[onboard] Skipped openclaw onboard (existing config present)", + ); } await shellCmd(`openclaw models set "${modelKey}"`, { @@ -406,4 +418,7 @@ const createOnboardingService = ({ return { completeOnboarding, verifyGithubSetup }; }; -module.exports = { createOnboardingService }; +module.exports = { + createOnboardingService, + getImportedPlaceholderReview, +}; diff --git a/lib/server/onboarding/workspace.js b/lib/server/onboarding/workspace.js index 45497e84..7b593e3c 100644 --- a/lib/server/onboarding/workspace.js +++ b/lib/server/onboarding/workspace.js @@ -72,16 +72,22 @@ const renderGoogleAccountsMarkdown = (fs) => { } }; -const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl, skipExisting = false }) => { +const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => { try { const setupUiUrl = resolveSetupUiUrl(baseUrl); - const bootstrapDir = `${workspaceDir}/hooks/bootstrap`; + const bootstrapDir = path.join(workspaceDir, "hooks", "bootstrap"); fs.mkdirSync(bootstrapDir, { recursive: true }); // AlphaClaw-managed files are always overwritten (even during import) - fs.copyFileSync(path.join(kSetupDir, "core-prompts", "AGENTS.md"), `${bootstrapDir}/AGENTS.md`); + fs.copyFileSync( + path.join(kSetupDir, "core-prompts", "AGENTS.md"), + path.join(bootstrapDir, "AGENTS.md"), + ); - const toolsTemplate = fs.readFileSync(path.join(kSetupDir, "core-prompts", "TOOLS.md"), "utf8"); + const toolsTemplate = fs.readFileSync( + path.join(kSetupDir, "core-prompts", "TOOLS.md"), + "utf8", + ); let toolsContent = toolsTemplate.replace( /\{\{SETUP_UI_URL\}\}/g, setupUiUrl, @@ -98,7 +104,7 @@ const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl, skipExisting = fa toolsContent += googleAccountsSection; } - fs.writeFileSync(`${bootstrapDir}/TOOLS.md`, toolsContent); + fs.writeFileSync(path.join(bootstrapDir, "TOOLS.md"), toolsContent); console.log("[onboard] Bootstrap prompt files synced"); } catch (e) { console.error("[onboard] Bootstrap prompt sync error:", e.message); diff --git a/lib/server/routes/onboarding.js b/lib/server/routes/onboarding.js index 2e2c487c..acf9f244 100644 --- a/lib/server/routes/onboarding.js +++ b/lib/server/routes/onboarding.js @@ -1,12 +1,19 @@ -const { createOnboardingService } = require("../onboarding"); -const { scanWorkspace } = require("../onboarding/import-scanner"); -const { detectSecrets, extractPreFillValues } = require("../onboarding/secret-detector"); const { - promoteCloneToWorkspace, + createOnboardingService, + getImportedPlaceholderReview, +} = require("../onboarding"); +const path = require("path"); +const { scanWorkspace } = require("../onboarding/import/import-scanner"); +const { + detectSecrets, + extractPreFillValues, +} = require("../onboarding/import/secret-detector"); +const { + promoteCloneToTarget, alignHookTransforms, applySecretExtraction, isValidTempDir, -} = require("../onboarding/import-applier"); +} = require("../onboarding/import/import-applier"); const { cleanupTempClone } = require("../onboarding/github"); const sanitizeOnboardingError = (error) => { @@ -86,6 +93,8 @@ const registerOnboardingRoutes = ({ getBaseUrl, startGateway, }) => { + // Keep mutating onboarding routes marker-gated so in-progress imports + // can promote files before the final completion marker is written. const hasExplicitOnboardingMarker = () => fs.existsSync(constants.kOnboardingMarkerPath); @@ -139,12 +148,10 @@ const registerOnboardingRoutes = ({ const githubToken = String(req.body?.token || "").trim(); const mode = String(req.body?.mode || "new").trim(); if (!githubRepoInput || !githubToken) { - return res - .status(400) - .json({ - ok: false, - error: "GitHub token and workspace repo are required", - }); + return res.status(400).json({ + ok: false, + error: "GitHub token and workspace repo are required", + }); } const result = await onboardingService.verifyGithubSetup({ @@ -190,6 +197,14 @@ const registerOnboardingRoutes = ({ } const scan = scanWorkspace({ fs, baseDir: tempDir }); + if (!scan.sourceLayout?.supported) { + cleanupTempClone(tempDir); + return res.status(400).json({ + ok: false, + error: scan.sourceLayout?.error || "Unsupported import source layout", + }); + } + const secrets = detectSecrets({ fs, baseDir: tempDir, @@ -236,6 +251,15 @@ const registerOnboardingRoutes = ({ .json({ ok: false, error: "Temp directory not found" }); } + const scan = scanWorkspace({ fs, baseDir: tempDir }); + if (!scan.sourceLayout?.supported) { + cleanupTempClone(tempDir); + return res.status(400).json({ + ok: false, + error: scan.sourceLayout?.error || "Unsupported import source layout", + }); + } + let envVars = []; if (!skipSecretExtraction && approvedSecrets.length > 0) { const extraction = applySecretExtraction({ @@ -246,8 +270,8 @@ const registerOnboardingRoutes = ({ envVars = extraction.envVars; } - const configFiles = ["openclaw.json", ".openclaw/openclaw.json"].filter((f) => - fs.existsSync(`${tempDir}/${f}`), + const configFiles = ["openclaw.json"].filter((f) => + fs.existsSync(path.join(tempDir, f)), ); const transformAlignment = alignHookTransforms({ fs, @@ -261,18 +285,22 @@ const registerOnboardingRoutes = ({ configFiles, }); - const promoteResult = promoteCloneToWorkspace({ + const promoteTargetDir = + scan.sourceLayout.kind === "workspace-only" + ? constants.WORKSPACE_DIR + : constants.OPENCLAW_DIR; + const promoteResult = promoteCloneToTarget({ fs, tempDir, - openclawDir: constants.OPENCLAW_DIR, + targetDir: promoteTargetDir, + sourceSubdir: scan.sourceLayout.promoteSourceSubdir || "", + cleanupBootstrap: scan.sourceLayout.kind === "full-openclaw-root", }); if (!promoteResult.ok) { - return res - .status(500) - .json({ ok: false, error: promoteResult.error }); + return res.status(500).json({ ok: false, error: promoteResult.error }); } - const existing = require("../env").readEnvFile(); + const existing = typeof readEnvFile === "function" ? readEnvFile() : []; const merged = [...existing]; if (githubToken) { const tokenIdx = merged.findIndex((v) => v.key === "GITHUB_TOKEN"); @@ -284,7 +312,9 @@ const registerOnboardingRoutes = ({ } if (githubRepoInput) { const normalizedRepo = resolveGithubRepoUrl(githubRepoInput); - const repoIdx = merged.findIndex((v) => v.key === "GITHUB_WORKSPACE_REPO"); + const repoIdx = merged.findIndex( + (v) => v.key === "GITHUB_WORKSPACE_REPO", + ); if (repoIdx >= 0) { merged[repoIdx] = { key: "GITHUB_WORKSPACE_REPO", @@ -309,10 +339,21 @@ const registerOnboardingRoutes = ({ writeEnvFile(merged); reloadEnv(); } + const systemVars = + constants.kSystemVars instanceof Set ? constants.kSystemVars : new Set(); + const placeholderReview = getImportedPlaceholderReview({ + fs, + openclawDir: constants.OPENCLAW_DIR, + envVars: merged, + systemVars, + normalizeConfig: true, + }); return res.json({ ok: true, preFill, + placeholderReview, + sourceLayout: scan.sourceLayout, envVarsImported: envVars.length, transformsAligned: transformAlignment.alignedCount, }); diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 7ab4d6a4..550cfa19 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -67,6 +67,98 @@ describe("frontend/api", () => { expect(result).toEqual({ ok: true }); }); + it("verifyGithubOnboardingRepo posts repo, token, and mode", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, repoExists: true })); + const api = await loadApiModule(); + + const result = await api.verifyGithubOnboardingRepo( + "my-org/source-repo", + "ghp_123", + "existing", + ); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/onboard/github/verify", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + repo: "my-org/source-repo", + token: "ghp_123", + mode: "existing", + }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); + expect(result).toEqual({ ok: true, repoExists: true }); + }); + + it("scanImportRepo posts the temp dir payload", async () => { + global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, hasOpenclawSetup: true })); + const api = await loadApiModule(); + + const result = await api.scanImportRepo("/tmp/alphaclaw-import-1234"); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/onboard/import/scan", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ tempDir: "/tmp/alphaclaw-import-1234" }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); + expect(result).toEqual({ ok: true, hasOpenclawSetup: true }); + }); + + it("applyImport posts import approval payload", async () => { + global.fetch.mockResolvedValue( + mockJsonResponse(200, { + ok: true, + envVarsImported: 2, + placeholderReview: { + found: true, + count: 1, + vars: [{ key: "SLACK_BOT_TOKEN", status: "missing" }], + }, + }), + ); + const api = await loadApiModule(); + + const result = await api.applyImport({ + tempDir: "/tmp/alphaclaw-import-1234", + approvedSecrets: [{ suggestedEnvVar: "OPENAI_API_KEY", value: "sk-123" }], + skipSecretExtraction: false, + githubRepo: "owner/target-repo", + githubToken: "ghp_123", + }); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/onboard/import/apply", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + tempDir: "/tmp/alphaclaw-import-1234", + approvedSecrets: [{ suggestedEnvVar: "OPENAI_API_KEY", value: "sk-123" }], + skipSecretExtraction: false, + githubRepo: "owner/target-repo", + githubToken: "ghp_123", + }), + headers: expect.any(Headers), + }), + ); + expectLastFetchHeaders("application/json"); + expect(result).toEqual({ + ok: true, + envVarsImported: 2, + placeholderReview: { + found: true, + count: 1, + vars: [{ key: "SLACK_BOT_TOKEN", status: "missing" }], + }, + }); + }); + it("saveEnvVars uses PUT with expected request body", async () => { global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, changed: true })); const api = await loadApiModule(); diff --git a/tests/server/gateway.test.js b/tests/server/gateway.test.js index 26031fd6..35d34717 100644 --- a/tests/server/gateway.test.js +++ b/tests/server/gateway.test.js @@ -196,7 +196,7 @@ describe("server/gateway restart behavior", () => { expect(gateway.isOnboarded()).toBe(true); }); - it("backfills onboarding marker from config with primary model", () => { + it("does not backfill onboarding marker from config with primary model", () => { fs.existsSync = vi.fn((targetPath) => targetPath === `${OPENCLAW_DIR}/openclaw.json`); fs.mkdirSync = vi.fn(); fs.writeFileSync = vi.fn(); @@ -214,15 +214,23 @@ describe("server/gateway restart behavior", () => { }), ); - expect(gateway.isOnboarded()).toBe(true); - expect(fs.mkdirSync).toHaveBeenCalledWith( - ALPHACLAW_DIR, - { recursive: true }, - ); - expect(fs.writeFileSync).toHaveBeenCalledWith( - kOnboardingMarkerPath, - expect.stringContaining('"reason": "config_primary_model"'), + expect(gateway.isOnboarded()).toBe(false); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("does not treat nested openclaw config as onboarded", () => { + fs.existsSync = vi.fn( + (targetPath) => targetPath === `${OPENCLAW_DIR}/.openclaw/openclaw.json`, ); + fs.mkdirSync = vi.fn(); + fs.writeFileSync = vi.fn(); + delete require.cache[modulePath]; + const gateway = require(modulePath); + + expect(gateway.isOnboarded()).toBe(false); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); }); it("backfills onboarding marker from legacy onboarding artifact", () => { diff --git a/tests/server/import-applier.test.js b/tests/server/import-applier.test.js index 5b275696..69aace90 100644 --- a/tests/server/import-applier.test.js +++ b/tests/server/import-applier.test.js @@ -4,7 +4,7 @@ const path = require("path"); const { alignHookTransforms, -} = require("../../lib/server/onboarding/import-applier"); +} = require("../../lib/server/onboarding/import/import-applier"); const kTempDirs = []; diff --git a/tests/server/import-scanner.test.js b/tests/server/import-scanner.test.js index 8f745c8b..de94717c 100644 --- a/tests/server/import-scanner.test.js +++ b/tests/server/import-scanner.test.js @@ -1,5 +1,7 @@ const path = require("path"); -const { scanWorkspace } = require("../../lib/server/onboarding/import-scanner"); +const { + scanWorkspace, +} = require("../../lib/server/onboarding/import/import-scanner"); const createMockFs = (files = {}, dirs = []) => { const fileMap = new Map(Object.entries(files)); @@ -8,8 +10,10 @@ const createMockFs = (files = {}, dirs = []) => { return { statSync: (p) => { const rel = p; - if (fileMap.has(rel)) return { isFile: () => true, isDirectory: () => false }; - if (dirSet.has(rel)) return { isFile: () => false, isDirectory: () => true }; + if (fileMap.has(rel)) + return { isFile: () => true, isDirectory: () => false }; + if (dirSet.has(rel)) + return { isFile: () => false, isDirectory: () => true }; throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }, readFileSync: (p, enc) => { @@ -67,15 +71,63 @@ describe("import-scanner", () => { expect(result.hasOpenclawSetup).toBe(true); expect(result.gatewayConfig.found).toBe(true); expect(result.gatewayConfig.files).toContain("openclaw.json"); + expect(result.sourceLayout).toEqual({ + kind: "full-openclaw-root", + supported: true, + promoteSourceSubdir: "", + }); + }); + + it("ignores openclaw.json5 as an unsupported config filename", () => { + const fs = createMockFs({ + "/tmp/test/openclaw.json5": JSON.stringify({ + channels: { telegram: { botToken: "123" } }, + }), + }); + const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); + expect(result.hasOpenclawSetup).toBe(false); + expect(result.gatewayConfig.found).toBe(false); + expect(result.sourceLayout).toEqual({ + kind: "empty", + supported: true, + promoteSourceSubdir: "", + }); }); - it("detects nested .openclaw/openclaw.json", () => { + it("rejects nested .openclaw/openclaw.json sources", () => { const fs = createMockFs({ "/tmp/test/.openclaw/openclaw.json": "{}", }); const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); - expect(result.gatewayConfig.found).toBe(true); - expect(result.gatewayConfig.files).toContain(".openclaw/openclaw.json"); + expect(result.gatewayConfig.found).toBe(false); + expect(result.unsupportedNested).toEqual({ + found: true, + files: [".openclaw/openclaw.json"], + }); + expect(result.sourceLayout).toEqual({ + kind: "unsupported-nested-openclaw", + supported: false, + error: + "This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.", + }); + }); + + it("rejects nested .openclaw env files as unsupported", () => { + const fs = createMockFs({ + "/tmp/test/.openclaw/.env": "FOO=bar", + }); + const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); + expect(result.envFiles.found).toBe(false); + expect(result.unsupportedNested).toEqual({ + found: true, + files: [".openclaw/.env"], + }); + expect(result.sourceLayout).toEqual({ + kind: "unsupported-nested-openclaw", + supported: false, + error: + "This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.", + }); }); it("detects .env files", () => { @@ -227,7 +279,8 @@ describe("import-scanner", () => { }, }, }), - "/tmp/test/.env": "OPENCLAW_GATEWAY_TOKEN=runtime\nWEBHOOK_TOKEN=repo-value\n", + "/tmp/test/.env": + "OPENCLAW_GATEWAY_TOKEN=runtime\nWEBHOOK_TOKEN=repo-value\n", }); const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); expect(result.managedEnvConflicts).toEqual({ @@ -258,4 +311,37 @@ describe("import-scanner", () => { const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); expect(result.gatewayConfig.files).toContain("channels.json"); }); + + it("classifies root workspace content as workspace-only", () => { + const fs = createMockFs({ + "/tmp/test/AGENTS.md": "# agents", + "/tmp/test/skills/email/SKILL.md": "# skill", + }); + const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); + expect(result.sourceLayout).toEqual({ + kind: "workspace-only", + supported: true, + promoteSourceSubdir: "", + }); + }); + + it("classifies nested workspace directory repos as workspace-only", () => { + const fs = createMockFs( + { + "/tmp/test/workspace/skills/email/SKILL.md": "# skill", + }, + [ + "/tmp/test/workspace", + "/tmp/test/workspace/skills", + "/tmp/test/workspace/skills/email", + ], + ); + const result = scanWorkspace({ fs, baseDir: "/tmp/test" }); + expect(result.skills.files).toContain("email/SKILL.md"); + expect(result.sourceLayout).toEqual({ + kind: "workspace-only", + supported: true, + promoteSourceSubdir: "workspace", + }); + }); }); diff --git a/tests/server/import-temp.test.js b/tests/server/import-temp.test.js new file mode 100644 index 00000000..fec8fa2a --- /dev/null +++ b/tests/server/import-temp.test.js @@ -0,0 +1,56 @@ +const path = require("path"); + +const { + cleanupStaleImportTempDirs, + isValidImportTempDir, + kImportTempTtlMs, +} = require("../../lib/server/onboarding/import/import-temp"); + +describe("server/onboarding/import-temp", () => { + it("rejects paths that escape the temp root", () => { + const escapedPath = path.join( + require("os").tmpdir(), + "..", + "outside", + "alphaclaw-import-evil", + ); + + expect(isValidImportTempDir(escapedPath)).toBe(false); + }); + + it("cleans up only stale managed import temp dirs", () => { + const tempRoot = path.resolve(require("os").tmpdir()); + const staleDir = path.join(tempRoot, "alphaclaw-import-stale"); + const freshDir = path.join(tempRoot, "alphaclaw-import-fresh"); + const otherDir = path.join(tempRoot, "other-dir"); + const removed = []; + + const fsModule = { + readdirSync: vi.fn(() => [ + { name: "alphaclaw-import-stale", isDirectory: () => true }, + { name: "alphaclaw-import-fresh", isDirectory: () => true }, + { name: "other-dir", isDirectory: () => true }, + ]), + statSync: vi.fn((targetPath) => { + if (targetPath === staleDir) { + return { mtimeMs: 1000 }; + } + if (targetPath === freshDir) { + return { mtimeMs: 5000 }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }), + rmSync: vi.fn((targetPath) => { + removed.push(targetPath); + }), + }; + + const result = cleanupStaleImportTempDirs({ + fsModule, + nowMs: 1000 + kImportTempTtlMs + 1, + }); + + expect(result).toEqual({ removedCount: 1 }); + expect(removed).toEqual([staleDir]); + }); +}); diff --git a/tests/server/onboarding-github.test.js b/tests/server/onboarding-github.test.js new file mode 100644 index 00000000..f0ea5afd --- /dev/null +++ b/tests/server/onboarding-github.test.js @@ -0,0 +1,27 @@ +const fs = require("fs"); + +const { cloneRepoToTemp } = require("../../lib/server/onboarding/github"); + +describe("server/onboarding/github", () => { + it("clones without embedding the github token in the command line", async () => { + const shellCmd = vi.fn(async (cmd, opts = {}) => { + expect(cmd).toContain('git clone --depth=1 "https://github.com/my-org/source-repo.git"'); + expect(cmd).not.toContain("ghp_secret_token_value"); + expect(opts.env?.ALPHACLAW_GITHUB_TOKEN).toBe("ghp_secret_token_value"); + expect(typeof opts.env?.GIT_ASKPASS).toBe("string"); + expect(fs.existsSync(opts.env.GIT_ASKPASS)).toBe(true); + return ""; + }); + + const result = await cloneRepoToTemp({ + repoUrl: "my-org/source-repo", + githubToken: "ghp_secret_token_value", + shellCmd, + }); + + expect(result.ok).toBe(true); + expect(shellCmd).toHaveBeenCalledTimes(1); + const [, opts] = shellCmd.mock.calls[0]; + expect(fs.existsSync(opts.env.GIT_ASKPASS)).toBe(false); + }); +}); diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index b0fe1c2d..5adfafd5 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -1,4 +1,5 @@ const path = require("path"); +const os = require("os"); const express = require("express"); const request = require("supertest"); @@ -13,8 +14,13 @@ const createBaseDeps = ({ onboarded = false, hasCodexOauth = false } = {}) => { existsSync: vi.fn((targetPath) => onboarded ? targetPath === kOnboardingMarkerPath : false, ), + statSync: vi.fn(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }), + readdirSync: vi.fn(() => []), copyFileSync: vi.fn(), rmSync: vi.fn(), + renameSync: vi.fn(), readFileSync: vi.fn(() => "{}"), writeFileSync: vi.fn(), appendFileSync: vi.fn(), @@ -197,6 +203,82 @@ describe("server/routes/onboarding", () => { expect(deps.reloadEnv).toHaveBeenCalledTimes(1); }); + it("allows existing source repos owned by a different accessible org", async () => { + const deps = createBaseDeps(); + const app = createApp(deps); + global.fetch + .mockResolvedValueOnce({ + ok: true, + headers: { get: () => "repo" }, + json: async () => ({ login: "owner" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ full_name: "my-org/source-repo" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json: async () => [{ sha: "abc123" }], + }) + .mockResolvedValueOnce({ + ok: true, + headers: { get: () => "repo" }, + json: async () => ({ login: "owner" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ full_name: "my-org/source-repo" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json: async () => [{ sha: "abc123" }], + }); + deps.shellCmd.mockResolvedValueOnce(""); + + const verifyRes = await request(app).post("/api/onboard/github/verify").send({ + repo: "my-org/source-repo", + token: "ghp_test_123456789", + mode: "existing", + }); + + expect(verifyRes.status).toBe(200); + expect(verifyRes.body).toMatchObject({ + ok: true, + repoExists: true, + repoIsEmpty: false, + }); + }); + + it("rejects new workspace repos whose owner differs from the token user", async () => { + const deps = createBaseDeps(); + const app = createApp(deps); + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => "repo" }, + json: async () => ({ login: "owner" }), + }); + + const res = await request(app).post("/api/onboard/github/verify").send({ + repo: "my-org/new-repo", + token: "ghp_test_123456789", + mode: "new", + }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + ok: false, + error: 'New workspace repo owner must match your token user "owner"', + }); + }); + it("installs deterministic hourly git sync cron during successful onboarding", async () => { const deps = createBaseDeps(); deps.fs.readFileSync.mockImplementation((p) => { @@ -356,6 +438,7 @@ describe("server/routes/onboarding", () => { it("fills missing imported env refs with placeholders during import onboarding", async () => { const deps = createBaseDeps(); + mockGithubVerifyAndCreate(); const files = new Map([ [ "/tmp/openclaw/openclaw.json", @@ -440,7 +523,9 @@ describe("server/routes/onboarding", () => { ); expect( deps.shellCmd.mock.calls.some(([cmd]) => - cmd.includes('git remote set-url origin "https://github.com/owner/target-repo.git"'), + cmd.includes( + 'git init -b main && git remote add origin "https://github.com/owner/target-repo.git"', + ), ), ).toBe(true); expect(deps.shellCmd).toHaveBeenCalledWith( @@ -450,4 +535,323 @@ describe("server/routes/onboarding", () => { }), ); }); + + it("does not treat nested openclaw config as an imported config during completion", async () => { + const deps = createBaseDeps(); + mockGithubVerifyAndCreate(); + const files = new Map([ + [ + "/tmp/openclaw/.openclaw/openclaw.json", + JSON.stringify({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.1-codex", + }, + }, + }, + }), + ], + [path.join(kSetupDir, "skills", "control-ui", "SKILL.md"), "BASE={{BASE_URL}}"], + [path.join(kSetupDir, "core-prompts", "TOOLS.md"), "Setup: {{SETUP_UI_URL}}"], + [path.join(kSetupDir, "hourly-git-sync.sh"), "echo Auto-commit hourly sync"], + ]); + deps.fs.existsSync.mockImplementation((targetPath) => files.has(targetPath)); + deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || "{}"); + deps.fs.writeFileSync.mockImplementation((targetPath, contents) => { + files.set(targetPath, String(contents)); + }); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard").send({ + ...makeValidBody(), + importMode: true, + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect( + deps.shellCmd.mock.calls.some(([cmd]) => cmd.startsWith("openclaw onboard ")), + ).toBe(true); + expect( + deps.shellCmd.mock.calls.some(([cmd]) => cmd.includes('git remote set-url origin')), + ).toBe(false); + expect( + deps.shellCmd.mock.calls.some(([cmd]) => + cmd.includes('git init -b main && git remote add origin "https://github.com/owner/repo.git"'), + ), + ).toBe(true); + }); + + it("creates the target repo during import onboarding before git-sync", async () => { + const deps = createBaseDeps(); + mockGithubVerifyAndCreate({ + repoStatus: 404, + repoOk: false, + createOk: true, + login: "owner", + }); + const files = new Map([ + ["/tmp/openclaw/openclaw.json", JSON.stringify({ gateway: { auth: {} } })], + ["/tmp/openclaw/.git", "gitdir"], + [path.join(kSetupDir, "skills", "control-ui", "SKILL.md"), "BASE={{BASE_URL}}"], + [path.join(kSetupDir, "core-prompts", "TOOLS.md"), "Setup: {{SETUP_UI_URL}}"], + [path.join(kSetupDir, "hourly-git-sync.sh"), "echo Auto-commit hourly sync"], + ]); + deps.fs.existsSync.mockImplementation((targetPath) => files.has(targetPath)); + deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || "{}"); + deps.fs.writeFileSync.mockImplementation((targetPath, contents) => { + files.set(targetPath, String(contents)); + }); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard").send({ + ...makeValidBody(), + vars: makeValidBody().vars.map((entry) => + entry.key === "GITHUB_WORKSPACE_REPO" + ? { ...entry, value: "owner/import-target" } + : entry, + ), + importMode: true, + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/user/repos", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "import-target", + private: true, + auto_init: false, + }), + }), + ); + expect( + deps.shellCmd.mock.calls.some(([cmd]) => + cmd.includes( + 'git init -b main && git remote add origin "https://github.com/owner/import-target.git"', + ), + ), + ).toBe(true); + expect( + deps.shellCmd.mock.calls.some(([cmd]) => + cmd.includes('alphaclaw git-sync -m "imported existing setup via AlphaClaw"'), + ), + ).toBe(true); + }); + + it("rejects nested .openclaw import sources during scan", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-nested"); + deps.fs.existsSync.mockImplementation((targetPath) => targetPath === tempDir); + deps.fs.statSync.mockImplementation((targetPath) => { + if (targetPath === tempDir || targetPath === `${tempDir}/.openclaw`) { + return { isFile: () => false, isDirectory: () => true }; + } + if (targetPath === `${tempDir}/.openclaw/openclaw.json`) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [{ name: ".openclaw", isFile: () => false, isDirectory: () => true }]; + } + if (targetPath === `${tempDir}/.openclaw`) { + return [{ name: "openclaw.json", isFile: () => true, isDirectory: () => false }]; + } + return []; + }); + deps.fs.readFileSync.mockImplementation((targetPath) => + targetPath === `${tempDir}/.openclaw/openclaw.json` ? "{}" : "{}", + ); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/scan").send({ tempDir }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + ok: false, + error: + "This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.", + }); + }); + + it("promotes workspace-only imports into WORKSPACE_DIR", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-workspace"); + deps.fs.existsSync.mockImplementation((targetPath) => { + if ( + targetPath === tempDir || + targetPath === `${tempDir}/workspace` || + targetPath === `${tempDir}/workspace/skills` || + targetPath === `${tempDir}/workspace/skills/email` + ) { + return true; + } + return false; + }); + deps.fs.statSync.mockImplementation((targetPath) => { + if ( + targetPath === tempDir || + targetPath === `${tempDir}/workspace` || + targetPath === `${tempDir}/workspace/skills` || + targetPath === `${tempDir}/workspace/skills/email` + ) { + return { isFile: () => false, isDirectory: () => true }; + } + if (targetPath === `${tempDir}/workspace/skills/email/SKILL.md`) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [{ name: "workspace", isFile: () => false, isDirectory: () => true }]; + } + if (targetPath === `${tempDir}/workspace`) { + return [{ name: "skills", isFile: () => false, isDirectory: () => true }]; + } + if (targetPath === `${tempDir}/workspace/skills`) { + return [{ name: "email", isFile: () => false, isDirectory: () => true }]; + } + if (targetPath === `${tempDir}/workspace/skills/email`) { + return [{ name: "SKILL.md", isFile: () => true, isDirectory: () => false }]; + } + return []; + }); + deps.fs.readFileSync.mockImplementation(() => "{}"); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/apply").send({ + tempDir, + approvedSecrets: [], + skipSecretExtraction: true, + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + ok: true, + sourceLayout: { + kind: "workspace-only", + supported: true, + promoteSourceSubdir: "workspace", + }, + }); + expect(deps.fs.renameSync).toHaveBeenCalledWith( + `${tempDir}/workspace`, + deps.constants.WORKSPACE_DIR, + ); + }); + + it("returns unresolved placeholder review data after import apply", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-placeholder-review"); + const fileEntry = (name) => ({ + name, + isFile: () => true, + isDirectory: () => false, + }); + const dirEntry = (name) => ({ + name, + isFile: () => false, + isDirectory: () => true, + }); + const files = new Map([ + [ + path.join(tempDir, "openclaw.json"), + JSON.stringify({ + env: { + vars: { + NOTION_API_KEY: "${NOTION_API_KEY}", + }, + }, + channels: { + $include: "channels.json", + }, + gateway: { + auth: { + token: "${GATEWAY_AUTH_TOKEN}", + }, + }, + }), + ], + [ + path.join(tempDir, "channels.json"), + JSON.stringify({ + slack: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + }, + }), + ], + ]); + const directories = new Set([tempDir]); + deps.readEnvFile.mockReturnValue([ + { key: "NOTION_API_KEY", value: "notion-live-value" }, + ]); + deps.fs.existsSync.mockImplementation( + (targetPath) => directories.has(targetPath) || files.has(targetPath), + ); + deps.fs.statSync.mockImplementation((targetPath) => { + if (directories.has(targetPath)) { + return { isFile: () => false, isDirectory: () => true }; + } + if (files.has(targetPath)) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [fileEntry("openclaw.json"), fileEntry("channels.json")]; + } + if (targetPath === deps.constants.OPENCLAW_DIR) { + return []; + } + if (targetPath === path.join(tempDir, "workspace")) { + return []; + } + return []; + }); + deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || "{}"); + deps.fs.writeFileSync.mockImplementation((targetPath, contents) => { + files.set(targetPath, String(contents)); + }); + deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => { + if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) { + directories.delete(tempDir); + directories.add(targetPath); + for (const [filePath, contents] of [...files.entries()]) { + if (!filePath.startsWith(`${sourcePath}/`)) continue; + files.delete(filePath); + files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents); + } + return; + } + throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`); + }); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/apply").send({ + tempDir, + approvedSecrets: [], + skipSecretExtraction: true, + }); + + expect(res.status).toBe(200); + expect(res.body.placeholderReview).toEqual({ + found: true, + count: 2, + vars: [ + { key: "SLACK_APP_TOKEN", status: "missing" }, + { key: "SLACK_BOT_TOKEN", status: "missing" }, + ], + }); + expect(files.get(path.join(deps.constants.OPENCLAW_DIR, "openclaw.json"))).toContain( + '"token": "${OPENCLAW_GATEWAY_TOKEN}"', + ); + }); }); diff --git a/tests/server/secret-detector.test.js b/tests/server/secret-detector.test.js index ef04f816..c9141f8c 100644 --- a/tests/server/secret-detector.test.js +++ b/tests/server/secret-detector.test.js @@ -5,7 +5,7 @@ const { matchesValuePrefix, maskValue, parseEnvFileSecrets, -} = require("../../lib/server/onboarding/secret-detector"); +} = require("../../lib/server/onboarding/import/secret-detector"); const createMockFs = (files = {}) => ({ readFileSync: (p) => { From 89f2b98bde4e529fb352d421ccbc91d7c58e2e4a Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 14:41:57 -0800 Subject: [PATCH 124/417] Harden onboarding import secret handling and error redaction. Validate approved import secrets against server-side scan results, block path traversal during secret rewrites, and redact fine-grained GitHub tokens from onboarding error responses. --- .../onboarding/welcome-form-step.js | 33 +------- .../onboarding/import/import-applier.js | 39 ++++++++-- lib/server/routes/onboarding.js | 55 +++++++++++++- tests/server/routes-onboarding.test.js | 75 +++++++++++++++++++ 4 files changed, 161 insertions(+), 41 deletions(-) diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index 19e220d2..3f1775b3 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -49,8 +49,6 @@ export const WelcomeFormStep = ({ allValid, handleSubmit, }) => { - const [repoTouched, setRepoTouched] = useState(false); - const [sourceRepoTouched, setSourceRepoTouched] = useState(false); const [showOptionalOpenai, setShowOptionalOpenai] = useState(false); const [showOptionalGemini, setShowOptionalGemini] = useState(false); const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; @@ -60,10 +58,7 @@ export const WelcomeFormStep = ({ : vals._GITHUB_TARGET_REPO_MODE || kGithubTargetRepoModeCreate; useEffect(() => { - if (activeGroup.id !== "github") { - setRepoTouched(false); - setSourceRepoTouched(false); - } + if (activeGroup.id !== "github") return; }, [activeGroup.id]); useEffect(() => { @@ -263,11 +258,6 @@ export const WelcomeFormStep = ({ key=${field.key} value=${vals[field.key] || ""} onInput=${(e) => setValue(field.key, e.target.value)} - onBlur=${field.key === "GITHUB_WORKSPACE_REPO" - ? () => setRepoTouched(true) - : field.key === "_GITHUB_SOURCE_REPO" - ? () => setSourceRepoTouched(true) - : undefined} placeholder=${field.placeholder || ""} isSecret=${!field.isText} inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono" @@ -313,27 +303,6 @@ export const WelcomeFormStep = ({
        `, )} - ${activeGroup.id === "github" && - repoTouched && - vals.GITHUB_WORKSPACE_REPO && - !isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO) - ? html`
        - Target Repo must be in - owner/repo - format. -
        ` - : null} - ${activeGroup.id === "github" && - githubFlow === kGithubFlowImport && - sourceRepoTouched && - vals._GITHUB_SOURCE_REPO && - !isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO) - ? html`
        - Source Repo must be in - owner/repo - format. -
        ` - : null} ${error ? html`
        isValidImportTempDir(tempDir); const kTransformsRoot = path.join("hooks", "transforms"); @@ -131,6 +133,20 @@ const pathExists = (fs, targetPath) => { return false; } }; + +const resolveExtractionTargetPath = (baseDir, file) => { + const relativeFile = String(file || "").trim(); + if (!relativeFile || path.isAbsolute(relativeFile)) return ""; + const resolvedBaseDir = path.resolve(baseDir); + const resolvedFilePath = path.resolve(resolvedBaseDir, relativeFile); + if ( + resolvedFilePath !== resolvedBaseDir && + !resolvedFilePath.startsWith(`${resolvedBaseDir}${path.sep}`) + ) { + return ""; + } + return resolvedFilePath; +}; const movePath = (fs, src, dest) => { fs.mkdirSync(path.dirname(dest), { recursive: true }); try { @@ -253,21 +269,26 @@ const applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => { for (const secret of approvedSecrets) { const envVar = String(secret.suggestedEnvVar || "").trim(); const value = String(secret.value || "").trim(); - if (!envVar || !value) continue; + if (!envVar || !value || !kEnvVarNamePattern.test(envVar)) continue; envVars.push({ key: envVar, value }); if (secret.file && !secret.file.startsWith(".env")) { - if (!rewriteMap.has(secret.file)) { - rewriteMap.set(secret.file, []); + const fullPath = resolveExtractionTargetPath(baseDir, secret.file); + if (!fullPath) continue; + if (!rewriteMap.has(fullPath)) { + rewriteMap.set(fullPath, []); } - rewriteMap.get(secret.file).push({ value, envRef: `\${${envVar}}` }); + rewriteMap.get(fullPath).push({ + value, + envRef: `\${${envVar}}`, + relativeFile: secret.file, + }); } } - for (const [file, replacements] of rewriteMap) { + for (const [fullPath, replacements] of rewriteMap) { try { - const fullPath = path.join(baseDir, file); let content = fs.readFileSync(fullPath, "utf8"); const sorted = [...replacements].sort( (a, b) => b.value.length - a.value.length, @@ -281,9 +302,11 @@ const applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => { ); } fs.writeFileSync(fullPath, content); - console.log(`[import] Rewrote secrets in ${file}`); + console.log( + `[import] Rewrote secrets in ${replacements[0]?.relativeFile || fullPath}`, + ); } catch (e) { - console.error(`[import] Rewrite error for ${file}: ${e.message}`); + console.error(`[import] Rewrite error for ${fullPath}: ${e.message}`); } } diff --git a/lib/server/routes/onboarding.js b/lib/server/routes/onboarding.js index acf9f244..1c50499f 100644 --- a/lib/server/routes/onboarding.js +++ b/lib/server/routes/onboarding.js @@ -23,6 +23,7 @@ const sanitizeOnboardingError = (error) => { const redacted = String(raw || "Onboarding failed") .replace(/sk-[^\s"]+/g, "***") .replace(/ghp_[^\s"]+/g, "***") + .replace(/github_pat_[^\s"]+/g, "***") .replace(/(?:token|api[_-]?key)["'\s:=]+[^\s"']+/gi, (match) => match.replace(/[^\s"':=]+$/g, "***"), ); @@ -115,6 +116,42 @@ const registerOnboardingRoutes = ({ startGateway, }); + const kEnvVarNamePattern = /^[A-Z_][A-Z0-9_]*$/; + const validateApprovedSecrets = ({ approvedSecrets = [], scannedSecrets = [] }) => { + if (!Array.isArray(approvedSecrets)) return { ok: true, secrets: [] }; + const scannedByFingerprint = new Map( + scannedSecrets.map((secret) => [ + [ + String(secret?.configPath || ""), + String(secret?.file || ""), + String(secret?.value || ""), + ].join("\u0000"), + secret, + ]), + ); + const secrets = []; + for (const approvedSecret of approvedSecrets) { + const fingerprint = [ + String(approvedSecret?.configPath || ""), + String(approvedSecret?.file || ""), + String(approvedSecret?.value || ""), + ].join("\u0000"); + const scannedSecret = scannedByFingerprint.get(fingerprint); + const envVarName = String(approvedSecret?.suggestedEnvVar || "").trim(); + if (!scannedSecret || !envVarName || !kEnvVarNamePattern.test(envVarName)) { + return { + ok: false, + error: "Invalid approved secrets payload", + }; + } + secrets.push({ + ...scannedSecret, + suggestedEnvVar: envVarName, + }); + } + return { ok: true, secrets }; + }; + app.get("/api/onboard/status", (req, res) => { res.json({ onboarded: hasExplicitOnboardingMarker() }); }); @@ -261,11 +298,27 @@ const registerOnboardingRoutes = ({ } let envVars = []; + const scannedSecrets = detectSecrets({ + fs, + baseDir: tempDir, + configFiles: scan.gatewayConfig.files, + envFiles: scan.envFiles.files, + }); + const approvedSecretValidation = validateApprovedSecrets({ + approvedSecrets, + scannedSecrets, + }); + if (!approvedSecretValidation.ok) { + return res.status(400).json({ + ok: false, + error: approvedSecretValidation.error, + }); + } if (!skipSecretExtraction && approvedSecrets.length > 0) { const extraction = applySecretExtraction({ fs, baseDir: tempDir, - approvedSecrets, + approvedSecrets: approvedSecretValidation.secrets, }); envVars = extraction.envVars; } diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index 5adfafd5..24096f9b 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -384,6 +384,22 @@ describe("server/routes/onboarding", () => { }); }); + it("redacts fine-grained GitHub tokens from onboarding errors", async () => { + const deps = createBaseDeps(); + const app = createApp(deps); + mockGithubVerifyAndCreate(); + deps.shellCmd.mockRejectedValueOnce( + new Error('boom github_pat_super_secret_value openclaw onboard'), + ); + + const res = await request(app).post("/api/onboard").send(makeValidBody()); + + expect(res.status).toBe(500); + expect(res.body.ok).toBe(false); + expect(res.body.error).not.toContain("github_pat_super_secret_value"); + expect(res.body.error).toContain("***"); + }); + it("returns a helpful OOM message when onboarding runs out of memory", async () => { const deps = createBaseDeps(); const app = createApp(deps); @@ -854,4 +870,63 @@ describe("server/routes/onboarding", () => { '"token": "${OPENCLAW_GATEWAY_TOKEN}"', ); }); + + it("rejects import apply when approved secrets were not in the server scan", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-invalid-secret"); + const files = new Map([ + [ + path.join(tempDir, "openclaw.json"), + JSON.stringify({ + models: { + providers: { + openai: { + apiKey: "sk-live-real-secret", + }, + }, + }, + }), + ], + ]); + deps.fs.existsSync.mockImplementation( + (targetPath) => targetPath === tempDir || files.has(targetPath), + ); + deps.fs.statSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return { isFile: () => false, isDirectory: () => true }; + } + if (files.has(targetPath)) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [{ name: "openclaw.json", isFile: () => true, isDirectory: () => false }]; + } + return []; + }); + deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || "{}"); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/apply").send({ + tempDir, + approvedSecrets: [ + { + file: "../outside.json", + configPath: "models.providers.openai.apiKey", + value: "sk-live-real-secret", + suggestedEnvVar: "BAD KEY", + }, + ], + skipSecretExtraction: false, + }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + ok: false, + error: "Invalid approved secrets payload", + }); + expect(deps.writeEnvFile).not.toHaveBeenCalled(); + }); }); From d421818fe9268aa02a2ade51e43aec4f899bb66f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 14:42:06 -0800 Subject: [PATCH 125/417] 0.4.6-beta.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e618e0c..00d17dca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.7", + "version": "0.4.6-beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.7", + "version": "0.4.6-beta.8", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 822236cb..7233d9bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.7", + "version": "0.4.6-beta.8", "publishConfig": { "access": "public" }, From 8a0c2b6ceb5fbb06a05c9fd7de6b5b78cb54420d Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 14:42:21 -0800 Subject: [PATCH 126/417] 0.4.6-beta.9 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00d17dca..cbe6148f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.8", + "version": "0.4.6-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.8", + "version": "0.4.6-beta.9", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 7233d9bb..59d56b96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.8", + "version": "0.4.6-beta.9", "publishConfig": { "access": "public" }, From 9c31c9701270981a79b01e7b1d3fd3c00ee66e98 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 15:06:35 -0800 Subject: [PATCH 127/417] 0.5.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cbe6148f..36a92522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.9", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.9", + "version": "0.5.0", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 59d56b96..80c82689 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.4.6-beta.9", + "version": "0.5.0", "publishConfig": { "access": "public" }, From 6745d19ea134c46b3dff77ebc98c2be66689e056 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 19:46:52 -0800 Subject: [PATCH 128/417] feat(ui): add onboarding pre-step and visual polish - Extract 'Start fresh' vs 'Import' into a dedicated pre-step - Add neon cyan hover effects to path selection cards - Add pulsing animation to active step pill in header - Display AlphaClaw version at bottom of onboarding flow --- lib/public/css/theme.css | 57 ++++++++++++ lib/public/js/app.js | 93 +++++++++++++------ .../onboarding/use-welcome-storage.js | 4 +- .../onboarding/welcome-form-step.js | 25 +---- .../components/onboarding/welcome-header.js | 32 ++++--- .../components/onboarding/welcome-pre-step.js | 81 ++++++++++++++++ lib/public/js/components/welcome/index.js | 15 ++- .../js/components/welcome/use-welcome.js | 20 +++- 8 files changed, 257 insertions(+), 70 deletions(-) create mode 100644 lib/public/js/components/onboarding/welcome-pre-step.js diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css index c4dae6be..7fafa082 100644 --- a/lib/public/css/theme.css +++ b/lib/public/css/theme.css @@ -246,6 +246,45 @@ textarea:focus { background: rgba(99, 235, 255, 0.08); } +.ac-path-card { + border: 1px solid var(--panel-border-contrast); + background: rgba(0, 0, 0, 0.2); + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; +} + +.ac-path-card:hover { + border-color: rgba(99, 235, 255, 0.5); + background: rgba(99, 235, 255, 0.04); + box-shadow: + inset 0 0 0 1px rgba(99, 235, 255, 0.1), + 0 0 12px rgba(99, 235, 255, 0.08); +} + +.ac-path-card:hover .ac-path-icon { + color: var(--accent); + border-color: rgba(99, 235, 255, 0.3); + background: rgba(99, 235, 255, 0.1); +} + +.ac-path-card:hover .ac-path-title { + color: #b9f8ff; +} + +.ac-path-card:hover .ac-path-desc { + color: #94a3b8; +} + +.ac-path-icon { + transition: + color 0.15s ease, + border-color 0.15s ease, + background 0.15s ease; +} + .ac-btn-secondary { border: 1px solid var(--panel-border-contrast); color: #d1d5db; @@ -426,6 +465,24 @@ textarea:focus { } } +@keyframes acStepPillPulse { + 0%, + 100% { + box-shadow: + 0 0 0 0 rgba(99, 235, 255, 0.14), + 0 0 5px rgba(99, 235, 255, 0.18); + } + 50% { + box-shadow: + 0 0 0 3px rgba(99, 235, 255, 0.08), + 0 0 9px rgba(99, 235, 255, 0.32); + } +} + +.ac-step-pill-pulse { + animation: acStepPillPulse 2.6s ease-in-out infinite; +} + .ac-status-dot { width: 8px; height: 8px; diff --git a/lib/public/js/app.js b/lib/public/js/app.js index f3e16292..338a60c9 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -1,7 +1,12 @@ import { h, render } from "https://esm.sh/preact"; import { useState, useEffect } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; -import { Router, Route, Switch, useLocation } from "https://esm.sh/wouter-preact"; +import { + Router, + Route, + Switch, + useLocation, +} from "https://esm.sh/wouter-preact"; import { logout } from "./lib/api.js"; import { Welcome } from "./components/welcome/index.js"; import { ToastContainer } from "./components/toast.js"; @@ -9,12 +14,12 @@ import { GlobalRestartBanner } from "./components/global-restart-banner.js"; import { LoadingSpinner } from "./components/loading-spinner.js"; import { AppSidebar } from "./components/sidebar.js"; import { + AgentsRoute, BrowseRoute, DoctorRoute, EnvarsRoute, GeneralRoute, ModelsRoute, - ProvidersRoute, RouteRedirect, TelegramRoute, UsageRoute, @@ -24,34 +29,48 @@ import { import { useAppShellController } from "./hooks/use-app-shell-controller.js"; import { useAppShellUi } from "./hooks/use-app-shell-ui.js"; import { useBrowseNavigation } from "./hooks/use-browse-navigation.js"; -import { getHashRouterPath, useHashLocation } from "./hooks/use-hash-location.js"; +import { + getHashRouterPath, + useHashLocation, +} from "./hooks/use-hash-location.js"; import { readUiSettings, writeUiSettings } from "./lib/ui-settings.js"; const html = htm.bind(h); -const kDoctorWarningDismissedUntilUiSettingKey = "doctorWarningDismissedUntilMs"; +const kDoctorWarningDismissedUntilUiSettingKey = + "doctorWarningDismissedUntilMs"; const kOneWeekMs = 7 * 24 * 60 * 60 * 1000; const App = () => { const [location, setLocation] = useLocation(); - const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] = useState(() => { - const settings = readUiSettings(); - return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0); - }); + const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] = + useState(() => { + const settings = readUiSettings(); + return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0); + }); - const { state: controllerState, actions: controllerActions } = useAppShellController({ - location, - }); - const { refs: shellRefs, state: shellState, actions: shellActions } = useAppShellUi(); - const { state: browseState, actions: browseActions, constants: browseConstants } = - useBrowseNavigation({ + const { state: controllerState, actions: controllerActions } = + useAppShellController({ location, - setLocation, - onCloseMobileSidebar: shellActions.closeMobileSidebar, }); + const { + refs: shellRefs, + state: shellState, + actions: shellActions, + } = useAppShellUi(); + const { + state: browseState, + actions: browseActions, + constants: browseConstants, + } = useBrowseNavigation({ + location, + setLocation, + onCloseMobileSidebar: shellActions.closeMobileSidebar, + }); useEffect(() => { const settings = readUiSettings(); - settings[kDoctorWarningDismissedUntilUiSettingKey] = doctorWarningDismissedUntilMs; + settings[kDoctorWarningDismissedUntilUiSettingKey] = + doctorWarningDismissedUntilMs; writeUiSettings(settings); }, [doctorWarningDismissedUntilMs]); @@ -83,10 +102,13 @@ const App = () => { if (!controllerState.onboarded) { return html`
        - <${Welcome} onComplete=${controllerActions.handleOnboardingComplete} /> + <${Welcome} + onComplete=${controllerActions.handleOnboardingComplete} + acVersion=${controllerState.acVersion} + />
        <${ToastContainer} /> `; @@ -150,7 +172,9 @@ const App = () => { onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} onEditSelectedBrowseFile=${() => setLocation( - browseActions.buildBrowseRoute(browseState.selectedBrowsePath, { view: "edit" }), + browseActions.buildBrowseRoute(browseState.selectedBrowsePath, { + view: "edit", + }), )} onClearSelection=${() => { browseActions.clearBrowsePreview(); @@ -163,14 +187,22 @@ const App = () => { onscroll=${shellActions.handlePaneScroll} style=${{ display: browseState.isBrowseRoute ? "none" : "block" }} > -
        +
        + + +
        + `; +}; diff --git a/lib/public/js/components/welcome/index.js b/lib/public/js/components/welcome/index.js index 85bee1da..4fa7311d 100644 --- a/lib/public/js/components/welcome/index.js +++ b/lib/public/js/components/welcome/index.js @@ -1,6 +1,7 @@ import { h } from "https://esm.sh/preact"; import htm from "https://esm.sh/htm"; import { kWelcomeGroups } from "../onboarding/welcome-config.js"; +import { WelcomePreStep } from "../onboarding/welcome-pre-step.js"; import { WelcomeImportStep } from "../onboarding/welcome-import-step.js"; import { WelcomePlaceholderReviewStep } from "../onboarding/welcome-placeholder-review-step.js"; import { WelcomeSecretReviewStep } from "../onboarding/welcome-secret-review-step.js"; @@ -12,7 +13,7 @@ import { useWelcome } from "./use-welcome.js"; const html = htm.bind(h); -export const Welcome = ({ onComplete }) => { +export const Welcome = ({ onComplete, acVersion }) => { const { state, actions } = useWelcome({ onComplete }); return html` @@ -20,6 +21,7 @@ export const Welcome = ({ onComplete }) => { <${WelcomeHeader} groups=${kWelcomeGroups} step=${state.step} + isPreStep=${state.isPreStep} isSetupStep=${state.isSetupStep} isPairingStep=${state.isPairingStep} stepNumber=${state.stepNumber} @@ -27,7 +29,9 @@ export const Welcome = ({ onComplete }) => { />
        - ${state.isImportStep + ${state.isPreStep + ? html`<${WelcomePreStep} onSelectFlow=${actions.handleSelectFlow} />` + : state.isImportStep ? html`<${WelcomeImportStep} scanResult=${state.importScanResult} scanning=${state.importScanning} @@ -107,6 +111,13 @@ export const Welcome = ({ onComplete }) => { /> `}
        + ${acVersion + ? html` +
        + v${acVersion} +
        + ` + : null}
        `; }; diff --git a/lib/public/js/components/welcome/use-welcome.js b/lib/public/js/components/welcome/use-welcome.js index 2b175d9c..e281c958 100644 --- a/lib/public/js/components/welcome/use-welcome.js +++ b/lib/public/js/components/welcome/use-welcome.js @@ -160,9 +160,10 @@ export const useWelcome = ({ onComplete }) => { : false; const allValid = kWelcomeGroups.every((group) => group.validate(vals, { hasAi })); + const isPreStep = step === -1; const isSetupStep = step === kSetupStepIndex; const isPairingStep = step === kPairingStepIndex; - const activeGroup = step < kSetupStepIndex ? kWelcomeGroups[step] : null; + const activeGroup = step >= 0 && step < kSetupStepIndex ? kWelcomeGroups[step] : null; const currentGroupValid = activeGroup ? activeGroup.validate(vals, { hasAi }) : false; @@ -277,7 +278,7 @@ export const useWelcome = ({ onComplete }) => { const goBack = () => { if (isSetupStep) return; setFormError(null); - setStep((prev) => Math.max(0, prev - 1)); + setStep((prev) => Math.max(-1, prev - 1)); }; const goBackFromSetupError = () => { @@ -469,10 +470,17 @@ export const useWelcome = ({ onComplete }) => { setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1)); }; + const handleSelectFlow = (flow) => { + setValue("_GITHUB_FLOW", flow); + setStep(0); + }; + const isImportStep = importStep === kImportStepId; const isSecretReviewStep = importStep === kSecretReviewStepId; const isPlaceholderReviewStep = importStep === kPlaceholderReviewStepId; - const activeStepLabel = isImportStep + const activeStepLabel = isPreStep + ? "Getting Started" + : isImportStep ? "Import" : isSecretReviewStep ? "Review Secrets" @@ -484,7 +492,9 @@ export const useWelcome = ({ onComplete }) => { ? "Pairing" : activeGroup?.title || "Setup"; const stepNumber = - isImportStep || isSecretReviewStep || isPlaceholderReviewStep + isPreStep + ? 0 + : isImportStep || isSecretReviewStep || isPlaceholderReviewStep ? step + 1 : isSetupStep ? kWelcomeGroups.length + 1 @@ -512,6 +522,7 @@ export const useWelcome = ({ onComplete }) => { visibleAiFieldKeys, hasAi, allValid, + isPreStep, isSetupStep, isPairingStep, activeGroup, @@ -549,6 +560,7 @@ export const useWelcome = ({ onComplete }) => { goBack, goBackFromSetupError, goNext, + handleSelectFlow, handleImportApprove, handleShowSecretReview, handleSecretReviewBack, From ae20d88a66ab508a093fca4fa323dfe25ed7a892 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 20:14:46 -0800 Subject: [PATCH 129/417] fix runtime artifact bootstrap for imports Move gog keyring file setup into the post-onboarding runtime artifact path so import flows are not blocked by startup-created files, while still restoring the keyring password env on every start. Clean up the onboarding UI by removing the unused agents route and tightening GitHub copy. --- bin/alphaclaw.js | 4 +++- lib/public/js/app.js | 8 ++------ lib/public/js/components/onboarding/welcome-config.js | 2 +- lib/server/onboarding/workspace.js | 10 ++++++++++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index f5abd3ed..186ca403 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -481,6 +481,8 @@ if (!kSetupPassword) { process.env.OPENCLAW_HOME = rootDir; process.env.OPENCLAW_CONFIG_PATH = path.join(openclawDir, "openclaw.json"); +process.env.GOG_KEYRING_PASSWORD = + process.env.GOG_KEYRING_PASSWORD || "alphaclaw"; // --------------------------------------------------------------------------- // 8. Install gog (Google Workspace CLI) if not present @@ -516,7 +518,7 @@ if (!gogInstalled) { } // --------------------------------------------------------------------------- -// 7. Install/reconcile system cron entry +// 9. Install/reconcile system cron entry // --------------------------------------------------------------------------- const packagedHourlyGitSyncPath = path.join(setupDir, "hourly-git-sync.sh"); diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 338a60c9..0a44d160 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -14,7 +14,6 @@ import { GlobalRestartBanner } from "./components/global-restart-banner.js"; import { LoadingSpinner } from "./components/loading-spinner.js"; import { AppSidebar } from "./components/sidebar.js"; import { - AgentsRoute, BrowseRoute, DoctorRoute, EnvarsRoute, @@ -105,8 +104,8 @@ const App = () => { class="min-h-screen flex flex-col items-center pt-12 pb-8 px-4" style="position: relative; z-index: 1" > - <${Welcome} - onComplete=${controllerActions.handleOnboardingComplete} + <${Welcome} + onComplete=${controllerActions.handleOnboardingComplete} acVersion=${controllerState.acVersion} />
        @@ -241,9 +240,6 @@ const App = () => { <${Route} path="/doctor"> <${DoctorRoute} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} /> - <${Route} path="/agents"> - <${AgentsRoute} /> - <${Route} path="/telegram"> <${TelegramRoute} onBack=${browseActions.exitSubScreen} /> diff --git a/lib/public/js/components/onboarding/welcome-config.js b/lib/public/js/components/onboarding/welcome-config.js index 533190d8..f23b95c4 100644 --- a/lib/public/js/components/onboarding/welcome-config.js +++ b/lib/public/js/components/onboarding/welcome-config.js @@ -29,7 +29,7 @@ export const kWelcomeGroups = [ { id: "github", title: "GitHub", - description: "Backs up your agent's config and workspace", + description: "Auto-backup your config and workspace", fields: [ { key: "_GITHUB_SOURCE_REPO", diff --git a/lib/server/onboarding/workspace.js b/lib/server/onboarding/workspace.js index 7b593e3c..06be162c 100644 --- a/lib/server/onboarding/workspace.js +++ b/lib/server/onboarding/workspace.js @@ -1,4 +1,5 @@ const path = require("path"); +const { execSync } = require("child_process"); const { kSetupDir, OPENCLAW_DIR, @@ -139,6 +140,15 @@ const ensureOpenclawRuntimeArtifacts = ({ } catch (e) { console.log(`[alphaclaw] .env symlink skipped: ${e.message}`); } + + const gogConfigFile = path.join(openclawDir, "gogcli", "config.json"); + if (!fs.existsSync(gogConfigFile)) { + fs.mkdirSync(path.join(openclawDir, "gogcli"), { recursive: true }); + try { + execSync("gog auth keyring file", { stdio: "ignore" }); + console.log("[alphaclaw] gog keyring configured (file backend)"); + } catch {} + } }; module.exports = { From a26f571257fce5a32ec90c232328ff9ecdfeb378 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 20:14:54 -0800 Subject: [PATCH 130/417] 0.5.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36a92522..7b6101bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 80c82689..7b92599a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.5.0", + "version": "0.5.1", "publishConfig": { "access": "public" }, From 0ef0cd7149e877ebc6721bdee8f0186774ba2448 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 21:15:11 -0800 Subject: [PATCH 131/417] fix onboarding imports and model provider setup Normalize imported config secrets and seed auth profiles so provider-backed models configure correctly. Expand the model picker with broader provider support, popular suggestions, and grouped searchable results. --- lib/public/js/components/models-tab/index.js | 251 ++++++++++++++---- .../models-tab/provider-auth-card.js | 22 +- lib/public/js/components/models.js | 17 +- .../onboarding/welcome-import-step.js | 8 +- .../onboarding/welcome-secret-review-step.js | 2 + lib/public/js/components/providers.js | 22 +- lib/public/js/lib/model-config.js | 151 ++++++++++- lib/server/auth-profiles.js | 14 + lib/server/constants.js | 19 ++ .../onboarding/import/import-applier.js | 69 +++++ .../onboarding/import/import-scanner.js | 9 +- .../onboarding/import/secret-detector.js | 50 +++- lib/server/onboarding/index.js | 50 ++++ lib/server/onboarding/openclaw.js | 4 + lib/server/routes/onboarding.js | 9 + lib/server/routes/system.js | 14 + tests/frontend/model-config.test.js | 10 + tests/server/helpers.test.js | 12 + tests/server/import-scanner.test.js | 3 +- tests/server/onboarding-openclaw.test.js | 34 ++- tests/server/routes-models.test.js | 27 ++ tests/server/routes-onboarding.test.js | 151 +++++++++++ tests/server/secret-detector.test.js | 110 ++++++++ 23 files changed, 972 insertions(+), 86 deletions(-) diff --git a/lib/public/js/components/models-tab/index.js b/lib/public/js/components/models-tab/index.js index 34f7ccb5..204f21d5 100644 --- a/lib/public/js/components/models-tab/index.js +++ b/lib/public/js/components/models-tab/index.js @@ -1,5 +1,5 @@ import { h } from "https://esm.sh/preact"; -import { useState, useMemo } from "https://esm.sh/preact/hooks"; +import { useState, useMemo, useRef, useEffect } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { PageHeader } from "../page-header.js"; import { LoadingSpinner } from "../loading-spinner.js"; @@ -7,14 +7,26 @@ import { ActionButton } from "../action-button.js"; import { Badge } from "../badge.js"; import { useModels } from "./use-models.js"; import { ProviderAuthCard } from "./provider-auth-card.js"; -import { getModelProvider, getFeaturedModels } from "../../lib/model-config.js"; +import { + getModelProvider, + getAuthProviderFromModelProvider, + getFeaturedModels, + kProviderLabels, + kProviderOrder, +} from "../../lib/model-config.js"; const html = htm.bind(h); +const getModelsTabAuthProvider = (modelKey) => { + const provider = getModelProvider(modelKey); + if (provider === "openai-codex") return "openai-codex"; + return getAuthProviderFromModelProvider(provider); +}; + const deriveRequiredProviders = (configuredModels) => { const providers = new Set(); for (const modelKey of Object.keys(configuredModels)) { - const provider = getModelProvider(modelKey); + const provider = getModelsTabAuthProvider(modelKey); if (provider) providers.add(provider); } return [...providers]; @@ -24,9 +36,172 @@ const kProviderDisplayOrder = [ "anthropic", "openai", "openai-codex", - "google", + ...kProviderOrder.filter((provider) => !["anthropic", "openai"].includes(provider)), ]; +const getModelCatalogProvider = (model) => + String(model?.provider || getModelProvider(model?.key)).trim(); + +const getProviderSortIndex = (provider) => { + const index = kProviderDisplayOrder.indexOf(provider); + return index >= 0 ? index : Number.MAX_SAFE_INTEGER; +}; + +const formatProviderSectionLabel = (provider) => + String(kProviderLabels[provider] || provider).toUpperCase(); + +const normalizeSearch = (value) => String(value || "").trim().toLowerCase(); +const buildModelSearchText = (model) => + [ + model?.label || "", + model?.key || "", + model?.provider || getModelProvider(model?.key), + ] + .join(" ") + .toLowerCase(); + +const SearchableModelPicker = ({ + options = [], + popularModels = [], + placeholder = "Add model...", + onSelect = () => {}, +}) => { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const normalizedQuery = normalizeSearch(query); + const filteredOptions = useMemo( + () => + normalizedQuery + ? options.filter((option) => + buildModelSearchText(option).includes(normalizedQuery), + ) + : options, + [options, normalizedQuery], + ); + const groupedOptions = useMemo(() => { + const groups = []; + const showPopularGroup = !normalizedQuery; + const visibleOptionKeys = new Set(filteredOptions.map((option) => option.key)); + const visiblePopularModels = popularModels.filter((model) => + visibleOptionKeys.has(model.key), + ); + if (showPopularGroup && visiblePopularModels.length > 0) { + groups.push({ + provider: "popular", + label: "POPULAR", + options: visiblePopularModels, + }); + } + for (const option of filteredOptions) { + const provider = getModelCatalogProvider(option); + const label = formatProviderSectionLabel(provider); + const currentGroup = groups[groups.length - 1]; + if (!currentGroup || currentGroup.provider !== provider) { + groups.push({ provider, label, options: [option] }); + continue; + } + currentGroup.options.push(option); + } + return groups; + }, [filteredOptions, popularModels, normalizedQuery]); + + useEffect(() => { + const handleOutsidePointer = (event) => { + if (!rootRef.current?.contains(event.target)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handleOutsidePointer); + return () => document.removeEventListener("mousedown", handleOutsidePointer); + }, []); + + const handleSelect = (modelKey) => { + if (!modelKey) return; + onSelect(modelKey); + setQuery(""); + setOpen(false); + }; + + const handleKeyDown = (event) => { + const firstVisibleOption = groupedOptions[0]?.options?.[0]; + if (event.key === "Escape") { + setOpen(false); + return; + } + if (event.key === "Enter" && firstVisibleOption?.key) { + event.preventDefault(); + handleSelect(firstVisibleOption.key); + } + }; + + return html` +
        + setOpen(true)} + onInput=${(event) => { + setQuery(event.target.value); + setOpen(true); + }} + onKeyDown=${handleKeyDown} + class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500" + /> + ${open + ? html` +
        +
        + ${filteredOptions.length > 0 + ? groupedOptions.map( + (group, index) => html` +
        +
        0 ? "border-t border-border" : "" + }`} + > + ${group.label} +
        + ${group.options.map( + (model) => html` + + `, + )} +
        + `, + ) + : html` +
        + No models match that search. +
        + `} +
        +
        + ` + : null} +
        + `; +}; + export const Models = ({ onRestartRequired = () => {} }) => { const { catalog, @@ -52,26 +227,28 @@ export const Models = ({ onRestartRequired = () => {} }) => { refreshCodexStatus, } = useModels(); - const [showAllModels, setShowAllModels] = useState(false); - const configuredKeys = useMemo( () => new Set(Object.keys(configuredModels)), [configuredModels], ); const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]); + const popularPickerModels = useMemo( + () => featuredModels.filter((model) => !configuredKeys.has(model.key)), + [featuredModels, configuredKeys], + ); const pickerModels = useMemo(() => { - const base = showAllModels - ? catalog - : featuredModels.length > 0 - ? featuredModels - : catalog; - return base.filter((m) => !configuredKeys.has(m.key)); - }, [catalog, featuredModels, showAllModels, configuredKeys]); - - const canToggleFullCatalog = - featuredModels.length > 0 && catalog.length > featuredModels.length; + return [...catalog] + .filter((model) => !configuredKeys.has(model.key)) + .sort((a, b) => { + const providerCompare = + getProviderSortIndex(getModelCatalogProvider(a)) - + getProviderSortIndex(getModelCatalogProvider(b)); + if (providerCompare !== 0) return providerCompare; + return String(a.label || a.key).localeCompare(String(b.label || b.key)); + }); + }, [catalog, configuredKeys]); const requiredProviders = useMemo( () => deriveRequiredProviders(configuredModels), @@ -106,7 +283,7 @@ export const Models = ({ onRestartRequired = () => {} }) => { () => Object.keys(configuredModels).map((key) => { const catalogEntry = catalog.find((m) => m.key === key); - const provider = getModelProvider(key); + const provider = getModelsTabAuthProvider(key); const hasAuth = !!providerHasAuth[provider]; return { key, @@ -213,38 +390,16 @@ export const Models = ({ onRestartRequired = () => {} }) => {
        `} -
        - - - ${canToggleFullCatalog - ? html` - - ` - : null} + />
        ${loading diff --git a/lib/public/js/components/models-tab/provider-auth-card.js b/lib/public/js/components/models-tab/provider-auth-card.js index b95cd29c..dc8154d5 100644 --- a/lib/public/js/components/models-tab/provider-auth-card.js +++ b/lib/public/js/components/models-tab/provider-auth-card.js @@ -6,6 +6,10 @@ import { SecretInput } from "../secret-input.js"; import { ActionButton } from "../action-button.js"; import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js"; import { showToast } from "../toast.js"; +import { + kProviderAuthFields, + kProviderLabels, +} from "../../lib/model-config.js"; const html = htm.bind(h); @@ -71,10 +75,24 @@ const kDefaultMode = { field: "key", }; +const buildDefaultProviderModes = (provider) => { + const fields = kProviderAuthFields[provider] || []; + if (fields.length === 0) return [kDefaultMode]; + return fields.map((fieldDef) => ({ + id: "api_key", + label: fieldDef.label || "API Key", + profileSuffix: "default", + placeholder: fieldDef.placeholder || "...", + hint: fieldDef.hint, + url: fieldDef.url, + field: "key", + })); +}; + const getProviderMeta = (provider) => kProviderMeta[provider] || { - label: provider, - modes: [kDefaultMode], + label: kProviderLabels[provider] || provider, + modes: buildDefaultProviderModes(provider), }; const resolveProfileId = (mode, provider) => { diff --git a/lib/public/js/components/models.js b/lib/public/js/components/models.js index ab86505f..a09ee4ef 100644 --- a/lib/public/js/components/models.js +++ b/lib/public/js/components/models.js @@ -137,7 +137,10 @@ export const Models = () => { const setEnvValue = (key, value) => { setEnvVars((prev) => { - const next = prev.map((v) => (v.key === key ? { ...v, value } : v)); + const existing = prev.some((entry) => entry.key === key); + const next = existing + ? prev.map((v) => (v.key === key ? { ...v, value } : v)) + : [...prev, { key, value, editable: true }]; kModelsTabCache = { ...(kModelsTabCache || {}), envVars: next }; return next; }); @@ -263,15 +266,11 @@ export const Models = () => { (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""), ); const hasSelectedProviderAuth = - selectedModelProvider === "anthropic" - ? !!(getKeyVal(envVars, "ANTHROPIC_API_KEY") || getKeyVal(envVars, "ANTHROPIC_TOKEN")) - : selectedModelProvider === "openai" - ? !!getKeyVal(envVars, "OPENAI_API_KEY") - : selectedModelProvider === "openai-codex" + selectedModelProvider === "openai-codex" ? !!codexStatus.connected - : selectedModelProvider === "google" - ? !!getKeyVal(envVars, "GEMINI_API_KEY") - : false; + : (kProviderAuthFields[selectedAuthProvider] || []).some((field) => + Boolean(getKeyVal(envVars, field.key)), + ); const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth)); const renderCredentialField = (field) => html` diff --git a/lib/public/js/components/onboarding/welcome-import-step.js b/lib/public/js/components/onboarding/welcome-import-step.js index 64e7cdd5..5b171db6 100644 --- a/lib/public/js/components/onboarding/welcome-import-step.js +++ b/lib/public/js/components/onboarding/welcome-import-step.js @@ -237,9 +237,10 @@ export const WelcomeImportStep = ({
        - AlphaClaw controls deployment env vars + AlphaClaw controls deployment tokens and env vars (${(scanResult.managedEnvConflicts.vars || []).join(", ")}). - Imported values for these will be normalized during import. + Imported values for these will be overwritten with AlphaClaw-managed + env var references during import.
        ` : null} @@ -266,8 +267,7 @@ export const WelcomeImportStep = ({ >
        - ${secretCount} possible secret${secretCount === 1 ? "" : "s"} - detected + ${`${secretCount} possible secret${secretCount === 1 ? "" : "s"} detected`}

        Review and extract to environment variables diff --git a/lib/public/js/components/onboarding/welcome-secret-review-step.js b/lib/public/js/components/onboarding/welcome-secret-review-step.js index 5db84c56..cd472024 100644 --- a/lib/public/js/components/onboarding/welcome-secret-review-step.js +++ b/lib/public/js/components/onboarding/welcome-secret-review-step.js @@ -174,12 +174,14 @@ export const WelcomeSecretReviewStep = ({ <${ActionButton} onClick=${onBack} tone="secondary" + size="md" idleLabel="Back" className="w-full" /> <${ActionButton} onClick=${handleExtract} tone="primary" + size="md" idleLabel=${selectedCount > 0 ? `Extract ${selectedCount} Secret${selectedCount === 1 ? "" : "s"}` : "Skip All"} diff --git a/lib/public/js/components/providers.js b/lib/public/js/components/providers.js index 1167210d..037d014c 100644 --- a/lib/public/js/components/providers.js +++ b/lib/public/js/components/providers.js @@ -189,7 +189,10 @@ export const Providers = ({ onRestartRequired = () => {} }) => { const setEnvValue = (key, value) => { setEnvVars((prev) => { - const next = prev.map((v) => (v.key === key ? { ...v, value } : v)); + const existing = prev.some((entry) => entry.key === key); + const next = existing + ? prev.map((v) => (v.key === key ? { ...v, value } : v)) + : [...prev, { key, value, editable: true }]; kProvidersTabCache = { ...(kProvidersTabCache || {}), envVars: next }; return next; }); @@ -224,18 +227,11 @@ export const Providers = ({ onRestartRequired = () => {} }) => { (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || ""), ); const hasSelectedProviderAuth = - selectedModelProvider === "anthropic" - ? !!( - getKeyVal(envVars, "ANTHROPIC_API_KEY") || - getKeyVal(envVars, "ANTHROPIC_TOKEN") - ) - : selectedModelProvider === "openai" - ? !!getKeyVal(envVars, "OPENAI_API_KEY") - : selectedModelProvider === "openai-codex" - ? !!codexStatus.connected - : selectedModelProvider === "google" - ? !!getKeyVal(envVars, "GEMINI_API_KEY") - : false; + selectedModelProvider === "openai-codex" + ? !!codexStatus.connected + : (kProviderAuthFields[selectedAuthProvider] || []).some((field) => + Boolean(getKeyVal(envVars, field.key)), + ); const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth)); diff --git a/lib/public/js/lib/model-config.js b/lib/public/js/lib/model-config.js index a80140f9..cce55252 100644 --- a/lib/public/js/lib/model-config.js +++ b/lib/public/js/lib/model-config.js @@ -1,7 +1,12 @@ export const getModelProvider = (modelKey) => String(modelKey || "").split("/")[0] || ""; -export const getAuthProviderFromModelProvider = (provider) => - provider === "openai-codex" ? "openai" : provider; +export const getAuthProviderFromModelProvider = (provider) => { + const normalized = String(provider || "").trim(); + if (normalized === "openai-codex") return "openai"; + if (normalized === "volcengine-plan") return "volcengine"; + if (normalized === "byteplus-plan") return "byteplus"; + return normalized; +}; export const kFeaturedModelDefs = [ { @@ -71,6 +76,50 @@ export const kProviderAuthFields = { placeholder: "AI...", }, ], + opencode: [ + { + key: "OPENCODE_API_KEY", + label: "OpenCode API Key", + placeholder: "oc-...", + }, + ], + openrouter: [ + { + key: "OPENROUTER_API_KEY", + label: "OpenRouter API Key", + url: "https://openrouter.ai", + linkText: "Get key", + placeholder: "sk-or-...", + }, + ], + zai: [ + { + key: "ZAI_API_KEY", + label: "Z.AI API Key", + placeholder: "zai-...", + }, + ], + "vercel-ai-gateway": [ + { + key: "AI_GATEWAY_API_KEY", + label: "AI Gateway API Key", + placeholder: "aigw_...", + }, + ], + kilocode: [ + { + key: "KILOCODE_API_KEY", + label: "KiloCode API Key", + placeholder: "kilo_...", + }, + ], + xai: [ + { + key: "XAI_API_KEY", + label: "xAI API Key", + placeholder: "xai-...", + }, + ], mistral: [ { key: "MISTRAL_API_KEY", @@ -98,6 +147,55 @@ export const kProviderAuthFields = { placeholder: "gsk_...", }, ], + cerebras: [ + { + key: "CEREBRAS_API_KEY", + label: "Cerebras API Key", + placeholder: "csk-...", + }, + ], + moonshot: [ + { + key: "MOONSHOT_API_KEY", + label: "Moonshot API Key", + placeholder: "sk-...", + }, + ], + "kimi-coding": [ + { + key: "KIMI_API_KEY", + label: "Kimi API Key", + placeholder: "sk-...", + }, + ], + volcengine: [ + { + key: "VOLCANO_ENGINE_API_KEY", + label: "Volcano Engine API Key", + placeholder: "ve-...", + }, + ], + byteplus: [ + { + key: "BYTEPLUS_API_KEY", + label: "BytePlus API Key", + placeholder: "bp-...", + }, + ], + synthetic: [ + { + key: "SYNTHETIC_API_KEY", + label: "Synthetic API Key", + placeholder: "syn-...", + }, + ], + minimax: [ + { + key: "MINIMAX_API_KEY", + label: "MiniMax API Key", + placeholder: "minimax-...", + }, + ], deepgram: [ { key: "DEEPGRAM_API_KEY", @@ -107,26 +205,61 @@ export const kProviderAuthFields = { placeholder: "dg-...", }, ], + vllm: [ + { + key: "VLLM_API_KEY", + label: "vLLM API Key", + placeholder: "vllm-local", + }, + ], }; export const kProviderLabels = { anthropic: "Anthropic", openai: "OpenAI", google: "Gemini", + opencode: "OpenCode Zen", + openrouter: "OpenRouter", + zai: "Z.AI", + "vercel-ai-gateway": "Vercel AI Gateway", + kilocode: "Kilo Gateway", + xai: "xAI", mistral: "Mistral", + cerebras: "Cerebras", + moonshot: "Moonshot", + "kimi-coding": "Kimi Coding", + volcengine: "Volcano Engine", + byteplus: "BytePlus", + synthetic: "Synthetic", + minimax: "MiniMax", voyage: "Voyage", groq: "Groq", deepgram: "Deepgram", + vllm: "vLLM", }; export const kProviderOrder = [ "anthropic", "openai", "google", + "zai", + "xai", + "openrouter", + "opencode", + "kilocode", + "vercel-ai-gateway", + "minimax", + "moonshot", + "kimi-coding", + "volcengine", + "byteplus", + "synthetic", "mistral", + "cerebras", "voyage", "groq", "deepgram", + "vllm", ]; export const kCoreProviders = new Set(["anthropic", "openai", "google"]); @@ -135,10 +268,24 @@ export const kProviderFeatures = { anthropic: ["Agent Model"], openai: ["Agent Model", "Embeddings", "Audio"], google: ["Agent Model", "Embeddings", "Audio"], + opencode: ["Agent Model"], + openrouter: ["Agent Model"], + zai: ["Agent Model"], + "vercel-ai-gateway": ["Agent Model"], + kilocode: ["Agent Model"], + xai: ["Agent Model"], mistral: ["Agent Model", "Embeddings", "Audio"], + cerebras: ["Agent Model"], + moonshot: ["Agent Model"], + "kimi-coding": ["Agent Model"], + volcengine: ["Agent Model"], + byteplus: ["Agent Model"], + synthetic: ["Agent Model"], + minimax: ["Agent Model"], voyage: ["Embeddings"], groq: ["Agent Model", "Audio"], deepgram: ["Audio"], + vllm: ["Agent Model"], }; export const kFeatureDefs = [ diff --git a/lib/server/auth-profiles.js b/lib/server/auth-profiles.js index 7ef67d2d..75746f0f 100644 --- a/lib/server/auth-profiles.js +++ b/lib/server/auth-profiles.js @@ -7,10 +7,24 @@ const kApiKeyEnvVarByProvider = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GEMINI_API_KEY", + opencode: "OPENCODE_API_KEY", + openrouter: "OPENROUTER_API_KEY", + zai: "ZAI_API_KEY", + "vercel-ai-gateway": "AI_GATEWAY_API_KEY", + kilocode: "KILOCODE_API_KEY", + xai: "XAI_API_KEY", mistral: "MISTRAL_API_KEY", + cerebras: "CEREBRAS_API_KEY", + moonshot: "MOONSHOT_API_KEY", + "kimi-coding": "KIMI_API_KEY", + volcengine: "VOLCANO_ENGINE_API_KEY", + byteplus: "BYTEPLUS_API_KEY", + synthetic: "SYNTHETIC_API_KEY", + minimax: "MINIMAX_API_KEY", voyage: "VOYAGE_API_KEY", groq: "GROQ_API_KEY", deepgram: "DEEPGRAM_API_KEY", + vllm: "VLLM_API_KEY", }; const normalizeSecret = (raw) => diff --git a/lib/server/constants.js b/lib/server/constants.js index d0c2bb24..36bf7243 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -71,6 +71,25 @@ const kOnboardingModelProviders = new Set([ "openai", "openai-codex", "google", + "opencode", + "openrouter", + "zai", + "vercel-ai-gateway", + "kilocode", + "xai", + "mistral", + "cerebras", + "moonshot", + "kimi-coding", + "volcengine", + "volcengine-plan", + "byteplus", + "byteplus-plan", + "synthetic", + "minimax", + "voyage", + "groq", + "vllm", ]); const kFallbackOnboardingModels = [ { diff --git a/lib/server/onboarding/import/import-applier.js b/lib/server/onboarding/import/import-applier.js index 4003fe6c..da6a537c 100644 --- a/lib/server/onboarding/import/import-applier.js +++ b/lib/server/onboarding/import/import-applier.js @@ -4,6 +4,11 @@ const { normalizeHookPath, normalizeTransformModulePath, } = require("./import-config"); +const { + getCanonicalEnvVarForConfigPath, + getEnvRefName, + isAlreadyEnvRef, +} = require("./secret-detector"); const kEnvVarNamePattern = /^[A-Z_][A-Z0-9_]*$/; @@ -313,9 +318,73 @@ const applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => { return { envVars }; }; +const remapEnvVars = (envVars, renameMap) => { + const mapped = []; + for (const entry of envVars) { + const key = String(entry?.key || "").trim(); + if (!key) continue; + const nextKey = renameMap.get(key) || key; + const existing = mapped.find((item) => item.key === nextKey); + if (existing) { + existing.value = entry.value; + } else { + mapped.push({ key: nextKey, value: entry.value }); + } + } + return mapped; +}; + +const canonicalizeConfigEnvRefs = ({ fs, baseDir, configFiles = [], envVars = [] }) => { + const renameMap = new Map(); + let rewrittenRefs = 0; + + const rewriteNode = (node, parentPath = "") => { + if (!node || typeof node !== "object") return false; + let changed = false; + for (const [key, value] of Object.entries(node)) { + const dotPath = parentPath ? `${parentPath}.${key}` : key; + if (typeof value === "string" && isAlreadyEnvRef(value)) { + const currentEnvRef = getEnvRefName(value); + const canonicalEnvVar = getCanonicalEnvVarForConfigPath(dotPath); + if (canonicalEnvVar && currentEnvRef && currentEnvRef !== canonicalEnvVar) { + node[key] = `\${${canonicalEnvVar}}`; + renameMap.set(currentEnvRef, canonicalEnvVar); + rewrittenRefs += 1; + changed = true; + } + continue; + } + if (value && typeof value === "object") { + changed = rewriteNode(value, dotPath) || changed; + } + } + return changed; + }; + + for (const configFile of configFiles) { + const fullPath = resolveExtractionTargetPath(baseDir, configFile); + if (!fullPath) continue; + try { + const parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); + if (!parsed || typeof parsed !== "object") continue; + const changed = rewriteNode(parsed); + if (changed) { + fs.writeFileSync(fullPath, JSON.stringify(parsed, null, 2)); + } + } catch {} + } + + return { + envVars: remapEnvVars(envVars, renameMap), + rewrittenRefs, + renamedEnvVars: renameMap.size, + }; +}; + module.exports = { promoteCloneToTarget, alignHookTransforms, applySecretExtraction, + canonicalizeConfigEnvRefs, isValidTempDir, }; diff --git a/lib/server/onboarding/import/import-scanner.js b/lib/server/onboarding/import/import-scanner.js index f9544566..5c338b88 100644 --- a/lib/server/onboarding/import/import-scanner.js +++ b/lib/server/onboarding/import/import-scanner.js @@ -220,6 +220,7 @@ const collectManagedEnvRefs = (value, found) => { const collectManagedEnvConflicts = (fs, baseDir, configFiles, envFiles) => { const managedVars = new Set(); let gatewayAuthNormalized = false; + let webhookTokenNormalized = false; for (const configFile of configFiles) { try { @@ -231,6 +232,11 @@ const collectManagedEnvConflicts = (fs, baseDir, configFiles, envFiles) => { gatewayAuthNormalized = true; managedVars.add("OPENCLAW_GATEWAY_TOKEN"); } + const webhookToken = String(cfg?.hooks?.token || "").trim(); + if (webhookToken && webhookToken !== "${WEBHOOK_TOKEN}") { + webhookTokenNormalized = true; + managedVars.add("WEBHOOK_TOKEN"); + } } catch {} } @@ -251,9 +257,10 @@ const collectManagedEnvConflicts = (fs, baseDir, configFiles, envFiles) => { } return { - found: managedVars.size > 0 || gatewayAuthNormalized, + found: managedVars.size > 0 || gatewayAuthNormalized || webhookTokenNormalized, vars: [...managedVars].sort(), gatewayAuthNormalized, + webhookTokenNormalized, }; }; diff --git a/lib/server/onboarding/import/secret-detector.js b/lib/server/onboarding/import/secret-detector.js index f83ece03..333fdd85 100644 --- a/lib/server/onboarding/import/secret-detector.js +++ b/lib/server/onboarding/import/secret-detector.js @@ -59,14 +59,27 @@ const kConfigPathToEnvVar = { "models.providers.openai.apiKey": "OPENAI_API_KEY", "models.providers.anthropic.apiKey": "ANTHROPIC_API_KEY", "models.providers.google.apiKey": "GEMINI_API_KEY", + "models.providers.opencode.apiKey": "OPENCODE_API_KEY", "models.providers.openrouter.apiKey": "OPENROUTER_API_KEY", + "models.providers.zai.apiKey": "ZAI_API_KEY", + "models.providers.vercel-ai-gateway.apiKey": "AI_GATEWAY_API_KEY", + "models.providers.kilocode.apiKey": "KILOCODE_API_KEY", + "models.providers.xai.apiKey": "XAI_API_KEY", "models.providers.mistral.apiKey": "MISTRAL_API_KEY", "models.providers.groq.apiKey": "GROQ_API_KEY", "models.providers.cerebras.apiKey": "CEREBRAS_API_KEY", + "models.providers.moonshot.apiKey": "MOONSHOT_API_KEY", + "models.providers.kimi-coding.apiKey": "KIMI_API_KEY", + "models.providers.volcengine.apiKey": "VOLCANO_ENGINE_API_KEY", + "models.providers.byteplus.apiKey": "BYTEPLUS_API_KEY", + "models.providers.synthetic.apiKey": "SYNTHETIC_API_KEY", + "models.providers.minimax.apiKey": "MINIMAX_API_KEY", "models.providers.voyage.apiKey": "VOYAGE_API_KEY", + "models.providers.vllm.apiKey": "VLLM_API_KEY", "tools.web.search.apiKey": "BRAVE_API_KEY", "audio.apiKey": "ELEVENLABS_API_KEY", "talk.apiKey": "ELEVENLABS_API_KEY", + "hooks.token": null, // Dropped — normalized to WEBHOOK_TOKEN at deploy/import time "gateway.auth.token": null, // Dropped — set at deploy time }; @@ -99,15 +112,31 @@ const maskValue = (value) => { return str.slice(0, 4) + "****" + str.slice(-4); }; -const configPathToEnvName = (dotPath) => { +const toEnvSegment = (value) => + String(value || "") + .replace(/([a-z])([A-Z])/g, "$1_$2") + .toUpperCase() + .replace(/[^A-Z0-9_]/g, "_"); + +const getCanonicalEnvVarForConfigPath = (dotPath) => { if (kConfigPathToEnvVar[dotPath] !== undefined) { return kConfigPathToEnvVar[dotPath]; } + const providerPathMatch = String(dotPath || "").match( + /^models\.providers\.([^.]+)\.([^.]+)$/, + ); + if (providerPathMatch) { + const [, providerKey, fieldKey] = providerPathMatch; + return `${toEnvSegment(providerKey)}_${toEnvSegment(fieldKey)}`; + } + return ""; +}; + +const configPathToEnvName = (dotPath) => { + const canonicalName = getCanonicalEnvVarForConfigPath(dotPath); + if (canonicalName) return canonicalName; const lastKey = dotPath.split(".").pop() || ""; - return lastKey - .replace(/([a-z])([A-Z])/g, "$1_$2") - .toUpperCase() - .replace(/[^A-Z0-9_]/g, "_"); + return toEnvSegment(lastKey); }; const walkConfig = (obj, parentPath, results) => { @@ -172,6 +201,13 @@ const walkConfig = (obj, parentPath, results) => { const isAlreadyEnvRef = (value) => /^\$\{[A-Z_][A-Z0-9_]*\}$/.test(String(value || "").trim()); +const getEnvRefName = (value) => { + const match = String(value || "") + .trim() + .match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + return match?.[1] || ""; +}; + const parseEnvFileSecrets = (content, fileName) => { const results = []; const lines = String(content || "").split("\n"); @@ -280,9 +316,13 @@ const extractPreFillValues = ({ fs, baseDir, configFiles = [] }) => { }; module.exports = { + configPathToEnvName, detectSecrets, extractPreFillValues, + getCanonicalEnvVarForConfigPath, + getEnvRefName, isSensitiveKey, + isAlreadyEnvRef, matchesValuePrefix, maskValue, parseEnvFileSecrets, diff --git a/lib/server/onboarding/index.js b/lib/server/onboarding/index.js index 7fb90f64..8104bc01 100644 --- a/lib/server/onboarding/index.js +++ b/lib/server/onboarding/index.js @@ -62,6 +62,46 @@ const collectEnvRefs = (value, found = new Set()) => { const getEnvVarValue = (items, key) => items.find((entry) => entry.key === key)?.value || ""; +const syncApiKeyAuthProfilesFromEnvVars = (authProfiles, envVars = []) => { + if (!authProfiles?.getEnvVarForApiKeyProvider) return; + const providers = [ + "anthropic", + "openai", + "google", + "opencode", + "openrouter", + "zai", + "vercel-ai-gateway", + "kilocode", + "xai", + "mistral", + "cerebras", + "moonshot", + "kimi-coding", + "volcengine", + "byteplus", + "synthetic", + "minimax", + "voyage", + "groq", + "deepgram", + "vllm", + ]; + const envMap = new Map( + (envVars || []).map((entry) => [ + String(entry?.key || "").trim(), + String(entry?.value || ""), + ]), + ); + for (const provider of providers) { + const envKey = authProfiles.getEnvVarForApiKeyProvider(provider); + if (!envKey) continue; + const value = String(envMap.get(envKey) || "").trim(); + if (!value || value === kPlaceholderEnvValue) continue; + authProfiles.upsertApiKeyProfileForEnvVar?.(provider, value); + } +}; + const buildPlaceholderReview = ({ referencedEnvVars, envVars = [], @@ -115,6 +155,15 @@ const normalizeImportedConfig = ({ fs, openclawDir }) => { }; changed = true; } + const currentWebhookToken = String(cfg?.hooks?.token || "").trim(); + const expectedWebhookTokenRef = "${WEBHOOK_TOKEN}"; + if (cfg.hooks && currentWebhookToken !== expectedWebhookTokenRef) { + cfg.hooks = { + ...(cfg.hooks || {}), + token: expectedWebhookTokenRef, + }; + changed = true; + } if ( cfg.hooks && Object.prototype.hasOwnProperty.call(cfg.hooks, "transformsDir") @@ -269,6 +318,7 @@ const createOnboardingService = ({ } writeEnvFile(varsToSave); reloadEnv(); + syncApiKeyAuthProfilesFromEnvVars(authProfiles, varsToSave); const [, repoName] = repoUrl.split("/"); const repoCheck = await ensureGithubRepoAccessible({ diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js index 149c5466..34317f3b 100644 --- a/lib/server/onboarding/openclaw.js +++ b/lib/server/onboarding/openclaw.js @@ -131,6 +131,7 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); if (!cfg.channels) cfg.channels = {}; if (!cfg.plugins) cfg.plugins = {}; + if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = []; if (!cfg.plugins.load) cfg.plugins.load = {}; if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = []; if (!cfg.plugins.entries) cfg.plugins.entries = {}; @@ -171,6 +172,9 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { cfg.plugins.load.paths.push(kUsageTrackerPluginPath); } + if (!cfg.plugins.allow.includes("usage-tracker")) { + cfg.plugins.allow.push("usage-tracker"); + } cfg.plugins.entries["usage-tracker"] = { enabled: true }; let content = JSON.stringify(cfg, null, 2); diff --git a/lib/server/routes/onboarding.js b/lib/server/routes/onboarding.js index 1c50499f..23711a2c 100644 --- a/lib/server/routes/onboarding.js +++ b/lib/server/routes/onboarding.js @@ -12,6 +12,7 @@ const { promoteCloneToTarget, alignHookTransforms, applySecretExtraction, + canonicalizeConfigEnvRefs, isValidTempDir, } = require("../onboarding/import/import-applier"); const { cleanupTempClone } = require("../onboarding/github"); @@ -322,6 +323,13 @@ const registerOnboardingRoutes = ({ }); envVars = extraction.envVars; } + const canonicalization = canonicalizeConfigEnvRefs({ + fs, + baseDir: tempDir, + configFiles: scan.gatewayConfig.files, + envVars, + }); + envVars = canonicalization.envVars; const configFiles = ["openclaw.json"].filter((f) => fs.existsSync(path.join(tempDir, f)), @@ -408,6 +416,7 @@ const registerOnboardingRoutes = ({ placeholderReview, sourceLayout: scan.sourceLayout, envVarsImported: envVars.length, + canonicalizedEnvRefs: canonicalization.rewrittenRefs, transformsAligned: transformAlignment.alignedCount, }); } catch (err) { diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index e877682f..d661dd60 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -111,10 +111,24 @@ const registerSystemRoutes = ({ "anthropic", "openai", "google", + "opencode", + "openrouter", + "zai", + "vercel-ai-gateway", + "kilocode", + "xai", "mistral", + "cerebras", + "moonshot", + "kimi-coding", + "volcengine", + "byteplus", + "synthetic", + "minimax", "voyage", "groq", "deepgram", + "vllm", ]; for (const provider of providers) { const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider); diff --git a/tests/frontend/model-config.test.js b/tests/frontend/model-config.test.js index 83912c5d..ef44e695 100644 --- a/tests/frontend/model-config.test.js +++ b/tests/frontend/model-config.test.js @@ -5,6 +5,12 @@ describe("frontend/model-config", () => { it("maps openai-codex auth provider to openai", async () => { const modelConfig = await loadModelConfig(); expect(modelConfig.getAuthProviderFromModelProvider("openai-codex")).toBe("openai"); + expect(modelConfig.getAuthProviderFromModelProvider("volcengine-plan")).toBe( + "volcengine", + ); + expect(modelConfig.getAuthProviderFromModelProvider("byteplus-plan")).toBe( + "byteplus", + ); expect(modelConfig.getAuthProviderFromModelProvider("google")).toBe("google"); }); @@ -13,6 +19,10 @@ describe("frontend/model-config", () => { const keys = modelConfig.getVisibleAiFieldKeys("openai-codex"); expect(keys.has("OPENAI_API_KEY")).toBe(false); expect(keys.has("ANTHROPIC_API_KEY")).toBe(false); + const zaiKeys = modelConfig.getVisibleAiFieldKeys("zai"); + expect(zaiKeys.has("ZAI_API_KEY")).toBe(true); + const volcengineKeys = modelConfig.getVisibleAiFieldKeys("volcengine-plan"); + expect(volcengineKeys.has("VOLCANO_ENGINE_API_KEY")).toBe(true); }); it("picks featured models in defined preference order", async () => { diff --git a/tests/server/helpers.test.js b/tests/server/helpers.test.js index 1b15266c..481d3790 100644 --- a/tests/server/helpers.test.js +++ b/tests/server/helpers.test.js @@ -59,6 +59,8 @@ describe("server/helpers", () => { { key: "unknown/model-a", name: "Ignore me" }, { key: "openai/gpt-5.1-codex", name: "OpenAI A" }, { key: "anthropic/claude-opus-4-6", name: "Opus 4.6" }, + { key: "zai/glm-5", name: "GLM 5" }, + { key: "minimax/MiniMax-M2.5", name: "MiniMax M2.5" }, { key: "openai/gpt-5.1-codex", name: "Duplicate" }, { key: "google/gemini-3-pro-preview" }, { bad: "shape" }, @@ -75,11 +77,21 @@ describe("server/helpers", () => { provider: "google", label: "google/gemini-3-pro-preview", }, + { + key: "minimax/MiniMax-M2.5", + provider: "minimax", + label: "MiniMax M2.5", + }, { key: "openai/gpt-5.1-codex", provider: "openai", label: "OpenAI A", }, + { + key: "zai/glm-5", + provider: "zai", + label: "GLM 5", + }, ]); }); }); diff --git a/tests/server/import-scanner.test.js b/tests/server/import-scanner.test.js index de94717c..2311ec71 100644 --- a/tests/server/import-scanner.test.js +++ b/tests/server/import-scanner.test.js @@ -271,7 +271,7 @@ describe("import-scanner", () => { const fs = createMockFs({ "/tmp/test/openclaw.json": JSON.stringify({ hooks: { - token: "${WEBHOOK_TOKEN}", + token: "repo-hook-token", }, gateway: { auth: { @@ -287,6 +287,7 @@ describe("import-scanner", () => { found: true, vars: ["OPENCLAW_GATEWAY_TOKEN", "WEBHOOK_TOKEN"], gatewayAuthNormalized: true, + webhookTokenNormalized: true, }); }); diff --git a/tests/server/onboarding-openclaw.test.js b/tests/server/onboarding-openclaw.test.js index 382d88a3..d4876633 100644 --- a/tests/server/onboarding-openclaw.test.js +++ b/tests/server/onboarding-openclaw.test.js @@ -18,7 +18,11 @@ describe("server/onboarding/openclaw", () => { configPath, JSON.stringify( { - plugins: { load: { paths: [pluginPath] }, entries: {} }, + plugins: { + allow: ["memory-core"], + load: { paths: [pluginPath] }, + entries: {}, + }, channels: {}, notes: "alphaclaw", }, @@ -36,9 +40,37 @@ describe("server/onboarding/openclaw", () => { const next = JSON.parse(fs.readFileSync(configPath, "utf8")); expect(next.notes).toBe("${GOG_KEYRING_PASSWORD}"); + expect(next.plugins.allow).toEqual(["memory-core", "usage-tracker"]); expect(next.plugins.load.paths).toContain(pluginPath); expect(next.plugins.load.paths).not.toContain( "/app/node_modules/@chrysb/${GOG_KEYRING_PASSWORD}/lib/plugin/usage-tracker", ); }); + + it("creates plugins.allow when missing before adding usage-tracker", () => { + const openclawDir = createTempOpenclawDir(); + const configPath = path.join(openclawDir, "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify( + { + plugins: { load: { paths: [] }, entries: {} }, + channels: {}, + }, + null, + 2, + ), + "utf8", + ); + + writeSanitizedOpenclawConfig({ + fs, + openclawDir, + varMap: {}, + }); + + const next = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(next.plugins.allow).toEqual(["usage-tracker"]); + expect(next.plugins.entries["usage-tracker"]).toEqual({ enabled: true }); + }); }); diff --git a/tests/server/routes-models.test.js b/tests/server/routes-models.test.js index 2dede717..bc0e2959 100644 --- a/tests/server/routes-models.test.js +++ b/tests/server/routes-models.test.js @@ -197,4 +197,31 @@ describe("server/routes/models", () => { ]); expect(deps.reloadEnv).toHaveBeenCalledTimes(1); }); + + it("writes newly supported provider API keys back to env vars", async () => { + const deps = createModelDeps(); + deps.shellCmd.mockResolvedValue(""); + deps.authProfiles.getEnvVarForApiKeyProvider.mockImplementation((provider) => + provider === "zai" ? "ZAI_API_KEY" : "", + ); + deps.readEnvFile.mockReturnValue([{ key: "ZAI_API_KEY", value: "" }]); + const app = createApp(deps); + + const res = await request(app).put("/api/models/config").send({ + profiles: [ + { + id: "zai:default", + type: "api_key", + provider: "zai", + key: "zai-live-123", + }, + ], + }); + + expect(res.status).toBe(200); + expect(deps.writeEnvFile).toHaveBeenCalledWith([ + { key: "ZAI_API_KEY", value: "zai-live-123" }, + ]); + expect(deps.reloadEnv).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index 24096f9b..96077012 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -48,6 +48,15 @@ const createBaseDeps = ({ onboarded = false, hasCodexOauth = false } = {}) => { resolveModelProvider: vi.fn((modelKey) => String(modelKey).split("/")[0]), hasCodexOauthProfile: vi.fn(() => hasCodexOauth), authProfiles: { + getEnvVarForApiKeyProvider: vi.fn((provider) => { + const envKeys = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GEMINI_API_KEY", + }; + return envKeys[provider] || ""; + }), + upsertApiKeyProfileForEnvVar: vi.fn(), syncConfigAuthReferencesForAgent: vi.fn(), }, ensureGatewayProxyConfig: vi.fn(), @@ -296,6 +305,10 @@ describe("server/routes/onboarding", () => { expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); expect(deps.startGateway).toHaveBeenCalledTimes(1); + expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).toHaveBeenCalledWith( + "openai", + "sk-test-123456789", + ); expect(deps.authProfiles.syncConfigAuthReferencesForAgent).toHaveBeenCalledTimes(1); expect(deps.fs.copyFileSync).toHaveBeenCalledWith( path.join(kSetupDir, "core-prompts", "AGENTS.md"), @@ -367,6 +380,36 @@ describe("server/routes/onboarding", () => { expect(res.body.error).toContain('Repository "owner/repo" already exists'); }); + it("seeds anthropic api key auth profile during onboarding", async () => { + const deps = createBaseDeps(); + deps.fs.readFileSync.mockImplementation((p) => { + if (p === "/tmp/openclaw/openclaw.json") return "{}"; + if (p === path.join(kSetupDir, "skills", "control-ui", "SKILL.md")) return "BASE={{BASE_URL}}"; + if (p === path.join(kSetupDir, "core-prompts", "TOOLS.md")) return "Setup: {{SETUP_UI_URL}}"; + if (p === path.join(kSetupDir, "hourly-git-sync.sh")) return "echo Auto-commit hourly sync"; + return "{}"; + }); + const app = createApp(deps); + mockGithubVerifyAndCreate(); + + const res = await request(app).post("/api/onboard").send({ + modelKey: "anthropic/claude-opus-4-6", + vars: [ + { key: "ANTHROPIC_API_KEY", value: "sk-ant-test-123456789" }, + { key: "GITHUB_TOKEN", value: "ghp_test_123456789" }, + { key: "GITHUB_WORKSPACE_REPO", value: "owner/repo" }, + { key: "TELEGRAM_BOT_TOKEN", value: "telegram_123456789" }, + ], + }); + + expect(res.status).toBe(200); + expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).toHaveBeenCalledWith( + "anthropic", + "sk-ant-test-123456789", + ); + expect(deps.authProfiles.syncConfigAuthReferencesForAgent).toHaveBeenCalledTimes(1); + }); + it("sanitizes onboarding command failures to avoid leaking secrets", async () => { const deps = createBaseDeps(); const app = createApp(deps); @@ -792,6 +835,9 @@ describe("server/routes/onboarding", () => { token: "${GATEWAY_AUTH_TOKEN}", }, }, + hooks: { + token: "repo-hook-token", + }, }), ], [ @@ -869,6 +915,111 @@ describe("server/routes/onboarding", () => { expect(files.get(path.join(deps.constants.OPENCLAW_DIR, "openclaw.json"))).toContain( '"token": "${OPENCLAW_GATEWAY_TOKEN}"', ); + expect(files.get(path.join(deps.constants.OPENCLAW_DIR, "openclaw.json"))).toContain( + '"token": "${WEBHOOK_TOKEN}"', + ); + }); + + it("canonicalizes imported env refs for known config paths during import apply", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-canonical-env-ref"); + const fileEntry = (name) => ({ + name, + isFile: () => true, + isDirectory: () => false, + }); + const files = new Map([ + [ + path.join(tempDir, "openclaw.json"), + JSON.stringify({ + tools: { + web: { + search: { + provider: "brave", + apiKey: "${REDACTED_USE_ENV_VAR}", + }, + }, + }, + }), + ], + [path.join(tempDir, ".env"), "REDACTED_USE_ENV_VAR=brave-live-value\n"], + ]); + const directories = new Set([tempDir]); + deps.fs.existsSync.mockImplementation( + (targetPath) => directories.has(targetPath) || files.has(targetPath), + ); + deps.fs.statSync.mockImplementation((targetPath) => { + if (directories.has(targetPath)) { + return { isFile: () => false, isDirectory: () => true }; + } + if (files.has(targetPath)) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [fileEntry("openclaw.json"), fileEntry(".env")]; + } + if (targetPath === deps.constants.OPENCLAW_DIR) { + return []; + } + return []; + }); + deps.fs.readFileSync.mockImplementation( + (targetPath) => files.get(targetPath) || "{}", + ); + deps.fs.writeFileSync.mockImplementation((targetPath, contents) => { + files.set(targetPath, String(contents)); + }); + deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => { + if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) { + directories.delete(tempDir); + directories.add(targetPath); + for (const [filePath, contents] of [...files.entries()]) { + if (!filePath.startsWith(`${sourcePath}/`)) continue; + files.delete(filePath); + files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents); + } + return; + } + throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`); + }); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/apply").send({ + tempDir, + approvedSecrets: [ + { + file: ".env", + configPath: ".env:REDACTED_USE_ENV_VAR", + key: "REDACTED_USE_ENV_VAR", + value: "brave-live-value", + maskedValue: "brav****alue", + suggestedEnvVar: "REDACTED_USE_ENV_VAR", + confidence: "high", + source: "env-file", + fileName: ".env", + }, + ], + skipSecretExtraction: false, + }); + + expect(res.status).toBe(200); + expect(res.body.placeholderReview).toEqual({ + found: false, + count: 0, + vars: [], + }); + expect(res.body.canonicalizedEnvRefs).toBe(1); + expect(deps.writeEnvFile).toHaveBeenCalledWith([ + { key: "BRAVE_API_KEY", value: "brave-live-value" }, + ]); + const importedConfig = files.get( + path.join(deps.constants.OPENCLAW_DIR, "openclaw.json"), + ); + expect(importedConfig).toContain('"apiKey": "${BRAVE_API_KEY}"'); + expect(importedConfig).not.toContain("REDACTED_USE_ENV_VAR"); }); it("rejects import apply when approved secrets were not in the server scan", async () => { diff --git a/tests/server/secret-detector.test.js b/tests/server/secret-detector.test.js index c9141f8c..e8063a93 100644 --- a/tests/server/secret-detector.test.js +++ b/tests/server/secret-detector.test.js @@ -108,9 +108,98 @@ describe("secret-detector", () => { expect(anthropic).toBeDefined(); }); + it("uses documented explicit env names for known providers", () => { + const cfg = { + models: { + providers: { + zai: { apiKey: "zai-secret-value-12345" }, + xai: { apiKey: "xai-secret-value-12345" }, + minimax: { apiKey: "minimax-secret-value-12345" }, + moonshot: { apiKey: "moonshot-secret-value-12345" }, + "kimi-coding": { apiKey: "kimi-secret-value-12345" }, + "vercel-ai-gateway": { apiKey: "gateway-secret-value-12345" }, + volcengine: { apiKey: "volcengine-secret-value-12345" }, + }, + }, + }; + const fs = createMockFs({ + "/base/openclaw.json": JSON.stringify(cfg), + }); + const secrets = detectSecrets({ + fs, + baseDir: "/base", + configFiles: ["openclaw.json"], + envFiles: [], + }); + expect( + secrets.find((s) => s.configPath === "models.providers.zai.apiKey") + ?.suggestedEnvVar, + ).toBe("ZAI_API_KEY"); + expect( + secrets.find((s) => s.configPath === "models.providers.xai.apiKey") + ?.suggestedEnvVar, + ).toBe("XAI_API_KEY"); + expect( + secrets.find((s) => s.configPath === "models.providers.minimax.apiKey") + ?.suggestedEnvVar, + ).toBe("MINIMAX_API_KEY"); + expect( + secrets.find((s) => s.configPath === "models.providers.moonshot.apiKey") + ?.suggestedEnvVar, + ).toBe("MOONSHOT_API_KEY"); + expect( + secrets.find( + (s) => s.configPath === "models.providers.kimi-coding.apiKey", + )?.suggestedEnvVar, + ).toBe("KIMI_API_KEY"); + expect( + secrets.find( + (s) => s.configPath === "models.providers.vercel-ai-gateway.apiKey", + )?.suggestedEnvVar, + ).toBe("AI_GATEWAY_API_KEY"); + expect( + secrets.find( + (s) => s.configPath === "models.providers.volcengine.apiKey", + )?.suggestedEnvVar, + ).toBe("VOLCANO_ENGINE_API_KEY"); + }); + + it("falls back to provider-scoped env names for unmapped model providers", () => { + const cfg = { + models: { + providers: { + "kimi-code": { apiKey: "kimi-secret-value-12345" }, + customproxy: { apiKey: "custom-secret-value-12345" }, + }, + }, + }; + const fs = createMockFs({ + "/base/openclaw.json": JSON.stringify(cfg), + }); + const secrets = detectSecrets({ + fs, + baseDir: "/base", + configFiles: ["openclaw.json"], + envFiles: [], + }); + const kimi = secrets.find( + (s) => s.configPath === "models.providers.kimi-code.apiKey", + ); + const customproxy = secrets.find( + (s) => s.configPath === "models.providers.customproxy.apiKey", + ); + expect(kimi?.suggestedEnvVar).toBe("KIMI_CODE_API_KEY"); + expect(customproxy?.suggestedEnvVar).toBe("CUSTOMPROXY_API_KEY"); + }); + it("detects secrets by value prefix", () => { const cfg = { custom: { myField: "ghp_abcdef1234567890123456" }, + models: { + providers: { + xai: { apiKey: "xai-abcdef1234567890" }, + }, + }, }; const fs = createMockFs({ "/base/openclaw.json": JSON.stringify(cfg), @@ -122,8 +211,12 @@ describe("secret-detector", () => { envFiles: [], }); const ghp = secrets.find((s) => s.source === "value-prefix"); + const xai = secrets.find( + (s) => s.configPath === "models.providers.xai.apiKey", + ); expect(ghp).toBeDefined(); expect(ghp.confidence).toBe("high"); + expect(xai?.suggestedEnvVar).toBe("XAI_API_KEY"); }); it("skips values that are already env var references", () => { @@ -200,6 +293,23 @@ describe("secret-detector", () => { const gw = secrets.find((s) => s.configPath === "gateway.auth.token"); expect(gw).toBeUndefined(); }); + + it("drops hooks.token because import normalizes it to WEBHOOK_TOKEN", () => { + const cfg = { + hooks: { token: "some-webhook-token-value" }, + }; + const fs = createMockFs({ + "/base/openclaw.json": JSON.stringify(cfg), + }); + const secrets = detectSecrets({ + fs, + baseDir: "/base", + configFiles: ["openclaw.json"], + envFiles: [], + }); + const webhook = secrets.find((s) => s.configPath === "hooks.token"); + expect(webhook).toBeUndefined(); + }); }); describe("extractPreFillValues", () => { From 5f37bbb59f3810ccfe649369f92a84380bf0c170 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 21:16:47 -0800 Subject: [PATCH 132/417] fix usage overview prompt token totals Include cache write tokens in the overview prompt token calculation so the summary matches the full token usage shown elsewhere. --- lib/public/js/components/usage-tab/overview-section.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/public/js/components/usage-tab/overview-section.js b/lib/public/js/components/usage-tab/overview-section.js index 450b3920..e05cb607 100644 --- a/lib/public/js/components/usage-tab/overview-section.js +++ b/lib/public/js/components/usage-tab/overview-section.js @@ -31,8 +31,9 @@ const getCacheHitRateValueClass = (ratio) => { const getOverviewMetrics = (summary) => { const totals = summary?.totals || {}; const cacheReadTokens = Number(totals.cacheReadTokens || 0); + const cacheWriteTokens = Number(totals.cacheWriteTokens || 0); const inputTokens = Number(totals.inputTokens || 0); - const promptTokens = inputTokens + cacheReadTokens; + const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens; const turnCount = Number(totals.turnCount || 0); const totalTokens = Number(totals.totalTokens || 0); const totalCost = Number(totals.totalCost || 0); From 39be6371c5d60907b5a4449807d806e82847fac2 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 22:39:47 -0800 Subject: [PATCH 133/417] fix import onboarding and gateway config handling Keep imported setups valid through onboarding by normalizing channel state, preserving approved secret values in the UI, and honoring dynamic gateway configuration during proxying and webhooks. --- .../onboarding/welcome-import-step.js | 3 +- .../onboarding/welcome-secret-review-step.js | 16 +- .../onboarding/welcome-secret-review-utils.js | 19 ++ .../js/components/welcome/use-welcome.js | 3 + lib/server.js | 10 +- lib/server/constants.js | 8 +- lib/server/gateway.js | 20 +- .../onboarding/import/import-applier.js | 58 ++++ .../onboarding/import/secret-detector.js | 8 +- lib/server/onboarding/index.js | 76 +++++ lib/server/onboarding/openclaw.js | 95 +++++- lib/server/routes/onboarding.js | 6 +- lib/server/routes/proxy.js | 11 +- lib/server/webhook-middleware.js | 7 +- .../welcome-secret-review-utils.test.js | 53 ++++ tests/server/import-applier.test.js | 50 ++++ tests/server/onboarding-openclaw.test.js | 35 +++ tests/server/routes-onboarding.test.js | 277 ++++++++++++++++++ tests/server/secret-detector.test.js | 14 + 19 files changed, 734 insertions(+), 35 deletions(-) create mode 100644 lib/public/js/components/onboarding/welcome-secret-review-utils.js create mode 100644 tests/frontend/welcome-secret-review-utils.test.js diff --git a/lib/public/js/components/onboarding/welcome-import-step.js b/lib/public/js/components/onboarding/welcome-import-step.js index 5b171db6..38c9aa86 100644 --- a/lib/public/js/components/onboarding/welcome-import-step.js +++ b/lib/public/js/components/onboarding/welcome-import-step.js @@ -3,6 +3,7 @@ import { useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { ActionButton } from "../action-button.js"; import { LoadingSpinner } from "../loading-spinner.js"; +import { buildApprovedImportSecrets } from "./welcome-secret-review-utils.js"; const html = htm.bind(h); @@ -292,7 +293,7 @@ export const WelcomeImportStep = ({ className="w-full" /> <${ActionButton} - onClick=${() => onApprove([])} + onClick=${() => onApprove(buildApprovedImportSecrets(scanResult.secrets))} loading=${scanning} tone="primary" size="md" diff --git a/lib/public/js/components/onboarding/welcome-secret-review-step.js b/lib/public/js/components/onboarding/welcome-secret-review-step.js index cd472024..abf1eb8a 100644 --- a/lib/public/js/components/onboarding/welcome-secret-review-step.js +++ b/lib/public/js/components/onboarding/welcome-secret-review-step.js @@ -3,6 +3,7 @@ import { useState, useCallback } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; import { ActionButton } from "../action-button.js"; import { LoadingSpinner } from "../loading-spinner.js"; +import { buildApprovedImportSecrets } from "./welcome-secret-review-utils.js"; const html = htm.bind(h); @@ -116,13 +117,16 @@ export const WelcomeSecretReviewStep = ({ ).length; const handleExtract = () => { - const approved = secrets - .filter((s) => selections[s.configPath]?.selected) - .map((s) => ({ - ...s, + const approved = buildApprovedImportSecrets( + secrets.map((secret) => ({ + ...secret, + confidence: selections[secret.configPath]?.selected + ? "high" + : "medium", suggestedEnvVar: - selections[s.configPath]?.envVarName || s.suggestedEnvVar, - })); + selections[secret.configPath]?.envVarName || secret.suggestedEnvVar, + })), + ); onApprove(approved); }; diff --git a/lib/public/js/components/onboarding/welcome-secret-review-utils.js b/lib/public/js/components/onboarding/welcome-secret-review-utils.js new file mode 100644 index 00000000..531370b2 --- /dev/null +++ b/lib/public/js/components/onboarding/welcome-secret-review-utils.js @@ -0,0 +1,19 @@ +export const buildApprovedImportSecrets = (secrets = []) => + (Array.isArray(secrets) ? secrets : []) + .filter((secret) => secret?.confidence === "high") + .map((secret) => ({ + ...secret, + suggestedEnvVar: secret?.suggestedEnvVar || "", + })); + +export const buildApprovedImportVals = (approvedSecrets = []) => + (Array.isArray(approvedSecrets) ? approvedSecrets : []).reduce( + (nextVals, secret) => { + const envVar = String(secret?.suggestedEnvVar || "").trim(); + const value = String(secret?.value || ""); + if (!envVar || !value) return nextVals; + nextVals[envVar] = value; + return nextVals; + }, + {}, + ); diff --git a/lib/public/js/components/welcome/use-welcome.js b/lib/public/js/components/welcome/use-welcome.js index e281c958..2c73ac8d 100644 --- a/lib/public/js/components/welcome/use-welcome.js +++ b/lib/public/js/components/welcome/use-welcome.js @@ -29,6 +29,7 @@ import { } from "../onboarding/use-welcome-storage.js"; import { useWelcomeCodex } from "../onboarding/use-welcome-codex.js"; import { useWelcomePairing } from "../onboarding/use-welcome-pairing.js"; +import { buildApprovedImportVals } from "../onboarding/welcome-secret-review-utils.js"; const kMaxOnboardingVars = 64; const kMaxEnvKeyLength = 128; @@ -413,6 +414,7 @@ export const useWelcome = ({ onComplete }) => { setImportError(null); try { const skipSecretExtraction = approvedSecrets.length === 0; + const approvedImportVals = buildApprovedImportVals(approvedSecrets); const result = await applyImport({ tempDir: importTempDir, approvedSecrets, @@ -430,6 +432,7 @@ export const useWelcome = ({ onComplete }) => { ); setVals((prev) => ({ ...prev, + ...approvedImportVals, ...(result.preFill || {}), [kImportPlaceholderReviewKey]: nextPlaceholderReview, [kImportPlaceholderSkipConfirmedKey]: false, diff --git a/lib/server.js b/lib/server.js index e2d70976..26001f09 100644 --- a/lib/server.js +++ b/lib/server.js @@ -67,6 +67,7 @@ const { } = require("./server/env"); const { gatewayEnv, + getGatewayUrl, isOnboarded, isGatewayRunning, startGateway, @@ -122,7 +123,7 @@ const { registerUsageRoutes } = require("./server/routes/usage"); const { registerGmailRoutes } = require("./server/routes/gmail"); const { registerDoctorRoutes } = require("./server/routes/doctor"); -const { PORT, GATEWAY_URL, kTrustProxyHops, SETUP_API_PREFIXES } = constants; +const { PORT, kTrustProxyHops, SETUP_API_PREFIXES } = constants; startEnvWatcher(); attachGatewaySignalHandlers(); @@ -139,7 +140,7 @@ app.use("/gmail-pubsub", express.raw({ type: "*/*", limit: "5mb" })); app.use(express.json({ limit: "5mb" })); const proxy = httpProxy.createProxyServer({ - target: GATEWAY_URL, + target: getGatewayUrl(), ws: true, changeOrigin: true, }); @@ -196,7 +197,7 @@ initDoctorDb({ rootDir: constants.kRootDir, }); const webhookMiddleware = createWebhookMiddleware({ - gatewayUrl: constants.GATEWAY_URL, + getGatewayUrl, insertRequest, maxPayloadBytes: constants.kMaxPayloadBytes, }); @@ -382,6 +383,7 @@ registerDoctorRoutes({ registerProxyRoutes({ app, proxy, + getGatewayUrl, SETUP_API_PREFIXES, requireAuth, webhookMiddleware, @@ -407,7 +409,7 @@ server.on("upgrade", (req, socket, head) => { return; } } - proxy.ws(req, socket, head); + proxy.ws(req, socket, head, { target: getGatewayUrl() }); }); server.listen(PORT, "0.0.0.0", () => { diff --git a/lib/server/constants.js b/lib/server/constants.js index 36bf7243..73a7266c 100644 --- a/lib/server/constants.js +++ b/lib/server/constants.js @@ -12,9 +12,9 @@ const kNpmPackageRoot = path.resolve(kPackageRoot, ".."); const kSetupDir = path.join(kPackageRoot, "setup"); const PORT = parseInt(process.env.PORT || "3000", 10); -const GATEWAY_PORT = 18789; +const kDefaultGatewayPort = 18789; const GATEWAY_HOST = "127.0.0.1"; -const GATEWAY_URL = `http://${GATEWAY_HOST}:${GATEWAY_PORT}`; +const kDefaultGatewayUrl = `http://${GATEWAY_HOST}:${kDefaultGatewayPort}`; const OPENCLAW_DIR = path.join(kRootDir, ".openclaw"); const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || ""; const ENV_FILE_PATH = path.join(kRootDir, ".env"); @@ -373,9 +373,9 @@ module.exports = { kNpmPackageRoot, kSetupDir, PORT, - GATEWAY_PORT, + kDefaultGatewayPort, GATEWAY_HOST, - GATEWAY_URL, + kDefaultGatewayUrl, OPENCLAW_DIR, GATEWAY_TOKEN, ENV_FILE_PATH, diff --git a/lib/server/gateway.js b/lib/server/gateway.js index 31e68569..79dc16e1 100644 --- a/lib/server/gateway.js +++ b/lib/server/gateway.js @@ -6,7 +6,7 @@ const { ALPHACLAW_DIR, OPENCLAW_DIR, GATEWAY_HOST, - GATEWAY_PORT, + kDefaultGatewayPort, kControlUiSkillPath, kChannelDefs, kOnboardingMarkerPath, @@ -74,9 +74,23 @@ const isOnboarded = () => { return false; }; +const getGatewayPort = () => { + try { + const configPath = `${OPENCLAW_DIR}/openclaw.json`; + if (!fs.existsSync(configPath)) return kDefaultGatewayPort; + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + const parsedPort = Number.parseInt(String(cfg?.gateway?.port || ""), 10); + return parsedPort > 0 ? parsedPort : kDefaultGatewayPort; + } catch { + return kDefaultGatewayPort; + } +}; + +const getGatewayUrl = () => `http://${GATEWAY_HOST}:${getGatewayPort()}`; + const isGatewayRunning = () => new Promise((resolve) => { - const sock = net.createConnection(GATEWAY_PORT, GATEWAY_HOST); + const sock = net.createConnection(getGatewayPort(), GATEWAY_HOST); sock.setTimeout(1000); sock.on("connect", () => { sock.destroy(); @@ -341,6 +355,8 @@ const getChannelStatus = () => { module.exports = { gatewayEnv, + getGatewayPort, + getGatewayUrl, isOnboarded, isGatewayRunning, launchGatewayProcess, diff --git a/lib/server/onboarding/import/import-applier.js b/lib/server/onboarding/import/import-applier.js index da6a537c..14e0c21c 100644 --- a/lib/server/onboarding/import/import-applier.js +++ b/lib/server/onboarding/import/import-applier.js @@ -152,6 +152,41 @@ const resolveExtractionTargetPath = (baseDir, file) => { } return resolvedFilePath; }; +const isConfigPathIndex = (segment) => /^\d+$/.test(String(segment || "")); +const setConfigValueAtPath = ({ + root, + dotPath, + expectedValue, + nextValue, +}) => { + const pathSegments = String(dotPath || "") + .split(".") + .filter(Boolean); + if (!root || typeof root !== "object" || pathSegments.length === 0) { + return false; + } + + let current = root; + for (let index = 0; index < pathSegments.length - 1; index += 1) { + const segment = pathSegments[index]; + const nextNode = isConfigPathIndex(segment) + ? current?.[Number(segment)] + : current?.[segment]; + if (!nextNode || typeof nextNode !== "object") { + return false; + } + current = nextNode; + } + + const lastSegment = pathSegments[pathSegments.length - 1]; + const targetKey = isConfigPathIndex(lastSegment) + ? Number(lastSegment) + : lastSegment; + if (typeof current?.[targetKey] !== "string") return false; + if (current[targetKey] !== expectedValue) return false; + current[targetKey] = nextValue; + return true; +}; const movePath = (fs, src, dest) => { fs.mkdirSync(path.dirname(dest), { recursive: true }); try { @@ -285,6 +320,7 @@ const applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => { rewriteMap.set(fullPath, []); } rewriteMap.get(fullPath).push({ + configPath: secret.configPath, value, envRef: `\${${envVar}}`, relativeFile: secret.file, @@ -295,9 +331,31 @@ const applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => { for (const [fullPath, replacements] of rewriteMap) { try { let content = fs.readFileSync(fullPath, "utf8"); + let parsed = null; + try { + parsed = JSON.parse(content); + } catch {} const sorted = [...replacements].sort( (a, b) => b.value.length - a.value.length, ); + let structuredChanged = false; + if (parsed && typeof parsed === "object") { + for (const { configPath, value, envRef } of sorted) { + if ( + setConfigValueAtPath({ + root: parsed, + dotPath: configPath, + expectedValue: value, + nextValue: envRef, + }) + ) { + structuredChanged = true; + } + } + } + if (structuredChanged) { + content = JSON.stringify(parsed, null, 2); + } for (const { value, envRef } of sorted) { const secretJson = JSON.stringify(value); const envRefJson = JSON.stringify(envRef); diff --git a/lib/server/onboarding/import/secret-detector.js b/lib/server/onboarding/import/secret-detector.js index 333fdd85..e092c58a 100644 --- a/lib/server/onboarding/import/secret-detector.js +++ b/lib/server/onboarding/import/secret-detector.js @@ -284,6 +284,7 @@ const extractPreFillValues = ({ fs, baseDir, configFiles = [] }) => { try { const raw = fs.readFileSync(path.join(baseDir, cfgFile), "utf8"); const cfg = JSON.parse(raw); + const configFileName = path.basename(String(cfgFile || "")).toLowerCase(); if (cfg.models?.active) preFill.MODEL_KEY = cfg.models.active; @@ -298,7 +299,12 @@ const extractPreFillValues = ({ fs, baseDir, configFiles = [] }) => { preFill.GEMINI_API_KEY = providers.google.apiKey; } - const channels = cfg.channels || {}; + const channels = + cfg.channels && typeof cfg.channels === "object" + ? cfg.channels + : configFileName.includes("channel") + ? cfg + : {}; if (channels.telegram?.botToken && !isAlreadyEnvRef(channels.telegram.botToken)) { preFill.TELEGRAM_BOT_TOKEN = channels.telegram.botToken; } diff --git a/lib/server/onboarding/index.js b/lib/server/onboarding/index.js index 8104bc01..51d9b63e 100644 --- a/lib/server/onboarding/index.js +++ b/lib/server/onboarding/index.js @@ -12,6 +12,7 @@ const { } = require("./github"); const { buildOnboardArgs, + writeManagedImportOpenclawConfig, writeSanitizedOpenclawConfig, } = require("./openclaw"); const { @@ -28,6 +29,7 @@ const { installGogCliSkill } = require("../gog-skill"); const kPlaceholderEnvValue = "placeholder"; const kEnvRefPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g; +const kImportedPairingKeys = ["allowFrom", "groupAllowFrom"]; const upsertEnvVar = (items, key, value) => { const normalizedKey = String(key || "").trim(); @@ -42,6 +44,65 @@ const upsertEnvVar = (items, key, value) => { return items; }; +const clearImportedChannelPairingState = (channelsRoot) => { + if (!channelsRoot || typeof channelsRoot !== "object") return false; + let changed = false; + for (const [channelKey, channelConfig] of Object.entries(channelsRoot)) { + if (!channelConfig || typeof channelConfig !== "object") continue; + if ( + channelKey === "telegram" && + Object.prototype.hasOwnProperty.call(channelConfig, "accounts") + ) { + delete channelConfig.accounts; + changed = true; + } + for (const pairingKey of kImportedPairingKeys) { + if ( + Object.prototype.hasOwnProperty.call(channelConfig, pairingKey) && + (!Array.isArray(channelConfig[pairingKey]) || + channelConfig[pairingKey].length > 0) + ) { + channelConfig[pairingKey] = []; + changed = true; + } + } + if ( + channelConfig.dmPolicy === "allowlist" && + (!Array.isArray(channelConfig.allowFrom) || + channelConfig.allowFrom.length === 0) + ) { + channelConfig.dmPolicy = "pairing"; + changed = true; + } + } + return changed; +}; + +const clearImportedCredentialPairings = ({ fs, openclawDir }) => { + const credentialsDir = path.join(openclawDir, "credentials"); + if (!fs.existsSync(credentialsDir)) return; + let entries = []; + try { + entries = fs.readdirSync(credentialsDir); + } catch { + return; + } + for (const entry of entries) { + const fileName = typeof entry === "string" ? entry : entry?.name; + if (!fileName || !fileName.endsWith("-allowFrom.json")) continue; + const filePath = path.join(credentialsDir, fileName); + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (!parsed || typeof parsed !== "object") continue; + if (Array.isArray(parsed.allowFrom) && parsed.allowFrom.length === 0) { + continue; + } + parsed.allowFrom = []; + fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2)); + } catch {} + } +}; + const collectEnvRefs = (value, found = new Set()) => { if (typeof value === "string") { for (const match of value.matchAll(kEnvRefPattern)) { @@ -173,10 +234,19 @@ const normalizeImportedConfig = ({ fs, openclawDir }) => { cfg.hooks = nextHooks; changed = true; } + const configFileName = path.basename(configPath).toLowerCase(); + const channelsRoot = + cfg.channels && typeof cfg.channels === "object" + ? cfg.channels + : configFileName.includes("channel") + ? cfg + : null; + changed = clearImportedChannelPairingState(channelsRoot) || changed; if (changed) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); } } + clearImportedCredentialPairings({ fs, openclawDir }); }; const getImportedConfigEnvRefs = ({ fs, openclawDir }) => { @@ -418,6 +488,12 @@ const createOnboardingService = ({ if (!existingConfigPresent) { writeSanitizedOpenclawConfig({ fs, openclawDir: OPENCLAW_DIR, varMap }); + } else if (importMode) { + writeManagedImportOpenclawConfig({ + fs, + openclawDir: OPENCLAW_DIR, + varMap, + }); } authProfiles?.syncConfigAuthReferencesForAgent?.(); ensureGatewayProxyConfig(getBaseUrl(req)); diff --git a/lib/server/onboarding/openclaw.js b/lib/server/onboarding/openclaw.js index 34317f3b..8f90d178 100644 --- a/lib/server/onboarding/openclaw.js +++ b/lib/server/onboarding/openclaw.js @@ -9,6 +9,10 @@ const kUsageTrackerPluginPath = path.resolve( "usage-tracker", ); const kDefaultToolsProfile = "full"; +const kBootstrapExtraFiles = [ + "hooks/bootstrap/AGENTS.md", + "hooks/bootstrap/TOOLS.md", +]; const buildOnboardArgs = ({ varMap, @@ -126,9 +130,13 @@ const buildOnboardArgs = ({ return onboardArgs; }; -const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { - const configPath = `${openclawDir}/openclaw.json`; - const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); +const ensurePluginAllowed = (cfg, pluginKey) => { + if (!cfg.plugins.allow.includes(pluginKey)) { + cfg.plugins.allow.push(pluginKey); + } +}; + +const ensureManagedConfigShell = (cfg) => { if (!cfg.channels) cfg.channels = {}; if (!cfg.plugins) cfg.plugins = {}; if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = []; @@ -146,9 +154,22 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { cfg.hooks.internal.entries["bootstrap-extra-files"] = { ...(cfg.hooks.internal.entries["bootstrap-extra-files"] || {}), enabled: true, - paths: ["hooks/bootstrap/AGENTS.md", "hooks/bootstrap/TOOLS.md"], + paths: kBootstrapExtraFiles, }; +}; + +const getSafeImportedDmPolicy = (channelConfig = {}) => { + if ( + channelConfig?.dmPolicy === "allowlist" && + (!Array.isArray(channelConfig?.allowFrom) || + channelConfig.allowFrom.length === 0) + ) { + return "pairing"; + } + return channelConfig?.dmPolicy || "pairing"; +}; +const applyFreshOnboardingChannels = ({ cfg, varMap }) => { if (varMap.TELEGRAM_BOT_TOKEN) { cfg.channels.telegram = { enabled: true, @@ -157,6 +178,7 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { groupPolicy: "allowlist", }; cfg.plugins.entries.telegram = { enabled: true }; + ensurePluginAllowed(cfg, "telegram"); console.log("[onboard] Telegram configured"); } if (varMap.DISCORD_BOT_TOKEN) { @@ -167,15 +189,21 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { groupPolicy: "allowlist", }; cfg.plugins.entries.discord = { enabled: true }; + ensurePluginAllowed(cfg, "discord"); console.log("[onboard] Discord configured"); } if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { cfg.plugins.load.paths.push(kUsageTrackerPluginPath); } - if (!cfg.plugins.allow.includes("usage-tracker")) { - cfg.plugins.allow.push("usage-tracker"); - } + ensurePluginAllowed(cfg, "usage-tracker"); cfg.plugins.entries["usage-tracker"] = { enabled: true }; +}; + +const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { + const configPath = `${openclawDir}/openclaw.json`; + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + ensureManagedConfigShell(cfg); + applyFreshOnboardingChannels({ cfg, varMap }); let content = JSON.stringify(cfg, null, 2); const replacements = buildSecretReplacements(varMap, process.env); @@ -196,4 +224,55 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => { console.log("[onboard] Config sanitized"); }; -module.exports = { buildOnboardArgs, writeSanitizedOpenclawConfig }; +const writeManagedImportOpenclawConfig = ({ fs, openclawDir, varMap }) => { + const configPath = `${openclawDir}/openclaw.json`; + const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + ensureManagedConfigShell(cfg); + + if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { + cfg.plugins.load.paths.push(kUsageTrackerPluginPath); + } + ensurePluginAllowed(cfg, "usage-tracker"); + cfg.plugins.entries["usage-tracker"] = { + ...(cfg.plugins.entries["usage-tracker"] || {}), + enabled: true, + }; + + if (varMap.TELEGRAM_BOT_TOKEN) { + cfg.channels.telegram = { + ...(cfg.channels.telegram || {}), + enabled: true, + botToken: "${TELEGRAM_BOT_TOKEN}", + dmPolicy: getSafeImportedDmPolicy(cfg.channels.telegram), + groupPolicy: cfg.channels.telegram?.groupPolicy || "allowlist", + }; + cfg.plugins.entries.telegram = { + ...(cfg.plugins.entries.telegram || {}), + enabled: true, + }; + ensurePluginAllowed(cfg, "telegram"); + } + + if (varMap.DISCORD_BOT_TOKEN) { + cfg.channels.discord = { + ...(cfg.channels.discord || {}), + enabled: true, + token: "${DISCORD_BOT_TOKEN}", + dmPolicy: getSafeImportedDmPolicy(cfg.channels.discord), + groupPolicy: cfg.channels.discord?.groupPolicy || "allowlist", + }; + cfg.plugins.entries.discord = { + ...(cfg.plugins.entries.discord || {}), + enabled: true, + }; + ensurePluginAllowed(cfg, "discord"); + } + + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); +}; + +module.exports = { + buildOnboardArgs, + writeManagedImportOpenclawConfig, + writeSanitizedOpenclawConfig, +}; diff --git a/lib/server/routes/onboarding.js b/lib/server/routes/onboarding.js index 23711a2c..e414fb5a 100644 --- a/lib/server/routes/onboarding.js +++ b/lib/server/routes/onboarding.js @@ -331,9 +331,9 @@ const registerOnboardingRoutes = ({ }); envVars = canonicalization.envVars; - const configFiles = ["openclaw.json"].filter((f) => - fs.existsSync(path.join(tempDir, f)), - ); + const configFiles = Array.isArray(scan.gatewayConfig?.files) + ? scan.gatewayConfig.files + : ["openclaw.json"].filter((f) => fs.existsSync(path.join(tempDir, f))); const transformAlignment = alignHookTransforms({ fs, baseDir: tempDir, diff --git a/lib/server/routes/proxy.js b/lib/server/routes/proxy.js index f0061b9a..c946e524 100644 --- a/lib/server/routes/proxy.js +++ b/lib/server/routes/proxy.js @@ -1,6 +1,7 @@ const registerProxyRoutes = ({ app, proxy, + getGatewayUrl, SETUP_API_PREFIXES, requireAuth, webhookMiddleware, @@ -13,20 +14,22 @@ const registerProxyRoutes = ({ app.all("/openclaw", requireAuth, (req, res) => { req.url = "/"; - proxy.web(req, res); + proxy.web(req, res, { target: getGatewayUrl() }); }); app.all(kOpenClawPathPattern, requireAuth, (req, res) => { req.url = req.url.replace(/^\/openclaw/, ""); - proxy.web(req, res); + proxy.web(req, res, { target: getGatewayUrl() }); }); - app.all(kAssetsPathPattern, requireAuth, (req, res) => proxy.web(req, res)); + app.all(kAssetsPathPattern, requireAuth, (req, res) => + proxy.web(req, res, { target: getGatewayUrl() }), + ); app.all(kHooksPathPattern, webhookMiddleware); app.all(kWebhookPathPattern, webhookMiddleware); app.all(kApiPathPattern, (req, res) => { if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return; - proxy.web(req, res); + proxy.web(req, res, { target: getGatewayUrl() }); }); }; diff --git a/lib/server/webhook-middleware.js b/lib/server/webhook-middleware.js index babda5e3..733637d3 100644 --- a/lib/server/webhook-middleware.js +++ b/lib/server/webhook-middleware.js @@ -122,11 +122,10 @@ const buildGmailDedupedBodyBuffer = ({ parsedBody, filteredMessages }) => { const createWebhookMiddleware = ({ gatewayUrl, + getGatewayUrl, insertRequest, maxPayloadBytes = 50 * 1024, }) => { - const gateway = new URL(gatewayUrl); - const protocolClient = gateway.protocol === "https:" ? https : http; const gmailSeenMessageIds = new Map(); let lastGmailDedupeCleanupAt = 0; @@ -141,6 +140,10 @@ const createWebhookMiddleware = ({ }; return (req, res) => { + const resolvedGatewayUrl = + typeof getGatewayUrl === "function" ? getGatewayUrl() : gatewayUrl; + const gateway = new URL(resolvedGatewayUrl); + const protocolClient = gateway.protocol === "https:" ? https : http; const inboundUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); let tokenFromQuery = ""; if (!req.headers.authorization && inboundUrl.searchParams.has("token")) { diff --git a/tests/frontend/welcome-secret-review-utils.test.js b/tests/frontend/welcome-secret-review-utils.test.js new file mode 100644 index 00000000..6c255963 --- /dev/null +++ b/tests/frontend/welcome-secret-review-utils.test.js @@ -0,0 +1,53 @@ +const loadWelcomeSecretReviewUtils = async () => + import( + "../../lib/public/js/components/onboarding/welcome-secret-review-utils.js" + ); + +describe("frontend/welcome secret review utils", () => { + it("builds default approved secrets from high-confidence findings", async () => { + const utils = await loadWelcomeSecretReviewUtils(); + const secrets = [ + { + configPath: "channels.discord.token", + confidence: "high", + suggestedEnvVar: "DISCORD_BOT_TOKEN", + }, + { + configPath: "models.providers.custom.apiKey", + confidence: "medium", + suggestedEnvVar: "CUSTOM_API_KEY", + }, + ]; + + expect(utils.buildApprovedImportSecrets(secrets)).toEqual([ + { + configPath: "channels.discord.token", + confidence: "high", + suggestedEnvVar: "DISCORD_BOT_TOKEN", + }, + ]); + }); + + it("builds onboarding vals from approved extracted secrets", async () => { + const utils = await loadWelcomeSecretReviewUtils(); + const approvedSecrets = [ + { + suggestedEnvVar: "DISCORD_BOT_TOKEN", + value: "discord-secret", + }, + { + suggestedEnvVar: "BRAVE_API_KEY", + value: "brave-secret", + }, + { + suggestedEnvVar: "", + value: "ignored", + }, + ]; + + expect(utils.buildApprovedImportVals(approvedSecrets)).toEqual({ + DISCORD_BOT_TOKEN: "discord-secret", + BRAVE_API_KEY: "brave-secret", + }); + }); +}); diff --git a/tests/server/import-applier.test.js b/tests/server/import-applier.test.js index 69aace90..482fdc35 100644 --- a/tests/server/import-applier.test.js +++ b/tests/server/import-applier.test.js @@ -4,6 +4,7 @@ const path = require("path"); const { alignHookTransforms, + applySecretExtraction, } = require("../../lib/server/onboarding/import/import-applier"); const kTempDirs = []; @@ -121,4 +122,53 @@ describe("import-applier", () => { "fathom/fathom-transform.mjs", ); }); + + it("rewrites approved config secrets by config path before fallback replacement", () => { + const baseDir = createTempDir(); + const configPath = path.join(baseDir, "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify( + { + channels: { + discord: { + token: "discord-live-secret", + }, + }, + notes: { + repeatedToken: "discord-live-secret", + }, + }, + null, + 2, + ), + "utf8", + ); + + const result = applySecretExtraction({ + fs, + baseDir, + approvedSecrets: [ + { + file: "openclaw.json", + configPath: "channels.discord.token", + value: "discord-live-secret", + suggestedEnvVar: "DISCORD_BOT_TOKEN", + }, + ], + }); + + expect(result).toEqual({ + envVars: [ + { + key: "DISCORD_BOT_TOKEN", + value: "discord-live-secret", + }, + ], + }); + + const updatedConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(updatedConfig.channels.discord.token).toBe("${DISCORD_BOT_TOKEN}"); + expect(updatedConfig.notes.repeatedToken).toBe("${DISCORD_BOT_TOKEN}"); + }); }); diff --git a/tests/server/onboarding-openclaw.test.js b/tests/server/onboarding-openclaw.test.js index d4876633..2bfb4185 100644 --- a/tests/server/onboarding-openclaw.test.js +++ b/tests/server/onboarding-openclaw.test.js @@ -3,6 +3,7 @@ const os = require("os"); const path = require("path"); const { + writeManagedImportOpenclawConfig, writeSanitizedOpenclawConfig, } = require("../../lib/server/onboarding/openclaw"); @@ -73,4 +74,38 @@ describe("server/onboarding/openclaw", () => { expect(next.plugins.allow).toEqual(["usage-tracker"]); expect(next.plugins.entries["usage-tracker"]).toEqual({ enabled: true }); }); + + it("resets imported allowlist dmPolicy to pairing when re-enabling discord", () => { + const openclawDir = createTempOpenclawDir(); + const configPath = path.join(openclawDir, "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify( + { + plugins: { allow: [], load: { paths: [] }, entries: {} }, + channels: { + discord: { + enabled: false, + dmPolicy: "allowlist", + allowFrom: [], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + writeManagedImportOpenclawConfig({ + fs, + openclawDir, + varMap: { DISCORD_BOT_TOKEN: "discord-live-secret" }, + }); + + const next = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(next.channels.discord.enabled).toBe(true); + expect(next.channels.discord.dmPolicy).toBe("pairing"); + expect(next.channels.discord.token).toBe("${DISCORD_BOT_TOKEN}"); + }); }); diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index 96077012..1fae0566 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -577,6 +577,15 @@ describe("server/routes/onboarding", () => { expect(files.get("/tmp/openclaw/openclaw.json")).toContain( '"token": "${OPENCLAW_GATEWAY_TOKEN}"', ); + expect(files.get("/tmp/openclaw/openclaw.json")).toContain( + '"botToken": "${TELEGRAM_BOT_TOKEN}"', + ); + expect(files.get("/tmp/openclaw/openclaw.json")).toContain( + '"usage-tracker"', + ); + expect(files.get("/tmp/openclaw/openclaw.json")).toContain( + '"bootstrap-extra-files"', + ); expect(files.get("/tmp/openclaw/openclaw.json")).not.toContain( '"transformsDir"', ); @@ -920,6 +929,274 @@ describe("server/routes/onboarding", () => { ); }); + it("keeps imported channels enabled and clears pairing allowlists during import apply", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-reset-pairings"); + const fileEntry = (name) => ({ + name, + isFile: () => true, + isDirectory: () => false, + }); + const dirEntry = (name) => ({ + name, + isFile: () => false, + isDirectory: () => true, + }); + const files = new Map([ + [ + path.join(tempDir, "openclaw.json"), + JSON.stringify({ + channels: { + $include: "channels.json", + }, + }), + ], + [ + path.join(tempDir, "channels.json"), + JSON.stringify({ + telegram: { + enabled: true, + botToken: "${TELEGRAM_BOT_TOKEN}", + dmPolicy: "allowlist", + accounts: { + midas: { + enabled: true, + allowFrom: ["legacy-user"], + }, + }, + groupAllowFrom: ["telegram-user"], + groups: { + "-100123": { + enabled: true, + }, + }, + }, + discord: { + enabled: true, + dmPolicy: "allowlist", + token: "${DISCORD_BOT_TOKEN}", + allowFrom: ["discord-user"], + }, + }), + ], + [ + path.join(tempDir, "credentials", "telegram-main-allowFrom.json"), + JSON.stringify({ + allowFrom: ["telegram-user"], + source: "imported", + }), + ], + [ + path.join(tempDir, "credentials", "discord-main-allowFrom.json"), + JSON.stringify({ + allowFrom: ["discord-user"], + source: "imported", + }), + ], + ]); + const directories = new Set([tempDir, path.join(tempDir, "credentials")]); + deps.fs.existsSync.mockImplementation( + (targetPath) => directories.has(targetPath) || files.has(targetPath), + ); + deps.fs.statSync.mockImplementation((targetPath) => { + if (directories.has(targetPath)) { + return { isFile: () => false, isDirectory: () => true }; + } + if (files.has(targetPath)) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [ + fileEntry("openclaw.json"), + fileEntry("channels.json"), + dirEntry("credentials"), + ]; + } + if (targetPath === path.join(tempDir, "credentials")) { + return [ + fileEntry("telegram-main-allowFrom.json"), + fileEntry("discord-main-allowFrom.json"), + ]; + } + if (targetPath === deps.constants.OPENCLAW_DIR) { + return []; + } + if (targetPath === path.join(deps.constants.OPENCLAW_DIR, "credentials")) { + return ["telegram-main-allowFrom.json", "discord-main-allowFrom.json"]; + } + return []; + }); + deps.fs.readFileSync.mockImplementation( + (targetPath) => files.get(targetPath) || "{}", + ); + deps.fs.writeFileSync.mockImplementation((targetPath, contents) => { + files.set(targetPath, String(contents)); + }); + deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => { + if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) { + for (const directoryPath of [...directories]) { + if ( + directoryPath === sourcePath || + directoryPath.startsWith(`${sourcePath}/`) + ) { + directories.delete(directoryPath); + directories.add(`${targetPath}${directoryPath.slice(sourcePath.length)}`); + } + } + for (const [filePath, contents] of [...files.entries()]) { + if (!filePath.startsWith(`${sourcePath}/`)) continue; + files.delete(filePath); + files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents); + } + return; + } + throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`); + }); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/apply").send({ + tempDir, + approvedSecrets: [], + skipSecretExtraction: true, + }); + + expect(res.status).toBe(200); + expect( + JSON.parse(files.get(path.join(deps.constants.OPENCLAW_DIR, "channels.json"))), + ).toEqual({ + telegram: { + enabled: true, + botToken: "${TELEGRAM_BOT_TOKEN}", + dmPolicy: "pairing", + groupAllowFrom: [], + groups: { + "-100123": { + enabled: true, + }, + }, + }, + discord: { + enabled: true, + dmPolicy: "pairing", + token: "${DISCORD_BOT_TOKEN}", + allowFrom: [], + }, + }); + expect( + JSON.parse( + files.get( + path.join( + deps.constants.OPENCLAW_DIR, + "credentials", + "telegram-main-allowFrom.json", + ), + ), + ), + ).toEqual({ + allowFrom: [], + source: "imported", + }); + expect( + JSON.parse( + files.get( + path.join( + deps.constants.OPENCLAW_DIR, + "credentials", + "discord-main-allowFrom.json", + ), + ), + ), + ).toEqual({ + allowFrom: [], + source: "imported", + }); + }); + + it("returns prefill values from included channel config files during import apply", async () => { + const deps = createBaseDeps(); + const tempDir = path.join(os.tmpdir(), "alphaclaw-import-prefill-includes"); + const fileEntry = (name) => ({ + name, + isFile: () => true, + isDirectory: () => false, + }); + const files = new Map([ + [ + path.join(tempDir, "openclaw.json"), + JSON.stringify({ + channels: { + $include: "channels.json", + }, + }), + ], + [ + path.join(tempDir, "channels.json"), + JSON.stringify({ + discord: { + enabled: true, + token: "MTQ3discord-secret", + }, + }), + ], + ]); + const directories = new Set([tempDir]); + deps.fs.existsSync.mockImplementation( + (targetPath) => directories.has(targetPath) || files.has(targetPath), + ); + deps.fs.statSync.mockImplementation((targetPath) => { + if (directories.has(targetPath)) { + return { isFile: () => false, isDirectory: () => true }; + } + if (files.has(targetPath)) { + return { isFile: () => true, isDirectory: () => false }; + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + deps.fs.readdirSync.mockImplementation((targetPath) => { + if (targetPath === tempDir) { + return [fileEntry("openclaw.json"), fileEntry("channels.json")]; + } + if (targetPath === deps.constants.OPENCLAW_DIR) { + return []; + } + return []; + }); + deps.fs.readFileSync.mockImplementation( + (targetPath) => files.get(targetPath) || "{}", + ); + deps.fs.writeFileSync.mockImplementation((targetPath, contents) => { + files.set(targetPath, String(contents)); + }); + deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => { + if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) { + directories.delete(tempDir); + directories.add(targetPath); + for (const [filePath, contents] of [...files.entries()]) { + if (!filePath.startsWith(`${sourcePath}/`)) continue; + files.delete(filePath); + files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents); + } + return; + } + throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`); + }); + const app = createApp(deps); + + const res = await request(app).post("/api/onboard/import/apply").send({ + tempDir, + approvedSecrets: [], + skipSecretExtraction: true, + }); + + expect(res.status).toBe(200); + expect(res.body.preFill).toEqual({ + DISCORD_BOT_TOKEN: "MTQ3discord-secret", + }); + }); + it("canonicalizes imported env refs for known config paths during import apply", async () => { const deps = createBaseDeps(); const tempDir = path.join(os.tmpdir(), "alphaclaw-import-canonical-env-ref"); diff --git a/tests/server/secret-detector.test.js b/tests/server/secret-detector.test.js index e8063a93..b868f70a 100644 --- a/tests/server/secret-detector.test.js +++ b/tests/server/secret-detector.test.js @@ -360,6 +360,20 @@ describe("secret-detector", () => { }); expect(preFill.TELEGRAM_BOT_TOKEN).toBeUndefined(); }); + + it("reads channel prefill values from standalone channel config files", () => { + const fs = createMockFs({ + "/base/channels.json": JSON.stringify({ + discord: { token: "MTQ3xyz" }, + }), + }); + const preFill = extractPreFillValues({ + fs, + baseDir: "/base", + configFiles: ["channels.json"], + }); + expect(preFill.DISCORD_BOT_TOKEN).toBe("MTQ3xyz"); + }); }); describe("parseEnvFileSecrets", () => { From 7477217bcc7e50d8679998924f8792225be5a26e Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 7 Mar 2026 22:40:39 -0800 Subject: [PATCH 134/417] 0.5.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b6101bb..b1c411cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 7b92599a..5739b4d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.5.1", + "version": "0.5.2", "publishConfig": { "access": "public" }, From 5122def300153e159209678201e8a9eb3fc02cc2 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 8 Mar 2026 07:45:42 -0700 Subject: [PATCH 135/417] Bump OpenClaw base to 2026.3.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5739b4d8..047ff13c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.3.2" + "openclaw": "2026.3.7" }, "devDependencies": { "@vitest/coverage-v8": "^4.0.18", From a8764b738f36dd0f2f56a169cb30c6450f3c626d Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 8 Mar 2026 08:10:05 -0700 Subject: [PATCH 136/417] 0.5.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1c411cb..a47e2f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "dependencies": { "express": "^4.21.0", diff --git a/package.json b/package.json index 047ff13c..4da34d93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.5.2", + "version": "0.5.3", "publishConfig": { "access": "public" }, From ce0f205fe89ddbbe50c5a8247345a463148a26d2 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 8 Mar 2026 11:44:21 -0700 Subject: [PATCH 137/417] Improve setup workflows and webhook routing. Polish session-driven delivery defaults, Gmail reconfiguration UX, model and doctor flows, and supporting frontend utilities so the beta release better matches the latest setup behavior. --- lib/public/js/components/agent-send-modal.js | 36 ++-- lib/public/js/components/doctor/index.js | 11 +- lib/public/js/components/file-tree.js | 27 ++- .../js/components/file-viewer/constants.js | 2 + lib/public/js/components/file-viewer/index.js | 1 + .../file-viewer/markdown-split-view.js | 3 +- .../components/file-viewer/use-file-viewer.js | 30 +++- lib/public/js/components/file-viewer/utils.js | 19 ++ .../components/google/gmail-setup-wizard.js | 167 ++++++++++++------ lib/public/js/components/google/index.js | 89 +++++----- .../js/components/google/use-gmail-watch.js | 4 +- lib/public/js/components/icons.js | 13 ++ lib/public/js/components/models-tab/index.js | 8 +- .../models-tab/provider-auth-card.js | 2 +- .../onboarding/welcome-form-step.js | 10 +- .../js/components/session-select-field.js | 72 ++++++++ lib/public/js/components/webhooks.js | 158 ++++++++++++----- .../js/components/welcome/use-welcome.js | 61 ++++--- .../use-destination-session-selection.js | 85 +++++++++ lib/public/js/hooks/useAgentSessions.js | 14 ++ lib/public/js/lib/api.js | 14 +- lib/public/js/lib/clipboard.js | 40 +++++ lib/public/js/lib/model-config.js | 4 +- lib/server/auth-profiles.js | 3 + lib/server/constants.js | 4 +- lib/server/db/usage/pricing.js | 2 +- lib/server/doctor/prompt.js | 42 +++-- lib/server/gmail-watch.js | 71 +++++--- lib/server/onboarding/github.js | 2 +- lib/server/routes/gmail.js | 6 +- lib/server/routes/models.js | 85 ++++++++- lib/server/routes/system.js | 54 +++--- lib/server/routes/webhooks.js | 4 +- lib/server/webhooks.js | 36 +++- tests/frontend/api.test.js | 58 ++++++ tests/frontend/clipboard.test.js | 63 +++++++ tests/frontend/file-viewer-utils.test.js | 41 +++++ tests/frontend/model-config.test.js | 5 +- tests/server/doctor-prompt.test.js | 35 ++++ tests/server/doctor-service.test.js | 83 +++++++++ tests/server/gmail-watch.test.js | 55 ++++++ tests/server/helpers.test.js | 6 +- tests/server/routes-models.test.js | 57 +++++- tests/server/routes-system.test.js | 5 +- tests/server/webhooks.test.js | 89 ++++++++++ 45 files changed, 1388 insertions(+), 288 deletions(-) create mode 100644 lib/public/js/components/session-select-field.js create mode 100644 lib/public/js/hooks/use-destination-session-selection.js create mode 100644 lib/public/js/lib/clipboard.js create mode 100644 tests/frontend/clipboard.test.js create mode 100644 tests/frontend/file-viewer-utils.test.js create mode 100644 tests/server/webhooks.test.js diff --git a/lib/public/js/components/agent-send-modal.js b/lib/public/js/components/agent-send-modal.js index dec795d3..0d152188 100644 --- a/lib/public/js/components/agent-send-modal.js +++ b/lib/public/js/components/agent-send-modal.js @@ -5,6 +5,7 @@ import { ModalShell } from "./modal-shell.js"; import { ActionButton } from "./action-button.js"; import { PageHeader } from "./page-header.js"; import { CloseIcon } from "./icons.js"; +import { SessionSelectField } from "./session-select-field.js"; import { useAgentSessions } from "../hooks/useAgentSessions.js"; const html = htm.bind(h); @@ -83,31 +84,16 @@ export const AgentSendModal = ({ `} /> -

        - - - ${loadingSessions - ? html`
        Loading sessions...
        ` - : null} - ${loadError ? html`
        ${loadError}
        ` : null} -
        + <${SessionSelectField} + label="Send to session" + sessions=${sessions} + selectedSessionKey=${selectedSessionKey} + onChangeSessionKey=${setSelectedSessionKey} + disabled=${loadingSessions || sending} + loading=${loadingSessions} + error=${loadError} + emptyOptionLabel="No sessions available" + />
        +
        + ${streaming + ? html` + + ` + : null} + +
        +
        +
        + `; +}; diff --git a/lib/public/js/components/routes/index.js b/lib/public/js/components/routes/index.js index f81a3aba..6cab777a 100644 --- a/lib/public/js/components/routes/index.js +++ b/lib/public/js/components/routes/index.js @@ -1,5 +1,6 @@ export { AgentsRoute } from "./agents-route.js"; export { BrowseRoute } from "./browse-route.js"; +export { ChatRoute } from "./chat-route.js"; export { DoctorRoute } from "./doctor-route.js"; export { EnvarsRoute } from "./envars-route.js"; export { GeneralRoute } from "./general-route.js"; diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index 5cf16bd6..2a5d9fd6 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -1,7 +1,12 @@ import { h } from "https://esm.sh/preact"; import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks"; import htm from "https://esm.sh/htm"; -import { AddLineIcon, HomeLineIcon, FolderLineIcon } from "./icons.js"; +import { + AddLineIcon, + HomeLineIcon, + FolderLineIcon, + ChatVoiceLineIcon, +} from "./icons.js"; import { FileTree } from "./file-tree.js"; import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; import { UpdateActionButton } from "./update-action-button.js"; @@ -54,6 +59,10 @@ export const AppSidebar = ({ selectedAgentId = "", onSelectAgent = () => {}, onAddAgent = () => {}, + chatSessions = [], + selectedChatSessionKey = "", + onSelectChatSession = () => {}, + onStartChat = () => {}, }) => { const browseLayoutRef = useRef(null); const browseBottomPanelRef = useRef(null); @@ -168,6 +177,14 @@ export const AppSidebar = ({ > <${FolderLineIcon} className="sidebar-tab-icon" /> +
        +
        + + +
        { const [sidebarTab, setSidebarTab] = useState(() => { if (location.startsWith("/browse")) return "browse"; + if (location.startsWith("/chat")) return "chat"; return "menu"; }); const [lastBrowsePath, setLastBrowsePath] = useState(() => { @@ -28,7 +29,8 @@ export const useBrowseNavigation = ({ typeof storedRoute === "string" && storedRoute.startsWith("/") && !storedRoute.startsWith("/browse") && - !storedRoute.startsWith("/agents") + !storedRoute.startsWith("/agents") && + !storedRoute.startsWith("/chat") ) { return storedRoute; } @@ -57,7 +59,8 @@ export const useBrowseNavigation = ({ useEffect(() => { setSidebarTab((currentTab) => { if (location.startsWith("/browse")) return "browse"; - if (currentTab === "browse") return "menu"; + if (location.startsWith("/chat")) return "chat"; + if (currentTab === "browse" || currentTab === "chat") return "menu"; return currentTab; }); }, [location]); @@ -79,6 +82,7 @@ export const useBrowseNavigation = ({ useEffect(() => { if (location.startsWith("/browse")) return; + if (location.startsWith("/chat")) return; if (location.startsWith("/telegram")) return; setLastMenuRoute((currentRoute) => currentRoute === location ? currentRoute : location, @@ -150,10 +154,17 @@ export const useBrowseNavigation = ({ setLocation(lastMenuRoute || `/${kDefaultUiTab}`); return; } + if (nextTab === "menu" && location.startsWith("/chat")) { + setLocation(lastMenuRoute || `/${kDefaultUiTab}`); + return; + } if (nextTab === "browse" && !location.startsWith("/browse")) { setLocation(buildBrowseRoute(lastBrowsePath)); return; } + if (nextTab === "chat" && !location.startsWith("/chat")) { + setLocation("/chat"); + } }, [lastBrowsePath, lastMenuRoute, location, setLocation]); const handleSelectNavItem = useCallback((itemId) => { diff --git a/lib/public/js/lib/app-navigation.js b/lib/public/js/lib/app-navigation.js index fa226496..7b47c26f 100644 --- a/lib/public/js/lib/app-navigation.js +++ b/lib/public/js/lib/app-navigation.js @@ -28,6 +28,7 @@ export const kNavSections = [ export const getSelectedNavId = ({ isBrowseRoute = false, location = "" } = {}) => { if (isBrowseRoute) return "browse"; if (location.startsWith("/telegram")) return ""; + if (location.startsWith("/chat")) return ""; if (location.startsWith("/models")) return "models"; if (location.startsWith("/agents")) return "agents"; if (location.startsWith("/providers")) return "models"; diff --git a/lib/public/js/lib/storage-keys.js b/lib/public/js/lib/storage-keys.js index 98df4574..aecfd2f8 100644 --- a/lib/public/js/lib/storage-keys.js +++ b/lib/public/js/lib/storage-keys.js @@ -27,3 +27,6 @@ export const kTelegramWorkspaceCacheKey = "alphaclaw.telegram.workspaceCache"; export const kAgentSessionsCacheKey = "alphaclaw.agent.sessionsCache"; export const kAgentLastSessionKey = "alphaclaw.agent.lastSessionKey"; +// --- Chat --- +export const kChatSessionDraftsStorageKey = "alphaclaw.chat.sessionDrafts"; + diff --git a/lib/server.js b/lib/server.js index d5907ed5..0973c223 100644 --- a/lib/server.js +++ b/lib/server.js @@ -67,6 +67,7 @@ const { } = require("./server/env"); const { gatewayEnv, + getGatewayPort, getGatewayUrl, isOnboarded, isGatewayRunning, @@ -108,6 +109,7 @@ const { createWatchdog } = require("./server/watchdog"); const { createDoctorService } = require("./server/doctor/service"); const { createAgentsService } = require("./server/agents/service"); const { createOperationEventsService } = require("./server/operation-events"); +const { createChatWsService } = require("./server/chat-ws"); const { runOnboardedBootSequence } = require("./server/startup"); const { registerAuthRoutes } = require("./server/routes/auth"); @@ -184,6 +186,11 @@ const openclawVersionService = createOpenclawVersionService({ const alphaclawVersionService = createAlphaclawVersionService(); const restartRequiredState = createRestartRequiredState({ isGatewayRunning }); const operationEvents = createOperationEventsService(); +const chatWsService = createChatWsService({ + fs, + openclawDir: constants.OPENCLAW_DIR, + getGatewayPort, +}); const { requireAuth, isAuthorizedRequest } = registerAuthRoutes({ app, @@ -389,6 +396,26 @@ registerAgentRoutes({ restartRequiredState, operationEvents, }); +app.get("/api/chat/history", requireAuth, async (req, res) => { + const sessionKey = String(req.query?.sessionKey || "").trim(); + if (!sessionKey) { + return res.status(400).json({ ok: false, error: "sessionKey is required" }); + } + try { + const { messages, rawHistory } = await chatWsService.fetchHistory(sessionKey); + return res.json({ + ok: true, + sessionKey, + messages, + rawHistory, + }); + } catch (err) { + return res.status(502).json({ + ok: false, + error: err?.message || "Could not load chat history", + }); + } +}); registerProxyRoutes({ app, proxy, @@ -404,6 +431,22 @@ server.on("upgrade", (req, socket, head) => { req.url || "/", `http://${req.headers.host || "localhost"}`, ); + if (requestUrl.pathname === "/api/ws/chat") { + const upgradeReq = { + headers: req.headers, + path: requestUrl.pathname, + query: Object.fromEntries(requestUrl.searchParams.entries()), + }; + if (!isAuthorizedRequest(upgradeReq)) { + socket.write( + "HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nUnauthorized", + ); + socket.destroy(); + return; + } + chatWsService.handleUpgrade(req, socket, head); + return; + } if (requestUrl.pathname.startsWith("/openclaw")) { const upgradeReq = { headers: req.headers, diff --git a/lib/server/chat-ws.js b/lib/server/chat-ws.js new file mode 100644 index 00000000..2438fba6 --- /dev/null +++ b/lib/server/chat-ws.js @@ -0,0 +1,568 @@ +const { readOpenclawConfig } = require("./openclaw-config"); + +const kWsOpen = 1; +const kHistoryLimit = 200; +const kEnvRefPattern = /^\$\{([A-Z0-9_]+)\}$/i; +const kConnectTimeoutMs = 8000; +const kHistoryTimeoutMs = 12000; +const kGatewayReqTimeoutMs = 15000; +const kGatewayProtocolVersion = 3; +const kGatewayAdminScopes = ["operator.admin"]; + +const collectHistoryTextFragments = (value) => { + if (typeof value === "string") { + return value.length > 0 ? [value] : []; + } + if (Array.isArray(value)) { + return value.flatMap((entry) => collectHistoryTextFragments(entry)); + } + if (!value || typeof value !== "object") return []; + + if (typeof value.type === "string") { + const partType = String(value.type || "").toLowerCase(); + if (partType === "text") { + return collectHistoryTextFragments(value.text); + } + if ( + partType === "thinking" || + partType === "toolcall" || + partType === "tool_call" || + partType === "toolresult" || + partType === "tool_result" + ) { + return []; + } + } + + const textFields = [ + value.text, + value.message, + value.content, + value.parts, + value.value, + value.output, + value.input, + ]; + + const fragments = textFields.flatMap((entry) => collectHistoryTextFragments(entry)); + + if (fragments.length > 0) return fragments; + + // Fallback: scan object values to catch unknown transcript block shapes. + return Object.values(value).flatMap((entry) => collectHistoryTextFragments(entry)); +}; + +const normalizeHistoryContent = (rawContent) => { + const parts = collectHistoryTextFragments(rawContent); + return parts + .join("") + .replace(/\r\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +}; + +const normalizeHistoryRole = (rawRole = "") => { + const role = String(rawRole || "").toLowerCase(); + if ( + role === "user" || + role === "human" || + role === "client" || + role === "input" || + role.includes("user") + ) { + return "user"; + } + return "assistant"; +}; + +const normalizeHistoryTimestamp = (messageRow = {}) => { + const numericCandidate = + Number(messageRow?.timestamp) || Number(messageRow?.createdAt) || 0; + if (numericCandidate > 0) return numericCandidate; + const parsedDateMs = Date.parse( + String(messageRow?.timestamp || messageRow?.createdAt || ""), + ); + return Number.isFinite(parsedDateMs) && parsedDateMs > 0 + ? parsedDateMs + : Date.now(); +}; + +const sanitizeError = (error) => { + const message = error instanceof Error ? error.message : String(error || ""); + if (message.toLowerCase().includes("not connected")) { + return "Agent runtime is not connected right now."; + } + return "Something went wrong. Please try again."; +}; + +const resolveTokenValue = (candidate = "") => { + const normalizedCandidate = String(candidate || "").trim(); + if (!normalizedCandidate) return ""; + const envMatch = normalizedCandidate.match(kEnvRefPattern); + if (!envMatch) return normalizedCandidate; + const envKey = String(envMatch[1] || "").trim(); + if (!envKey) return ""; + return String(process.env[envKey] || "").trim(); +}; + +const withTimeout = async (promise, timeoutMs, label) => { + let timeoutId = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +}; + +const createChatWsService = ({ + fs, + openclawDir = "", + getGatewayPort = () => 18789, +}) => { + let WebSocketServer = null; + let GatewayWebSocket = null; + try { + const wsModule = require("ws"); + ({ WebSocketServer } = wsModule); + GatewayWebSocket = wsModule.WebSocket || wsModule; + } catch (err) { + console.warn( + `[alphaclaw] chat websocket disabled: missing ws dependency (${err.message})`, + ); + return { + handleUpgrade: (request, socket) => { + socket.write( + "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nChat websocket unavailable", + ); + socket.destroy(); + }, + fetchHistory: async () => { + throw new Error("Chat websocket unavailable"); + }, + }; + } + + const wss = new WebSocketServer({ + noServer: true, + maxPayload: 1 * 1024 * 1024, + }); + let gatewaySocket = null; + let gatewayConnectPromise = null; + const pendingGatewayRequests = new Map(); + const runTargets = new Map(); + const browserRuns = new WeakMap(); + + const sendJson = (ws, payload = {}) => { + if (!ws || ws.readyState !== kWsOpen) return; + ws.send(JSON.stringify(payload)); + }; + + const getGatewayToken = () => { + const config = readOpenclawConfig({ + fsModule: fs, + openclawDir, + fallback: {}, + }); + const envToken = String(process.env.OPENCLAW_GATEWAY_TOKEN || "").trim(); + if (envToken) return envToken; + return resolveTokenValue(config?.gateway?.auth?.token); + }; + + const registerRunForBrowser = (ws, runId) => { + const existingRuns = browserRuns.get(ws); + if (existingRuns) { + existingRuns.add(runId); + return; + } + browserRuns.set(ws, new Set([runId])); + }; + + const clearRunTargetsForBrowser = (ws) => { + const runs = browserRuns.get(ws); + if (!runs) return; + for (const runId of runs) runTargets.delete(runId); + runs.clear(); + browserRuns.delete(ws); + }; + + const settleGatewayRequest = (id, payload) => { + const pending = pendingGatewayRequests.get(id); + if (!pending) return; + pendingGatewayRequests.delete(id); + if (payload?.ok) { + pending.resolve(payload.payload || null); + return; + } + pending.reject( + new Error( + payload?.error?.message || + payload?.error?.code || + "Gateway request failed", + ), + ); + }; + + const rejectAllGatewayRequests = (reason = "Gateway disconnected") => { + for (const [id, pending] of pendingGatewayRequests.entries()) { + pendingGatewayRequests.delete(id); + pending.reject(new Error(reason)); + } + }; + + const markGatewayDisconnected = (reason = "Gateway disconnected") => { + gatewaySocket = null; + gatewayConnectPromise = null; + rejectAllGatewayRequests(reason); + }; + + const handleGatewayEvent = (eventPayload = {}) => { + const eventName = String(eventPayload.event || ""); + const payload = eventPayload.payload || {}; + if (eventName === "agent") { + const runId = String(payload?.runId || ""); + if (!runId) return; + const target = runTargets.get(runId); + if (!target) return; + const stream = String(payload?.stream || ""); + const data = payload?.data || {}; + if (stream === "assistant") { + const rawDelta = + data?.delta == null || data?.delta === "" + ? data?.text + : data?.delta; + const delta = String(rawDelta || ""); + if (!delta) return; + sendJson(target.ws, { + type: "chunk", + messageId: target.messageId, + content: delta, + sessionKey: target.sessionKey, + }); + return; + } + if (stream === "lifecycle" && String(data?.phase || "") === "end") { + sendJson(target.ws, { + type: "done", + messageId: target.messageId, + sessionKey: target.sessionKey, + }); + runTargets.delete(runId); + const runs = browserRuns.get(target.ws); + if (runs) runs.delete(runId); + } + return; + } + if (eventName === "chat") { + const runId = String(payload?.runId || ""); + const target = runTargets.get(runId); + if (!target) return; + if (String(payload?.state || "") === "error") { + sendJson(target.ws, { + type: "error", + message: "Something went wrong connecting to the agent.", + messageId: target.messageId, + sessionKey: target.sessionKey, + }); + runTargets.delete(runId); + const runs = browserRuns.get(target.ws); + if (runs) runs.delete(runId); + } + } + }; + + const ensureGatewayConnected = async () => { + if (gatewaySocket && gatewaySocket.readyState === kWsOpen) return gatewaySocket; + if (!gatewayConnectPromise) { + gatewayConnectPromise = withTimeout( + new Promise((resolve, reject) => { + const socket = new GatewayWebSocket(`ws://127.0.0.1:${getGatewayPort()}`); + const connectRequestId = crypto.randomUUID(); + const connectParams = { + minProtocol: kGatewayProtocolVersion, + maxProtocol: kGatewayProtocolVersion, + client: { + id: "gateway-client", + version: "0.1.0", + platform: process.platform, + mode: "backend", + }, + role: "operator", + scopes: kGatewayAdminScopes, + caps: [], + commands: [], + permissions: {}, + auth: { token: getGatewayToken() }, + locale: "en-US", + userAgent: "alphaclaw-chat-bridge/0.1.0", + }; + + socket.on("message", (rawData) => { + let payload = null; + try { + payload = JSON.parse(String(rawData || "")); + } catch { + return; + } + if (!payload || typeof payload !== "object") return; + if ( + payload.type === "event" && + String(payload.event || "") === "connect.challenge" + ) { + socket.send( + JSON.stringify({ + type: "req", + id: connectRequestId, + method: "connect", + params: connectParams, + }), + ); + return; + } + if (payload.type === "res") { + if (String(payload.id || "") === connectRequestId) { + if (payload.ok && payload?.payload?.type === "hello-ok") { + gatewaySocket = socket; + resolve(socket); + return; + } + reject( + new Error( + payload?.error?.message || + payload?.error?.code || + "OpenClaw gateway connect failed", + ), + ); + try { + socket.close(); + } catch {} + return; + } + settleGatewayRequest(String(payload.id || ""), payload); + return; + } + if (payload.type === "event") { + handleGatewayEvent(payload); + } + }); + + socket.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err || ""); + reject(new Error(message || "OpenClaw gateway websocket failed")); + markGatewayDisconnected("OpenClaw gateway websocket failed"); + }); + + socket.on("close", (code) => { + markGatewayDisconnected(`Gateway disconnected (code ${code})`); + }); + }), + kConnectTimeoutMs, + "OpenClaw client connect", + ) + .finally(() => { + gatewayConnectPromise = null; + }); + } + return gatewayConnectPromise; + }; + + const requestGateway = async ( + method = "", + params = {}, + timeoutMs = kGatewayReqTimeoutMs, + ) => { + const socket = await ensureGatewayConnected(); + if (!socket || socket.readyState !== kWsOpen) { + throw new Error("OpenClaw gateway is not connected"); + } + const requestId = crypto.randomUUID(); + const responsePromise = new Promise((resolve, reject) => { + pendingGatewayRequests.set(requestId, { resolve, reject }); + }); + socket.send( + JSON.stringify({ + type: "req", + id: requestId, + method, + params, + }), + ); + return withTimeout(responsePromise, timeoutMs, `OpenClaw ${method} request`).finally( + () => { + pendingGatewayRequests.delete(requestId); + }, + ); + }; + + const handleHistory = async ({ ws, payload }) => { + const sessionKey = String(payload?.sessionKey || "").trim(); + if (!sessionKey) { + sendJson(ws, { type: "history", messages: [] }); + return; + } + const { messages, rawHistory } = await fetchHistory(sessionKey); + sendJson(ws, { + type: "history", + sessionKey, + messages, + rawHistory, + }); + }; + + const handleMessage = async ({ ws, payload }) => { + const sessionKey = String(payload?.sessionKey || "").trim(); + const content = String(payload?.content || "").trim(); + const messageId = crypto.randomUUID(); + if (!sessionKey || !content) { + sendJson(ws, { + type: "error", + message: "sessionKey and content are required", + messageId, + }); + return; + } + const result = await requestGateway("chat.send", { + sessionKey, + message: content, + idempotencyKey: crypto.randomUUID(), + }); + const runId = String(result?.runId || "").trim(); + if (!runId) { + sendJson(ws, { + type: "error", + message: "Something went wrong connecting to the agent.", + messageId, + sessionKey, + }); + return; + } + runTargets.set(runId, { ws, messageId, sessionKey }); + registerRunForBrowser(ws, runId); + sendJson(ws, { + type: "started", + sessionKey, + runId, + messageId, + }); + }; + + const handleStop = async ({ ws, payload }) => { + const sessionKey = String(payload?.sessionKey || "").trim(); + if (!sessionKey) { + sendJson(ws, { + type: "error", + message: "sessionKey is required", + }); + return; + } + const runs = browserRuns.get(ws); + if (runs) { + for (const runId of Array.from(runs)) { + const target = runTargets.get(runId); + if (!target || String(target.sessionKey || "") !== sessionKey) continue; + runTargets.delete(runId); + runs.delete(runId); + } + } + await requestGateway("chat.abort", { sessionKey }); + sendJson(ws, { + type: "done", + sessionKey, + stopped: true, + }); + }; + + wss.on("connection", (ws) => { + ws.on("close", () => { + clearRunTargetsForBrowser(ws); + }); + ws.on("message", (rawData) => { + let payload = null; + try { + payload = JSON.parse(String(rawData || "")); + } catch { + return; + } + if (!payload || typeof payload !== "object") return; + const type = String(payload.type || ""); + const run = async () => { + if (type === "history") { + await handleHistory({ ws, payload }); + return; + } + if (type === "message") { + await handleMessage({ ws, payload }); + return; + } + if (type === "stop") { + await handleStop({ ws, payload }); + } + }; + run().catch((err) => { + sendJson(ws, { + type: "error", + message: sanitizeError(err), + }); + }); + }); + }); + + const fetchHistory = async (sessionKey = "") => { + const normalizedSessionKey = String(sessionKey || "").trim(); + if (!normalizedSessionKey) { + return { messages: [], rawHistory: null }; + } + const history = await requestGateway( + "chat.history", + { + sessionKey: normalizedSessionKey, + limit: kHistoryLimit, + }, + kHistoryTimeoutMs, + ); + const rawMessages = Array.isArray(history?.messages) + ? history.messages + : Array.isArray(history?.history) + ? history.history + : Array.isArray(history?.items) + ? history.items + : []; + const messages = rawMessages + .map((messageRow) => { + let content = normalizeHistoryContent( + messageRow?.content ?? messageRow?.text ?? messageRow?.message, + ); + const role = normalizeHistoryRole(messageRow?.role ?? messageRow?.author); + if (role === "user") { + content = content.replace(/^\[.*?\]\s*/, ""); + } + return { + role, + content, + timestamp: normalizeHistoryTimestamp(messageRow), + }; + }) + .filter((messageRow) => String(messageRow.content || "").trim()); + return { + messages, + rawHistory: history || null, + }; + }; + + return { + handleUpgrade: (request, socket, head) => { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + }, + fetchHistory, + }; +}; + +module.exports = { createChatWsService }; diff --git a/package-lock.json b/package-lock.json index d8ae13ad..e68e27bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "^2026.3.8" + "openclaw": "^2026.3.8", + "openclaw-node": "^0.2.1", + "ws": "^8.19.0" }, "bin": { "alphaclaw": "bin/alphaclaw.js" @@ -9921,6 +9923,22 @@ "node-llama-cpp": "3.16.2" } }, + "node_modules/openclaw-node": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/openclaw-node/-/openclaw-node-0.2.1.tgz", + "integrity": "sha512-5hlp9hLMe0apYSxv7Xzd7UVxy8sCbhrDMjTGJDwn20/kC/EiP2vkQOEvDcj4/UxKzcptUqu8ryCu8KmJoFaMtg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/clemenshelm" + }, + "optionalDependencies": { + "ws": "^8.0.0" + } + }, "node_modules/openclaw/node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index 532ca248..7690fc95 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "^2026.3.8" + "openclaw": "^2026.3.8", + "openclaw-node": "^0.2.1", + "ws": "^8.19.0" }, "devDependencies": { "@vitest/coverage-v8": "^4.0.18", From 4a70cdbae73c63572e6df2ccdab6c070b7b4895a Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 11 Mar 2026 00:02:52 -0700 Subject: [PATCH 195/417] Refine chat transcript UX and split chat styles into dedicated chat.css. Show tool activity as minimal per-call rows with expandable payload/result details, preserve richer history metadata, and keep chat styling isolated from agents styles for easier maintenance. --- lib/public/css/agents.css | 299 ------------ lib/public/css/chat.css | 426 ++++++++++++++++++ lib/public/js/components/routes/chat-route.js | 397 +++++++++++++--- lib/public/setup.html | 1 + lib/server/chat-ws.js | 92 +++- 5 files changed, 842 insertions(+), 373 deletions(-) create mode 100644 lib/public/css/chat.css diff --git a/lib/public/css/agents.css b/lib/public/css/agents.css index 45194cc4..5e32990d 100644 --- a/lib/public/css/agents.css +++ b/lib/public/css/agents.css @@ -90,302 +90,3 @@ padding: 32px; text-align: center; } - -/* ── Chat route ──────────────────────────────── */ - -.app-content-pane.chat-pane { - padding: 0; -} - -.chat-route-shell { - display: flex; - flex-direction: column; - min-height: 100%; - height: 100%; -} - -.chat-route-header { - padding: 16px 24px 10px; - border-bottom: 1px solid var(--border); -} - -.chat-route-title { - font-size: 16px; - font-weight: 600; - color: var(--text); -} - -.chat-route-subtitle { - margin-top: 4px; - font-size: 12px; - color: var(--text-muted); -} - -.chat-route-warning { - margin-top: 8px; - font-size: 12px; - color: #fca5a5; -} - -.chat-thread { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - padding: 16px 24px; - display: flex; - flex-direction: column; - gap: 10px; -} - -.chat-empty-state { - color: var(--text-dim); - font-size: 12px; - text-align: center; - margin-top: 24px; -} - -.chat-bubble { - max-width: 86%; - border-radius: 10px; - border: 1px solid var(--panel-border-contrast); - background: rgba(255, 255, 255, 0.02); - padding: 12px 14px; -} - -.chat-bubble.is-user { - align-self: flex-end; - background: rgba(99, 235, 255, 0.06); - border-color: rgba(99, 235, 255, 0.24); -} - -.chat-bubble.is-assistant { - align-self: flex-start; -} - -.chat-bubble-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 8px; - font-size: 11px; - color: var(--text-muted); -} - -.chat-bubble-content { - margin: 0; - font-family: inherit; - font-size: 12px; - color: var(--text); - line-height: 1.6; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; -} - -.chat-bubble-markdown > :first-child { - margin-top: 0; -} - -.chat-bubble-markdown > :last-child { - margin-bottom: 0; -} - -.chat-bubble-markdown { - white-space: normal; -} - -.chat-bubble-markdown p, -.chat-bubble-markdown ul, -.chat-bubble-markdown ol, -.chat-bubble-markdown pre, -.chat-bubble-markdown blockquote { - margin: 8px 0; -} - -.chat-bubble-markdown ul, -.chat-bubble-markdown ol { - padding-left: 18px; - padding-top: 0; - padding-bottom: 0; -} - -.chat-bubble-markdown ul { - list-style: disc; -} - -.chat-bubble-markdown ol { - list-style: decimal; -} - -.chat-bubble-markdown li > p { - margin: 0; -} - -.chat-bubble-markdown li + li { - margin-top: 4px; -} - -.chat-bubble-markdown > * + * { - margin-top: 8px; -} - -.chat-bubble-markdown h1, -.chat-bubble-markdown h2, -.chat-bubble-markdown h3, -.chat-bubble-markdown h4 { - margin: 0; - line-height: 1.25; -} - -.chat-bubble-markdown h1 { - font-size: 16px; -} - -.chat-bubble-markdown h2 { - font-size: 14px; -} - -.chat-bubble-markdown h3, -.chat-bubble-markdown h4 { - font-size: 13px; -} - -.chat-bubble-markdown h1 + *, -.chat-bubble-markdown h2 + *, -.chat-bubble-markdown h3 + *, -.chat-bubble-markdown h4 + * { - margin-top: 10px; -} - -.chat-bubble-markdown code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 11px; - background: rgba(255, 255, 255, 0.06); - padding: 1px 4px; - border-radius: 4px; -} - -.chat-bubble-markdown pre code { - display: block; - white-space: pre-wrap; - overflow-wrap: anywhere; - padding: 8px; -} - -.chat-bubble-json { - white-space: pre-wrap; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 11px; - line-height: 1.6; - background: rgba(255, 255, 255, 0.04); - border: 1px solid var(--panel-border-contrast); - border-radius: 8px; - padding: 10px; -} - -.chat-typing-indicator { - min-width: 170px; -} - -.chat-typing-dots { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 2px 0 1px; -} - -.chat-typing-dots span { - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--text-muted); - opacity: 0.45; - animation: chatTypingPulse 1.2s infinite ease-in-out; -} - -.chat-typing-dots span:nth-child(2) { - animation-delay: 0.15s; -} - -.chat-typing-dots span:nth-child(3) { - animation-delay: 0.3s; -} - -@keyframes chatTypingPulse { - 0%, - 80%, - 100% { - transform: translateY(0); - opacity: 0.35; - } - 40% { - transform: translateY(-2px); - opacity: 0.9; - } -} - -.chat-composer { - border-top: 1px solid var(--border); - padding: 12px 24px 16px; - display: flex; - align-items: flex-end; - gap: 10px; -} - -.chat-composer-input { - flex: 1; - min-height: 68px; - max-height: 180px; - border-radius: 10px; - resize: vertical; - font-family: inherit; - font-size: 12px; - line-height: 1.4; - padding: 10px; -} - -.chat-composer-send { - white-space: nowrap; - padding: 8px 14px; - border-radius: 9px; - font-family: inherit; - font-size: 12px; -} - -.chat-composer-actions { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.chat-composer-stop { - white-space: nowrap; - padding: 8px 12px; - border-radius: 9px; - font-family: inherit; - font-size: 12px; -} - -.chat-raw-debug { - margin-top: 8px; - border-top: 1px dashed var(--border); - padding-top: 8px; -} - -.chat-raw-debug summary { - cursor: pointer; - color: var(--text-muted); - font-size: 11px; -} - -.chat-raw-debug pre { - margin: 8px 0 0; - font-size: 11px; - color: var(--text-muted); - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; -} diff --git a/lib/public/css/chat.css b/lib/public/css/chat.css new file mode 100644 index 00000000..5814bcd7 --- /dev/null +++ b/lib/public/css/chat.css @@ -0,0 +1,426 @@ +/* ── Chat route ──────────────────────────────── */ + +.app-content-pane.chat-pane { + padding: 0; +} + +.chat-route-shell { + display: flex; + flex-direction: column; + min-height: 100%; + height: 100%; +} + +.chat-route-header { + padding: 16px 24px 10px; + border-bottom: 1px solid var(--border); +} + +.chat-route-title { + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +.chat-route-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); +} + +.chat-route-warning { + margin-top: 8px; + font-size: 12px; + color: #fca5a5; +} + +.chat-thread { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 16px 24px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-empty-state { + color: var(--text-dim); + font-size: 12px; + text-align: center; + margin-top: 24px; +} + +.chat-bubble { + max-width: 86%; + border-radius: 10px; + border: 1px solid var(--panel-border-contrast); + background: rgba(255, 255, 255, 0.02); + padding: 12px 14px; +} + +.chat-bubble.is-user { + align-self: flex-end; + background: rgba(99, 235, 255, 0.06); + border-color: rgba(99, 235, 255, 0.24); +} + +.chat-bubble.is-assistant { + align-self: flex-start; +} + +.chat-bubble-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + font-size: 11px; + color: var(--text-muted); +} + +.chat-bubble-content { + margin: 0; + font-family: inherit; + font-size: 12px; + color: var(--text); + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-bubble-markdown > :first-child { + margin-top: 0; +} + +.chat-bubble-markdown > :last-child { + margin-bottom: 0; +} + +.chat-bubble-markdown { + white-space: normal; +} + +.chat-bubble-markdown p, +.chat-bubble-markdown ul, +.chat-bubble-markdown ol, +.chat-bubble-markdown pre, +.chat-bubble-markdown blockquote { + margin: 8px 0; +} + +.chat-bubble-markdown ul, +.chat-bubble-markdown ol { + padding-left: 18px; + padding-top: 0; + padding-bottom: 0; +} + +.chat-bubble-markdown ul { + list-style: disc; +} + +.chat-bubble-markdown ol { + list-style: decimal; +} + +.chat-bubble-markdown li > p { + margin: 0; +} + +.chat-bubble-markdown li + li { + margin-top: 4px; +} + +.chat-bubble-markdown > * + * { + margin-top: 8px; +} + +.chat-bubble-markdown h1, +.chat-bubble-markdown h2, +.chat-bubble-markdown h3, +.chat-bubble-markdown h4 { + margin: 0; + line-height: 1.25; +} + +.chat-bubble-markdown h1 { + font-size: 16px; +} + +.chat-bubble-markdown h2 { + font-size: 14px; +} + +.chat-bubble-markdown h3, +.chat-bubble-markdown h4 { + font-size: 13px; +} + +.chat-bubble-markdown h1 + *, +.chat-bubble-markdown h2 + *, +.chat-bubble-markdown h3 + *, +.chat-bubble-markdown h4 + * { + margin-top: 10px; +} + +.chat-bubble-markdown code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 11px; + background: rgba(255, 255, 255, 0.06); + padding: 1px 4px; + border-radius: 4px; +} + +.chat-bubble-markdown pre code { + display: block; + white-space: pre-wrap; + overflow-wrap: anywhere; + padding: 8px; +} + +.chat-bubble-json { + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 11px; + line-height: 1.6; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--panel-border-contrast); + border-radius: 8px; + padding: 10px; +} + +.chat-message-json { + margin-top: 10px; + border-top: 1px dashed var(--border); + padding-top: 8px; +} + +.chat-message-json summary { + cursor: pointer; + color: var(--text-muted); + font-size: 11px; +} + +.chat-message-json pre { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-tool-call-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 6px; +} + +.chat-tool-call-row { + border: 1px dashed var(--border); + border-radius: 8px; + padding: 6px 8px; +} + +.chat-tool-call-row summary { + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.chat-tool-call-name { + color: var(--text); + font-size: 12px; +} + +.chat-tool-call-status { + color: var(--text-muted); + font-size: 10px; + text-transform: lowercase; +} + +.chat-tool-inline { + font-weight: 600; +} + +.chat-tool-inline-message { + margin: 0; +} + +.chat-tool-inline-message summary { + cursor: pointer; + list-style: none; + color: var(--text); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + font-size: 12px; + line-height: 1.6; +} + +.chat-tool-inline-message summary::-webkit-details-marker { + display: none; +} + +.chat-tool-inline-message summary::before { + content: "▸"; + color: var(--text-muted); + font-size: 12px; + line-height: 1; + margin-right: 2px; +} + +.chat-tool-inline-message[open] summary::before { + content: "▾"; +} + +.chat-tool-inline-icon { + font-size: 12px; + line-height: 1; +} + +.chat-tool-inline-title { + font-weight: 600; +} + +.chat-tool-inline-time { + margin-left: auto; + font-size: 11px; + color: var(--text-muted); +} + +.chat-tool-inline-body { + margin-top: 8px; +} + +.chat-tool-inline-label { + margin-top: 8px; + margin-bottom: 4px; + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.chat-tool-inline-body pre { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-typing-indicator { + min-width: 170px; +} + +.chat-typing-dots { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 0 1px; +} + +.chat-typing-dots span { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--text-muted); + opacity: 0.45; + animation: chatTypingPulse 1.2s infinite ease-in-out; +} + +.chat-typing-dots span:nth-child(2) { + animation-delay: 0.15s; +} + +.chat-typing-dots span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes chatTypingPulse { + 0%, + 80%, + 100% { + transform: translateY(0); + opacity: 0.35; + } + 40% { + transform: translateY(-2px); + opacity: 0.9; + } +} + +.chat-composer { + border-top: 1px solid var(--border); + padding: 12px 24px 16px; + display: flex; + align-items: flex-end; + gap: 10px; +} + +.chat-composer-input { + flex: 1; + min-height: 68px; + max-height: 180px; + border-radius: 10px; + resize: vertical; + font-family: inherit; + font-size: 12px; + line-height: 1.4; + padding: 10px; +} + +.chat-composer-send { + white-space: nowrap; + padding: 8px 14px; + border-radius: 9px; + font-family: inherit; + font-size: 12px; +} + +.chat-composer-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.chat-composer-stop { + white-space: nowrap; + padding: 8px 12px; + border-radius: 9px; + font-family: inherit; + font-size: 12px; +} + +.chat-raw-debug { + margin-top: 8px; + border-top: 1px dashed var(--border); + padding-top: 8px; +} + +.chat-raw-debug summary { + cursor: pointer; + color: var(--text-muted); + font-size: 11px; +} + +.chat-raw-debug pre { + margin: 8px 0 0; + font-size: 11px; + color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} diff --git a/lib/public/js/components/routes/chat-route.js b/lib/public/js/components/routes/chat-route.js index 207720af..f81cc2ac 100644 --- a/lib/public/js/components/routes/chat-route.js +++ b/lib/public/js/components/routes/chat-route.js @@ -18,11 +18,17 @@ const kWsReconnectMaxAttempts = 8; const kAutoscrollBottomThresholdPx = 40; const kChatDebugQueryFlag = "chatDebug"; -const buildMessage = ({ role = "assistant", content = "", createdAt = Date.now() } = {}) => ({ +const buildMessage = ({ + role = "assistant", + content = "", + createdAt = Date.now(), + debugPayload = null, +} = {}) => ({ id: crypto.randomUUID(), role, content: String(content || ""), createdAt: Number(createdAt) || Date.now(), + debugPayload, }); const formatChatTime = (createdAt) => { @@ -65,16 +71,62 @@ const parseJsonMessage = (value = "") => { } }; +const extractToolCallsFromPayload = (payload = null) => { + const normalizedPayload = + payload && typeof payload === "object" ? payload : {}; + if ( + Array.isArray(normalizedPayload?.toolCalls) && + normalizedPayload.toolCalls.length > 0 + ) { + return normalizedPayload.toolCalls; + } + const rawParts = Array.isArray(normalizedPayload?.rawMessage?.content) + ? normalizedPayload.rawMessage.content + : []; + return rawParts + .filter((part) => String(part?.type || "").toLowerCase() === "toolcall") + .map((part) => ({ + id: String(part?.id || ""), + name: String(part?.name || ""), + arguments: part?.arguments || null, + partialJson: String(part?.partialJson || ""), + })) + .filter((toolCall) => toolCall.name || toolCall.id); +}; + +const normalizeToolResult = (toolResult = null) => { + if (!toolResult || typeof toolResult !== "object") return null; + const rawMessage = toolResult?.rawMessage || toolResult; + if (!rawMessage || typeof rawMessage !== "object") return null; + const contentParts = Array.isArray(rawMessage?.content) ? rawMessage.content : []; + const text = contentParts + .map((part) => String(part?.text || "")) + .filter((value) => value.length > 0) + .join("\n") + .trim(); + return { + toolCallId: String(rawMessage?.toolCallId || toolResult?.toolCallId || ""), + toolName: String(rawMessage?.toolName || toolResult?.toolName || ""), + text, + isError: Boolean( + rawMessage?.isError === true || + toolResult?.isError === true || + String(rawMessage?.status || "").toLowerCase() === "error", + ), + rawMessage, + }; +}; + const renderMarkdownHtml = (value = "") => - marked.parse(escapeHtmlForMarkdown(normalizeListMarkers(normalizeMarkdownInput(value))), { - gfm: true, - breaks: true, - }); + marked.parse( + escapeHtmlForMarkdown(normalizeListMarkers(normalizeMarkdownInput(value))), + { + gfm: true, + breaks: true, + }, + ); -export const ChatRoute = ({ - sessions = [], - selectedSessionKey = "", -}) => { +export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => { const [messagesBySession, setMessagesBySession] = useState({}); const [draft, setDraft] = useState(""); const [draftBySession, setDraftBySession] = useState(() => { @@ -103,7 +155,9 @@ export const ChatRoute = ({ const realtimeDisabledRef = useRef(false); const shouldAutoScrollRef = useRef(true); const appendDebugEvent = useCallback((sessionKey, label, payload) => { - const normalizedSessionKey = String(sessionKey || selectedSessionKeyRef.current || ""); + const normalizedSessionKey = String( + sessionKey || selectedSessionKeyRef.current || "", + ); if (!normalizedSessionKey) return; const nextEvent = { id: crypto.randomUUID(), @@ -132,14 +186,18 @@ export const ChatRoute = ({ useEffect(() => { try { - localStorage.setItem(kChatSessionDraftsStorageKey, JSON.stringify(draftBySession)); + localStorage.setItem( + kChatSessionDraftsStorageKey, + JSON.stringify(draftBySession), + ); } catch {} }, [draftBySession]); const selectedSession = useMemo( () => sessions.find( - (sessionRow) => String(sessionRow?.key || "") === String(selectedSessionKey || ""), + (sessionRow) => + String(sessionRow?.key || "") === String(selectedSessionKey || ""), ) || null, [selectedSessionKey, sessions], ); @@ -182,7 +240,9 @@ export const ChatRoute = ({ const connect = () => { if (realtimeDisabledRef.current) return; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket(`${protocol}//${window.location.host}/api/ws/chat`); + const ws = new WebSocket( + `${protocol}//${window.location.host}/api/ws/chat`, + ); wsRef.current = ws; ws.onopen = () => { @@ -210,7 +270,10 @@ export const ChatRoute = ({ setHistoryLoading(false); if (realtimeDisabledRef.current) return; if (reconnectAttemptsRef.current >= kWsReconnectMaxAttempts) return; - const delayMs = Math.min(1000 * 2 ** reconnectAttemptsRef.current, 5000); + const delayMs = Math.min( + 1000 * 2 ** reconnectAttemptsRef.current, + 5000, + ); reconnectAttemptsRef.current += 1; setConnectionError("Realtime chat socket disconnected."); reconnectTimerRef.current = setTimeout(connect, delayMs); @@ -240,15 +303,23 @@ export const ChatRoute = ({ if (payload.type === "history") { const historySessionKey = String(payload.sessionKey || ""); if (!historySessionKey) return; - const historyMessages = (Array.isArray(payload.messages) ? payload.messages : []) + const historyMessages = ( + Array.isArray(payload.messages) ? payload.messages : [] + ) .map((messageRow) => buildMessage({ role: String(messageRow?.role || "assistant"), content: String(messageRow?.content || ""), createdAt: Number(messageRow?.timestamp) || Date.now(), + debugPayload: messageRow || null, }), ) - .filter((messageRow) => String(messageRow.content || "").trim()); + .filter( + (messageRow) => + String(messageRow.content || "").trim() || + extractToolCallsFromPayload(messageRow?.debugPayload).length > + 0, + ); setMessagesBySession((currentMap) => ({ ...currentMap, [historySessionKey]: historyMessages, @@ -262,7 +333,9 @@ export const ChatRoute = ({ } if (payload.type === "chunk") { - const chunkSessionKey = String(payload.sessionKey || selectedSessionKeyRef.current || ""); + const chunkSessionKey = String( + payload.sessionKey || selectedSessionKeyRef.current || "", + ); const messageId = String(payload.messageId || ""); const chunkText = String(payload.content || ""); if (!chunkSessionKey || !messageId) return; @@ -283,6 +356,15 @@ export const ChatRoute = ({ { ...lastMessage, content: `${String(lastMessage.content || "")}${chunkText}`, + debugPayload: { + ...(lastMessage?.debugPayload || {}), + source: "stream", + messageId, + sessionKey: chunkSessionKey, + chunkCount: + Number(lastMessage?.debugPayload?.chunkCount || 1) + 1, + lastChunk: chunkText, + }, }, ], }; @@ -296,6 +378,13 @@ export const ChatRoute = ({ role: "assistant", content: chunkText, createdAt: Date.now(), + debugPayload: { + source: "stream", + messageId, + sessionKey: chunkSessionKey, + chunkCount: 1, + lastChunk: chunkText, + }, }, ], }; @@ -304,7 +393,9 @@ export const ChatRoute = ({ } if (payload.type === "started") { - const nextSessionKey = String(payload.sessionKey || selectedSessionKeyRef.current || ""); + const nextSessionKey = String( + payload.sessionKey || selectedSessionKeyRef.current || "", + ); const runId = String(payload.runId || ""); if (!nextSessionKey || !runId) return; setActiveRunBySession((currentMap) => ({ @@ -315,7 +406,9 @@ export const ChatRoute = ({ } if (payload.type === "done") { - const doneSessionKey = String(payload.sessionKey || selectedSessionKeyRef.current || ""); + const doneSessionKey = String( + payload.sessionKey || selectedSessionKeyRef.current || "", + ); if (doneSessionKey) { setActiveRunBySession((currentMap) => { const nextMap = { ...currentMap }; @@ -333,7 +426,9 @@ export const ChatRoute = ({ setSending(false); setStreaming(false); setHistoryLoading(false); - const errorSessionKey = String(payload.sessionKey || selectedSessionKeyRef.current || ""); + const errorSessionKey = String( + payload.sessionKey || selectedSessionKeyRef.current || "", + ); if (errorSessionKey) { setActiveRunBySession((currentMap) => { const nextMap = { ...currentMap }; @@ -347,7 +442,8 @@ export const ChatRoute = ({ buildMessage({ role: "assistant", content: - String(payload.message || "").trim() || "Something went wrong.", + String(payload.message || "").trim() || + "Something went wrong.", }), ], })); @@ -402,15 +498,22 @@ export const ChatRoute = ({ throw new Error(payload?.error || "Could not load chat history"); } appendDebugEvent(selectedSessionKey, "http:history-response", payload); - const historyMessages = (Array.isArray(payload.messages) ? payload.messages : []) + const historyMessages = ( + Array.isArray(payload.messages) ? payload.messages : [] + ) .map((messageRow) => buildMessage({ role: String(messageRow?.role || "assistant"), content: String(messageRow?.content || ""), createdAt: Number(messageRow?.timestamp) || Date.now(), + debugPayload: messageRow || null, }), ) - .filter((messageRow) => String(messageRow.content || "").trim()); + .filter( + (messageRow) => + String(messageRow.content || "").trim() || + extractToolCallsFromPayload(messageRow?.debugPayload).length > 0, + ); setMessagesBySession((currentMap) => ({ ...currentMap, [selectedSessionKey]: historyMessages, @@ -447,7 +550,9 @@ export const ChatRoute = ({ } const ws = wsRef.current; if (ws) ws.close(); - setConnectionError("Chat runtime unavailable (missing server dependency)."); + setConnectionError( + "Chat runtime unavailable (missing server dependency).", + ); } else { setConnectionError(errorMessage); } @@ -468,7 +573,8 @@ export const ChatRoute = ({ threadElement.scrollHeight - threadElement.scrollTop - threadElement.clientHeight; - shouldAutoScrollRef.current = distanceFromBottom <= kAutoscrollBottomThresholdPx; + shouldAutoScrollRef.current = + distanceFromBottom <= kAutoscrollBottomThresholdPx; }, []); useEffect(() => { @@ -496,13 +602,22 @@ export const ChatRoute = ({ const ws = wsRef.current; if (!messageText || !selectedSessionKey || sending || streaming) return; if (!ws || ws.readyState !== 1) { - showToast("Chat websocket is unavailable in this environment.", "warning"); + showToast( + "Chat websocket is unavailable in this environment.", + "warning", + ); return; } const userMessage = buildMessage({ role: "user", content: messageText, + debugPayload: { + source: "composer", + type: "message", + content: messageText, + sessionKey: selectedSessionKey, + }, }); setDraft(""); setDraftBySession((currentMap) => ({ @@ -512,7 +627,10 @@ export const ChatRoute = ({ setSending(true); setMessagesBySession((currentMap) => ({ ...currentMap, - [selectedSessionKey]: [...(currentMap[selectedSessionKey] || []), userMessage], + [selectedSessionKey]: [ + ...(currentMap[selectedSessionKey] || []), + userMessage, + ], })); setStreaming(true); ws.send( @@ -546,8 +664,12 @@ export const ChatRoute = ({ setSending(false); }, [appendDebugEvent, selectedSessionKey]); - const rawHistory = selectedSessionKey ? rawHistoryBySession[selectedSessionKey] : null; - const debugEvents = selectedSessionKey ? debugEventsBySession[selectedSessionKey] || [] : []; + const rawHistory = selectedSessionKey + ? rawHistoryBySession[selectedSessionKey] + : null; + const debugEvents = selectedSessionKey + ? debugEventsBySession[selectedSessionKey] || [] + : []; return html`
        @@ -564,42 +686,177 @@ export const ChatRoute = ({
        ${!selectedSessionKey - ? html`
        Select a session to begin chatting.
        ` + ? html`
        + Select a session to begin chatting. +
        ` : historyLoading ? html`
        Loading history...
        ` - : messages.length === 0 - ? html`
        Start a message in this session.
        ` - : messages.map( - (message) => html` -
        -
        - ${message.role === "user" ? "You" : "Agent"} - ${formatChatTime(message.createdAt)} -
        + : messages.length === 0 + ? html`
        + Start a message in this session. +
        ` + : messages.map( + (message) => html` ${(() => { - const parsedJson = parseJsonMessage(message.content); - if (parsedJson) { - return html`
        ${JSON.stringify(
        -                          parsedJson,
        -                          null,
        -                          2,
        -                        )}
        `; - } + const toolCalls = extractToolCallsFromPayload( + message.debugPayload, + ); + const hasVisibleContent = + String(message.content || "").trim().length > 0; + const isToolMessage = message.role === "tool"; + const shouldRenderContent = + hasVisibleContent && + !isToolMessage && + !( + toolCalls.length > 0 && + String(message.content || "").startsWith( + "Tool calls:", + ) + ); + const primaryToolCall = toolCalls[0] || null; + const matchedResult = normalizeToolResult( + message?.debugPayload?.toolResult || null, + ); return html`
        + key=${message.id} + class=${`chat-bubble ${message.role === "user" ? "is-user" : "is-assistant"}`} + > + ${!isToolMessage + ? html` +
        + ${message.role === "user" + ? "You" + : "Agent"} + ${formatChatTime(message.createdAt)} +
        + ` + : null} + ${isToolMessage && primaryToolCall + ? html` +
        + + 🛠️ + ${String( + primaryToolCall?.name || "unknown", + )} + ${formatChatTime( + message.createdAt, + )} + +
        +
        + Payload +
        +
        +${JSON.stringify(
        +                                        {
        +                                          id:
        +                                            String(primaryToolCall?.id || "") ||
        +                                            null,
        +                                          name:
        +                                            String(
        +                                              primaryToolCall?.name || "",
        +                                            ) || null,
        +                                          arguments:
        +                                            primaryToolCall?.arguments || null,
        +                                          partialJson:
        +                                            String(
        +                                              primaryToolCall?.partialJson ||
        +                                                "",
        +                                            ) || null,
        +                                        },
        +                                        null,
        +                                        2,
        +                                      )}
        + ${matchedResult + ? html` +
        + Result${matchedResult.isError + ? " (error)" + : ""} +
        +
        +${JSON.stringify(
        +                                              {
        +                                                toolCallId:
        +                                                  matchedResult.toolCallId,
        +                                                toolName:
        +                                                  matchedResult.toolName,
        +                                                text: matchedResult.text || "",
        +                                                isError: matchedResult.isError,
        +                                                rawMessage:
        +                                                  matchedResult.rawMessage ||
        +                                                  null,
        +                                              },
        +                                              null,
        +                                              2,
        +                                            )}
        + ` + : null} +
        +
        + ` + : null} + ${shouldRenderContent + ? (() => { + const parsedJson = parseJsonMessage( + message.content, + ); + if (parsedJson) { + return html`
        +${JSON.stringify(parsedJson, null, 2)}
        `; + } + return html` +
        + `; + })() + : null} + ${!isToolMessage + ? html` +
        + JSON +
        +${JSON.stringify(
        +                                      message.debugPayload || {
        +                                        role: message.role,
        +                                        content: message.content,
        +                                        createdAt: message.createdAt,
        +                                      },
        +                                      null,
        +                                      2,
        +                                    )}
        +
        + ` + : null} +
        `; })()} -
        - `, - )} + `, + )} ${selectedSessionKey && (sending || streaming) ? html`
        @@ -616,15 +873,15 @@ export const ChatRoute = ({ ${selectedSessionKey ? chatDebugEnabled ? html` -
        - Raw history JSON -
        ${JSON.stringify(rawHistory || null, null, 2)}
        -
        -
        - Inbound event log -
        ${JSON.stringify(debugEvents, null, 2)}
        -
        - ` +
        + Raw history JSON +
        ${JSON.stringify(rawHistory || null, null, 2)}
        +
        +
        + Inbound event log +
        ${JSON.stringify(debugEvents, null, 2)}
        +
        + ` : null : null}
        @@ -654,7 +911,11 @@ export const ChatRoute = ({ `} /> @@ -95,14 +95,14 @@ export const AgentSendModal = ({ emptyOptionLabel="No sessions available" />
        - +
        diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js b/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js index 6d1c4feb..034118aa 100644 --- a/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js +++ b/lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js @@ -118,7 +118,7 @@ export const ChannelItemTrailing = ({ ${canEditOrDelete ? html` <${OverflowMenuItem} - className="text-red-300 hover:text-red-200" + className="text-status-error hover:text-status-error" onClick=${() => openDeleteChannelDialog(accountData)} > Delete @@ -146,7 +146,7 @@ export const ChannelCardItem = ({
        onSetLocation(`/telegram/${encodeURIComponent(accountId)}`) @@ -163,18 +163,18 @@ export const ChannelCardItem = ({ /> ` : null} - + ${item?.label || channelMeta?.label || "Channel"} ${canOpenWorkspace ? html` - Workspace + Workspace +

        This will remove access for ${String( pendingBindAccount?.ownerAgentName || "the other agent", ).trim()} to this channel. diff --git a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js index 50cc5812..a65a1ff1 100644 --- a/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js +++ b/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js @@ -186,7 +186,7 @@ export const useChannelItems = ({ onClick: () => setShowAssignedElsewhere((current) => !current), dimmedLabel: true, trailing: html` - + ${assignedElsewhereItems.length} diff --git a/lib/public/js/components/agents-tab/agent-detail-panel.js b/lib/public/js/components/agents-tab/agent-detail-panel.js index 7532af03..4463b35b 100644 --- a/lib/public/js/components/agents-tab/agent-detail-panel.js +++ b/lib/public/js/components/agents-tab/agent-detail-panel.js @@ -92,7 +92,7 @@ export const AgentDetailPanel = ({

        -
        +
        ${agent.id}
        diff --git a/lib/public/js/components/agents-tab/agent-identity-section.js b/lib/public/js/components/agents-tab/agent-identity-section.js index ee34d06a..c8486dc7 100644 --- a/lib/public/js/components/agents-tab/agent-identity-section.js +++ b/lib/public/js/components/agents-tab/agent-identity-section.js @@ -7,8 +7,8 @@ const html = htm.bind(h); const kPropertyRowClass = "flex items-start justify-between gap-4 py-2.5 border-b border-border last:border-b-0"; -const kLabelClass = "text-xs text-gray-400 shrink-0 w-28"; -const kValueClass = "text-sm text-gray-200 text-right min-w-0 break-all"; +const kLabelClass = "text-xs text-fg-muted shrink-0 w-28"; +const kValueClass = "text-sm text-body text-right min-w-0 break-all"; const normalizeIdentity = (identity = {}) => ({ name: String(identity?.name || "").trim(), @@ -100,46 +100,46 @@ export const AgentIdentitySection = ({ ? html`
        - ${error ? html`

        ${error}

        ` : null} + ${error ? html`

        ${error}

        ` : null}
        ` : html` @@ -147,25 +147,25 @@ export const AgentIdentitySection = ({
        Name - ${identity.name || html``} + ${identity.name || html``}
        Emoji - ${identity.emoji || html``} + ${identity.emoji || html``}
        Avatar - ${identity.avatar || html``} + ${identity.avatar || html``}
        Theme - ${identity.theme || html``} + ${identity.theme || html``}
        diff --git a/lib/public/js/components/agents-tab/agent-overview/model-card.js b/lib/public/js/components/agents-tab/agent-overview/model-card.js index 53274994..ca5bd67f 100644 --- a/lib/public/js/components/agents-tab/agent-overview/model-card.js +++ b/lib/public/js/components/agents-tab/agent-overview/model-card.js @@ -81,13 +81,13 @@ export const AgentModelCard = ({
        ${loading ? html` -
        +
        <${LoadingSpinner} className="h-4 w-4" /> Loading model settings...
        ` : modelEntries.length === 0 - ? html`

        + ? html`

        No authorized models available yet. Add one from the Models tab first.

        ` @@ -100,7 +100,7 @@ export const AgentModelCard = ({ class="flex items-center justify-between py-1" >
        - + ${getModelDisplayLabel(entry)} ${entry.key === effectiveModel @@ -109,7 +109,7 @@ export const AgentModelCard = ({ @@ -139,13 +139,13 @@ export const AgentModelCard = ({ /> ${authorizedModelOptions.length === 0 ? html` -

        +

        Add and authorize models from the Models tab before assigning one here.

        ` : html` -

        +

        Only models that already have working auth are available here.

        diff --git a/lib/public/js/components/agents-tab/agent-overview/tools-card.js b/lib/public/js/components/agents-tab/agent-overview/tools-card.js index 9ea0f46e..ebee476a 100644 --- a/lib/public/js/components/agents-tab/agent-overview/tools-card.js +++ b/lib/public/js/components/agents-tab/agent-overview/tools-card.js @@ -16,7 +16,7 @@ export const AgentToolsCard = ({

        Tools

        ${profileLabel} - + ${enabledCount}/${totalCount} enabled ${agent.workspace} -
        +
        ${loadingWorkspaceSize ? "Calculating size..." : workspaceSizeBytes != null @@ -41,7 +41,7 @@ export const WorkspaceCard = ({
        ` - : html`

        No workspace configured

        `} + : html`

        No workspace configured

        `}
        `; }; diff --git a/lib/public/js/components/agents-tab/agent-pairing-section.js b/lib/public/js/components/agents-tab/agent-pairing-section.js index 8af1a324..af1c1004 100644 --- a/lib/public/js/components/agents-tab/agent-pairing-section.js +++ b/lib/public/js/components/agents-tab/agent-pairing-section.js @@ -293,7 +293,7 @@ export const AgentPairingSection = ({ agent = {} }) => { return html`

        Pairing

        -

        Loading pairing status...

        +

        Loading pairing status...

        `; } diff --git a/lib/public/js/components/agents-tab/agent-tools/index.js b/lib/public/js/components/agents-tab/agent-tools/index.js index 04eb84e9..3100d833 100644 --- a/lib/public/js/components/agents-tab/agent-tools/index.js +++ b/lib/public/js/components/agents-tab/agent-tools/index.js @@ -23,13 +23,13 @@ const kProfileOptions = kToolProfiles.map((p) => ({ const ToolRow = ({ tool, onToggle }) => html`
        -
        +
        ${tool.label} ${tool.help ? html`<${InfoTooltip} text=${tool.help} widthClass="w-72" />` : null}
        - ${tool.id} + ${tool.id}
        <${ToggleSwitch} checked=${tool.enabled} @@ -72,7 +72,7 @@ export const AgentToolsPanel = ({ agent = {}, tools = {} }) => {

        Preset

        - ${enabledTotal}/${totalTools} tools enabled
        diff --git a/lib/public/js/components/agents-tab/create-agent-modal.js b/lib/public/js/components/agents-tab/create-agent-modal.js index ddcf681f..e8344a54 100644 --- a/lib/public/js/components/agents-tab/create-agent-modal.js +++ b/lib/public/js/components/agents-tab/create-agent-modal.js @@ -112,25 +112,25 @@ export const CreateAgentModal = ({ class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary" aria-label="Close modal" > - <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" /> + <${CloseIcon} className="w-3.5 h-3.5 text-body" /> `} />
        - `};var e4=P.bind(T),oh=({jobId:t="",onSetLocation:e=()=>{}})=>e4` - <${Y$} jobId=${t} onSetLocation=${e} /> -`;var ew=(t="")=>{let e=String(t||"").trim().toUpperCase();return e==="P0"?"danger":e==="P1"?"warning":"neutral"};var tw=(t="")=>{let e=String(t||"").trim().toLowerCase().replace(/[_-]+/g," ");return e==="token efficiency"?"info":e==="redundancy"?"accent":e==="mixed concerns"?"cyan":e==="workspace state"?"secondary":"info"},nw=(t="")=>{let e=String(t||"").trim().replace(/[_-]+/g," ");return e?e.replace(/\b\w/g,n=>n.toUpperCase()):"Workspace"},sw=(t=[])=>t.reduce((e,n)=>{let s=String(n?.priority||"").trim().toUpperCase();return(s==="P0"||s==="P1"||s==="P2")&&(e[s]+=1),e},{P0:0,P1:0,P2:0});var hc=(t=null,e=0)=>!t||t.runInProgress||t.needsInitialRun||!t.stale||!t.changeSummary?.hasMeaningfulChanges?!1:Number(e||0)<=Date.now(),ow=(t=null)=>{if(!t)return"";let e=Number(t.changeSummary?.changedFilesCount||0);return e>0?`Drift Doctor has not been run in the last week and ${e} file${e===1?"":"s"} changed since the last review.`:"Doctor has not been run in the last week."},X$=(t=0)=>`${Number(t||0).toLocaleString()} chars`,Q$=(t="")=>String(t||"").startsWith("hooks/bootstrap/"),fc=(t=null)=>{let e=t?.bootstrapContext,n=(e?.activeTruncatedFiles||[]).filter(o=>!Q$(o?.path)),s=(e?.activeNearLimitFiles||[]).filter(o=>!Q$(o?.path));return[...n.map(o=>({path:o.path,size:X$(o.rawChars),statusText:`-${Number(Math.max(0,Number(o.rawChars||0)-Number(o.injectedChars||0))).toLocaleString()} cut`,statusTone:"danger"})),...s.map(o=>({path:o.path,size:X$(o.rawChars),statusText:"Near limit",statusTone:"warning"}))]},rw=(t=null)=>fc(t).length>0,iw=(t=null)=>{let e=fc(t);if(!e.length)return"";let n=e.some(o=>o.statusTone==="danger"),s=e.some(o=>o.statusTone==="warning");return n&&s?"Some of your main files are being truncated or nearing the limit:":s?e.length===1?"One of your main files is nearing the limit:":"Some of your main files are nearing the limit:":e.length===1?"One of your main files is being truncated:":"Some of your main files are being truncated:"},aw=(t=null)=>{let e=Number(t?.changedFilesCount||0);return e===0?"No changes since last run":`${e} change${e===1?"":"s"} since last run`},lw=(t=null)=>!t||typeof t!="object"?"":t.status==="running"?"Running":t.status==="failed"?"Failed":(t.cardCount||0)===0?"No findings":`${t.cardCount||0} finding${t.cardCount===1?"":"s"}`,cw=(t=null)=>{if(!t||typeof t!="object")return[];if(t.status==="running")return[{tone:"cyan",count:0,label:"Running"}];if(t.status==="failed")return[{tone:"neutral",count:0,label:"Failed"}];if((t.cardCount||0)===0)return[{tone:"success",count:0,label:"No findings"}];let e=[];return Number(t?.priorityCounts?.P0||0)>0&&e.push({tone:"danger",count:0,label:"P0"}),Number(t?.priorityCounts?.P1||0)>0&&e.push({tone:"warning",count:0,label:"P1"}),e.length>0?e.slice(0,2):[{tone:"neutral",count:0,label:"P2"}]},dw=()=>[{value:"open",label:"Open"},{value:"dismissed",label:"Dismissed"},{value:"fixed",label:"Fixed"}];var t4=P.bind(T),uw=({cards:t=[]})=>{let e=sw(t);return t4` + `};var e3=P.bind(T),of=({jobId:t="",onSetLocation:e=()=>{}})=>e3` + <${Y0} jobId=${t} onSetLocation=${e} /> +`;var ew=(t="")=>{let e=String(t||"").trim().toUpperCase();return e==="P0"?"danger":e==="P1"?"warning":"neutral"};var tw=(t="")=>{let e=String(t||"").trim().toLowerCase().replace(/[_-]+/g," ");return e==="token efficiency"?"info":e==="redundancy"?"accent":e==="mixed concerns"?"cyan":e==="workspace state"?"secondary":"info"},nw=(t="")=>{let e=String(t||"").trim().replace(/[_-]+/g," ");return e?e.replace(/\b\w/g,n=>n.toUpperCase()):"Workspace"},sw=(t=[])=>t.reduce((e,n)=>{let s=String(n?.priority||"").trim().toUpperCase();return(s==="P0"||s==="P1"||s==="P2")&&(e[s]+=1),e},{P0:0,P1:0,P2:0});var fc=(t=null,e=0)=>!t||t.runInProgress||t.needsInitialRun||!t.stale||!t.changeSummary?.hasMeaningfulChanges?!1:Number(e||0)<=Date.now(),ow=(t=null)=>{if(!t)return"";let e=Number(t.changeSummary?.changedFilesCount||0);return e>0?`Drift Doctor has not been run in the last week and ${e} file${e===1?"":"s"} changed since the last review.`:"Doctor has not been run in the last week."},X0=(t=0)=>`${Number(t||0).toLocaleString()} chars`,Q0=(t="")=>String(t||"").startsWith("hooks/bootstrap/"),hc=(t=null)=>{let e=t?.bootstrapContext,n=(e?.activeTruncatedFiles||[]).filter(o=>!Q0(o?.path)),s=(e?.activeNearLimitFiles||[]).filter(o=>!Q0(o?.path));return[...n.map(o=>({path:o.path,size:X0(o.rawChars),statusText:`-${Number(Math.max(0,Number(o.rawChars||0)-Number(o.injectedChars||0))).toLocaleString()} cut`,statusTone:"danger"})),...s.map(o=>({path:o.path,size:X0(o.rawChars),statusText:"Near limit",statusTone:"warning"}))]},rw=(t=null)=>hc(t).length>0,iw=(t=null)=>{let e=hc(t);if(!e.length)return"";let n=e.some(o=>o.statusTone==="danger"),s=e.some(o=>o.statusTone==="warning");return n&&s?"Some of your main files are being truncated or nearing the limit:":s?e.length===1?"One of your main files is nearing the limit:":"Some of your main files are nearing the limit:":e.length===1?"One of your main files is being truncated:":"Some of your main files are being truncated:"},aw=(t=null)=>{let e=Number(t?.changedFilesCount||0);return e===0?"No changes since last run":`${e} change${e===1?"":"s"} since last run`},lw=(t=null)=>!t||typeof t!="object"?"":t.status==="running"?"Running":t.status==="failed"?"Failed":(t.cardCount||0)===0?"No findings":`${t.cardCount||0} finding${t.cardCount===1?"":"s"}`,cw=(t=null)=>{if(!t||typeof t!="object")return[];if(t.status==="running")return[{tone:"cyan",count:0,label:"Running"}];if(t.status==="failed")return[{tone:"neutral",count:0,label:"Failed"}];if((t.cardCount||0)===0)return[{tone:"success",count:0,label:"No findings"}];let e=[];return Number(t?.priorityCounts?.P0||0)>0&&e.push({tone:"danger",count:0,label:"P0"}),Number(t?.priorityCounts?.P1||0)>0&&e.push({tone:"warning",count:0,label:"P1"}),e.length>0?e.slice(0,2):[{tone:"neutral",count:0,label:"P2"}]},dw=()=>[{value:"open",label:"Open"},{value:"dismissed",label:"Dismissed"},{value:"fixed",label:"Fixed"}];var t3=P.bind(T),uw=({cards:t=[]})=>{let e=sw(t);return t3`
        <${cs} title="Open Findings" value=${t.length} /> - <${cs} title="P0" value=${e.P0} toneClassName="text-red-400" /> - <${cs} title="P1" value=${e.P1} toneClassName="text-yellow-400" /> - <${cs} title="P2" value=${e.P2} toneClassName="text-gray-300" /> + <${cs} title="P0" value=${e.P0} toneClassName="text-status-error-muted" /> + <${cs} title="P1" value=${e.P1} toneClassName="text-status-warning-muted" /> + <${cs} title="P2" value=${e.P2} toneClassName="text-body" />
        - `};var lt=P.bind(T),n4=t=>t?typeof t=="string"?{path:t}:typeof t=="object"&&t.path?t:null:null,rh=(t,e,n)=>e&&n&&n>e?`${t}:${e}-${n}`:e?`${t}:${e}`:t,ih=(t,e)=>{let n={};return t&&(n.line=t),e&&e>t&&(n.lineEnd=e),n},s4=(t,e,{startLine:n,endLine:s}={})=>{let o=rh(t,n,s);return lt` + `};var lt=P.bind(T),n3=t=>t?typeof t=="string"?{path:t}:typeof t=="object"&&t.path?t:null:null,rf=(t,e,n)=>e&&n&&n>e?`${t}:${e}-${n}`:e?`${t}:${e}`:t,af=(t,e)=>{let n={};return t&&(n.line=t),e&&e>t&&(n.lineEnd=e),n},s3=(t,e,{startLine:n,endLine:s}={})=>{let o=rf(t,n,s);return lt` - `},pw=7,o4=({item:t,onOpenFile:e,isOutdated:n})=>{let s=t.snippet,o=String(s.text||"").split(` -`),r=o.length>pw,[i,a]=x(!r),l=i?o:o.slice(0,pw),c=String(s.endLine||s.startLine||1).length;return lt` + `},pw=7,o3=({item:t,onOpenFile:e,isOutdated:n})=>{let s=t.snippet,o=String(s.text||"").split(` +`),r=o.length>pw,[i,a]=y(!r),l=i?o:o.slice(0,pw),c=String(s.endLine||s.startLine||1).length;return lt`
        - ${n?lt`file changed since scan`:lt`snapshot`} + >`:lt`snapshot`}
        ${l.map((d,u)=>lt`
        ${s.startLine+u}${d||" "}
        `)} - ${i&&s.truncated?lt`
        ... truncated
        `:""} + ${i&&s.truncated?lt`
        ... truncated
        `:""}
        ${r&&!i?lt`
        - `},r4=(t={},e,n)=>{if(t?.path&&t?.snippet){let s=n.has(t.path);return lt`<${o4} + `},r3=(t={},e,n)=>{if(t?.path&&t?.snippet){let s=n.has(t.path);return lt`<${o3} item=${t} onOpenFile=${e} isOutdated=${s} - />`}return t?.path?s4(t.path,e,{startLine:t.startLine,endLine:t.endLine}):t?.text?t.text:JSON.stringify(t)},hw=({cards:t=[],busyCardId:e=0,onAskAgentFix:n=()=>{},onUpdateStatus:s=()=>{},onOpenFile:o=()=>{},changedPaths:r=[],showRunMeta:i=!1,hideEmptyState:a=!1})=>{let l=new Set(r);return lt` + />`}return t?.path?s3(t.path,e,{startLine:t.startLine,endLine:t.endLine}):t?.text?t.text:JSON.stringify(t)},fw=({cards:t=[],busyCardId:e=0,onAskAgentFix:n=()=>{},onUpdateStatus:s=()=>{},onOpenFile:o=()=>{},changedPaths:r=[],showRunMeta:i=!1,hideEmptyState:a=!1})=>{let l=new Set(r);return lt`
        ${t.length?lt`
        @@ -5445,7 +5445,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s <${ae} tone=${ew(c.priority)}> ${c.priority} -

        +

        ${c.title}

        @@ -5454,7 +5454,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s ${nw(c.category)} ${i?lt` - Run #${c.runId} `:null} @@ -5462,16 +5462,16 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        ${c.summary?lt`

        ${c.summary}

        `:null}
        -
        - +
        + @@ -5483,7 +5483,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        Recommendation
        -

        +

        ${c.recommendation}

        @@ -5493,11 +5493,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s Target paths
        - ${c.targetPaths.map(d=>{let u=n4(d);if(!u)return null;let p=rh(u.path,u.startLine,u.endLine);return lt` + ${c.targetPaths.map(d=>{let u=n3(d);if(!u)return null;let p=rf(u.path,u.startLine,u.endLine);return lt` @@ -5510,8 +5510,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        Evidence
        ${c.evidence.map(d=>lt` -
        - ${r4(d,o,l)} +
        + ${r3(d,o,l)}
        `)}
        @@ -5559,22 +5559,22 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        `:a?null:lt`
        -

        +

        No findings currently for this selection.

        `}
        - `};var zn=P.bind(T),vr=({label:t="Send to session",sessions:e=[],selectedSessionKey:n="",onChangeSessionKey:s=()=>{},disabled:o=!1,loading:r=!1,error:i="",allowNone:a=!1,noneValue:l="__none__",noneLabel:c="None",emptyOptionLabel:d="No sessions available",helperText:u="",emptyStateText:p="",loadingLabel:h="Loading sessions...",containerClassName:m="space-y-2",labelClassName:g="text-xs text-gray-500",selectClassName:f="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500",helperClassName:b="text-xs text-gray-500",statusClassName:y="text-xs text-gray-500",errorClassName:v="text-xs text-red-400"})=>{let w=n||(a?l:""),$=o||r;return zn` -
        - ${t?zn``:null} + `};var zn=P.bind(T),vr=({label:t="Send to session",sessions:e=[],selectedSessionKey:n="",onChangeSessionKey:s=()=>{},disabled:o=!1,loading:r=!1,error:i="",allowNone:a=!1,noneValue:l="__none__",noneLabel:c="None",emptyOptionLabel:d="No sessions available",helperText:u="",emptyStateText:p="",loadingLabel:f="Loading sessions...",containerClassName:g="space-y-2",labelClassName:m="text-xs text-fg-muted",selectClassName:h="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body focus:border-fg-muted",helperClassName:b="text-xs text-fg-muted",statusClassName:x="text-xs text-fg-muted",errorClassName:v="text-xs text-status-error-muted"})=>{let w=n||(a?l:""),$=o||r;return zn` +
        + ${t?zn``:null} ${u?zn`
        ${u}
        `:null} ${i?zn`
        ${i}
        `:null} - ${!r&&!i&&p&&e.length===0?zn`
        ${p}
        `:null} + ${!r&&!i&&p&&e.length===0?zn`
        ${p}
        `:null}
        - `};var fw=P.bind(T),gc=({visible:t=!1,title:e="Send to agent",messageLabel:n="Message",messageRows:s=8,initialMessage:o="",resetKey:r="",submitLabel:i="Send message",loadingLabel:a="Sending...",cancelLabel:l="Cancel",onClose:c=()=>{},onSubmit:d=async()=>!0,sessionFilter:u=void 0})=>{let{sessions:p,selectedSessionKey:h,setSelectedSessionKey:m,selectedSession:g,loading:f,error:b}=xr({enabled:t,filter:u}),[y,v]=x(""),[w,$]=x(!1);L(()=>{t&&v(String(o||""))},[t,o,r]);let S=async()=>{if(!g||w)return;let C=String(y||"").trim();if(C){$(!0);try{await d({selectedSession:g,selectedSessionKey:h,message:C})!==!1&&c()}finally{$(!1)}}};return fw` + `};var hw=P.bind(T),mc=({visible:t=!1,title:e="Send to agent",messageLabel:n="Message",messageRows:s=8,initialMessage:o="",resetKey:r="",submitLabel:i="Send message",loadingLabel:a="Sending...",cancelLabel:l="Cancel",onClose:c=()=>{},onSubmit:d=async()=>!0,sessionFilter:u=void 0})=>{let{sessions:p,selectedSessionKey:f,setSelectedSessionKey:g,selectedSession:m,loading:h,error:b}=yr({enabled:t,filter:u}),[x,v]=y(""),[w,$]=y(!1);L(()=>{t&&v(String(o||""))},[t,o,r]);let S=async()=>{if(!m||w)return;let C=String(x||"").trim();if(C){$(!0);try{await d({selectedSession:m,selectedSessionKey:f,message:C})!==!1&&c()}finally{$(!1)}}};return hw` <${De} visible=${t} onClose=${()=>{w||c()}} @@ -5596,35 +5596,35 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s > <${Ne} title=${e} - actions=${fw` + actions=${hw` `} /> <${vr} label="Send to session" sessions=${p} - selectedSessionKey=${h} - onChangeSessionKey=${m} - disabled=${f||w} - loading=${f} + selectedSessionKey=${f} + onChangeSessionKey=${g} + disabled=${h||w} + loading=${h} error=${b} emptyOptionLabel="No sessions available" />
        - +
        @@ -5637,7 +5637,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s /> <${Z} onClick=${S} - disabled=${!g||f||!!b||!String(y||"").trim()} + disabled=${!m||h||!!b||!String(x||"").trim()} loading=${w} tone="primary" size="md" @@ -5646,8 +5646,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s />
        - `};var i4=P.bind(T),gw=({visible:t=!1,card:e=null,onClose:n=()=>{},onComplete:s=()=>{}})=>{let o=async({selectedSession:r,message:i})=>{if(!e?.id)return!1;try{await tg({cardId:e.id,sessionId:r?.sessionId||"",replyChannel:r?.replyChannel||"",replyTo:r?.replyTo||"",prompt:i});try{await pa({cardId:e.id,status:"fixed"}),N("Doctor fix request sent and finding marked fixed","success")}catch(a){N(a.message||"Doctor fix request sent, but could not mark the finding fixed","warning")}return await s(),!0}catch(a){return N(a.message||"Could not send Doctor fix request","error"),!1}};return i4` - <${gc} + `};var i3=P.bind(T),mw=({visible:t=!1,card:e=null,onClose:n=()=>{},onComplete:s=()=>{}})=>{let o=async({selectedSession:r,message:i})=>{if(!e?.id)return!1;try{await tm({cardId:e.id,sessionId:r?.sessionId||"",replyChannel:r?.replyChannel||"",replyTo:r?.replyTo||"",prompt:i});try{await pa({cardId:e.id,status:"fixed"}),N("Doctor fix request sent and finding marked fixed","success")}catch(a){N(a.message||"Doctor fix request sent, but could not mark the finding fixed","warning")}return await s(),!0}catch(a){return N(a.message||"Could not send Doctor fix request","error"),!1}};return i3` + <${mc} visible=${t} title="Ask agent to fix" messageLabel="Instructions" @@ -5658,7 +5658,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s onClose=${n} onSubmit=${o} /> - `};var $t=P.bind(T),ah=15e3,mw=2e3,a4=()=>$t` + `};var $t=P.bind(T),lf=15e3,gw=2e3,a3=()=>$t` {r.current&&(r.current.destroy(),r.current=null)}},[h,i,s d="M8 20V14H16V20H19V4H5V20H8ZM10 20H14V16H10V20ZM21 20H23V22H1V20H3V3C3 2.44772 3.44772 2 4 2H20C20.5523 2 21 2.44772 21 3V20ZM11 8V6H13V8H15V10H13V12H11V10H9V8H11Z" > -`,bw=({isActive:t=!1,onOpenFile:e=()=>{}})=>{let n=Re(ua,ah,{enabled:t}),s=n.data?.status||null,o=s?.runInProgress?mw:ah,r=Re(()=>Qf(10),o,{enabled:t}),[i,a]=x("all"),[l,c]=x("open"),[d,u]=x(0),[p,h]=x(null),[m,g]=x(""),f=r.data?.runs||[],b=String(s?.activeRunId||""),y=String(i||""),w=y!==""&&y!=="all"&&!f.some(J=>String(J.id||"")===y)&&(m===y||s?.runInProgress&&b===y)?{id:Number(y||0),status:"running",summary:"",priorityCounts:{P0:0,P1:0,P2:0},statusCounts:{open:0,dismissed:0,fixed:0}}:null,$=w?[w,...f]:f,S=i!=="all"&&!!b&&String(i||"")===b,C=i==="all"?null:$.find(J=>String(J.id||"")===String(i||""))||null,_=Re(()=>eg({runId:i||"all"}),s?.runInProgress||C?.status==="running"?mw:ah,{enabled:t}),k=_.data?.cards||[];L(()=>{t&&(n.refresh(),r.refresh())},[t]),L(()=>{if(!f.length){if(m&&y===m||S&&s?.runInProgress)return;i!=="all"&&a("all");return}i==="all"||f.some(oe=>String(oe.id||"")===String(i||""))||S&&s?.runInProgress||a("all")},[f,y,i,S,m,s?.runInProgress]),L(()=>{if(!m)return;if(i!==m){a(m);return}let J=f.some(ce=>String(ce.id||"")===String(m||"")),oe=!!b&&b===m&&!!s?.runInProgress;!J&&!oe||g("")},[b,s?.runInProgress,m,f,i]),L(()=>{_.refresh()},[i]);let A=C?.status==="running"||S&&s?.runInProgress,O=F(()=>A?"":C?.summary||"",[C,A]),D=F(()=>dw(),[]),j=F(()=>aw(s?.changeSummary||null),[s]),E=F(()=>s?.runInProgress||s?.needsInitialRun?!0:Number(s?.changeSummary?.changedFilesCount||0)>0,[s]),M=E?"":"No workspace changes since the last completed Drift Doctor run.",R=F(()=>hc(s,0),[s]),B=F(()=>rw(s),[s]),I=F(()=>iw(s),[s]),U=F(()=>fc(s),[s]),W=!!s?.lastRunAt,G=f.length>0,se=r.data!==null||r.error!==null,K=_.data!==null||_.error!==null,z=!se||G&&!K,X=F(()=>l==="all"?k:k.filter(J=>String(J?.status||"open").trim().toLowerCase()===l),[k,l]),de=F(()=>k.filter(J=>String(J?.status||"open").trim().toLowerCase()==="open"),[k]),le=F(()=>$.slice(0,2),[$]),ie=F(()=>$.slice(2),[$]),ne=F(()=>i==="all"?"":ie.some(J=>String(J.id||"")===String(i||""))?String(i||""):"",[ie,i]),ye=(J=!1)=>["inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",J?"border-cyan-500/40 bg-cyan-500/10 text-cyan-200 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]":"border-border bg-black/20 text-gray-300 hover:border-gray-500 hover:text-gray-100"].join(" "),fe=(J="neutral")=>J==="success"?"bg-green-400":J==="warning"?"bg-yellow-400":J==="danger"?"bg-red-400":J==="cyan"?"ac-status-dot ac-status-dot--info":"bg-gray-500",ue=!z&&(f.length>0||!!m||!!b||!!s?.runInProgress),ge=async()=>{try{let J=await Xf();if(N(J?.reusedPreviousRun?"No workspace changes since the last scan; reused previous findings":"Doctor run started","success"),J?.runId){let oe=String(J.runId);g(oe),a(oe)}n.refresh(),r.refresh(),_.refresh(),setTimeout(n.refresh,1200),setTimeout(r.refresh,1200),setTimeout(_.refresh,1200)}catch(J){N(J.message||"Could not start Doctor run","error")}},V=async(J,oe)=>{if(!(!J?.id||d))try{u(J.id),await pa({cardId:J.id,status:oe}),N("Doctor card updated","success"),await _.refresh(),await r.refresh(),await n.refresh()}catch(ce){N(ce.message||"Could not update Doctor card","error")}finally{u(0)}};return $t` +`,bw=({isActive:t=!1,onOpenFile:e=()=>{}})=>{let n=Re(ua,lf,{enabled:t}),s=n.data?.status||null,o=s?.runInProgress?gw:lf,r=Re(()=>Qh(10),o,{enabled:t}),[i,a]=y("all"),[l,c]=y("open"),[d,u]=y(0),[p,f]=y(null),[g,m]=y(""),h=r.data?.runs||[],b=String(s?.activeRunId||""),x=String(i||""),w=x!==""&&x!=="all"&&!h.some(J=>String(J.id||"")===x)&&(g===x||s?.runInProgress&&b===x)?{id:Number(x||0),status:"running",summary:"",priorityCounts:{P0:0,P1:0,P2:0},statusCounts:{open:0,dismissed:0,fixed:0}}:null,$=w?[w,...h]:h,S=i!=="all"&&!!b&&String(i||"")===b,C=i==="all"?null:$.find(J=>String(J.id||"")===String(i||""))||null,_=Re(()=>em({runId:i||"all"}),s?.runInProgress||C?.status==="running"?gw:lf,{enabled:t}),k=_.data?.cards||[];L(()=>{t&&(n.refresh(),r.refresh())},[t]),L(()=>{if(!h.length){if(g&&x===g||S&&s?.runInProgress)return;i!=="all"&&a("all");return}i==="all"||h.some(oe=>String(oe.id||"")===String(i||""))||S&&s?.runInProgress||a("all")},[h,x,i,S,g,s?.runInProgress]),L(()=>{if(!g)return;if(i!==g){a(g);return}let J=h.some(ce=>String(ce.id||"")===String(g||"")),oe=!!b&&b===g&&!!s?.runInProgress;!J&&!oe||m("")},[b,s?.runInProgress,g,h,i]),L(()=>{_.refresh()},[i]);let A=C?.status==="running"||S&&s?.runInProgress,O=F(()=>A?"":C?.summary||"",[C,A]),D=F(()=>dw(),[]),j=F(()=>aw(s?.changeSummary||null),[s]),E=F(()=>s?.runInProgress||s?.needsInitialRun?!0:Number(s?.changeSummary?.changedFilesCount||0)>0,[s]),M=E?"":"No workspace changes since the last completed Drift Doctor run.",R=F(()=>fc(s,0),[s]),B=F(()=>rw(s),[s]),I=F(()=>iw(s),[s]),U=F(()=>hc(s),[s]),W=!!s?.lastRunAt,G=h.length>0,se=r.data!==null||r.error!==null,K=_.data!==null||_.error!==null,z=!se||G&&!K,X=F(()=>l==="all"?k:k.filter(J=>String(J?.status||"open").trim().toLowerCase()===l),[k,l]),de=F(()=>k.filter(J=>String(J?.status||"open").trim().toLowerCase()==="open"),[k]),le=F(()=>$.slice(0,2),[$]),ie=F(()=>$.slice(2),[$]),ne=F(()=>i==="all"?"":ie.some(J=>String(J.id||"")===String(i||""))?String(i||""):"",[ie,i]),xe=(J=!1)=>["inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",J?"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]":"border-border bg-field text-body hover:border-fg-muted hover:text-bright"].join(" "),he=(J="neutral")=>J==="success"?"bg-green-400":J==="warning"?"bg-yellow-400":J==="danger"?"bg-red-400":J==="cyan"?"ac-status-dot ac-status-dot--info":"bg-gray-500",ue=!z&&(h.length>0||!!g||!!b||!!s?.runInProgress),me=async()=>{try{let J=await Xh();if(N(J?.reusedPreviousRun?"No workspace changes since the last scan; reused previous findings":"Doctor run started","success"),J?.runId){let oe=String(J.runId);m(oe),a(oe)}n.refresh(),r.refresh(),_.refresh(),setTimeout(n.refresh,1200),setTimeout(r.refresh,1200),setTimeout(_.refresh,1200)}catch(J){N(J.message||"Could not start Doctor run","error")}},V=async(J,oe)=>{if(!(!J?.id||d))try{u(J.id),await pa({cardId:J.id,status:oe}),N("Doctor card updated","success"),await _.refresh(),await r.refresh(),await n.refresh()}catch(ce){N(ce.message||"Could not update Doctor card","error")}finally{u(0)}};return $t`
        ${ue?$t` <${Ne} title="Drift Doctor" actions=${$t` <${Z} - onClick=${ge} + onClick=${me} disabled=${!E} loading=${!!s?.runInProgress} idleLabel="Run Drift Doctor" @@ -5688,7 +5688,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s `:null} ${z?$t`
        -
        +
        <${Ce} className="h-4 w-4" /> Loading Drift Doctor...
        @@ -5702,13 +5702,13 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        - + Last run ·${" "} - + ${Wo(s?.lastRunAt,{fallback:"Never"})} - + ${j}
        @@ -5716,7 +5716,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        -
        +
        ⚠️ ${I}
        @@ -5726,7 +5726,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s >
        -

        +

        Truncated files become partially hidden from your agent and could cause drift.

        @@ -5756,7 +5756,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s `:null} ${R?$t`
        Doctor should be run again because the latest completed run is older than one week and the workspace has @@ -5774,7 +5774,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        @@ -6185,11 +6185,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s onInput=${z=>B(z.target.value)} onPaste=${z=>M(z,"val")} onKeyDown=${z=>z.key==="Enter"&&I()} - class="flex-1 bg-black/30 border border-border rounded-lg px-3 py-1.5 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono" + class="flex-1 bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono" /> @@ -6197,18 +6197,18 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        - `};var $4=P.bind(T),ch=({onRestartRequired:t=()=>{}})=>$4` + `};var $3=P.bind(T),df=({onRestartRequired:t=()=>{}})=>$3` <${ww} onRestartRequired=${t} /> -`;var Qt=P.bind(T),w4=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},k4=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=x(!1),[l,c]=x(e||null),[d,u]=x(null),[p,h]=x(!1),[m,g]=x(""),[f,b]=x(!1),[y,v]=x(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",O=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(I=!1)=>{try{let U=await n(I);if(!R)return;c(U.currentVersion||e||null),u(U.latestVersion||null),h(!!U.hasUpdate),g(U.ok?"":U.error||"")}catch(U){if(!R)return;g(U.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let I=await n(!0);if(!R)return;c(I.currentVersion||e||null),u(I.latestVersion||null),h(!!I.hasUpdate),g(I.ok?"":I.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let D=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),g("");try{let I=R?await s():await n(!0);c(I.currentVersion||l),u(I.latestVersion||null),h(!!I.hasUpdate),g(I.ok?"":I.error||""),R?I.ok?I.updated||I.restarting?N(I.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${I.currentVersion}`,"success"):N(`Already at latest ${t} version`,"success"):N(I.error||`${t} update failed`,"error"):I.hasUpdate&&I.latestVersion?N(`${t} update available: ${I.latestVersion}`,"warning"):N(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!I?.ok,result:I})}catch(I){g(I.message||(R?`Could not update ${t}`:"Could not check updates")),N(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:I})}finally{a(!1)}}},j=()=>{if(!(_?i||o:i)){if(S&&C&&!f){v(!0);return}D()}},E=()=>{v(!1),D()},M=_?i||o:i;return Qt` +`;var Qt=P.bind(T),w3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},k3=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",O=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(I=!1)=>{try{let U=await n(I);if(!R)return;c(U.currentVersion||e||null),u(U.latestVersion||null),f(!!U.hasUpdate),m(U.ok?"":U.error||"")}catch(U){if(!R)return;m(U.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let I=await n(!0);if(!R)return;c(I.currentVersion||e||null),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let D=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),m("");try{let I=R?await s():await n(!0);c(I.currentVersion||l),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||""),R?I.ok?I.updated||I.restarting?N(I.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${I.currentVersion}`,"success"):N(`Already at latest ${t} version`,"success"):N(I.error||`${t} update failed`,"error"):I.hasUpdate&&I.latestVersion?N(`${t} update available: ${I.latestVersion}`,"warning"):N(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!I?.ok,result:I})}catch(I){m(I.message||(R?`Could not update ${t}`:"Could not check updates")),N(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:I})}finally{a(!1)}}},j=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}D()}},E=()=>{v(!1),D()},M=_?i||o:i;return Qt`
        -

        - ${t}${" "}${l?`${l}`:"..."} +

        + ${t}${" "}${l?`${l}`:"..."}

        - ${m&&S&&Qt`
        - ${m} + ${g}
        `}
        `} <${st} - visible=${y} + visible=${x} title="Update without changelog?" message="Are you sure you want to update without viewing the changelog?" confirmLabel=${`Update to ${C||"latest"}`} @@ -6272,20 +6272,20 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s onCancel=${()=>v(!1)} onConfirm=${E} /> - `},bc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ga})=>{let[u,p]=x(()=>Date.now()),h=t==="running"&&!n,m=h?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",g=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,f=g==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":g==="degraded"?"bg-yellow-500":g==="unhealthy"||g==="crash_loop"?"bg-red-500":"bg-gray-500",b=g==="unknown"?"initializing":g||"unknown",y=a||!!o?.operationInProgress,v=g==="degraded"&&!!r,w=y||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),Qt`
        + `},bc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ma})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,w=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),Qt`
        - + Gateway: - ${n?"restarting...":t||"checking..."}
        - ${!n&&h?Qt` - Uptime: ${w4($)}Uptime: ${w3($)} `:null} <${tn} @@ -6306,18 +6306,18 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s title="Open Watchdog tab" > Watchdog: - ${b} + ${b} `:Qt`
        Watchdog: - ${b} + ${b}
        `} ${i?Qt` @@ -6333,7 +6333,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s `:w?Qt` <${tn} onClick=${i} - loading=${y} + loading=${x} warning=${!0} idleLabel="Repair" loadingLabel="Repairing..." @@ -6348,28 +6348,28 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        - <${k4} + <${k3} label="OpenClaw" currentVersion=${e} - fetchVersion=${fg} + fetchVersion=${hm} applyUpdate=${d} updateInProgress=${l} onActionComplete=${c} />
        -
        `};var $r=P.bind(T),S4={webchat:"Browser",cli:"CLI"},C4=t=>S4[t.clientMode]||t.clientId||"Device",_4=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},M4=({d:t,onApprove:e,onReject:n})=>{let[s,o]=x(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=C4(t),a=_4(t);return s==="approve"?$r` -
        - Approved - ${i} +
        `};var $r=P.bind(T),S3={webchat:"Browser",cli:"CLI"},C3=t=>S3[t.clientMode]||t.clientId||"Device",_3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},M3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=C3(t),a=_3(t);return s==="approve"?$r` +
        + Approved + ${i}
        `:s==="reject"?$r` -
        - Rejected - ${i} +
        + Rejected + ${i}
        `:$r` -
        +
        ${i} - ${a&&$r`${a}`} + ${a&&$r`${a}`}
        <${Z} @@ -6387,30 +6387,30 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s className="font-medium px-3 py-1.5" />
        -
        `},yc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r` +
        `},xc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r`
        -

        Pending device pairings

        - ${t.map(s=>$r`<${M4} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} -
        `;var ho=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],A4={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function kw(t){return`https://console.developers.google.com/apis/api/${A4[t]||""}/overview`}function Sw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=x(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return ho`
        - ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),h=i[d.key],m=null;return s&&!h&&(u||p)?m=ho``:h&&(h.status==="ok"?m=ho`API ✓`:h.status==="not_enabled"?m=ho`Enable API`:h.status==="error"&&(m=ho`Enable API`)),ho` -
        +

        Pending device pairings

        + ${t.map(s=>$r`<${M3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} +
        `;var fo=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],A3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function kw(t){return`https://console.developers.google.com/apis/api/${A3[t]||""}/overview`}function Sw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return fo`
        + ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=fo``:f&&(f.status==="ok"?g=fo`API ✓`:f.status==="not_enabled"?g=fo`Enable API`:f.status==="error"&&(g=fo`Enable API`)),fo` +
        ${d.icon} ${d.label}
        - ${m} + ${g}
        `})} - ${l?ho` + ${l?fo` `:null} -
        `}function Cw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),_w=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=x(""),[p,h]=x(""),[m,g]=x(""),[f,b]=x(""),[y,v]=x(!1),[w,$]=x(r),[S,C]=x(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),h(String(c.clientSecret||"")),g(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let I=await B.text(),U=JSON.parse(I),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&h(W.client_secret)}catch{b("Invalid JSON file")}},D=async()=>{if(b(""),!d||!p||!m){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zf({clientId:d,clientSecret:p,email:m,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},j=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr` +
        `}function Cw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),_w=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let I=await B.text(),U=JSON.parse(I),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&f(W.client_secret)}catch{b("Invalid JSON file")}},D=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},j=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr`
        {r.current&&(r.current.destroy(),r.current=null)}},[h,i,s value=${k} onFocus=${R=>R.target.select()} onclick=${R=>R.target.select()} - class="flex-1 min-w-0 bg-black/40 border border-border rounded px-2 py-1 text-gray-300 text-xs focus:outline-none focus:border-gray-500" + class="flex-1 min-w-0 bg-field border border-border rounded px-2 py-1 text-body text-xs focus:outline-none focus:border-fg-muted" /> @@ -6443,13 +6443,13 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary" aria-label="Close modal" > - <${nt} className="w-3.5 h-3.5 text-gray-300" /> + <${nt} className="w-3.5 h-3.5 text-body" /> `} />
        - {r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        - or enter manually + or enter manually
        - + <${Pt} value=${d} onInput=${R=>u(R.target.value)} placeholder="xxxx.apps.googleusercontent.com" - inputClass="flex-1 bg-black/40 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-gray-500" + inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" />
        - <${Pt} value=${p} - onInput=${R=>h(R.target.value)} + onInput=${R=>f(R.target.value)} placeholder="GOCSPX-..." - inputClass="flex-1 bg-black/40 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-gray-500" + inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" />
        - g(R.target.value)} + value=${g} + onInput=${R=>m(R.target.value)} placeholder="you@gmail.com" - class="w-full bg-black/40 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-gray-500" + class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" />
        @@ -6663,8 +6663,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        <${Z} onClick=${D} - disabled=${y} - loading=${y} + disabled=${x} + loading=${x} tone="primary" size="lg" idleLabel=${o} @@ -6672,17 +6672,17 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s className="w-full px-4 py-2 rounded-lg text-sm" />
        - ${f?kr`
        ${f}
        `:null} - `};var Mw=P.bind(T),T4=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Aw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Mw` -
        -
        + ${h?kr`
        ${h}
        `:null} + `};var Mw=P.bind(T),T3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Aw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Mw` +
        +
        Gmail watch requires gmail:read. Add it in permissions above, then update permissions.
        - `;let a=T4({watchStatus:e,busy:n}),l=!!e?.enabled;return Mw` + `;let a=T3({watchStatus:e,busy:n}),l=!!e?.enabled;return Mw`
        r?.()} @@ -6709,12 +6709,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s />
        - `};var zi=P.bind(T),P4=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Tw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:h=null,gmailWatchBusy:m=!1,onEnableGmailWatch:g,onDisableGmailWatch:f,onOpenGmailSetup:b,onOpenGmailWebhook:y})=>{let v=!P4(o,r);return zi` -
        + `};var zi=P.bind(T),P3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Tw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!P3(o,r);return zi` +
        ${n?zi`
        - Select permissions + Select permissions ${t.authenticated?zi`
        <${Aw} account=${t} - watchStatus=${h} - busy=${m} - onEnable=${()=>g?.(t.id)} - onDisable=${()=>f?.(t.id)} - onOpenWebhook=${()=>y?.()} + watchStatus=${f} + busy=${g} + onEnable=${()=>m?.(t.id)} + onDisable=${()=>h?.(t.id)} + onOpenWebhook=${()=>x?.()} />
        @@ -6799,7 +6799,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s
        `:null}
        - `};var dh=P.bind(T),Pw=({visible:t,onClose:e,onSubmit:n,loading:s=!1,defaultEmail:o="",title:r="Add Company Account"})=>{let[i,a]=x(""),[l,c]=x("");if(L(()=>{t&&(a(String(o||"")),c(""))},[t,o]),!t)return null;let d=async()=>{c("");let u=String(i||"").trim();if(!u){c("Email is required");return}await n?.({email:u,setError:c})};return dh`<${De} + `};var uf=P.bind(T),Pw=({visible:t,onClose:e,onSubmit:n,loading:s=!1,defaultEmail:o="",title:r="Add Company Account"})=>{let[i,a]=y(""),[l,c]=y("");if(L(()=>{t&&(a(String(o||"")),c(""))},[t,o]),!t)return null;let d=async()=>{c("");let u=String(i||"").trim();if(!u){c("Email is required");return}await n?.({email:u,setError:c})};return uf`<${De} visible=${t} onClose=${e} closeOnOverlayClick=${!1} @@ -6807,23 +6807,23 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s > <${Ne} title=${r} - actions=${dh` + actions=${uf` `} />
        - -

        +

        This adds another account to the same company workspace. Only one company workspace is supported.

        {r.current&&(r.current.destroy(),r.current=null)}},[h,i,s value=${i} onInput=${u=>a(u.target.value)} placeholder="you@company.com" - class="w-full bg-black/40 border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-gray-500" + class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" />
        - ${l?dh`
        ${l}
        `:null} + ${l?uf`
        ${l}
        `:null}
        <${Z} @@ -6848,10 +6848,10 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[h,i,s className="w-full px-4 py-2 rounded-lg text-sm" />
        - `};var Rw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Hf,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Lw=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=x({}),[o,r]=x(!1),i=F(()=>e.map(y=>String(y?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Kf,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let y=new Map;for(let v of a?.accounts||[])y.set(String(v.accountId||""),v);return y},[a]),p=F(()=>{let y=new Map;for(let v of a?.clients||[])y.set(String(v.client||"default"),v);return y},[a]),h=(y,v)=>{s(w=>{let $=String(y||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},m=q(async(y,{destination:v=null}={})=>{let w=String(y||"");h(w,!0);try{let $=await qf(w,{destination:v});return await d(),$}finally{h(w,!1)}},[d]),g=q(async y=>{let v=String(y||"");h(v,!0);try{await Jf(v),await d()}finally{h(v,!1)}},[d]),f=q(async(y="")=>{let v=String(y||"");v&&h(v,!0);try{await Zf({accountId:v,force:!0}),await d()}finally{v&&h(v,!1)}},[d]),b=q(async({client:y="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await Gf({client:y,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:m,stopWatchForAccount:g,renewForAccount:f}};var gt=P.bind(T),R4=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Ew=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],L4=Ew.slice(0,3),xc=Is,vc=(t="",e=()=>{})=>gt` -
        + `};var Rw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Hh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Lw=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Kh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await qh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Jh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Zh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await Gh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),R3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Ew=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],L3=Ew.slice(0,3),yc=Is,vc=(t="",e=()=>{})=>mt` +
         ${t}
        @@ -6865,7 +6865,7 @@ ${t}
        -`,Iw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=x(0),[c,d]=x(""),[u,p]=x(!1),[h,m]=x(""),[g,f]=x(!1),[b,y]=x(!1),[v,w]=x(!1),[$,S]=x(!1),[C,_]=x(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:O,error:D}=xr({enabled:t,filter:pc}),{sessions:j,destinationSessionKey:E,setDestinationSessionKey:M,selectedDestination:R}=Ds({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),m(""),d(""),p(!1),f(!1),y(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,I=!!(String(n?.projectId||"").trim()||B),U=u||!I&&!g,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,se=G?L4:Ew,K=se.length,z=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async fe=>{if(await R4(fe)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{m(""),d(String(n?.projectId||"").trim()),f(!1),p(!0)},[n?.projectId]),ie=async()=>{try{m(""),await i({client:z,projectId:String(c||"").trim(),destination:R}),y(!0),l(fe=>Math.min(fe+1,K-1))}catch(fe){m(fe.message||"Could not finish setup")}},ne=async()=>{if(!s){if(U){if(!X)return;m("");try{await r({client:z,projectId:String(c||"").trim()}),p(!1),f(!0)}catch(fe){m(fe.message||"Could not save project id");return}return}l(fe=>Math.min(fe+1,K-1))}},ye=async()=>{if(!(v||$))try{w(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await ha({message:ue,sessionKey:k}),S(!0),N("Message sent to your agent","success")}catch(fe){N(fe.message||"Could not send message to agent","error")}finally{w(!1)}};return gt` +`,Iw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:O,error:D}=yr({enabled:t,filter:pc}),{sessions:j,destinationSessionKey:E,setDestinationSessionKey:M,selectedDestination:R}=Ds({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,I=!!(String(n?.projectId||"").trim()||B),U=u||!I&&!m,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,se=G?L3:Ew,K=se.length,z=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async he=>{if(await R3(he)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:z,projectId:String(c||"").trim(),destination:R}),x(!0),l(he=>Math.min(he+1,K-1))}catch(he){g(he.message||"Could not finish setup")}},ne=async()=>{if(!s){if(U){if(!X)return;g("");try{await r({client:z,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,K-1))}},xe=async()=>{if(!(v||$))try{w(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ue,sessionKey:k}),S(!0),N("Message sent to your agent","success")}catch(he){N(he.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` <${De} visible=${t} onClose=${o} @@ -6879,15 +6879,15 @@ ${t} - <${nt} className="w-3.5 h-3.5 text-gray-300" /> + <${nt} className="w-3.5 h-3.5 text-body" /> -
        Gmail Pub / Sub Setup
        +
        Gmail Pub / Sub Setup
        - ${se.map((fe,ue)=>gt` + ${se.map((he,ue)=>mt`
        `)}
        @@ -6895,15 +6895,15 @@ ${t} - ${h?gt`
        ${h}
        `:null} - ${U?gt` + ${f?mt`
        ${f}
        `:null} + ${U?mt`
        ${u?"Change project ID":"Project ID required"}
        -
        + `:null} - ${!U&&a===0?gt` + ${!U&&a===0?mt`
        -
        +
        Using project ${W}.
        -
        +
        If gcloud is not installed on your computer, follow the official install guide:${" "} de(`gcloud auth login gcloud config set project ${W}`))} `:null} ${!U&&a===1?vc(B?.enableApis||"",()=>de(B?.enableApis||"")):null} - ${!U&&a===2?gt` + ${!U&&a===2?mt` ${vc(`${B?.createTopic||""} ${B?.grantPublisher||""}`.trim(),()=>de(`${B?.createTopic||""} ${B?.grantPublisher||""}`.trim()))} `:null} - ${!G&&!U&&a===3?gt` + ${!G&&!U&&a===3?mt` ${vc(B?.createSubscription||"",()=>de(B?.createSubscription||""))}
        <${vr} label="Deliver to" @@ -6967,49 +6967,49 @@ ${B?.grantPublisher||""}`.trim()))} loading=${O} error=${D} allowNone=${!0} - noneValue=${xc} + noneValue=${yc} noneLabel="Default" loadingLabel="Loading sessions..." helperText=${G?"This Gmail webhook has already been created. To edit delivery routing, ask your agent.":null} - selectClassName="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed" - helperClassName="text-xs text-gray-500" - statusClassName="text-[11px] text-gray-500" - errorClassName="text-[11px] text-red-400" + selectClassName="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed" + helperClassName="text-xs text-fg-muted" + statusClassName="text-[11px] text-fg-muted" + errorClassName="text-[11px] text-status-error-muted" />
        `:null} - ${!G&&a===4?gt` + ${!G&&a===4?mt`
        Continue with your agent
        -
        +
        Tell your OpenClaw agent about what you want to build with incoming email to continue the setup.
        -
        +
        Send this to session
        <${Z} - onClick=${ye} + onClick=${xe} disabled=${!k||$} loading=${v} idleLabel=${$?"Sent":"Send to Agent"} @@ -7019,10 +7019,10 @@ ${B?.grantPublisher||""}`.trim()))} className="h-[34px] px-3" />
        - ${O?gt`
        + ${O?mt`
        Loading sessions...
        `:null} - ${D?gt`
        + ${D?mt`
        ${D}
        `:null}
        @@ -7030,21 +7030,21 @@ ${B?.grantPublisher||""}`.trim()))}
        `:null}
        - ${a===0?gt`${U?gt`
        `:gt`
        `:mt``}`:gt`<${Z} - onClick=${()=>l(fe=>Math.max(fe-1,0))} + `}`:mt`<${Z} + onClick=${()=>l(he=>Math.max(he-1,0))} disabled=${s} idleLabel="Back" tone="secondary" size="md" className="w-full justify-center" />`} - ${!G&&a===K-2?gt`<${Z} + ${!G&&a===K-2?mt`<${Z} onClick=${ie} disabled=${!1} loading=${s} @@ -7053,14 +7053,14 @@ ${B?.grantPublisher||""}`.trim()))} tone="primary" size="md" className="w-full justify-center" - />`:a`:a`:gt`<${Z} + />`:mt`<${Z} onClick=${o} disabled=${s||v} idleLabel="Done" @@ -7070,18 +7070,18 @@ ${B?.grantPublisher||""}`.trim()))} />`}
        - `};var Ns=P.bind(T),E4=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,I4="/assets/icons/google_icon.svg",Dw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Rw({gatewayStatus:t}),[a,l]=x(""),[c,d]=x({}),[u,p]=x({}),[h,m]=x({}),[g,f]=x({}),[b,y]=x(!1),[v,w]=x({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[$,S]=x(!1),[C,_]=x(!1),[k,A]=x(""),[O,D]=x({visible:!1,accountId:""}),{loading:j,watchByAccountId:E,clientConfigByClient:M,busyByAccountId:R,savingClient:B,refresh:I,saveClientSetup:U,startWatchForAccount:W,stopWatchForAccount:G}=Lw({gatewayStatus:t,accounts:s}),se=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),z=q(Q=>s.find(pe=>pe.id===Q)||null,[s]),X=q(Q=>{let pe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(te=>({...te,[Q.id]:[...pe]})),d(te=>{let Ee=te[Q.id];return!Ee||!E4(Ee,pe)?{...te,[Q.id]:[...pe]}:te})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(pe=>!pe.authenticated)?.id||"";l(pe=>pe&&s.some(te=>te.id===pe)?pe:Q),s.forEach(pe=>X(pe))},[s,X]);let de=q(Q=>{let pe=z(Q);if(!pe)return;let te=c[Q]||pe.activeScopes||wr();if(!te.length){window.alert("Select at least one service");return}let Ee=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(te.join(","))}&_ts=${Date.now()}`,ke=window.open(Ee,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=Ee)},[z,c]),le=(Q,pe)=>{d(te=>({...te,[Q]:Cw(te[Q]||[],pe)}))},ie=q(async Q=>{m(pe=>{let te={...pe};return delete te[Q],te}),f({[Q]:!0});try{let pe=await Vf(Q);pe.results&&m(te=>({...te,[Q]:pe.results}))}finally{f(pe=>{if(!pe[Q])return pe;let te={...pe};return delete te[Q],te})}},[]);L(()=>{let Q=async pe=>{if(pe.data?.google==="success"){N("\u2713 Google account connected","success");let te=String(pe.data?.accountId||"").trim();m({}),await i(),await I(),te&&await ie(te)}else pe.data?.google==="error"&&N(`\u2717 Google auth failed: ${pe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[ie,i,I]),L(()=>{!a||!z(a)?.authenticated||g[a]||h[a]||ie(a)},[s,h,g,a,z,ie]);let ne=async Q=>{let pe=await Uf(Q);if(!pe.ok){N(`Failed to disconnect: ${pe.error||"unknown"}`,"error");return}N("Google account disconnected","success"),m(te=>{let Ee={...te};return delete Ee[Q],Ee}),await i(),await I()},ye=({accountId:Q="",client:pe="default",personal:te=!1,title:Ee="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:H=te?"personal":"workspace",initialValues:re={}})=>{w({visible:!0,accountId:Q,client:pe,personal:te,title:Ee,submitLabel:ke,defaultInstrType:H,initialValues:re})},fe=()=>{w(Q=>({...Q,visible:!1}))},ue=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&de(Q.id)},ge=async({email:Q,setError:pe})=>{_(!0);try{let te=await jf({email:Q,client:"default",personal:!1,services:wr()});if(!te.ok){pe?.(te.error||"Could not add account");return}S(!1),te.accountId&&l(te.accountId),await i(),te.accountId&&de(te.accountId)}finally{_(!1)}},V=()=>{if(y(!1),K&&r){S(!0);return}ye({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{y(!1),ye({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},oe=async Q=>{let pe=z(Q);if(!pe)return;let te=$c(pe),Ee=te?"personal":pe.client||"default",ke={};try{let H=await Wf({accountId:pe.id,client:Ee});H?.ok&&(ke={clientId:String(H.clientId||""),clientSecret:String(H.clientSecret||"")})}catch{N("Could not load saved client credentials","warning")}ye({accountId:pe.id,client:Ee,personal:te,title:`Edit Credentials (${pe.email})`,submitLabel:"Save Credentials",defaultInstrType:te?"personal":"workspace",initialValues:{email:pe.email,...ke}})},ce=Q=>{D({visible:!0,accountId:String(Q||"")})},be=()=>{D({visible:!1,accountId:""})},Ve=async Q=>{let pe=z(Q);if(!pe)return;let te=String(pe.client||"default").trim()||"default",Ee=M.get(te);if(!Ee?.configured||!Ee?.webhookExists){ce(Q);return}try{(await W(Q))?.restartRequired&&e(!0),N("Gmail watch enabled","success")}catch(ke){N(ke.message||"Could not enable Gmail watch","error")}},et=async Q=>{try{await G(Q),N("Gmail watch disabled","info")}catch(pe){N(pe.message||"Could not disable Gmail watch","error")}},wt=async({client:Q,projectId:pe,destination:te=null})=>{let Ee=String(O.accountId||"").trim();Ee&&(await U({client:Q,projectId:pe,regeneratePushToken:!1}),await W(Ee,{destination:te}),N("Gmail setup complete and watch enabled","success"))},Nt=()=>Ns` + `};var Ns=P.bind(T),E3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,I3="/assets/icons/google_icon.svg",Dw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Rw({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[b,x]=y(!1),[v,w]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[$,S]=y(!1),[C,_]=y(!1),[k,A]=y(""),[O,D]=y({visible:!1,accountId:""}),{loading:j,watchByAccountId:E,clientConfigByClient:M,busyByAccountId:R,savingClient:B,refresh:I,saveClientSetup:U,startWatchForAccount:W,stopWatchForAccount:G}=Lw({gatewayStatus:t,accounts:s}),se=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),z=q(Q=>s.find(pe=>pe.id===Q)||null,[s]),X=q(Q=>{let pe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(te=>({...te,[Q.id]:[...pe]})),d(te=>{let Ee=te[Q.id];return!Ee||!E3(Ee,pe)?{...te,[Q.id]:[...pe]}:te})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(pe=>!pe.authenticated)?.id||"";l(pe=>pe&&s.some(te=>te.id===pe)?pe:Q),s.forEach(pe=>X(pe))},[s,X]);let de=q(Q=>{let pe=z(Q);if(!pe)return;let te=c[Q]||pe.activeScopes||wr();if(!te.length){window.alert("Select at least one service");return}let Ee=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(te.join(","))}&_ts=${Date.now()}`,ke=window.open(Ee,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=Ee)},[z,c]),le=(Q,pe)=>{d(te=>({...te,[Q]:Cw(te[Q]||[],pe)}))},ie=q(async Q=>{g(pe=>{let te={...pe};return delete te[Q],te}),h({[Q]:!0});try{let pe=await Vh(Q);pe.results&&g(te=>({...te,[Q]:pe.results}))}finally{h(pe=>{if(!pe[Q])return pe;let te={...pe};return delete te[Q],te})}},[]);L(()=>{let Q=async pe=>{if(pe.data?.google==="success"){N("\u2713 Google account connected","success");let te=String(pe.data?.accountId||"").trim();g({}),await i(),await I(),te&&await ie(te)}else pe.data?.google==="error"&&N(`\u2717 Google auth failed: ${pe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[ie,i,I]),L(()=>{!a||!z(a)?.authenticated||m[a]||f[a]||ie(a)},[s,f,m,a,z,ie]);let ne=async Q=>{let pe=await Uh(Q);if(!pe.ok){N(`Failed to disconnect: ${pe.error||"unknown"}`,"error");return}N("Google account disconnected","success"),g(te=>{let Ee={...te};return delete Ee[Q],Ee}),await i(),await I()},xe=({accountId:Q="",client:pe="default",personal:te=!1,title:Ee="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:H=te?"personal":"workspace",initialValues:re={}})=>{w({visible:!0,accountId:Q,client:pe,personal:te,title:Ee,submitLabel:ke,defaultInstrType:H,initialValues:re})},he=()=>{w(Q=>({...Q,visible:!1}))},ue=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&de(Q.id)},me=async({email:Q,setError:pe})=>{_(!0);try{let te=await jh({email:Q,client:"default",personal:!1,services:wr()});if(!te.ok){pe?.(te.error||"Could not add account");return}S(!1),te.accountId&&l(te.accountId),await i(),te.accountId&&de(te.accountId)}finally{_(!1)}},V=()=>{if(x(!1),K&&r){S(!0);return}xe({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{x(!1),xe({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},oe=async Q=>{let pe=z(Q);if(!pe)return;let te=$c(pe),Ee=te?"personal":pe.client||"default",ke={};try{let H=await Wh({accountId:pe.id,client:Ee});H?.ok&&(ke={clientId:String(H.clientId||""),clientSecret:String(H.clientSecret||"")})}catch{N("Could not load saved client credentials","warning")}xe({accountId:pe.id,client:Ee,personal:te,title:`Edit Credentials (${pe.email})`,submitLabel:"Save Credentials",defaultInstrType:te?"personal":"workspace",initialValues:{email:pe.email,...ke}})},ce=Q=>{D({visible:!0,accountId:String(Q||"")})},be=()=>{D({visible:!1,accountId:""})},Ve=async Q=>{let pe=z(Q);if(!pe)return;let te=String(pe.client||"default").trim()||"default",Ee=M.get(te);if(!Ee?.configured||!Ee?.webhookExists){ce(Q);return}try{(await W(Q))?.restartRequired&&e(!0),N("Gmail watch enabled","success")}catch(ke){N(ke.message||"Could not enable Gmail watch","error")}},et=async Q=>{try{await G(Q),N("Gmail watch disabled","info")}catch(pe){N(pe.message||"Could not disable Gmail watch","error")}},wt=async({client:Q,projectId:pe,destination:te=null})=>{let Ee=String(O.accountId||"").trim();Ee&&(await U({client:Q,projectId:pe,regeneratePushToken:!1}),await W(Ee,{destination:te}),N("Gmail setup complete and watch enabled","success"))},Nt=()=>Ns`
        -
        +
        Google logo -

        +

        Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and Meet.

        @@ -7114,8 +7114,8 @@ ${B?.grantPublisher||""}`.trim()))} open=${b} ariaLabel="Add Google account" title="Add Google account" - onClose=${()=>y(!1)} - onToggle=${()=>y(Q=>!Q)} + onClose=${()=>x(!1)} + onToggle=${()=>x(Q=>!Q)} renderTrigger=${({onToggle:Q,ariaLabel:pe,title:te})=>Ns` <${Z} onClick=${Q} @@ -7139,7 +7139,7 @@ ${B?.grantPublisher||""}`.trim()))}
        `:null}
        - ${o?Ns`
        + ${o?Ns`
        Loading...
        `:s.length?Ns`
        @@ -7151,8 +7151,8 @@ ${B?.grantPublisher||""}`.trim()))} onToggleExpanded=${pe=>l(te=>te===pe?"":pe)} scopes=${c[Q.id]||Q.activeScopes||wr()} savedScopes=${u[Q.id]||Q.activeScopes||wr()} - apiStatus=${h[Q.id]||{}} - checkingApis=${a===Q.id&&!!g[Q.id]} + apiStatus=${f[Q.id]||{}} + checkingApis=${a===Q.id&&!!m[Q.id]} onToggleScope=${le} onCheckApis=${ie} onUpdatePermissions=${pe=>de(pe)} @@ -7171,7 +7171,7 @@ ${B?.grantPublisher||""}`.trim()))} <${_w} visible=${v.visible} - onClose=${fe} + onClose=${he} onSaved=${ue} title=${v.title} submitLabel=${v.submitLabel} @@ -7185,7 +7185,7 @@ ${B?.grantPublisher||""}`.trim()))} <${Pw} visible=${$} onClose=${()=>S(!1)} - onSubmit=${ge} + onSubmit=${me} loading=${C} title="Add Company Account" /> @@ -7209,16 +7209,16 @@ ${B?.grantPublisher||""}`.trim()))} onCancel=${()=>A("")} onConfirm=${async()=>{let Q=k;A(""),await ne(Q)}} /> - `};var wc=P.bind(T),D4=(t,e)=>t.find(n=>n.key===e)?.value||"",N4=(t,e)=>{for(let n of t.providers)if((Mn[n]||[]).some(r=>!!D4(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Nw=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc` + `};var wc=P.bind(T),D3=(t,e)=>t.find(n=>n.key===e)?.value||"",N3=(t,e)=>{for(let n of t.providers)if((Mn[n]||[]).some(r=>!!D3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Nw=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc`

        Features

        - ${gm.map(r=>{let i=N4(r,s);return wc` + ${mg.map(r=>{let i=N3(r,s);return wc`
        - ${r.label} + ${r.label} ${i.active?wc` - + ${js[i.provider]||i.provider} <${ae} tone="success">Enabled @@ -7237,12 +7237,12 @@ ${B?.grantPublisher||""}`.trim()))} `})}
        - `};var O4=P.bind(T),Ow=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>hc(t,e)?O4` + `};var O3=P.bind(T),Ow=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>fc(t,e)?O3`
        -
        +
        -

        Drift Doctor

        -

        ${ow(t)}

        +

        Drift Doctor

        +

        ${ow(t)}

        <${Z} @@ -7258,23 +7258,23 @@ ${B?.grantPublisher||""}`.trim()))}
        - `:null;var kc="0 * * * *",Bw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=x(!1),[l,c]=x(!1),[d,u]=x(!0),[p,h]=x(kc),[m,g]=x(!1),[f,b]=x(kc),[y,v]=x(!1),[w,$]=x(!1),[S,C]=x(!1),_=ee(null),k=t,A=e,O=n,D=k?.gateway??null,j=k?.channels??null,E=k?.repo||null,M=k?.syncCron||null,R=k?.openclawVersion||null,B=bs.some(ue=>{let ge=j?.[ue];if(!ge)return!1;let V=ge.accounts&&typeof ge.accounts=="object"?ge.accounts:{};return Object.keys(V).length>0?Object.values(V).some(J=>J&&J.status!=="paired"):ge.status!=="paired"}),I=Re(async()=>(await Co()).pending||[],3e3,{enabled:B&&D==="running",cacheKey:"/api/pairings"}),U=I.data||[],W=D==="running"&&(w||!S),G=Re(async()=>{let ue=await ma();return C(ue?.cliAutoApproveComplete===!0),ue.pending||[]},5e3,{enabled:W,cacheKey:"/api/devices"}),se=G.data||[];L(()=>{o&&(I.refresh(),W&&G.refresh())},[G.refresh,o,s,I.refresh,w,W]),L(()=>{if(!r||!o)return;s(),I.refresh(),W&&G.refresh();let ue=setTimeout(()=>{s(),I.refresh(),W&&G.refresh()},1200),ge=setTimeout(()=>{s(),I.refresh(),W&&G.refresh()},3500);return()=>{clearTimeout(ue),clearTimeout(ge)}},[G.refresh,o,s,I.refresh,r,w,W]),L(()=>{M&&(u(M.enabled!==!1),h(M.schedule||kc),b(M.enabled===!1?"disabled":M.schedule||kc))},[M?.enabled,M?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),I.refresh(),setTimeout(()=>{s(),I.refresh()},700),setTimeout(()=>{s(),I.refresh()},1800)},z=async({enabled:ue=d,schedule:ge=p}={})=>{if(!m){g(!0);try{let V=await yg({enabled:ue,schedule:ge});if(!V.ok)throw new Error(V.error||"Could not save sync settings");N("Sync schedule updated","success"),s()}catch(V){N(V.message||"Could not save sync settings","error")}finally{g(!1)}}};return{state:{channels:j,dashboardLoading:i,devicePending:se,doctorStatus:O,gatewayStatus:D,hasUnpaired:B,openclawVersion:R,pending:U,pairingStatusRefreshing:y,repairingWatchdog:l,repo:E,savingSyncCron:m,syncCron:M,syncCronChoice:f,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ue,ge,V="")=>{await _o(ue,ge,V),K()},handleDeviceApprove:async ue=>{await ba(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleDeviceReject:async ue=>{await ya(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){$(!0),a(!0);try{let ue=await hg();console.log("[dashboard] response:",JSON.stringify(ue)),window.open(ue.url||"/openclaw","_blank")}catch(ue){console.error("[dashboard] error:",ue),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ue,ge,V="")=>{try{await Mo(ue,ge,V),K()}catch(J){N(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ue=>{b(ue);let ge=ue!=="disabled",V=ge?ue:p;u(ge),h(V),await z({enabled:ge,schedule:V})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ue=await fa();if(!ue.ok)throw new Error(ue.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ue){N(ue.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),Fw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:h=!1,onOpenclawVersionActionComplete:m=()=>{},onOpenclawUpdate:g=()=>{},onRestartRequired:f=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:y,actions:v}=Bw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc` + `:null;var kc="0 * * * *",Bw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(kc),[g,m]=y(!1),[h,b]=y(kc),[x,v]=y(!1),[w,$]=y(!1),[S,C]=y(!1),_=ee(null),k=t,A=e,O=n,D=k?.gateway??null,j=k?.channels??null,E=k?.repo||null,M=k?.syncCron||null,R=k?.openclawVersion||null,B=bs.some(ue=>{let me=j?.[ue];if(!me)return!1;let V=me.accounts&&typeof me.accounts=="object"?me.accounts:{};return Object.keys(V).length>0?Object.values(V).some(J=>J&&J.status!=="paired"):me.status!=="paired"}),I=Re(async()=>(await Co()).pending||[],3e3,{enabled:B&&D==="running",cacheKey:"/api/pairings"}),U=I.data||[],W=D==="running"&&(w||!S),G=Re(async()=>{let ue=await ga();return C(ue?.cliAutoApproveComplete===!0),ue.pending||[]},5e3,{enabled:W,cacheKey:"/api/devices"}),se=G.data||[];L(()=>{o&&(I.refresh(),W&&G.refresh())},[G.refresh,o,s,I.refresh,w,W]),L(()=>{if(!r||!o)return;s(),I.refresh(),W&&G.refresh();let ue=setTimeout(()=>{s(),I.refresh(),W&&G.refresh()},1200),me=setTimeout(()=>{s(),I.refresh(),W&&G.refresh()},3500);return()=>{clearTimeout(ue),clearTimeout(me)}},[G.refresh,o,s,I.refresh,r,w,W]),L(()=>{M&&(u(M.enabled!==!1),f(M.schedule||kc),b(M.enabled===!1?"disabled":M.schedule||kc))},[M?.enabled,M?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),I.refresh(),setTimeout(()=>{s(),I.refresh()},700),setTimeout(()=>{s(),I.refresh()},1800)},z=async({enabled:ue=d,schedule:me=p}={})=>{if(!g){m(!0);try{let V=await xm({enabled:ue,schedule:me});if(!V.ok)throw new Error(V.error||"Could not save sync settings");N("Sync schedule updated","success"),s()}catch(V){N(V.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:j,dashboardLoading:i,devicePending:se,doctorStatus:O,gatewayStatus:D,hasUnpaired:B,openclawVersion:R,pending:U,pairingStatusRefreshing:x,repairingWatchdog:l,repo:E,savingSyncCron:g,syncCron:M,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ue,me,V="")=>{await _o(ue,me,V),K()},handleDeviceApprove:async ue=>{await ba(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleDeviceReject:async ue=>{await xa(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){$(!0),a(!0);try{let ue=await fm();console.log("[dashboard] response:",JSON.stringify(ue)),window.open(ue.url||"/openclaw","_blank")}catch(ue){console.error("[dashboard] error:",ue),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ue,me,V="")=>{try{await Mo(ue,me,V),K()}catch(J){N(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ue=>{b(ue);let me=ue!=="disabled",V=me?ue:p;u(me),f(V),await z({enabled:me,schedule:V})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ue=await ha();if(!ue.ok)throw new Error(ue.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ue){N(ue.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),Fw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:x,actions:v}=Bw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc`
        <${bc} - status=${y.gatewayStatus} - openclawVersion=${y.openclawVersion} + status=${x.gatewayStatus} + openclawVersion=${x.openclawVersion} restarting=${d} onRestart=${u} - watchdogStatus=${y.watchdogStatus} + watchdogStatus=${x.watchdogStatus} onOpenWatchdog=${()=>i("watchdog")} onRepair=${v.handleWatchdogRepair} - repairing=${y.repairingWatchdog} - openclawUpdateInProgress=${h} - onOpenclawVersionActionComplete=${m} - onOpenclawUpdate=${g} + repairing=${x.repairingWatchdog} + openclawUpdateInProgress=${f} + onOpenclawVersionActionComplete=${g} + onOpenclawUpdate=${m} /> <${Ow} - doctorStatus=${y.doctorStatus} + doctorStatus=${x.doctorStatus} dismissedUntilMs=${o} onOpenDoctor=${()=>i("doctor")} onDismiss=${b} @@ -7282,7 +7282,7 @@ ${B?.grantPublisher||""}`.trim()))} <${nl} channelsSection=${Sc` <${Sb} - channels=${y.channels} + channels=${x.channels} agents=${s} onNavigate=${a} onRefreshStatuses=${r} @@ -7290,10 +7290,10 @@ ${B?.grantPublisher||""}`.trim()))} `} pairingsSection=${Sc` <${tl} - pending=${y.pending} - channels=${y.channels} - visible=${y.hasUnpaired} - statusRefreshing=${y.pairingStatusRefreshing} + pending=${x.pending} + channels=${x.channels} + visible=${x.hasUnpaired} + statusRefreshing=${x.pairingStatusRefreshing} onApprove=${v.handleApprove} onReject=${v.handleReject} /> @@ -7301,17 +7301,17 @@ ${B?.grantPublisher||""}`.trim()))} /> <${Nw} onSwitchTab=${i} /> <${Dw} - gatewayStatus=${y.gatewayStatus} - onRestartRequired=${f} + gatewayStatus=${x.gatewayStatus} + onRestartRequired=${h} onOpenGmailWebhook=${l} /> - ${y.repo&&Sc` + ${x.repo&&Sc`
        - Auto-sync + Auto-sync
        <${Ca} - className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500" + className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted" />
        @@ -7357,20 +7357,20 @@ ${B?.grantPublisher||""}`.trim()))}
        <${tn} onClick=${v.handleOpenDashboard} - loading=${y.dashboardLoading} + loading=${x.dashboardLoading} warning=${!1} idleLabel="Open" loadingLabel="Opening..." />
        - <${yc} - pending=${y.devicePending} + <${xc} + pending=${x.devicePending} onApprove=${v.handleDeviceApprove} onReject=${v.handleDeviceReject} />
        - `};var B4=P.bind(T),uh=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSetLocation:i=()=>{},onNavigate:a=()=>{},restartingGateway:l=!1,onRestartGateway:c=()=>{},restartSignal:d=0,openclawUpdateInProgress:u=!1,onOpenclawVersionActionComplete:p=()=>{},onOpenclawUpdate:h=()=>{},onRestartRequired:m=()=>{},onDismissDoctorWarning:g=()=>{}})=>B4` + `};var B3=P.bind(T),pf=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSetLocation:i=()=>{},onNavigate:a=()=>{},restartingGateway:l=!1,onRestartGateway:c=()=>{},restartSignal:d=0,openclawUpdateInProgress:u=!1,onOpenclawVersionActionComplete:p=()=>{},onOpenclawUpdate:f=()=>{},onRestartRequired:g=()=>{},onDismissDoctorWarning:m=()=>{}})=>B3`
        <${Fw} statusData=${t} @@ -7379,7 +7379,7 @@ ${B?.grantPublisher||""}`.trim()))} agents=${s} doctorWarningDismissedUntilMs=${o} onRefreshStatuses=${r} - onSwitchTab=${f=>i(`/${f}`)} + onSwitchTab=${h=>i(`/${h}`)} onNavigate=${a} onOpenGmailWebhook=${()=>i("/webhooks/gmail")} isActive=${!0} @@ -7388,15 +7388,15 @@ ${B?.grantPublisher||""}`.trim()))} restartSignal=${d} openclawUpdateInProgress=${u} onOpenclawVersionActionComplete=${p} - onOpenclawUpdate=${h} - onRestartRequired=${m} - onDismissDoctorWarning=${g} + onOpenclawUpdate=${f} + onRestartRequired=${g} + onDismissDoctorWarning=${m} />
        -`;var Dt=P.bind(T),F4={anthropic:{label:"Anthropic",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-ant-api03-...",url:"https://console.anthropic.com",field:"key"},{id:"token",label:"Setup Token",profileSuffix:"manual",placeholder:"sk-ant-oat01-...",hint:"From claude setup-token (uses your Claude subscription)",field:"token"}]},openai:{label:"OpenAI",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-...",url:"https://platform.openai.com",field:"key"}]},"openai-codex":{label:"OpenAI Codex",modes:[{id:"oauth",label:"Codex OAuth",isCodexOauth:!0}]},google:{label:"Gemini",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"AI...",url:"https://aistudio.google.com",field:"key"}]}},H4={id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"...",field:"key"},W4=t=>{let e=Mn[t]||[];return e.length===0?[H4]:e.map(n=>({id:"api_key",label:n.label||"API Key",profileSuffix:"default",placeholder:n.placeholder||"...",hint:n.hint,url:n.url,field:"key"}))},V4=t=>F4[t]||{label:js[t]||t,modes:W4(t)},ji=(t,e)=>`${t.provider||e}:${t.profileSuffix||"default"}`,Cc=t=>String(t?.key||t?.token||t?.access||"").trim(),z4=({codexStatus:t,onRefreshCodex:e})=>{let[n,s]=x(!1),[o,r]=x(!1),[i,a]=x(""),[l,c]=x(!1),d=ee(null);L(()=>()=>{d.current&&clearInterval(d.current)},[]),L(()=>{let m=async g=>{g.data?.codex==="success"?(N("Codex connected","success"),s(!1),r(!1),await e()):g.data?.codex==="error"&&N(`Codex auth failed: ${g.data.message||"unknown error"}`,"error")};return window.addEventListener("message",m),()=>window.removeEventListener("message",m)},[e]);let u=()=>{s(!0),r(!0);let m=window.open("/auth/codex/start","codex-auth","popup=yes,width=640,height=780");if(!m||m.closed){r(!1),window.location.href="/auth/codex/start";return}d.current&&clearInterval(d.current),d.current=setInterval(()=>{m.closed&&(clearInterval(d.current),d.current=null,r(!1))},500)},p=async()=>{if(!(!i.trim()||l)){c(!0);try{let m=await Wr(i.trim());if(!m.ok)throw new Error(m.error||"Codex OAuth exchange failed");a(""),N("Codex connected","success"),s(!1),r(!1),await e()}catch(m){N(m.message||"Codex OAuth exchange failed","error")}finally{c(!1)}}},h=async()=>{let m=await Hr();if(!m.ok){N(m.error||"Failed to disconnect Codex","error");return}N("Codex disconnected","success"),s(!1),r(!1),a(""),await e()};return Dt` +`;var Dt=P.bind(T),F3={anthropic:{label:"Anthropic",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-ant-api03-...",url:"https://console.anthropic.com",field:"key"},{id:"token",label:"Setup Token",profileSuffix:"manual",placeholder:"sk-ant-oat01-...",hint:"From claude setup-token (uses your Claude subscription)",field:"token"}]},openai:{label:"OpenAI",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-...",url:"https://platform.openai.com",field:"key"}]},"openai-codex":{label:"OpenAI Codex",modes:[{id:"oauth",label:"Codex OAuth",isCodexOauth:!0}]},google:{label:"Gemini",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"AI...",url:"https://aistudio.google.com",field:"key"}]}},H3={id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"...",field:"key"},W3=t=>{let e=Mn[t]||[];return e.length===0?[H3]:e.map(n=>({id:"api_key",label:n.label||"API Key",profileSuffix:"default",placeholder:n.placeholder||"...",hint:n.hint,url:n.url,field:"key"}))},V3=t=>F3[t]||{label:js[t]||t,modes:W3(t)},ji=(t,e)=>`${t.provider||e}:${t.profileSuffix||"default"}`,Cc=t=>String(t?.key||t?.token||t?.access||"").trim(),z3=({codexStatus:t,onRefreshCodex:e})=>{let[n,s]=y(!1),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(!1),d=ee(null);L(()=>()=>{d.current&&clearInterval(d.current)},[]),L(()=>{let g=async m=>{m.data?.codex==="success"?(N("Codex connected","success"),s(!1),r(!1),await e()):m.data?.codex==="error"&&N(`Codex auth failed: ${m.data.message||"unknown error"}`,"error")};return window.addEventListener("message",g),()=>window.removeEventListener("message",g)},[e]);let u=()=>{s(!0),r(!0);let g=window.open("/auth/codex/start","codex-auth","popup=yes,width=640,height=780");if(!g||g.closed){r(!1),window.location.href="/auth/codex/start";return}d.current&&clearInterval(d.current),d.current=setInterval(()=>{g.closed&&(clearInterval(d.current),d.current=null,r(!1))},500)},p=async()=>{if(!(!i.trim()||l)){c(!0);try{let g=await Wr(i.trim());if(!g.ok)throw new Error(g.error||"Codex OAuth exchange failed");a(""),N("Codex connected","success"),s(!1),r(!1),await e()}catch(g){N(g.message||"Codex OAuth exchange failed","error")}finally{c(!1)}}},f=async()=>{let g=await Hr();if(!g.ok){N(g.error||"Failed to disconnect Codex","error");return}N("Codex disconnected","success"),s(!1),r(!1),a(""),await e()};return Dt`
        - Codex OAuth + Codex OAuth ${t.connected?Dt`<${ae} tone="success">Connected`:Dt`<${ae} tone="warning">Not connected`}
        ${t.connected?Dt` @@ -7408,7 +7408,7 @@ ${B?.grantPublisher||""}`.trim()))} Reconnect
        `:n?Dt`
        -

        +

        ${o?"Complete login in the popup, then paste the redirect URL.":"Paste the redirect URL from your browser to finish connecting."}

        - `},Hw=({provider:t,authProfiles:e,authOrder:n,codexStatus:s,onEditProfile:o,onEditAuthOrder:r,getProfileValue:i,getEffectiveOrder:a,onRefreshCodex:l})=>{let c=V4(t),d=c.modes.filter($=>!$.isCodexOauth),u=d.length>1,p=c.modes.some($=>$.isCodexOauth),h=a(t),m=h?.[0]||null,g=n[t]||null,f=d.some($=>{let S=ji($,t),C=e.find(k=>k.id===S)||null,_=i(S);return Cc(_)!==Cc(C)}),b=JSON.stringify(h||null)!==JSON.stringify(g),y=f||b,v=d.some($=>{let S=ji($,t),C=i(S);return!!(C?.key||C?.token||C?.access)})||t==="openai-codex"&&!!s?.connected,w=$=>{let S=ji($,t),C=d.map(k=>ji(k,t)),_=[S,...C.filter(k=>k!==S)];r(t,_)};return Dt` + `},Hw=({provider:t,authProfiles:e,authOrder:n,codexStatus:s,onEditProfile:o,onEditAuthOrder:r,getProfileValue:i,getEffectiveOrder:a,onRefreshCodex:l})=>{let c=V3(t),d=c.modes.filter($=>!$.isCodexOauth),u=d.length>1,p=c.modes.some($=>$.isCodexOauth),f=a(t),g=f?.[0]||null,m=n[t]||null,h=d.some($=>{let S=ji($,t),C=e.find(k=>k.id===S)||null,_=i(S);return Cc(_)!==Cc(C)}),b=JSON.stringify(f||null)!==JSON.stringify(m),x=h||b,v=d.some($=>{let S=ji($,t),C=i(S);return!!(C?.key||C?.token||C?.access)})||t==="openai-codex"&&!!s?.connected,w=$=>{let S=ji($,t),C=d.map(k=>ji(k,t)),_=[S,...C.filter(k=>k!==S)];r(t,_)};return Dt`

        ${c.label}

        - ${p&&d.length===0?null:y?Dt`<${ae} tone="warning">Unsaved`:v?Dt`<${ae} tone="success">Connected`:Dt`<${ae} tone="warning">Not configured`} + ${p&&d.length===0?null:x?Dt`<${ae} tone="warning">Unsaved`:v?Dt`<${ae} tone="success">Connected`:Dt`<${ae} tone="warning">Not configured`}
        - ${d.map($=>{let S=ji($,t),C=$.provider||t,_=i(S),k=_?.[$.field]||"",A=!u||m===S||!m&&$===d[0];return Dt` + ${d.map($=>{let S=ji($,t),C=$.provider||t,_=i(S),k=_?.[$.field]||"",A=!u||g===S||!g&&$===d[0];return Dt`
        - ${u&&A?Dt`<${ae} tone="cyan">Primary`:null} ${u&&!A&&k?Dt``:null} @@ -7489,25 +7489,25 @@ ${B?.grantPublisher||""}`.trim()))}
        <${Pt} value=${k} - onInput=${O=>{let D=O.target.value,j={type:$.id,provider:C,[$.field]:D};_?.expires&&(j.expires=_.expires),o(S,j);let E=e.find(R=>R.id===S)||null;Cc(j)===Cc(E)&&u?r(t,g):u&&D&&!A&&w($)}} + onInput=${O=>{let D=O.target.value,j={type:$.id,provider:C,[$.field]:D};_?.expires&&(j.expires=_.expires),o(S,j);let E=e.find(R=>R.id===S)||null;Cc(j)===Cc(E)&&u?r(t,m):u&&D&&!A&&w($)}} placeholder=${$.placeholder||""} isSecret=${!0} - inputClass="flex-1 w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono" + inputClass="flex-1 w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" /> - ${$.hint?Dt`

        ${$.hint}

        `:null} + ${$.hint?Dt`

        ${$.hint}

        `:null}
        `})} ${c.modes.some($=>$.isCodexOauth)?Dt`
        - <${z4} + <${z3} codexStatus=${s} onRefreshCodex=${l} />
        `:null}
        - `};var _t=P.bind(T),j4=t=>{let e=new Set;for(let n of Object.keys(t)){let s=ri(n);s&&e.add(s)}return[...e]},U4=["anthropic","openai","openai-codex",...zr.filter(t=>!["anthropic","openai"].includes(t))],Ww=({onRestartRequired:t=()=>{},agentId:e,embedded:n=!1})=>{let{catalog:s,primary:o,configuredModels:r,authProfiles:i,authOrder:a,codexStatus:l,loading:c,saving:d,ready:u,error:p,isDirty:h,addModel:m,removeModel:g,setPrimaryModel:f,editProfile:b,editAuthOrder:y,getProfileValue:v,getEffectiveOrder:w,cancelChanges:$,saveAll:S,refreshCodexStatus:C}=rl(e),_=F(()=>new Set(Object.keys(r)),[r]),k=F(()=>Lo(s),[s]),A=F(()=>k.filter(I=>!_.has(I.key)),[k,_]),O=F(()=>[...s].filter(I=>!_.has(I.key)).sort((I,U)=>{let W=zo(vs(I))-zo(vs(U));return W!==0?W:String(I.label||I.key).localeCompare(String(U.label||U.key))}),[s,_]),D=F(()=>j4(r),[r]),j=F(()=>{let I=[];for(let U of U4)D.includes(U)&&I.push(U);for(let U of D)I.includes(U)||I.push(U);return I},[D]),E=F(()=>sl({authProfiles:i,codexStatus:l}),[i,l]),M=F(()=>Object.keys(r).map(I=>{let U=s.find(se=>se.key===I),W=ri(I),G=!!E[W];return{key:I,label:U?.label||I,isPrimary:I===o,hasAuth:G}}),[r,s,o,E]),R=_t` - <${Qn} visible=${h}> + `};var _t=P.bind(T),j3=t=>{let e=new Set;for(let n of Object.keys(t)){let s=ri(n);s&&e.add(s)}return[...e]},U3=["anthropic","openai","openai-codex",...zr.filter(t=>!["anthropic","openai"].includes(t))],Ww=({onRestartRequired:t=()=>{},agentId:e,embedded:n=!1})=>{let{catalog:s,primary:o,configuredModels:r,authProfiles:i,authOrder:a,codexStatus:l,loading:c,saving:d,ready:u,error:p,isDirty:f,addModel:g,removeModel:m,setPrimaryModel:h,editProfile:b,editAuthOrder:x,getProfileValue:v,getEffectiveOrder:w,cancelChanges:$,saveAll:S,refreshCodexStatus:C}=rl(e),_=F(()=>new Set(Object.keys(r)),[r]),k=F(()=>Lo(s),[s]),A=F(()=>k.filter(I=>!_.has(I.key)),[k,_]),O=F(()=>[...s].filter(I=>!_.has(I.key)).sort((I,U)=>{let W=zo(vs(I))-zo(vs(U));return W!==0?W:String(I.label||I.key).localeCompare(String(U.label||U.key))}),[s,_]),D=F(()=>j3(r),[r]),j=F(()=>{let I=[];for(let U of U3)D.includes(U)&&I.push(U);for(let U of D)I.includes(U)||I.push(U);return I},[D]),E=F(()=>sl({authProfiles:i,codexStatus:l}),[i,l]),M=F(()=>Object.keys(r).map(I=>{let U=s.find(se=>se.key===I),W=ri(I),G=!!E[W];return{key:I,label:U?.label||I,isPrimary:I===o,hasAuth:G}}),[r,s,o,E]),R=_t` + <${Qn} visible=${f}> <${Z} onClick=${$} disabled=${d} @@ -7530,7 +7530,7 @@ ${B?.grantPublisher||""}`.trim()))} `;if(!u){let I=_t`
        -
        +
        <${Ce} className="h-4 w-4" /> Loading model settings...
        @@ -7546,30 +7546,30 @@ ${B?.grantPublisher||""}`.trim()))}

        Available Models

        - ${M.length===0?_t`

        + ${M.length===0?_t`

        No models configured. Add a model below.

        `:_t`
        ${M.map(I=>_t`
        - ${I.label} ${I.isPrimary?_t`<${ae} tone="cyan">Primary`:I.hasAuth?_t` `:_t`<${ae} tone="warning">Needs auth`}
        @@ -7583,13 +7583,13 @@ ${B?.grantPublisher||""}`.trim()))} options=${O} popularModels=${A} placeholder="Add model..." - onSelect=${I=>{m(I),o||f(I)}} + onSelect=${I=>{g(I),o||h(I)}} />
        - ${c?_t`

        + ${c?_t`

        Loading model catalog... -

        `:p?_t`

        ${p}

        `:null} +

        `:p?_t`

        ${p}

        `:null}
        @@ -7605,7 +7605,7 @@ ${B?.grantPublisher||""}`.trim()))} authOrder=${a} codexStatus=${l} onEditProfile=${b} - onEditAuthOrder=${y} + onEditAuthOrder=${x} getProfileValue=${v} getEffectiveOrder=${w} onRefreshCodex=${C} @@ -7626,13 +7626,13 @@ ${B?.grantPublisher||""}`.trim()))} > ${B} - `};var K4=P.bind(T),ph=({onRestartRequired:t=()=>{}})=>K4` + `};var K3=P.bind(T),ff=({onRestartRequired:t=()=>{}})=>K3` <${Ww} onRestartRequired=${t} /> -`;var G4=1e4,Vw=({enabled:t=!0}={})=>{let e=Re(async()=>{let n=await Tg(),s=Array.isArray(n?.nodes)?n.nodes:[],o=Array.isArray(n?.pending)?n.pending:[];return{nodes:s,pending:o}},G4,{enabled:t,cacheKey:"/api/nodes"});return{nodes:Array.isArray(e.data?.nodes)?e.data.nodes:[],pending:Array.isArray(e.data?.pending)?e.data.pending:[],loading:e.data===null&&!e.error,error:e.error?String(e.error.message||"Could not load nodes"):"",refresh:e.refresh}};var zw=()=>{let t=Vw({enabled:!0}),[e,n]=x(!1),[s,o]=x(!1),{data:r,error:i}=ct("/api/nodes/connect-info",xa,{maxAgeMs:6e4}),a=Array.isArray(t.nodes)?t.nodes.filter(c=>c?.paired!==!1):[];L(()=>{i&&N(i.message||"Could not load node connect command","error")},[i]);let l=q(async()=>{if(!s){o(!0);try{await t.refresh()}finally{o(!1)}}},[t.refresh,s]);return{state:{wizardVisible:e,nodes:a,pending:t.pending,loadingNodes:t.loading,refreshingNodes:s,nodesError:t.error,connectInfo:r},actions:{openWizard:()=>n(!0),closeWizard:()=>n(!1),refreshNodes:l}}};var q4=35e3,J4=1e4,Uw="nodesBrowserAttachStateByNode",Z4=/selected page has been closed/i,Y4=async(t,e=q4)=>{let n=null;try{return await Promise.race([t,new Promise((s,o)=>{n=setTimeout(()=>{o(new Error("Browser check timed out"))},e)})])}finally{n&&clearTimeout(n)}},jw=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},X4=t=>{let e=String(t?.message||"Could not check node browser status").trim();return Z4.test(e)?"Selected Chrome page was closed. Click Attach to reconnect.":e},Q4=()=>{let e=Fe()?.[Uw];return!e||typeof e!="object"||Array.isArray(e)?{}:e},eP=(t={})=>{x0(e=>({...e&&typeof e=="object"?e:{},[Uw]:t&&typeof t=="object"?t:{}}))},Kw=({nodes:t=[],onRefreshNodes:e=async()=>{}}={})=>{let[n,s]=x({}),[o,r]=x({}),[i,a]=x(""),[l,c]=x(()=>Q4()),[d,u]=x(""),[p,h]=x(null),[m,g]=x(""),f=ee(0),b=ee(""),y=async(k,{successMessage:A="Connection command copied",errorMessage:O="Could not copy connection command"}={})=>{if(await Ho(k)){N(A,"success");return}N(O,"error")},v=q(async(k,{silent:A=!1}={})=>{let O=String(k||"").trim();if(!(!O||b.current)){b.current=O,A||a(O),r(D=>({...D,[O]:""}));try{let D=await Y4(Lg(O,"user")),j=D?.status&&typeof D.status=="object"?D.status:null;s(E=>({...E,[O]:j}))}catch(D){let j=X4(D);s(E=>({...E,[O]:null})),r(E=>({...E,[O]:j})),A||N(j,"error")}finally{b.current="",A||a("")}}},[]),w=q((k,A)=>{let O=String(k||"").trim();O&&c(D=>{let j={...D&&typeof D=="object"?D:{},[O]:A===!0};return eP(j),j})},[]),$=q(async k=>{let A=String(k||"").trim();A&&(w(A,!0),await v(A))},[v,w]),S=q(k=>{let A=String(k||"").trim();A&&(w(A,!1),s(O=>{let D={...O||{}};return delete D[A],D}),r(O=>{let D={...O||{}};return delete D[A],D}))},[w]),C=q(k=>{let A=String(k||"").trim();A&&u(O=>O===A?"":A)},[]),_=q(async()=>{let k=String(p?.nodeId||"").trim();if(!(!k||m)){g(k);try{await Pg(k),S(k),N("Device removed","success"),h(null),u(""),await e()}catch(A){N(A.message||"Could not remove node","error")}finally{g("")}}},[S,e,p,m]);return L(()=>{if(i)return;let k=t.map(A=>({nodeId:String(A?.nodeId||"").trim(),connected:A?.connected===!0,browserCapable:jw(A)})).find(A=>!(!A.nodeId||!A.connected||!A.browserCapable||l?.[A.nodeId]!==!0||n?.[A.nodeId]||o?.[A.nodeId]))?.nodeId;k&&v(k,{silent:!0})},[l,o,n,i,v,t]),L(()=>{if(i)return;let k=t.map(j=>({nodeId:String(j?.nodeId||"").trim(),connected:j?.connected===!0,browserCapable:jw(j),browserRunning:n?.[String(j?.nodeId||"").trim()]?.running===!0})).filter(j=>j.nodeId&&j.connected&&j.browserCapable&&l?.[j.nodeId]===!0&&j.browserRunning).map(j=>j.nodeId);if(!k.length)return;let A=!0,D=setInterval(async()=>{if(!A||b.current)return;let j=f.current%k.length;f.current+=1;let E=k[j];await v(E,{silent:!0})},J4);return()=>{A=!1,clearInterval(D)}},[l,n,i,v,t]),{browserStatusByNodeId:n,browserErrorByNodeId:o,checkingBrowserNodeId:i,browserAttachStateByNodeId:l,menuOpenNodeId:d,removeDialogNode:p,removingNodeId:m,handleCopyText:y,handleCheckNodeBrowser:v,handleAttachNodeBrowser:$,handleDetachNodeBrowser:S,handleOpenNodeMenu:C,handleRemoveNode:_,setMenuOpenNodeId:u,setRemoveDialogNode:h}};var ot=P.bind(T),tP=t=>String(t||"").replace(/"/g,'\\"'),Gw=({node:t,connectInfo:e,maskToken:n=!1})=>{let s=String(e?.gatewayHost||"").trim()||"localhost",o=Number(e?.gatewayPort)||3e3,r=String(e?.gatewayToken||"").trim(),i=e?.tls===!0?"--tls":"",a=String(t?.displayName||t?.nodeId||"My Node").trim(),l=n?"****":r;return[l?`OPENCLAW_GATEWAY_TOKEN=${l}`:"","openclaw node run",`--host ${s}`,`--port ${o}`,i,`--display-name "${tP(a)}"`].filter(Boolean).join(" ")},nP=t=>t?.connected?ot`<${ae} tone="success">Connected`:t?.paired?ot`<${ae} tone="warning">Disconnected`:ot`<${ae} tone="danger">Pending approval`,sP=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},oP=t=>t.running?"success":"warning",rP=t=>t.running?"Attached":"Not connected",qw=({nodes:t=[],pending:e=[],loading:n=!1,error:s="",connectInfo:o=null,onRefreshNodes:r=async()=>{}})=>{let i=Kw({nodes:t,onRefreshNodes:r}),{browserStatusByNodeId:a,browserErrorByNodeId:l,checkingBrowserNodeId:c,browserAttachStateByNodeId:d,menuOpenNodeId:u,removeDialogNode:p,removingNodeId:h,handleCopyText:m,handleCheckNodeBrowser:g,handleAttachNodeBrowser:f,handleDetachNodeBrowser:b,handleOpenNodeMenu:y,handleRemoveNode:v,setMenuOpenNodeId:w,setRemoveDialogNode:$}=i;return ot` +`;var G3=1e4,Vw=({enabled:t=!0}={})=>{let e=Re(async()=>{let n=await Tm(),s=Array.isArray(n?.nodes)?n.nodes:[],o=Array.isArray(n?.pending)?n.pending:[];return{nodes:s,pending:o}},G3,{enabled:t,cacheKey:"/api/nodes"});return{nodes:Array.isArray(e.data?.nodes)?e.data.nodes:[],pending:Array.isArray(e.data?.pending)?e.data.pending:[],loading:e.data===null&&!e.error,error:e.error?String(e.error.message||"Could not load nodes"):"",refresh:e.refresh}};var zw=()=>{let t=Vw({enabled:!0}),[e,n]=y(!1),[s,o]=y(!1),{data:r,error:i}=ct("/api/nodes/connect-info",ya,{maxAgeMs:6e4}),a=Array.isArray(t.nodes)?t.nodes.filter(c=>c?.paired!==!1):[];L(()=>{i&&N(i.message||"Could not load node connect command","error")},[i]);let l=q(async()=>{if(!s){o(!0);try{await t.refresh()}finally{o(!1)}}},[t.refresh,s]);return{state:{wizardVisible:e,nodes:a,pending:t.pending,loadingNodes:t.loading,refreshingNodes:s,nodesError:t.error,connectInfo:r},actions:{openWizard:()=>n(!0),closeWizard:()=>n(!1),refreshNodes:l}}};var q3=35e3,J3=1e4,Uw="nodesBrowserAttachStateByNode",Z3=/selected page has been closed/i,Y3=async(t,e=q3)=>{let n=null;try{return await Promise.race([t,new Promise((s,o)=>{n=setTimeout(()=>{o(new Error("Browser check timed out"))},e)})])}finally{n&&clearTimeout(n)}},jw=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},X3=t=>{let e=String(t?.message||"Could not check node browser status").trim();return Z3.test(e)?"Selected Chrome page was closed. Click Attach to reconnect.":e},Q3=()=>{let e=Fe()?.[Uw];return!e||typeof e!="object"||Array.isArray(e)?{}:e},eR=(t={})=>{yx(e=>({...e&&typeof e=="object"?e:{},[Uw]:t&&typeof t=="object"?t:{}}))},Kw=({nodes:t=[],onRefreshNodes:e=async()=>{}}={})=>{let[n,s]=y({}),[o,r]=y({}),[i,a]=y(""),[l,c]=y(()=>Q3()),[d,u]=y(""),[p,f]=y(null),[g,m]=y(""),h=ee(0),b=ee(""),x=async(k,{successMessage:A="Connection command copied",errorMessage:O="Could not copy connection command"}={})=>{if(await Ho(k)){N(A,"success");return}N(O,"error")},v=q(async(k,{silent:A=!1}={})=>{let O=String(k||"").trim();if(!(!O||b.current)){b.current=O,A||a(O),r(D=>({...D,[O]:""}));try{let D=await Y3(Lm(O,"user")),j=D?.status&&typeof D.status=="object"?D.status:null;s(E=>({...E,[O]:j}))}catch(D){let j=X3(D);s(E=>({...E,[O]:null})),r(E=>({...E,[O]:j})),A||N(j,"error")}finally{b.current="",A||a("")}}},[]),w=q((k,A)=>{let O=String(k||"").trim();O&&c(D=>{let j={...D&&typeof D=="object"?D:{},[O]:A===!0};return eR(j),j})},[]),$=q(async k=>{let A=String(k||"").trim();A&&(w(A,!0),await v(A))},[v,w]),S=q(k=>{let A=String(k||"").trim();A&&(w(A,!1),s(O=>{let D={...O||{}};return delete D[A],D}),r(O=>{let D={...O||{}};return delete D[A],D}))},[w]),C=q(k=>{let A=String(k||"").trim();A&&u(O=>O===A?"":A)},[]),_=q(async()=>{let k=String(p?.nodeId||"").trim();if(!(!k||g)){m(k);try{await Pm(k),S(k),N("Device removed","success"),f(null),u(""),await e()}catch(A){N(A.message||"Could not remove node","error")}finally{m("")}}},[S,e,p,g]);return L(()=>{if(i)return;let k=t.map(A=>({nodeId:String(A?.nodeId||"").trim(),connected:A?.connected===!0,browserCapable:jw(A)})).find(A=>!(!A.nodeId||!A.connected||!A.browserCapable||l?.[A.nodeId]!==!0||n?.[A.nodeId]||o?.[A.nodeId]))?.nodeId;k&&v(k,{silent:!0})},[l,o,n,i,v,t]),L(()=>{if(i)return;let k=t.map(j=>({nodeId:String(j?.nodeId||"").trim(),connected:j?.connected===!0,browserCapable:jw(j),browserRunning:n?.[String(j?.nodeId||"").trim()]?.running===!0})).filter(j=>j.nodeId&&j.connected&&j.browserCapable&&l?.[j.nodeId]===!0&&j.browserRunning).map(j=>j.nodeId);if(!k.length)return;let A=!0,D=setInterval(async()=>{if(!A||b.current)return;let j=h.current%k.length;h.current+=1;let E=k[j];await v(E,{silent:!0})},J3);return()=>{A=!1,clearInterval(D)}},[l,n,i,v,t]),{browserStatusByNodeId:n,browserErrorByNodeId:o,checkingBrowserNodeId:i,browserAttachStateByNodeId:l,menuOpenNodeId:d,removeDialogNode:p,removingNodeId:g,handleCopyText:x,handleCheckNodeBrowser:v,handleAttachNodeBrowser:$,handleDetachNodeBrowser:S,handleOpenNodeMenu:C,handleRemoveNode:_,setMenuOpenNodeId:u,setRemoveDialogNode:f}};var ot=P.bind(T),tR=t=>String(t||"").replace(/"/g,'\\"'),Gw=({node:t,connectInfo:e,maskToken:n=!1})=>{let s=String(e?.gatewayHost||"").trim()||"localhost",o=Number(e?.gatewayPort)||3e3,r=String(e?.gatewayToken||"").trim(),i=e?.tls===!0?"--tls":"",a=String(t?.displayName||t?.nodeId||"My Node").trim(),l=n?"****":r;return[l?`OPENCLAW_GATEWAY_TOKEN=${l}`:"","openclaw node run",`--host ${s}`,`--port ${o}`,i,`--display-name "${tR(a)}"`].filter(Boolean).join(" ")},nR=t=>t?.connected?ot`<${ae} tone="success">Connected`:t?.paired?ot`<${ae} tone="warning">Disconnected`:ot`<${ae} tone="danger">Pending approval`,sR=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},oR=t=>t.running?"success":"warning",rR=t=>t.running?"Attached":"Not connected",qw=({nodes:t=[],pending:e=[],loading:n=!1,error:s="",connectInfo:o=null,onRefreshNodes:r=async()=>{}})=>{let i=Kw({nodes:t,onRefreshNodes:r}),{browserStatusByNodeId:a,browserErrorByNodeId:l,checkingBrowserNodeId:c,browserAttachStateByNodeId:d,menuOpenNodeId:u,removeDialogNode:p,removingNodeId:f,handleCopyText:g,handleCheckNodeBrowser:m,handleAttachNodeBrowser:h,handleDetachNodeBrowser:b,handleOpenNodeMenu:x,handleRemoveNode:v,setMenuOpenNodeId:w,setRemoveDialogNode:$}=i;return ot`
        ${e.length?ot`
        ${e.length} pending node${e.length===1?"":"s"} waiting for approval. @@ -7640,20 +7640,20 @@ ${B?.grantPublisher||""}`.trim()))} `:null} ${n?ot`
        -
        +
        <${Ce} className="h-4 w-4" /> Loading nodes...
        `:s?ot`
        ${s}
        `:t.length?ot`
        - ${t.map(S=>{let C=String(S?.nodeId||"").trim(),_=a[C]||null,k=l[C]||"",A=c===C,O=S?.connected&&sP(S)&&C,D=d?.[C]===!0,j=!!_||!!k,E=_?.running===!0,M=D&&!j&&!A,R=O&&D&&!A&&j&&!E;return ot` + ${t.map(S=>{let C=String(S?.nodeId||"").trim(),_=a[C]||null,k=l[C]||"",A=c===C,O=S?.connected&&sR(S)&&C,D=d?.[C]===!0,j=!!_||!!k,E=_?.running===!0,M=D&&!j&&!A,R=O&&D&&!A&&j&&!E;return ot`
        @@ -7666,8 +7666,8 @@ ${B?.grantPublisher||""}`.trim()))} ${C?ot`
        - ${nP(S)} + ${nR(S)} ${S?.paired?ot` <${pt} open=${u===C} ariaLabel="Open node actions" title="Open node actions" onClose=${()=>w("")} - onToggle=${()=>y(C)} + onToggle=${()=>x(C)} > <${We} - className="text-red-300 hover:text-red-200" + className="text-status-error hover:text-status-error" onClick=${()=>{w(""),$(S)}} > Remove device @@ -7699,15 +7699,15 @@ ${B?.grantPublisher||""}`.trim()))}
        - platform: + platform: ${S?.platform||"unknown"}
        - version: + version: ${S?.version||"unknown"}
        - capabilities: + capabilities: ${Array.isArray(S?.caps)?S.caps.join(", "):"none"} @@ -7727,13 +7727,13 @@ ${B?.grantPublisher||""}`.trim()))}
        ${D?ot`
        profile: user
        `:ot`
        Attach is disabled until you click ${" "} @@ -7746,8 +7746,8 @@ ${B?.grantPublisher||""}`.trim()))}
        ${_?ot` - <${ae} tone=${oP(_)} - >${rP(_)}${rR(_)} `:null} @@ -7763,7 +7763,7 @@ ${B?.grantPublisher||""}`.trim()))} `:null} ${O&&!D?ot` <${Z} - onClick=${()=>f(C)} + onClick=${()=>h(C)} idleLabel="Attach" tone="primary" size="sm" @@ -7771,7 +7771,7 @@ ${B?.grantPublisher||""}`.trim()))} `:null} ${R?ot` <${Z} - onClick=${()=>g(C)} + onClick=${()=>m(C)} idleLabel="Check" tone="secondary" size="sm" @@ -7781,7 +7781,7 @@ ${B?.grantPublisher||""}`.trim()))} @@ -7793,7 +7793,7 @@ ${B?.grantPublisher||""}`.trim()))} class="flex items-center justify-between gap-2" >
        driver: @@ -7811,7 +7811,7 @@ ${B?.grantPublisher||""}`.trim()))}
        `:null} ${k?ot`
        ${k}
        `:null} @@ -7822,7 +7822,7 @@ ${B?.grantPublisher||""}`.trim()))}
        -
        +
        Reconnect command
        @@ -7830,10 +7830,10 @@ ${B?.grantPublisher||""}`.trim()))} type="text" readonly value=${Gw({node:S,connectInfo:o,maskToken:!0})} - class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2 py-1.5 text-[11px] font-mono text-gray-300" + class="flex-1 min-w-0 bg-field border border-border rounded-lg px-2 py-1.5 text-[11px] font-mono text-body" /> <${Z} - onClick=${()=>m(Gw({node:S,connectInfo:o,maskToken:!1}),{successMessage:"Connection command copied",errorMessage:"Could not copy connection command"})} + onClick=${()=>g(Gw({node:S,connectInfo:o,maskToken:!1}),{successMessage:"Connection command copied",errorMessage:"Could not copy connection command"})} tone="secondary" size="sm" iconOnly=${!0} @@ -7855,10 +7855,10 @@ ${B?.grantPublisher||""}`.trim()))}
        <${Pa} className="h-12 w-12 text-cyan-400" />
        -

        +

        No connected nodes yet

        -

        +

        Connect a Mac, iOS, Android, or headless node to run system and browser commands through this gateway.

        @@ -7874,12 +7874,12 @@ ${B?.grantPublisher||""}`.trim()))} confirmLabel="Remove device" confirmLoadingLabel="Removing..." confirmTone="warning" - confirmLoading=${!!h} - confirmDisabled=${!!h} - onCancel=${()=>{h||$(null)}} + confirmLoading=${!!f} + confirmDisabled=${!!f} + onCancel=${()=>{f||$(null)}} onConfirm=${v} /> - `};var iP=P.bind(T),aP="https://github.com/openclaw/openclaw/releases/tag/v2026.3.13",lP=`Release reference: [OpenClaw 2026.3.13](${aP}) + `};var iR=P.bind(T),aR="https://github.com/openclaw/openclaw/releases/tag/v2026.3.13",lR=`Release reference: [OpenClaw 2026.3.13](${aR}) ## Requirements @@ -7919,11 +7919,11 @@ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**. | Config validation error on existing-session | Do not define a custom existing-session profile. Use \`defaultProfile: "user"\`. | | EADDRINUSE on port 9222 | Quit Chrome launched with \`--remote-debugging-port\` and relaunch normally. | | Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. | -| \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`,Jw=()=>{let t=F(()=>Le.parse(lP,{gfm:!0,breaks:!0}),[]);return iP` +| \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`,Jw=()=>{let t=F(()=>Le.parse(lR,{gfm:!0,breaks:!0}),[]);return iR`
        - + Chrome debugging setup / troubleshooting
        - `};var Zw=3e3,Yw=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=x(0),[a,l]=x(null),[c,d]=x(!1),[u,p]=x("My Mac Node"),[h,m]=x(""),[g,f]=x(!1),[b,y]=x([]),[v,w]=x(!1),$=ee(!1);L(()=>{t&&(i(0),m(""),f(!1),w(!1))},[t]),L(()=>{t&&(d(!0),xa().then(E=>{l(E||null)}).catch(E=>{N(E.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=F(()=>{let E=new Set,M=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||E.has(B)||R?.paired!==!1&&(E.add(B),M.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return M},[e]),C=F(()=>S.find(E=>E.nodeId===String(h||"").trim())||null,[S,h]),_=F(()=>{if(!a)return"";let E=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",I=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${E}`,`--port ${M}`,B.trim(),I?`--display-name "${I}"`:""].filter(Boolean).join(" ")},[a,u]),k=q(async()=>{if(!$.current){$.current=!0;try{await n();let E=await ma(),M=Array.isArray(E?.pending)?E.pending:[];y(M)}finally{$.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let E=!0,M=async()=>{if(E)try{await k()}catch{}};M();let R=setInterval(M,Zw);return()=>{E=!1,clearInterval(R)}},[k,r,t]),L(()=>{if(!t||r!==1)return;let E=S.some(B=>B.nodeId===String(h||"").trim()),M=String(u||"").trim().toLowerCase(),R=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||S[0];R&&(E&&String(h||"").trim()===R.nodeId||m(R.nodeId))},[u,S,h,r,t]);let A=q(async E=>{try{await ba(E),N("Pairing approved","success"),w(!0),await k()}catch(M){N(M.message||"Could not approve pairing","error")}},[k]),O=q(async E=>{try{await ya(E),N("Pairing rejected","info"),await k()}catch(M){N(M.message||"Could not reject pairing","error")}},[k]),D=q(async()=>{let E=String(h||"").trim();if(!E||g)return!1;f(!0);try{return await Rg(E),s(!0),N("Gateway routing now points to the selected node","success"),!0}catch(M){return N(M.message||"Could not configure gateway node routing","error"),!1}finally{f(!1)}},[g,s,h]),j=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:h,setSelectedNodeId:m,pairedNodes:S,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:g,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:Zw,handleDeviceApprove:A,handleDeviceReject:O,applyGatewayNodeRouting:D,completeWizard:j}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],Xw=({command:t="",onCopy:e=()=>{}})=>an` -
        + `};var Zw=3e3,Yw=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[b,x]=y([]),[v,w]=y(!1),$=ee(!1);L(()=>{t&&(i(0),g(""),h(!1),w(!1))},[t]),L(()=>{t&&(d(!0),ya().then(E=>{l(E||null)}).catch(E=>{N(E.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=F(()=>{let E=new Set,M=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||E.has(B)||R?.paired!==!1&&(E.add(B),M.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return M},[e]),C=F(()=>S.find(E=>E.nodeId===String(f||"").trim())||null,[S,f]),_=F(()=>{if(!a)return"";let E=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",I=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${E}`,`--port ${M}`,B.trim(),I?`--display-name "${I}"`:""].filter(Boolean).join(" ")},[a,u]),k=q(async()=>{if(!$.current){$.current=!0;try{await n();let E=await ga(),M=Array.isArray(E?.pending)?E.pending:[];x(M)}finally{$.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let E=!0,M=async()=>{if(E)try{await k()}catch{}};M();let R=setInterval(M,Zw);return()=>{E=!1,clearInterval(R)}},[k,r,t]),L(()=>{if(!t||r!==1)return;let E=S.some(B=>B.nodeId===String(f||"").trim()),M=String(u||"").trim().toLowerCase(),R=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||S[0];R&&(E&&String(f||"").trim()===R.nodeId||g(R.nodeId))},[u,S,f,r,t]);let A=q(async E=>{try{await ba(E),N("Pairing approved","success"),w(!0),await k()}catch(M){N(M.message||"Could not approve pairing","error")}},[k]),O=q(async E=>{try{await xa(E),N("Pairing rejected","info"),await k()}catch(M){N(M.message||"Could not reject pairing","error")}},[k]),D=q(async()=>{let E=String(f||"").trim();if(!E||m)return!1;h(!0);try{return await Rm(E),s(!0),N("Gateway routing now points to the selected node","success"),!0}catch(M){return N(M.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),j=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:S,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:Zw,handleDeviceApprove:A,handleDeviceReject:O,applyGatewayNodeRouting:D,completeWizard:j}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],Xw=({command:t="",onCopy:e=()=>{}})=>an` +
         ${t}
        @@ -7963,10 +7963,10 @@ ${t} - <${nt} className="w-3.5 h-3.5 text-gray-300" /> + <${nt} className="w-3.5 h-3.5 text-body" /> -
        Node Setup Wizard
        +
        Node Setup Wizard
        ${Ui.map((a,l)=>an`
        ${r.step===0?an` -
        +
        Install OpenClaw on the machine you want to connect as a node.
        ${Xw({command:"npm install -g openclaw",onCopy:()=>Qw("npm install -g openclaw","command")})} -
        Requires Node.js 22+.
        +
        Requires Node.js 22+.
        `:null} ${r.step===1?an`
        -
        +
        Run this on the device you want to connect:
        - ${r.loadingConnectInfo?an`
        + ${r.loadingConnectInfo?an`
        Loading command...
        `:Xw({command:r.connectCommand||"Could not build connect command.",onCopy:()=>Qw(r.connectCommand||"","command")})}
        ${r.devicePending.length?an` - <${yc} + <${xc} pending=${r.devicePending} onApprove=${r.handleDeviceApprove} onReject=${r.handleDeviceReject} /> `:r.selectedPairedNode&&!r.selectedPairedNode.connected?an`
        Node is paired but currently disconnected. Run the node command again on your device, then Finish will @@ -8023,7 +8023,7 @@ ${t} `:an`
        Pairing request will show up here. Checks every 3s.
        @@ -8104,7 +8104,7 @@ ${t}
        - `};var cP=P.bind(T),hh=({onRestartRequired:t=()=>{}})=>cP` + `};var cR=P.bind(T),hf=({onRestartRequired:t=()=>{}})=>cR`
        <${n1} onRestartRequired=${t} />
        @@ -8117,43 +8117,43 @@ ${t} `)}
        -`,d1=({accountId:t,botInfo:e,setBotInfo:n,onNext:s})=>{let[o,r]=x(!1),[i,a]=x(null),l=async()=>{r(!0),a(null);try{let c=await s1({accountId:t});if(!c.ok)throw new Error(c.error);n(c.bot)}catch(c){a(c.message)}r(!1)};return L(()=>{e||l()},[]),Mt` +`,d1=({accountId:t,botInfo:e,setBotInfo:n,onNext:s})=>{let[o,r]=y(!1),[i,a]=y(null),l=async()=>{r(!0),a(null);try{let c=await s1({accountId:t});if(!c.ok)throw new Error(c.error);n(c.bot)}catch(c){a(c.message)}r(!1)};return L(()=>{e||l()},[]),Mt`

        Verify Bot Setup

        ${e&&Mt` -
        +
        - @${e.username} + @${e.username} <${ae} tone="success">Connected
        -

        ${e.first_name}

        +

        ${e.first_name}

        `} ${i&&Mt`
        -

        ${i}

        +

        ${i}

        `} - ${!e&&!o&&!i&&Mt`

        Checking bot token...

        `} + ${!e&&!o&&!i&&Mt`

        Checking bot token...

        `} -
        -

        +

        +

        Before continuing, configure BotFather:

        -
          +
          1. - Open @BotFather in Telegram + Open @BotFather in Telegram
          2. - Send /mybots and + Send /mybots and select your bot
          3. - Go to Bot Settings > - Group Privacy + Go to Bot Settings > + Group Privacy
          4. -
          5. Turn it OFF
          6. +
          7. Turn it OFF
        @@ -8172,59 +8172,59 @@ ${t}

        Create a Telegram Group

        -
        -

        Create the group

        -
          +
          +

          Create the group

          +
          1. Open Telegram and create a${" "} - new group + new group
          2. - Search for and add your bot as a + Search for and add your bot as a member
          3. - Hit Next, give the group a name + Hit Next, give the group a name (e.g. "My Workspace"), and create it
          -
          -

          Enable topics

          -
            +
            +

            Enable topics

            +
            1. Tap the group name at the top to open settings
            2. - Tap Edit (pencil icon), scroll to - Topics, toggle it - ON + Tap Edit (pencil icon), scroll to + Topics, toggle it + ON
            -
            -

            Make the bot an admin

            -
              -
            1. Go to Members, tap your bot
            2. +
              +

              Make the bot an admin

              +
                +
              1. Go to Members, tap your bot
              2. - Promote it to Admin + Promote it to Admin
              3. Make sure - Manage Topics + Manage Topics permission is enabled
              -

              +

              Once all three steps are done, continue to verify the setup.

              @@ -8236,22 +8236,22 @@ ${t}
            -`,p1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=x(e||""),[h,m]=x(!1),[g,f]=x(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],y=async()=>{let $=u.trim();if($){m(!0),l(null);try{let S=await i1($,{accountId:t});if(!S.ok)throw new Error(S.error);n($),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}m(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),w=async()=>{if(!(!v||g)){l(null),f(!0);try{let $=String(r||"").trim(),S=await l1(e,{...$?{userId:$}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch($){l($.message)}f(!1)}};return Mt` +`,p1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],x=async()=>{let $=u.trim();if($){g(!0),l(null);try{let S=await i1($,{accountId:t});if(!S.ok)throw new Error(S.error);n($),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),w=async()=>{if(!(!v||m)){l(null),h(!0);try{let $=String(r||"").trim(),S=await l1(e,{...$?{userId:$}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch($){l($.message)}h(!1)}};return Mt`

            Verify Group

            -
            -

            To get your group chat ID:

            -
              +
              +

              To get your group chat ID:

              +
              1. - Invite @myidbot to your group + Invite @myidbot to your group
              2. - Send /getgroupid + Send /getgroupid
              3. Copy the ID (starts with - -100) + -100)
              @@ -8262,12 +8262,12 @@ ${t}p($.target.value)} placeholder="-100XXXXXXXXXX" - class="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500" + class="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> <${Z} - onClick=${y} - disabled=${!u.trim()||h} - loading=${h} + onClick=${x} + disabled=${!u.trim()||f} + loading=${f} tone="secondary" size="md" idleLabel="Verify" @@ -8278,32 +8278,32 @@ ${t} -

              ${a}

              +

              ${a}

            `} ${s&&Mt` -
            +
            - ${s.chat.title} + ${s.chat.title} <${ae} tone="success">Verified
            -
            +
            Topics: ${s.chat.isForum?"ON":"OFF"} Bot: ${s.bot.status}
            `} ${s&&b.length===0&&Mt` -
            -

            Your Telegram User ID

            +
            +

            Your Telegram User ID

            i($.target.value)} placeholder="e.g. 123456789" - class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-500" + class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> -

            +

            Auto-filled from Telegram admins. Edit if needed.

            @@ -8312,47 +8312,47 @@ ${t} -

            +

            Fix these before continuing:

            -
              +
                ${b.map($=>Mt`
              • ${$}
              • `)}
              -

              Once fixed, hit Verify again.

              +

              Once fixed, hit Verify again.

            `}
            - `},h1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=x(""),[l,c]=x(""),[d,u]=x(!1),[p,h]=x(null),[m,g]=x(null),[f,b]=x(null),y=async()=>{let S=await _c(e,{accountId:t});S.ok&&s(S.topics)};L(()=>{y()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),h(null);try{let _=await Mc(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(A=>!A.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await y(),N(`Created topic: ${S}`,"success")}catch(_){h(_.message)}u(!1)}},w=async(S,C)=>{g(S);try{let _=await Ac(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await y(),_.removedFromRegistryOnly?N(`Removed stale topic from registry: ${C}`,"success"):N(`Deleted topic: ${C}`,"success")}catch(_){N(`Failed to delete: ${_.message}`,"error")}g(null)},$=Object.entries(n||{});return Mt` + `},f1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,b]=y(null),x=async()=>{let S=await _c(e,{accountId:t});S.ok&&s(S.topics)};L(()=>{x()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),f(null);try{let _=await Mc(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(A=>!A.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await x(),N(`Created topic: ${S}`,"success")}catch(_){f(_.message)}u(!1)}},w=async(S,C)=>{m(S);try{let _=await Ac(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await x(),_.removedFromRegistryOnly?N(`Removed stale topic from registry: ${C}`,"success"):N(`Deleted topic: ${C}`,"success")}catch(_){N(`Failed to delete: ${_.message}`,"error")}m(null)},$=Object.entries(n||{});return Mt`

            Create Topics

            ${$.length>0&&Mt`
            - - - - + + - ${$.map(([S,C])=>At` + ${$.map(([S,C])=>Mt` @@ -8410,7 +8486,7 @@ ${t}
            - <${Z} + <${Y} onClick=${v} disabled=${d||!i.trim()} loading=${d} @@ -8425,7 +8501,7 @@ ${t}
            - ${p&&At` + ${p&&Mt`

            ${p}

            @@ -8459,7 +8535,7 @@ ${t}{if(!h)return;let S=h;b(null),await w(S.id,S.name)}} />
            - `},m1=({groupId:t,groupInfo:e,topics:n,onBack:s,onDone:o})=>At` + `},x1=({groupId:t,groupInfo:e,topics:n,onBack:s,onDone:o})=>Mt`

            🎉 Setup complete

            @@ -8492,7 +8568,7 @@ ${t}
            - `;var Tt=P.bind(T),g1=({value:t,agents:e,onChange:n,className:s=""})=>Tt` + `;var Tt=P.bind(T),y1=({value:t,agents:e,onChange:n,className:s=""})=>Tt` -`,bf=({accountId:t,groupId:e,groupName:n,initialTopics:s,configAgentMaxConcurrent:o,configSubagentMaxConcurrent:r,debugEnabled:i,onResetOnboarding:a})=>{let[l,c]=y(s||{}),[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(null),[S,C]=y(""),[_,k]=y(""),[A,O]=y(""),[D,z]=y(""),[E,M]=y(""),[R,B]=y(null),[I,U]=y(null),[W,G]=y([]),se=async()=>{let V=await _c(e,{accountId:t});V.ok&&c(V.topics||{})};L(()=>{se()},[e]),L(()=>{s&&Object.keys(s).length>0&&c(s)},[s]),L(()=>{Ao().then(V=>G(Array.isArray(V?.agents)?V.agents:[])).catch(()=>{})},[]);let K=async()=>{let V=d.trim(),J=p.trim(),oe=g.trim();if(V){v(!0),B(null);try{let ce=await Mc(e,[{name:V,...J?{systemInstructions:J}:{},...oe?{agentId:oe}:{}}],{accountId:t});if(!ce.ok)throw new Error(ce.results?.[0]?.error||"Failed to create topic");let be=ce.results.filter(Ve=>!Ve.ok);if(be.length>0)throw new Error(be[0].error);u(""),f(""),m(""),b(!1),await se(),N(`Created topic: ${V}`,"success")}catch(ce){B(ce.message)}v(!1)}},j=async(V,J)=>{$(V);try{let oe=await Ac(e,V,{accountId:t});if(!oe.ok)throw new Error(oe.error);await se(),oe.removedFromRegistryOnly?N(`Removed stale topic from registry: ${J}`,"success"):N(`Deleted topic: ${J}`,"success")}catch(oe){N(`Failed to delete: ${oe.message}`,"error")}$(null)},X=(V,J,oe="",ce="")=>{C(String(V)),k(String(J||"")),O(String(oe||"")),z(String(ce||""))},de=()=>{C(""),k(""),O(""),z("")},le=async V=>{let J=_.trim(),oe=A.trim(),ce=D.trim();if(!J){B("Topic name is required");return}M(String(V)),B(null);try{let be=await l1(e,V,{name:J,systemInstructions:oe,agentId:ce},{accountId:t});if(!be.ok)throw new Error(be.error||"Failed to update topic");await se(),N(`Updated topic: ${J}`,"success"),de()}catch(be){B(be.message)}M("")},ie=Object.entries(l||{}),ne=ie.length,xe=Math.max(ne*3,8),he=Math.max(xe-2,4),ue=Number.isFinite(o)?o:xe,me=Number.isFinite(r)?r:he;return Tt` +`,bf=({accountId:t,groupId:e,groupName:n,initialTopics:s,configAgentMaxConcurrent:o,configSubagentMaxConcurrent:r,debugEnabled:i,onResetOnboarding:a})=>{let[l,c]=y(s||{}),[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(null),[S,C]=y(""),[_,k]=y(""),[A,D]=y(""),[N,U]=y(""),[O,M]=y(""),[R,B]=y(null),[E,G]=y(null),[H,z]=y([]),te=async()=>{let j=await _c(e,{accountId:t});j.ok&&c(j.topics||{})};L(()=>{te()},[e]),L(()=>{s&&Object.keys(s).length>0&&c(s)},[s]),L(()=>{Mo().then(j=>z(Array.isArray(j?.agents)?j.agents:[])).catch(()=>{})},[]);let K=async()=>{let j=d.trim(),J=p.trim(),ie=g.trim();if(j){v(!0),B(null);try{let de=await Ac(e,[{name:j,...J?{systemInstructions:J}:{},...ie?{agentId:ie}:{}}],{accountId:t});if(!de.ok)throw new Error(de.results?.[0]?.error||"Failed to create topic");let be=de.results.filter(Ve=>!Ve.ok);if(be.length>0)throw new Error(be[0].error);u(""),f(""),m(""),b(!1),await te(),I(`Created topic: ${j}`,"success")}catch(de){B(de.message)}v(!1)}},V=async(j,J)=>{$(j);try{let ie=await Mc(e,j,{accountId:t});if(!ie.ok)throw new Error(ie.error);await te(),ie.removedFromRegistryOnly?I(`Removed stale topic from registry: ${J}`,"success"):I(`Deleted topic: ${J}`,"success")}catch(ie){I(`Failed to delete: ${ie.message}`,"error")}$(null)},Z=(j,J,ie="",de="")=>{C(String(j)),k(String(J||"")),D(String(ie||"")),U(String(de||""))},ue=()=>{C(""),k(""),D(""),U("")},re=async j=>{let J=_.trim(),ie=A.trim(),de=N.trim();if(!J){B("Topic name is required");return}M(String(j)),B(null);try{let be=await u1(e,j,{name:J,systemInstructions:ie,agentId:de},{accountId:t});if(!be.ok)throw new Error(be.error||"Failed to update topic");await te(),I(`Updated topic: ${J}`,"success"),ue()}catch(be){B(be.message)}M("")},oe=Object.entries(l||{}),ne=oe.length,me=Math.max(ne*3,8),pe=Math.max(me-2,4),ce=Number.isFinite(o)?o:me,ge=Number.isFinite(r)?r:pe;return Tt`
            ${i&&Tt`
            @@ -8520,7 +8596,7 @@ ${t}

            Existing Topics

            - ${ie.length>0?Tt` + ${oe.length>0?Tt`
            @@ -8535,7 +8611,7 @@ ${t} Thread ID - ${W.length>0&&Tt` + ${H.length>0&&Tt`
            - ${ie.map(([V,J])=>Tt` - ${S===String(V)?Tt` + ${oe.map(([j,J])=>Tt` + ${S===String(j)?Tt` - - ${$.map(([S,C])=>Mt` + ${w.map(([S,C])=>Pt` @@ -8486,7 +8516,7 @@ ${t}
            - <${Y} + <${Z} onClick=${v} disabled=${d||!i.trim()} loading=${d} @@ -8501,7 +8531,7 @@ ${t}
            - ${p&&Mt` + ${p&&Pt`

            ${p}

            @@ -8516,13 +8546,13 @@ ${t}
            - <${st} + <${rt} visible=${!!h} title="Delete topic?" message=${h?`This will delete "${h.name}" (thread ${h.id}) from your Telegram workspace.`:"This will delete this topic from your Telegram workspace."} @@ -8532,10 +8562,10 @@ ${t}{g||b(null)}} - onConfirm=${async()=>{if(!h)return;let S=h;b(null),await w(S.id,S.name)}} + onConfirm=${async()=>{if(!h)return;let S=h;b(null),await $(S.id,S.name)}} />
            - `},x1=({groupId:t,groupInfo:e,topics:n,onBack:s,onDone:o})=>Mt` + `},k1=({groupId:t,groupInfo:e,topics:n,onBack:s,onDone:o})=>Pt`

            🎉 Setup complete

            @@ -8568,18 +8598,18 @@ ${t}
            - `;var Tt=P.bind(T),y1=({value:t,agents:e,onChange:n,className:s=""})=>Tt` + `;var Rt=P.bind(T),S1=({value:t,agents:e,onChange:n,className:s=""})=>Rt` -`,bf=({accountId:t,groupId:e,groupName:n,initialTopics:s,configAgentMaxConcurrent:o,configSubagentMaxConcurrent:r,debugEnabled:i,onResetOnboarding:a})=>{let[l,c]=y(s||{}),[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(null),[S,C]=y(""),[_,k]=y(""),[A,D]=y(""),[N,U]=y(""),[O,M]=y(""),[R,B]=y(null),[E,G]=y(null),[H,z]=y([]),te=async()=>{let j=await _c(e,{accountId:t});j.ok&&c(j.topics||{})};L(()=>{te()},[e]),L(()=>{s&&Object.keys(s).length>0&&c(s)},[s]),L(()=>{Mo().then(j=>z(Array.isArray(j?.agents)?j.agents:[])).catch(()=>{})},[]);let K=async()=>{let j=d.trim(),J=p.trim(),ie=g.trim();if(j){v(!0),B(null);try{let de=await Ac(e,[{name:j,...J?{systemInstructions:J}:{},...ie?{agentId:ie}:{}}],{accountId:t});if(!de.ok)throw new Error(de.results?.[0]?.error||"Failed to create topic");let be=de.results.filter(Ve=>!Ve.ok);if(be.length>0)throw new Error(be[0].error);u(""),f(""),m(""),b(!1),await te(),I(`Created topic: ${j}`,"success")}catch(de){B(de.message)}v(!1)}},V=async(j,J)=>{$(j);try{let ie=await Mc(e,j,{accountId:t});if(!ie.ok)throw new Error(ie.error);await te(),ie.removedFromRegistryOnly?I(`Removed stale topic from registry: ${J}`,"success"):I(`Deleted topic: ${J}`,"success")}catch(ie){I(`Failed to delete: ${ie.message}`,"error")}$(null)},Z=(j,J,ie="",de="")=>{C(String(j)),k(String(J||"")),D(String(ie||"")),U(String(de||""))},ue=()=>{C(""),k(""),D(""),U("")},re=async j=>{let J=_.trim(),ie=A.trim(),de=N.trim();if(!J){B("Topic name is required");return}M(String(j)),B(null);try{let be=await u1(e,j,{name:J,systemInstructions:ie,agentId:de},{accountId:t});if(!be.ok)throw new Error(be.error||"Failed to update topic");await te(),I(`Updated topic: ${J}`,"success"),ue()}catch(be){B(be.message)}M("")},oe=Object.entries(l||{}),ne=oe.length,me=Math.max(ne*3,8),pe=Math.max(me-2,4),ce=Number.isFinite(o)?o:me,ge=Number.isFinite(r)?r:pe;return Tt` +`,yf=({accountId:t,groupId:e,groupName:n,initialTopics:s,configAgentMaxConcurrent:o,configSubagentMaxConcurrent:r,debugEnabled:i,onResetOnboarding:a})=>{let[l,c]=y(s||{}),[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),[$,w]=y(null),[S,C]=y(""),[_,k]=y(""),[A,D]=y(""),[O,z]=y(""),[N,M]=y(""),[L,B]=y(null),[E,U]=y(null),[F,K]=y([]),re=async()=>{let V=await Tc(e,{accountId:t});V.ok&&c(V.topics||{})};R(()=>{re()},[e]),R(()=>{s&&Object.keys(s).length>0&&c(s)},[s]),R(()=>{Mo().then(V=>K(Array.isArray(V?.agents)?V.agents:[])).catch(()=>{})},[]);let Y=async()=>{let V=d.trim(),q=p.trim(),ae=g.trim();if(V){v(!0),B(null);try{let fe=await Pc(e,[{name:V,...q?{systemInstructions:q}:{},...ae?{agentId:ae}:{}}],{accountId:t});if(!fe.ok)throw new Error(fe.results?.[0]?.error||"Failed to create topic");let ne=fe.results.filter(ve=>!ve.ok);if(ne.length>0)throw new Error(ne[0].error);u(""),f(""),m(""),b(!1),await re(),I(`Created topic: ${V}`,"success")}catch(fe){B(fe.message)}v(!1)}},j=async(V,q)=>{w(V);try{let ae=await Rc(e,V,{accountId:t});if(!ae.ok)throw new Error(ae.error);await re(),ae.removedFromRegistryOnly?I(`Removed stale topic from registry: ${q}`,"success"):I(`Deleted topic: ${q}`,"success")}catch(ae){I(`Failed to delete: ${ae.message}`,"error")}w(null)},J=(V,q,ae="",fe="")=>{C(String(V)),k(String(q||"")),D(String(ae||"")),z(String(fe||""))},pe=()=>{C(""),k(""),D(""),z("")},le=async V=>{let q=_.trim(),ae=A.trim(),fe=O.trim();if(!q){B("Topic name is required");return}M(String(V)),B(null);try{let ne=await g1(e,V,{name:q,systemInstructions:ae,agentId:fe},{accountId:t});if(!ne.ok)throw new Error(ne.error||"Failed to update topic");await re(),I(`Updated topic: ${q}`,"success"),pe()}catch(ne){B(ne.message)}M("")},ie=Object.entries(l||{}),se=ie.length,xe=Math.max(se*3,8),he=Math.max(xe-2,4),ue=Number.isFinite(o)?o:xe,be=Number.isFinite(r)?r:he;return Rt`
            - ${i&&Tt` + ${i&&Rt`
            - ${oe.map(([j,J])=>Tt` - ${S===String(j)?Tt` + ${ie.map(([V,q])=>Rt` + ${S===String(V)?Rt` - - ${$.map(([k,C])=>Mt` + ${w.map(([S,C])=>Mt` - + ${ie.map(([j,J])=>Tt` - ${k===String(j)?Tt` + ${S===String(j)?Tt` @@ -8556,12 +8556,12 @@ ${t}S(oe.target.value)} + onInput=${oe=>k(oe.target.value)} onKeyDown=${oe=>{oe.key==="Enter"&&le(j),oe.key==="Escape"&&de()}} class="w-full bg-field border border-border rounded-lg px-2 py-1.5 text-xs text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> -`,Yo=({editorShellClassName:t="file-viewer-editor-shell",editorShellAriaHidden:e,editorLineNumbers:n,editorLineNumbersRef:s,editorLineNumberRowRefs:o,shouldUseHighlightedEditor:r,highlightedEditorLines:i,editorHighlightRef:a,editorHighlightLineRefs:l,editorTextareaRef:c,renderContent:d,handleContentInput:u,handleEditorKeyDown:p,handleEditorScroll:f,handleEditorSelectionChange:g,isEditBlocked:m,isPreviewOnly:h,textareaWrap:b="soft"})=>Zo` +`,Yo=({editorShellClassName:t="file-viewer-editor-shell",editorShellAriaHidden:e,editorLineNumbers:n,editorLineNumbersRef:s,editorLineNumberRowRefs:o,shouldUseHighlightedEditor:r,highlightedEditorLines:i,editorHighlightRef:a,editorHighlightLineRefs:l,editorTextareaRef:c,renderContent:d,handleContentInput:u,handleEditorKeyDown:p,handleEditorScroll:f,handleEditorSelectionChange:g,isEditBlocked:m,isPreviewOnly:h,textareaWrap:x="soft"})=>Zo`
            - ${n.map(x=>Zo` + ${n.map(b=>Zo`
            {o.current[x-1]=v}} + ref=${v=>{o.current[b-1]=v}} > - ${x} + ${b}
            `)}
            ${r?Zo`
            - ${i.map(x=>Zo` + ${i.map(b=>Zo`
            {l.current[x.lineNumber-1]=v}} + key=${b.lineNumber} + ref=${v=>{l.current[b.lineNumber-1]=v}} >
            `)} @@ -4196,7 +4196,7 @@ ${(n||"").split(` handleEditorSelectionChange=${g} isEditBlocked=${m} isPreviewOnly=${h} - textareaWrap=${b} + textareaWrap=${x} />
            `:Zo` @@ -4210,11 +4210,11 @@ ${(n||"").split(` handleEditorSelectionChange=${g} isEditBlocked=${m} isPreviewOnly=${h} - textareaWrap=${b} + textareaWrap=${x} /> `}
            -`;var Y2=P.bind(T),Ey=({viewMode:t,previewRef:e,handlePreviewScroll:n,previewHtml:s,editorLineNumbers:o,editorLineNumbersRef:r,editorLineNumberRowRefs:i,shouldUseHighlightedEditor:a,highlightedEditorLines:l,editorHighlightRef:c,editorHighlightLineRefs:d,editorTextareaRef:u,renderContent:p,handleContentInput:f,handleEditorKeyDown:g,handleEditorScroll:m,handleEditorSelectionChange:h,isEditBlocked:b,isPreviewOnly:x})=>Y2` +`;var X2=P.bind(T),Ey=({viewMode:t,previewRef:e,handlePreviewScroll:n,previewHtml:s,editorLineNumbers:o,editorLineNumbersRef:r,editorLineNumberRowRefs:i,shouldUseHighlightedEditor:a,highlightedEditorLines:l,editorHighlightRef:c,editorHighlightLineRefs:d,editorTextareaRef:u,renderContent:p,handleContentInput:f,handleEditorKeyDown:g,handleEditorScroll:m,handleEditorSelectionChange:h,isEditBlocked:x,isPreviewOnly:b})=>X2`
            -`;var Iy=1e3,Dy=5e3,ml=50,gl=25e4,bl=5e3;var Oy=()=>{try{return String(window.localStorage.getItem(Xr)||"").trim()==="preview"?"preview":"edit"}catch{return"edit"}},Ny=()=>{try{let t=window.localStorage.getItem(Va);if(!t)return{};let e=JSON.parse(t);return!e||typeof e!="object"?{}:e}catch{return{}}},By=t=>{let e=String(t||"").trim();if(!e)return null;let s=Ny()[e];return!s||typeof s!="object"?null:{start:s.start,end:s.end}},pu=(t,e)=>{let n=String(t||"").trim();if(!(!n||!e||typeof e!="object"))try{let s=Ny();s[n]={start:e.start,end:e.end},window.localStorage.setItem(Va,JSON.stringify(s))}catch{}};var Fy=t=>String(t||"").split("/").map(e=>e.trim()).filter(Boolean),xl=(t,e)=>{let n=Number.parseInt(String(t??""),10);return Number.isFinite(n)?Math.max(0,Math.min(e,n)):0},yl=t=>{let e=String(t||"");return e?e.split(/\r\n|\r|\n/).length:1},vl=({contentLength:t=0,lineCount:e=1,charThreshold:n=25e4,lineThreshold:s=5e3})=>Number(t)>Number(n)||Number(e)>Number(s);var Xo=t=>{if(!t)return 0;let e=t.scrollHeight-t.clientHeight;return e<=0?0:t.scrollTop/e},ks=(t,e)=>{if(!t)return;let n=t.scrollHeight-t.clientHeight;if(n<=0){t.scrollTop=0;return}let s=Math.max(0,Math.min(1,e));t.scrollTop=n*s},Wy=({viewMode:t,setViewMode:e,previewRef:n,editorTextareaRef:s,editorLineNumbersRef:o,editorHighlightRef:r})=>{let i=te(0),a=te(!1);return{viewScrollRatioRef:i,isSyncingScrollRef:a,handleEditorScroll:u=>{if(a.current)return;let p=u.currentTarget.scrollTop,f=Xo(u.currentTarget);i.current=f,o.current&&(o.current.scrollTop=p,r.current&&(r.current.scrollTop=p,r.current.scrollLeft=u.currentTarget.scrollLeft),n.current&&(a.current=!0,ks(n.current,f),window.requestAnimationFrame(()=>{a.current=!1})))},handlePreviewScroll:u=>{if(a.current)return;let p=Xo(u.currentTarget);i.current=p,a.current=!0,ks(s.current,p),ks(o.current,p),ks(r.current,p),window.requestAnimationFrame(()=>{a.current=!1})},handleChangeViewMode:u=>{if(u===t)return;let p=Xo(t==="preview"?n.current:s.current);i.current=p,e(u),window.requestAnimationFrame(()=>{a.current=!0,u==="preview"?ks(n.current,p):(ks(s.current,p),ks(o.current,p),ks(r.current,p)),window.requestAnimationFrame(()=>{a.current=!1})})}}};var Hy=({hasSelectedPath:t,normalizedPath:e,isDiffView:n,isSqliteFile:s,sqliteSelectedTable:o,sqliteTableOffset:r,canEditFile:i,isFolderPath:a,loading:l,saving:c,initialContent:d,isDirty:u,setContent:p,setInitialContent:f,setFileKind:g,setImageDataUrl:m,setAudioDataUrl:h,setSqliteSummary:b,setSqliteSelectedTable:x,setSqliteTableOffset:v,setSqliteTableLoading:$,setSqliteTableError:w,setSqliteTableData:S,setError:C,setIsFolderPath:_,setExternalChangeNoticeShown:k,externalChangeNoticeShown:A,viewScrollRatioRef:D,setLoading:O})=>{let z=te(""),N=te(""),M=te(!1);return R(()=>{let L=!0;return z.current="",N.current="",t?(p(""),f(""),m(""),h(""),b(null),x(""),v(0),$(!1),w(""),S(null),g("text"),C(""),_(!1),k(!1),D.current=0,n?(O(!1),z.current=e,N.current="",()=>{L=!1}):((async()=>{O(!0),C(""),_(!1);try{let E=await pd(e);if(!L)return;let U=E?.kind==="image"?"image":E?.kind==="audio"?"audio":E?.kind==="sqlite"?"sqlite":"text";if(g(U),U==="image"){m(String(E?.imageDataUrl||"")),h(""),b(null),x(""),v(0),$(!1),w(""),S(null),p(""),f(""),k(!1),D.current=0,z.current=e,N.current="";return}if(U==="audio"){h(String(E?.audioDataUrl||"")),m(""),b(null),x(""),v(0),$(!1),w(""),S(null),p(""),f(""),k(!1),D.current=0,z.current=e,N.current="";return}if(U==="sqlite"){let re=E?.sqliteSummary||null,Y=re?.objects||[],j=Y.find(J=>J?.type==="table")?.name||Y[0]?.name||"";b(re),x(j),v(0),$(!1),w(""),S(null),m(""),h(""),p(""),f(""),k(!1),D.current=0,z.current=e,N.current="";return}m(""),h(""),b(null),x(""),v(0),$(!1),w(""),S(null);let F=E.content||"",K=Xb(e);p(K||F),mn(e,!!(K&&K!==F),{dispatchEvent:re=>window.dispatchEvent(re)}),f(F),k(!1),D.current=0,z.current=e,N.current=""}catch(E){if(!L)return;g("text"),m(""),h(""),b(null),x(""),v(0),$(!1),w(""),S(null);let U=E.message||"Could not load file";if(/path is not a file/i.test(U)){p(""),f(""),_(!0),C(""),z.current=e,N.current="";return}C(U)}finally{L&&O(!1)}})(),()=>{L=!1})):(p(""),f(""),g("text"),m(""),h(""),b(null),x(""),v(0),$(!1),w(""),S(null),C(""),_(!1),D.current=0,z.current="",()=>{L=!1})},[t,e,n]),R(()=>{if(!s||!e||!o)return S(null),w(""),$(!1),()=>{};let L=!0;return(async()=>{$(!0),w("");try{let E=await bg({filePath:e,table:o,limit:ml,offset:r});if(!L)return;S(E)}catch(E){if(!L)return;w(E.message||"Could not load sqlite table")}finally{L&&$(!1)}})(),()=>{L=!1}},[s,e,o,r]),R(()=>{if(!t||a||!i||n)return()=>{};let L=async()=>{if(!(l||c)&&!M.current){M.current=!0;try{let U=(await pd(e)).content||"";if(U===d){k(!1);return}if(!u){p(U),f(U),vs(e),mn(e,!1,{dispatchEvent:F=>window.dispatchEvent(F)}),k(!1),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));return}A||(I("This file changed on disk. Save to overwrite or reload by re-opening.","error"),k(!0))}catch{}finally{M.current=!1}}},B=window.setInterval(L,Dy);return()=>{window.clearInterval(B)}},[t,a,i,n,l,c,e,d,u,A]),{loadedFilePathRef:z,restoredSelectionPathRef:N}};var Vy=({hasSelectedPath:t,isDiffView:e,isPreviewOnly:n,normalizedPath:s})=>{let[o,r]=y(!1),[i,a]=y(""),[l,c]=y(""),[d,u]=y({statusKind:"",isDeleted:!1});return R(()=>{let p=!0;return!t||!e||n?(r(!1),a(""),c(""),u({statusKind:"",isDeleted:!1}),()=>{p=!1}):((async()=>{r(!0),a("");try{let g=await gg(s);if(!p)return;c(String(g?.content||"")),u({statusKind:String(g?.statusKind||""),isDeleted:!!g?.isDeleted})}catch(g){if(!p)return;a(g.message||"Could not load diff"),u({statusKind:"",isDeleted:!1})}finally{p&&r(!1)}})(),()=>{p=!1})},[t,e,n,s]),{diffLoading:o,diffError:i,diffContent:l,diffStatus:d}};var jy=({loadedFilePathRef:t,normalizedPath:e,canEditFile:n,hasSelectedPath:s,loading:o,content:r,initialContent:i})=>{R(()=>{if(t.current===e&&!(!n||!s||o)){if(r===i){vs(e),mn(e,!1,{dispatchEvent:a=>window.dispatchEvent(a)});return}qa(e,r),mn(e,!0,{dispatchEvent:a=>window.dispatchEvent(a)})}},[n,s,o,r,i,e])};var zy=({canEditFile:t,isPreviewOnly:e,isDiffView:n,viewMode:s,handleSave:o})=>{R(()=>{let r=i=>{(i.metaKey||i.ctrlKey)&&!i.shiftKey&&!i.altKey&&String(i.key||"").toLowerCase()==="s"&&(!t||e||n||s!=="edit"||(i.preventDefault(),o()))};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,e,n,s,o])};var X2=(t,e)=>{let n=String(t||"").split(` -`),s=Math.max(0,Math.min(e-1,n.length-1)),o=0;for(let r=0;r{let r=window.getComputedStyle(e),i=Number.parseFloat(r.lineHeight||""),a=Number.isFinite(i)&&i>0?i:20,l=Math.max(0,t*a-e.clientHeight*.4);e.scrollTop=l,n.current&&(n.current.scrollTop=l),s.current&&(s.current.scrollTop=l),o.current=Xo(e)},Ky=t=>{if(!t)return;let e=t.querySelectorAll(".line-highlight-flash");for(let n of e)n.classList.remove("line-highlight-flash")},Q2=(t,e,n)=>{if(!t)return;Ky(t);let s=t.querySelectorAll("[data-line-row]"),o=Math.min(n,s.length-1);for(let r=e;r<=o;r+=1){let i=s[r];i&&i.classList.add("line-highlight-flash")}},Gy=({canEditFile:t,isEditBlocked:e,loading:n,hasSelectedPath:s,normalizedPath:o,loadedFilePathRef:r,restoredSelectionPathRef:i,viewMode:a,content:l,lineTarget:c=0,lineEndTarget:d=0,editorTextareaRef:u,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})=>{let m=te("");R(()=>{c&&c>=1||m.current&&(Ky(p.current),m.current="")},[c,o,p]),R(()=>{if(e||!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(a!=="edit")return()=>{};if(!c||c<1)return()=>{};let h=d&&d>=c?d:c,b=`${o}:${c}-${h}`;if(m.current===b)return()=>{};let x=0,v=0,$=()=>{let w=u.current;if(!w){v+=1,v<6&&(x=window.requestAnimationFrame($));return}let S=String(l||""),C=X2(S,c);w.setSelectionRange(C,C);let _=c-1,k=h-1;window.requestAnimationFrame(()=>{let A=u.current;A&&(Uy({lineIndex:_,textareaElement:A,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g}),Q2(p.current,_,k))}),m.current=b,i.current=o};return x=window.requestAnimationFrame($),()=>{x&&window.cancelAnimationFrame(x)}},[t,e,n,s,o,l,a,c,d,r,i,u,p,f,g]),R(()=>{if(e)return i.current="",()=>{};if(!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(i.current===o)return()=>{};if(a!=="edit")return()=>{};if(c&&c>=1)return()=>{};let h=By(o);if(!h)return i.current=o,()=>{};let b=0,x=0,v=()=>{let $=u.current;if(!$){x+=1,x<6&&(b=window.requestAnimationFrame(v));return}let w=String(l||"").length,S=xl(h.start,w),C=xl(h.end,w);$.focus(),$.setSelectionRange(S,Math.max(S,C)),window.requestAnimationFrame(()=>{let _=u.current;if(!_)return;let k=String(l||""),A=xl(S,k.length),D=k.slice(0,A).split(` -`).length-1;Uy({lineIndex:D,textareaElement:_,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})}),i.current=o};return b=window.requestAnimationFrame(v),()=>{b&&window.cancelAnimationFrame(b)}},[t,e,n,s,o,l,a,c,r,i,u,p,f,g])};var $l=({enabled:t=!1,syncKey:e="",editorLineNumberRowRefs:n,editorHighlightLineRefs:s})=>{let o=G(()=>{if(!t)return;let r=n?.current||[],i=s?.current||[],a=Math.min(r.length,i.length);for(let l=0;l{o()},[o,e]),R(()=>{if(!t)return()=>{};let r=()=>o();return window.addEventListener("resize",r),()=>window.removeEventListener("resize",r)},[t,o]),{syncEditorLineNumberHeights:o}};var qy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestClearSelection:r=()=>{},onRequestEdit:i=()=>{}})=>{let a=String(t||"").trim(),l=Ys(a),[c,d]=y(""),[u,p]=y(""),[f,g]=y("text"),[m,h]=y(""),[b,x]=y(""),[v,$]=y(null),[w,S]=y(""),[C,_]=y(0),[k,A]=y(!1),[D,O]=y(""),[z,N]=y(null),[M,L]=y(Oy),[B,E]=y(!1),[U,F]=y(!1),[K,re]=y(!1),[Y,j]=y(!1),[J,pe]=y(!1),[le,ie]=y(""),[se,xe]=y(!1),[he,ue]=y(!1),[be,V]=y(!1),[q,ae]=y(()=>new Set),fe=te(null),ne=te(null),ve=te(null),ge=te(null),Ne=te([]),qe=te([]),Q=a.length>0,ce=f==="image",ee=f==="audio",Ee=f==="sqlite",ke=Q&&!se&&!e&&!ce&&!ee&&!Ee,H=String(n||"edit")==="diff",{viewScrollRatioRef:oe,handleEditorScroll:$e,handlePreviewScroll:we,handleChangeViewMode:Te}=Wy({viewMode:M,setViewMode:L,previewRef:ge,editorTextareaRef:ve,editorLineNumbersRef:fe,editorHighlightRef:ne}),{loadedFilePathRef:at,restoredSelectionPathRef:_r}=Hy({hasSelectedPath:Q,normalizedPath:a,isDiffView:H,isSqliteFile:Ee,sqliteSelectedTable:w,sqliteTableOffset:C,canEditFile:ke,isFolderPath:se,loading:B,saving:K,initialContent:u,isDirty:ke&&c!==u,setLoading:E,setContent:d,setInitialContent:p,setFileKind:g,setImageDataUrl:h,setAudioDataUrl:x,setSqliteSummary:$,setSqliteSelectedTable:S,setSqliteTableOffset:_,setSqliteTableLoading:A,setSqliteTableError:O,setSqliteTableData:N,setError:ie,setIsFolderPath:xe,setExternalChangeNoticeShown:V,externalChangeNoticeShown:be,viewScrollRatioRef:oe}),{diffLoading:Ar,diffError:sa,diffContent:jc,diffStatus:Mr}=Vy({hasSelectedPath:Q,isDiffView:H,isPreviewOnly:e,normalizedPath:a}),zc=W(()=>Fy(a),[a]),oa=at.current===a,Kt=oa?c:"",go=ke&&Kt!==(oa?u:""),bo=ke&&Xn(Zs,l),Tr=ke&&!bo&&Xn(Ja,l),ra=Tr&&!q.has(l),hs=bo||ra,xo=bo||Tr,Hs=Q&&!se&&!e&&!H&&!Y&&!K&&!xo,ms=W(()=>My(a),[a]),qn=ms==="markdown",yo=W(()=>yl(Kt),[Kt]),ye=W(()=>vl({contentLength:Kt.length,lineCount:yo,charThreshold:gl,lineThreshold:bl}),[Kt,yo]),yt=ms!=="plain"&&!ye,Lt=!ye,vt=W(()=>qn?du(Kt):{entries:[],body:Kt},[Kt,qn]),fn=W(()=>yt?hl(Kt,ms):[],[Kt,yt,ms]),Je=W(()=>Lt?Array.from({length:yo},(Ce,Ue)=>Ue+1):[],[yo,Lt]),Ct=W(()=>qn?Fe.parse(vt.body||"",{gfm:!0,breaks:!0}):"",[vt.body,qn]);$l({enabled:yt&&M==="edit",syncKey:`${a}:${Kt.length}:${fn.length}`,editorLineNumberRowRefs:Ne,editorHighlightLineRefs:qe}),R(()=>{!qn&&M!=="edit"&&L("edit")},[qn,M]),R(()=>{ae(new Set)},[a]),R(()=>{try{window.localStorage.setItem(Xr,M)}catch{}},[M]),R(()=>{if(!B)return F(!1),()=>{};let Ce=window.setTimeout(()=>{F(!0)},Iy);return()=>window.clearTimeout(Ce)},[B]),jy({loadedFilePathRef:at,normalizedPath:a,canEditFile:ke,hasSelectedPath:Q,loading:B,content:c,initialContent:u}),Gy({canEditFile:ke,isEditBlocked:hs,loading:B,hasSelectedPath:Q,normalizedPath:a,loadedFilePathRef:at,restoredSelectionPathRef:_r,viewMode:M,content:c,lineTarget:s,lineEndTarget:o,editorTextareaRef:ve,editorLineNumbersRef:fe,editorHighlightRef:ne,viewScrollRatioRef:oe});let Gt=G(async()=>{if(!(!ke||K||!go||hs)){re(!0),ie("");try{await dg(a,c),p(c),V(!1),vs(a),mn(a,!1,{dispatchEvent:Ce=>window.dispatchEvent(Ce)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:a}}))}catch(Ce){let Ue=Ce.message||"Could not save file";I(Ue,"error")}finally{re(!1)}}},[ke,K,go,hs,a,c]),vo=G(async()=>{if(Hs){j(!0),ie("");try{let Ce=await _a(a),Ue=String(Ce?.path||a);V(!1),vs(a),mn(a,!1,{dispatchEvent:Pr=>window.dispatchEvent(Pr)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:Ue}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-deleted",{detail:{path:Ue}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),I("File deleted","success"),r()}catch(Ce){let Ue=Ce.message||"Could not delete file";ie(Ue),/path is not a file/i.test(Ue)?(I("Only files can be deleted","warning"),r()):I(Ue,"error")}finally{j(!1)}}},[Hs,a,r]),Vs=G(async()=>{if(!(!H||!Mr?.isDeleted||J)){pe(!0);try{let Ce=await mg(a),Ue=String(Ce?.path||a);window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:Ue}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),I("File restored","success"),i(Ue)}catch(Ce){I(Ce.message||"Could not restore file","error")}finally{pe(!1)}}},[Mr?.isDeleted,H,a,i,J]);zy({canEditFile:ke,isPreviewOnly:e,isDiffView:H,viewMode:M,handleSave:Gt});let Rk=()=>{l&&(ae(Ce=>{let Ue=new Set(Ce);return Ue.add(l),Ue}),window.requestAnimationFrame(()=>{window.requestAnimationFrame(()=>{let Ce=ve.current;Ce&&(Ce.disabled||Ce.readOnly||Ce.focus())})}))},Uf=G((Ce,Ue=null)=>{!Q||!ke||(Ue&&pu(a,Ue),qa(a,Ce),mn(a,Ce!==u,{dispatchEvent:Pr=>window.dispatchEvent(Pr)}))},[Q,ke,a,u]);return{state:{hasSelectedPath:Q,isPreviewOnly:e,loading:B,saving:K,deleting:Y,restoring:J,showDelayedLoadingSpinner:U,error:le,isFolderPath:se,isImageFile:ce,imageDataUrl:m,isAudioFile:ee,audioDataUrl:b,isSqliteFile:Ee,sqliteSummary:v,sqliteSelectedTable:w,sqliteTableOffset:C,sqliteTableLoading:k,sqliteTableError:D,sqliteTableData:z,isDiffView:H,diffLoading:Ar,diffError:sa,diffContent:jc,diffStatus:Mr,isMarkdownFile:qn,frontmatterCollapsed:he,previewHtml:Ct,viewMode:M,renderContent:Kt},derived:{pathSegments:zc,isDirty:go,canEditFile:ke,canDeleteFile:Hs,isDeleteBlocked:xo,isEditBlocked:hs,isLockedFile:bo,isProtectedFile:Tr,isProtectedLocked:ra,shouldUseHighlightedEditor:yt,shouldRenderLineNumbers:Lt,parsedFrontmatter:vt,highlightedEditorLines:fn,editorLineNumbers:Je},refs:{previewRef:ge,editorLineNumbersRef:fe,editorLineNumberRowRefs:Ne,editorHighlightRef:ne,editorHighlightLineRefs:qe,editorTextareaRef:ve},actions:{setFrontmatterCollapsed:ue,setSqliteSelectedTable:S,setSqliteTableOffset:_,handleChangeViewMode:Te,handleSave:Gt,handleDiscard:()=>{!ke||!go||K||Y||(d(u),vs(a),mn(a,!1,{dispatchEvent:Ce=>window.dispatchEvent(Ce)}),I("Changes discarded","info"))},handleDelete:vo,handleRestore:Vs,handleEditProtectedFile:Rk,handleContentInput:Ce=>{if(hs||e)return;let Ue=Ce.target.value;d(Ue),Uf(Ue,{start:Ce.target.selectionStart,end:Ce.target.selectionEnd})},handleEditorKeyDown:Ce=>{if(Ce.key!=="Tab"||Ce.shiftKey||Ce.metaKey||Ce.ctrlKey||Ce.altKey||hs||e||!ke)return;let Ue=Ce.currentTarget;if(!Ue)return;Ce.preventDefault();let Pr=Number(Ue.selectionStart||0),Lk=Number(Ue.selectionEnd||0);Ue.setRangeText(" ",Pr,Lk,"end");let Kf=Ue.value;d(Kf),Uf(Kf,{start:Ue.selectionStart,end:Ue.selectionEnd})},handleEditorScroll:$e,handlePreviewScroll:we,handleEditorSelectionChange:()=>{if(!Q||!ke||B)return;let Ce=ve.current;Ce&&pu(a,{start:Ce.selectionStart,end:Ce.selectionEnd})}},context:{normalizedPath:a}}};var dn=P.bind(T),Jy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestEdit:r=()=>{},onRequestClearSelection:i=()=>{}})=>{let[a,l]=y(!1),{state:c,derived:d,refs:u,actions:p,context:f}=qy({filePath:t,isPreviewOnly:e,browseView:n,lineTarget:s,lineEndTarget:o,onRequestClearSelection:i,onRequestEdit:r});return!c.hasSelectedPath||c.isFolderPath?dn` +`;var Iy=1e3,Dy=5e3,ml=50,gl=25e4,bl=5e3;var Ny=()=>{try{return String(window.localStorage.getItem(Xr)||"").trim()==="preview"?"preview":"edit"}catch{return"edit"}},Oy=()=>{try{let t=window.localStorage.getItem(Va);if(!t)return{};let e=JSON.parse(t);return!e||typeof e!="object"?{}:e}catch{return{}}},By=t=>{let e=String(t||"").trim();if(!e)return null;let s=Oy()[e];return!s||typeof s!="object"?null:{start:s.start,end:s.end}},pu=(t,e)=>{let n=String(t||"").trim();if(!(!n||!e||typeof e!="object"))try{let s=Oy();s[n]={start:e.start,end:e.end},window.localStorage.setItem(Va,JSON.stringify(s))}catch{}};var Fy=t=>String(t||"").split("/").map(e=>e.trim()).filter(Boolean),xl=(t,e)=>{let n=Number.parseInt(String(t??""),10);return Number.isFinite(n)?Math.max(0,Math.min(e,n)):0},yl=t=>{let e=String(t||"");return e?e.split(/\r\n|\r|\n/).length:1},vl=({contentLength:t=0,lineCount:e=1,charThreshold:n=25e4,lineThreshold:s=5e3})=>Number(t)>Number(n)||Number(e)>Number(s);var Xo=t=>{if(!t)return 0;let e=t.scrollHeight-t.clientHeight;return e<=0?0:t.scrollTop/e},Cs=(t,e)=>{if(!t)return;let n=t.scrollHeight-t.clientHeight;if(n<=0){t.scrollTop=0;return}let s=Math.max(0,Math.min(1,e));t.scrollTop=n*s},Wy=({viewMode:t,setViewMode:e,previewRef:n,editorTextareaRef:s,editorLineNumbersRef:o,editorHighlightRef:r})=>{let i=te(0),a=te(!1);return{viewScrollRatioRef:i,isSyncingScrollRef:a,handleEditorScroll:u=>{if(a.current)return;let p=u.currentTarget.scrollTop,f=Xo(u.currentTarget);i.current=f,o.current&&(o.current.scrollTop=p,r.current&&(r.current.scrollTop=p,r.current.scrollLeft=u.currentTarget.scrollLeft),n.current&&(a.current=!0,Cs(n.current,f),window.requestAnimationFrame(()=>{a.current=!1})))},handlePreviewScroll:u=>{if(a.current)return;let p=Xo(u.currentTarget);i.current=p,a.current=!0,Cs(s.current,p),Cs(o.current,p),Cs(r.current,p),window.requestAnimationFrame(()=>{a.current=!1})},handleChangeViewMode:u=>{if(u===t)return;let p=Xo(t==="preview"?n.current:s.current);i.current=p,e(u),window.requestAnimationFrame(()=>{a.current=!0,u==="preview"?Cs(n.current,p):(Cs(s.current,p),Cs(o.current,p),Cs(r.current,p)),window.requestAnimationFrame(()=>{a.current=!1})})}}};var Hy=({hasSelectedPath:t,normalizedPath:e,isDiffView:n,isSqliteFile:s,sqliteSelectedTable:o,sqliteTableOffset:r,canEditFile:i,isFolderPath:a,loading:l,saving:c,initialContent:d,isDirty:u,setContent:p,setInitialContent:f,setFileKind:g,setImageDataUrl:m,setAudioDataUrl:h,setSqliteSummary:x,setSqliteSelectedTable:b,setSqliteTableOffset:v,setSqliteTableLoading:$,setSqliteTableError:w,setSqliteTableData:S,setError:C,setIsFolderPath:_,setExternalChangeNoticeShown:k,externalChangeNoticeShown:A,viewScrollRatioRef:D,setLoading:N})=>{let z=te(""),O=te(""),M=te(!1);return L(()=>{let R=!0;return z.current="",O.current="",t?(p(""),f(""),m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null),g("text"),C(""),_(!1),k(!1),D.current=0,n?(N(!1),z.current=e,O.current="",()=>{R=!1}):((async()=>{N(!0),C(""),_(!1);try{let E=await pd(e);if(!R)return;let U=E?.kind==="image"?"image":E?.kind==="audio"?"audio":E?.kind==="sqlite"?"sqlite":"text";if(g(U),U==="image"){m(String(E?.imageDataUrl||"")),h(""),x(null),b(""),v(0),$(!1),w(""),S(null),p(""),f(""),k(!1),D.current=0,z.current=e,O.current="";return}if(U==="audio"){h(String(E?.audioDataUrl||"")),m(""),x(null),b(""),v(0),$(!1),w(""),S(null),p(""),f(""),k(!1),D.current=0,z.current=e,O.current="";return}if(U==="sqlite"){let se=E?.sqliteSummary||null,Y=se?.objects||[],j=Y.find(J=>J?.type==="table")?.name||Y[0]?.name||"";x(se),b(j),v(0),$(!1),w(""),S(null),m(""),h(""),p(""),f(""),k(!1),D.current=0,z.current=e,O.current="";return}m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null);let H=E.content||"",K=Xb(e);p(K||H),mn(e,!!(K&&K!==H),{dispatchEvent:se=>window.dispatchEvent(se)}),f(H),k(!1),D.current=0,z.current=e,O.current=""}catch(E){if(!R)return;g("text"),m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null);let U=E.message||"Could not load file";if(/path is not a file/i.test(U)){p(""),f(""),_(!0),C(""),z.current=e,O.current="";return}C(U)}finally{R&&N(!1)}})(),()=>{R=!1})):(p(""),f(""),g("text"),m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null),C(""),_(!1),D.current=0,z.current="",()=>{R=!1})},[t,e,n]),L(()=>{if(!s||!e||!o)return S(null),w(""),$(!1),()=>{};let R=!0;return(async()=>{$(!0),w("");try{let E=await bg({filePath:e,table:o,limit:ml,offset:r});if(!R)return;S(E)}catch(E){if(!R)return;w(E.message||"Could not load sqlite table")}finally{R&&$(!1)}})(),()=>{R=!1}},[s,e,o,r]),L(()=>{if(!t||a||!i||n)return()=>{};let R=async()=>{if(!(l||c)&&!M.current){M.current=!0;try{let U=(await pd(e)).content||"";if(U===d){k(!1);return}if(!u){p(U),f(U),$s(e),mn(e,!1,{dispatchEvent:H=>window.dispatchEvent(H)}),k(!1),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));return}A||(I("This file changed on disk. Save to overwrite or reload by re-opening.","error"),k(!0))}catch{}finally{M.current=!1}}},B=window.setInterval(R,Dy);return()=>{window.clearInterval(B)}},[t,a,i,n,l,c,e,d,u,A]),{loadedFilePathRef:z,restoredSelectionPathRef:O}};var Vy=({hasSelectedPath:t,isDiffView:e,isPreviewOnly:n,normalizedPath:s})=>{let[o,r]=y(!1),[i,a]=y(""),[l,c]=y(""),[d,u]=y({statusKind:"",isDeleted:!1});return L(()=>{let p=!0;return!t||!e||n?(r(!1),a(""),c(""),u({statusKind:"",isDeleted:!1}),()=>{p=!1}):((async()=>{r(!0),a("");try{let g=await gg(s);if(!p)return;c(String(g?.content||"")),u({statusKind:String(g?.statusKind||""),isDeleted:!!g?.isDeleted})}catch(g){if(!p)return;a(g.message||"Could not load diff"),u({statusKind:"",isDeleted:!1})}finally{p&&r(!1)}})(),()=>{p=!1})},[t,e,n,s]),{diffLoading:o,diffError:i,diffContent:l,diffStatus:d}};var jy=({loadedFilePathRef:t,normalizedPath:e,canEditFile:n,hasSelectedPath:s,loading:o,content:r,initialContent:i})=>{L(()=>{if(t.current===e&&!(!n||!s||o)){if(r===i){$s(e),mn(e,!1,{dispatchEvent:a=>window.dispatchEvent(a)});return}qa(e,r),mn(e,!0,{dispatchEvent:a=>window.dispatchEvent(a)})}},[n,s,o,r,i,e])};var zy=({canEditFile:t,isPreviewOnly:e,isDiffView:n,viewMode:s,handleSave:o})=>{L(()=>{let r=i=>{(i.metaKey||i.ctrlKey)&&!i.shiftKey&&!i.altKey&&String(i.key||"").toLowerCase()==="s"&&(!t||e||n||s!=="edit"||(i.preventDefault(),o()))};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,e,n,s,o])};var Q2=(t,e)=>{let n=String(t||"").split(` +`),s=Math.max(0,Math.min(e-1,n.length-1)),o=0;for(let r=0;r{let r=window.getComputedStyle(e),i=Number.parseFloat(r.lineHeight||""),a=Number.isFinite(i)&&i>0?i:20,l=Math.max(0,t*a-e.clientHeight*.4);e.scrollTop=l,n.current&&(n.current.scrollTop=l),s.current&&(s.current.scrollTop=l),o.current=Xo(e)},Ky=t=>{if(!t)return;let e=t.querySelectorAll(".line-highlight-flash");for(let n of e)n.classList.remove("line-highlight-flash")},e_=(t,e,n)=>{if(!t)return;Ky(t);let s=t.querySelectorAll("[data-line-row]"),o=Math.min(n,s.length-1);for(let r=e;r<=o;r+=1){let i=s[r];i&&i.classList.add("line-highlight-flash")}},Gy=({canEditFile:t,isEditBlocked:e,loading:n,hasSelectedPath:s,normalizedPath:o,loadedFilePathRef:r,restoredSelectionPathRef:i,viewMode:a,content:l,lineTarget:c=0,lineEndTarget:d=0,editorTextareaRef:u,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})=>{let m=te("");L(()=>{c&&c>=1||m.current&&(Ky(p.current),m.current="")},[c,o,p]),L(()=>{if(e||!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(a!=="edit")return()=>{};if(!c||c<1)return()=>{};let h=d&&d>=c?d:c,x=`${o}:${c}-${h}`;if(m.current===x)return()=>{};let b=0,v=0,$=()=>{let w=u.current;if(!w){v+=1,v<6&&(b=window.requestAnimationFrame($));return}let S=String(l||""),C=Q2(S,c);w.setSelectionRange(C,C);let _=c-1,k=h-1;window.requestAnimationFrame(()=>{let A=u.current;A&&(Uy({lineIndex:_,textareaElement:A,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g}),e_(p.current,_,k))}),m.current=x,i.current=o};return b=window.requestAnimationFrame($),()=>{b&&window.cancelAnimationFrame(b)}},[t,e,n,s,o,l,a,c,d,r,i,u,p,f,g]),L(()=>{if(e)return i.current="",()=>{};if(!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(i.current===o)return()=>{};if(a!=="edit")return()=>{};if(c&&c>=1)return()=>{};let h=By(o);if(!h)return i.current=o,()=>{};let x=0,b=0,v=()=>{let $=u.current;if(!$){b+=1,b<6&&(x=window.requestAnimationFrame(v));return}let w=String(l||"").length,S=xl(h.start,w),C=xl(h.end,w);$.focus(),$.setSelectionRange(S,Math.max(S,C)),window.requestAnimationFrame(()=>{let _=u.current;if(!_)return;let k=String(l||""),A=xl(S,k.length),D=k.slice(0,A).split(` +`).length-1;Uy({lineIndex:D,textareaElement:_,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})}),i.current=o};return x=window.requestAnimationFrame(v),()=>{x&&window.cancelAnimationFrame(x)}},[t,e,n,s,o,l,a,c,r,i,u,p,f,g])};var $l=({enabled:t=!1,syncKey:e="",editorLineNumberRowRefs:n,editorHighlightLineRefs:s})=>{let o=G(()=>{if(!t)return;let r=n?.current||[],i=s?.current||[],a=Math.min(r.length,i.length);for(let l=0;l{o()},[o,e]),L(()=>{if(!t)return()=>{};let r=()=>o();return window.addEventListener("resize",r),()=>window.removeEventListener("resize",r)},[t,o]),{syncEditorLineNumberHeights:o}};var qy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestClearSelection:r=()=>{},onRequestEdit:i=()=>{}})=>{let a=String(t||"").trim(),l=Xs(a),[c,d]=y(""),[u,p]=y(""),[f,g]=y("text"),[m,h]=y(""),[x,b]=y(""),[v,$]=y(null),[w,S]=y(""),[C,_]=y(0),[k,A]=y(!1),[D,N]=y(""),[z,O]=y(null),[M,R]=y(Ny),[B,E]=y(!1),[U,H]=y(!1),[K,se]=y(!1),[Y,j]=y(!1),[J,pe]=y(!1),[le,ie]=y(""),[oe,xe]=y(!1),[he,ue]=y(!1),[be,V]=y(!1),[q,ae]=y(()=>new Set),fe=te(null),ne=te(null),ve=te(null),ge=te(null),Oe=te([]),qe=te([]),Q=a.length>0,ce=f==="image",ee=f==="audio",Ee=f==="sqlite",ke=Q&&!oe&&!e&&!ce&&!ee&&!Ee,W=String(n||"edit")==="diff",{viewScrollRatioRef:re,handleEditorScroll:$e,handlePreviewScroll:we,handleChangeViewMode:Te}=Wy({viewMode:M,setViewMode:R,previewRef:ge,editorTextareaRef:ve,editorLineNumbersRef:fe,editorHighlightRef:ne}),{loadedFilePathRef:at,restoredSelectionPathRef:_r}=Hy({hasSelectedPath:Q,normalizedPath:a,isDiffView:W,isSqliteFile:Ee,sqliteSelectedTable:w,sqliteTableOffset:C,canEditFile:ke,isFolderPath:oe,loading:B,saving:K,initialContent:u,isDirty:ke&&c!==u,setLoading:E,setContent:d,setInitialContent:p,setFileKind:g,setImageDataUrl:h,setAudioDataUrl:b,setSqliteSummary:$,setSqliteSelectedTable:S,setSqliteTableOffset:_,setSqliteTableLoading:A,setSqliteTableError:N,setSqliteTableData:O,setError:ie,setIsFolderPath:xe,setExternalChangeNoticeShown:V,externalChangeNoticeShown:be,viewScrollRatioRef:re}),{diffLoading:Ar,diffError:sa,diffContent:jc,diffStatus:Mr}=Vy({hasSelectedPath:Q,isDiffView:W,isPreviewOnly:e,normalizedPath:a}),zc=F(()=>Fy(a),[a]),oa=at.current===a,Kt=oa?c:"",bo=ke&&Kt!==(oa?u:""),xo=ke&&Qn(Ys,l),Tr=ke&&!xo&&Qn(Ja,l),ra=Tr&&!q.has(l),ms=xo||ra,yo=xo||Tr,js=Q&&!oe&&!e&&!W&&!Y&&!K&&!yo,gs=F(()=>My(a),[a]),qn=gs==="markdown",vo=F(()=>yl(Kt),[Kt]),ye=F(()=>vl({contentLength:Kt.length,lineCount:vo,charThreshold:gl,lineThreshold:bl}),[Kt,vo]),yt=gs!=="plain"&&!ye,Lt=!ye,vt=F(()=>qn?du(Kt):{entries:[],body:Kt},[Kt,qn]),fn=F(()=>yt?hl(Kt,gs):[],[Kt,yt,gs]),Je=F(()=>Lt?Array.from({length:vo},(Ce,Ue)=>Ue+1):[],[vo,Lt]),Ct=F(()=>qn?Fe.parse(vt.body||"",{gfm:!0,breaks:!0}):"",[vt.body,qn]);$l({enabled:yt&&M==="edit",syncKey:`${a}:${Kt.length}:${fn.length}`,editorLineNumberRowRefs:Oe,editorHighlightLineRefs:qe}),L(()=>{!qn&&M!=="edit"&&R("edit")},[qn,M]),L(()=>{ae(new Set)},[a]),L(()=>{try{window.localStorage.setItem(Xr,M)}catch{}},[M]),L(()=>{if(!B)return H(!1),()=>{};let Ce=window.setTimeout(()=>{H(!0)},Iy);return()=>window.clearTimeout(Ce)},[B]),jy({loadedFilePathRef:at,normalizedPath:a,canEditFile:ke,hasSelectedPath:Q,loading:B,content:c,initialContent:u}),Gy({canEditFile:ke,isEditBlocked:ms,loading:B,hasSelectedPath:Q,normalizedPath:a,loadedFilePathRef:at,restoredSelectionPathRef:_r,viewMode:M,content:c,lineTarget:s,lineEndTarget:o,editorTextareaRef:ve,editorLineNumbersRef:fe,editorHighlightRef:ne,viewScrollRatioRef:re});let Gt=G(async()=>{if(!(!ke||K||!bo||ms)){se(!0),ie("");try{await dg(a,c),p(c),V(!1),$s(a),mn(a,!1,{dispatchEvent:Ce=>window.dispatchEvent(Ce)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:a}}))}catch(Ce){let Ue=Ce.message||"Could not save file";I(Ue,"error")}finally{se(!1)}}},[ke,K,bo,ms,a,c]),$o=G(async()=>{if(js){j(!0),ie("");try{let Ce=await _a(a),Ue=String(Ce?.path||a);V(!1),$s(a),mn(a,!1,{dispatchEvent:Pr=>window.dispatchEvent(Pr)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:Ue}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-deleted",{detail:{path:Ue}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),I("File deleted","success"),r()}catch(Ce){let Ue=Ce.message||"Could not delete file";ie(Ue),/path is not a file/i.test(Ue)?(I("Only files can be deleted","warning"),r()):I(Ue,"error")}finally{j(!1)}}},[js,a,r]),zs=G(async()=>{if(!(!W||!Mr?.isDeleted||J)){pe(!0);try{let Ce=await mg(a),Ue=String(Ce?.path||a);window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:Ue}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),I("File restored","success"),i(Ue)}catch(Ce){I(Ce.message||"Could not restore file","error")}finally{pe(!1)}}},[Mr?.isDeleted,W,a,i,J]);zy({canEditFile:ke,isPreviewOnly:e,isDiffView:W,viewMode:M,handleSave:Gt});let Lk=()=>{l&&(ae(Ce=>{let Ue=new Set(Ce);return Ue.add(l),Ue}),window.requestAnimationFrame(()=>{window.requestAnimationFrame(()=>{let Ce=ve.current;Ce&&(Ce.disabled||Ce.readOnly||Ce.focus())})}))},Uf=G((Ce,Ue=null)=>{!Q||!ke||(Ue&&pu(a,Ue),qa(a,Ce),mn(a,Ce!==u,{dispatchEvent:Pr=>window.dispatchEvent(Pr)}))},[Q,ke,a,u]);return{state:{hasSelectedPath:Q,isPreviewOnly:e,loading:B,saving:K,deleting:Y,restoring:J,showDelayedLoadingSpinner:U,error:le,isFolderPath:oe,isImageFile:ce,imageDataUrl:m,isAudioFile:ee,audioDataUrl:x,isSqliteFile:Ee,sqliteSummary:v,sqliteSelectedTable:w,sqliteTableOffset:C,sqliteTableLoading:k,sqliteTableError:D,sqliteTableData:z,isDiffView:W,diffLoading:Ar,diffError:sa,diffContent:jc,diffStatus:Mr,isMarkdownFile:qn,frontmatterCollapsed:he,previewHtml:Ct,viewMode:M,renderContent:Kt},derived:{pathSegments:zc,isDirty:bo,canEditFile:ke,canDeleteFile:js,isDeleteBlocked:yo,isEditBlocked:ms,isLockedFile:xo,isProtectedFile:Tr,isProtectedLocked:ra,shouldUseHighlightedEditor:yt,shouldRenderLineNumbers:Lt,parsedFrontmatter:vt,highlightedEditorLines:fn,editorLineNumbers:Je},refs:{previewRef:ge,editorLineNumbersRef:fe,editorLineNumberRowRefs:Oe,editorHighlightRef:ne,editorHighlightLineRefs:qe,editorTextareaRef:ve},actions:{setFrontmatterCollapsed:ue,setSqliteSelectedTable:S,setSqliteTableOffset:_,handleChangeViewMode:Te,handleSave:Gt,handleDiscard:()=>{!ke||!bo||K||Y||(d(u),$s(a),mn(a,!1,{dispatchEvent:Ce=>window.dispatchEvent(Ce)}),I("Changes discarded","info"))},handleDelete:$o,handleRestore:zs,handleEditProtectedFile:Lk,handleContentInput:Ce=>{if(ms||e)return;let Ue=Ce.target.value;d(Ue),Uf(Ue,{start:Ce.target.selectionStart,end:Ce.target.selectionEnd})},handleEditorKeyDown:Ce=>{if(Ce.key!=="Tab"||Ce.shiftKey||Ce.metaKey||Ce.ctrlKey||Ce.altKey||ms||e||!ke)return;let Ue=Ce.currentTarget;if(!Ue)return;Ce.preventDefault();let Pr=Number(Ue.selectionStart||0),Ek=Number(Ue.selectionEnd||0);Ue.setRangeText(" ",Pr,Ek,"end");let Kf=Ue.value;d(Kf),Uf(Kf,{start:Ue.selectionStart,end:Ue.selectionEnd})},handleEditorScroll:$e,handlePreviewScroll:we,handleEditorSelectionChange:()=>{if(!Q||!ke||B)return;let Ce=ve.current;Ce&&pu(a,{start:Ce.selectionStart,end:Ce.selectionEnd})}},context:{normalizedPath:a}}};var dn=P.bind(T),Jy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestEdit:r=()=>{},onRequestClearSelection:i=()=>{}})=>{let[a,l]=y(!1),{state:c,derived:d,refs:u,actions:p,context:f}=qy({filePath:t,isPreviewOnly:e,browseView:n,lineTarget:s,lineEndTarget:o,onRequestClearSelection:i,onRequestEdit:r});return!c.hasSelectedPath||c.isFolderPath?dn`
            [ ]
            Browse and edit files
            Syncs to git
            @@ -4381,7 +4381,7 @@ ${(n||"").split(` onConfirm=${async()=>{await p.handleDelete(),l(!1)}} />
            - `};var e_=P.bind(T),fu=({activeBrowsePath:t="",browseView:e="edit",lineTarget:n=0,lineEndTarget:s=0,selectedBrowsePath:o="",onNavigateToBrowseFile:r=()=>{},onEditSelectedBrowseFile:i=()=>{},onClearSelection:a=()=>{}})=>e_` + `};var t_=P.bind(T),fu=({activeBrowsePath:t="",browseView:e="edit",lineTarget:n=0,lineEndTarget:s=0,selectedBrowsePath:o="",onNavigateToBrowseFile:r=()=>{},onEditSelectedBrowseFile:i=()=>{},onClearSelection:a=()=>{}})=>t_`
            <${Jy} filePath=${t} @@ -4393,7 +4393,7 @@ ${(n||"").split(` onRequestClearSelection=${a} />
            -`;var Ss="__all__",t_={0:"Sun",1:"Mon",2:"Tue",3:"Wed",4:"Thu",5:"Fri",6:"Sat",7:"Sun"},n_=({hourField:t,minuteField:e})=>{let n=Number.parseInt(String(t||""),10),s=Number.parseInt(String(e||""),10);if(!Number.isFinite(n)||!Number.isFinite(s))return"";let o=(n%24+24)%24,r=o>=12?"pm":"am",i=o%12===0?12:o%12,a=String(s).padStart(2,"0");return`${i}:${a}${r}`},Zy=t=>{let e=Number.parseInt(String(t||""),10);if(!Number.isFinite(e))return"";let n=(e%24+24)%24,s=n>=12?"pm":"am";return`${n%12===0?12:n%12}${s}`},s_=(t="")=>{let e=String(t||"").match(/^(\d{1,2})-(\d{1,2})$/);if(!e)return"";let n=Zy(e[1]),s=Zy(e[2]);return!n||!s?"":`${n}-${s}`},Yy=(t="")=>{let e=String(t||"").trim().split(/\s+/);if(e.length<5)return"";let[n,s,o,r,i]=e;if(o==="*"&&r==="*"&&i==="*"&&s==="*"&&/^\*\/\d+$/.test(n))return`Every ${n.slice(2)}m`;if(o==="*"&&r==="*"&&i==="*"&&n==="0"&&/^\*\/\d+$/.test(s))return`Every ${s.slice(2)}h`;let a=n_({hourField:s,minuteField:n});if(o==="*"&&r==="*"&&i==="1-5"&&/^\*\/\d+$/.test(n)&&/^\d{1,2}-\d{1,2}$/.test(s)){let l=n.slice(2),c=s_(s);if(c)return`Every ${l}m, ${c} weekdays`}if(o==="*"&&r==="*"&&i==="1-5"&&a)return`Weekdays at ${a}`;if(o==="*"&&r==="*"&&/^([0-7])(,[0-7])*$/.test(i)&&a)return`Every ${i.split(",").map(c=>t_[c]||c).join(", ")} at ${a}`;if(o==="*"&&r==="*"&&i==="*"&&a)return`Daily at ${a}`;if(/^\d{1,2}$/.test(o)&&r==="*"&&i==="*"&&a){let l=Number.parseInt(o,10);if(Number.isFinite(l)&&l>=1&&l<=31)return`Monthly on day ${l} at ${a}`}return""},Xy=(t="")=>String(t||"").trim().toLowerCase(),o_=()=>{try{return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone||""}catch{return""}},Qy=({scheduleTimeZone:t="",includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""})=>{let o=Xy(t);if(!o)return!1;if(e)return!0;if(!n)return!1;let r=Xy(s||o_());return r?r!==o:!0},Qo=(t={},{includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""}={})=>{let o=String(t?.kind||"").trim();if(o==="every"){let i=Number(t?.everyMs||0);return i>0?`Every ${bn(i)}`:"Every interval"}if(o==="at")return`At ${Qn(t?.at,{fallback:"scheduled time"})}`;if(o==="cron"){let i=String(t?.expr||"").trim();if(!i)return"Cron";let a=Yy(i),l=String(t?.tz||"").trim(),c=Qy({scheduleTimeZone:l,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return a?c?`${a} (${l})`:a:c?`${i} (${l})`:i}let r=String(t?.expr||t?.cron||t?.cronExpr||"").trim();if(r){let i=Yy(r),a=String(t?.tz||t?.timezone||"").trim(),l=Qy({scheduleTimeZone:a,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return i?l?`${i} (${a})`:i:l?`${r} (${a})`:r}return"Unknown schedule"},r_=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=n-e,o=s>0,r=Math.round(Math.abs(s)/1e3);if(r<60)return o?"in <1m":"just now";let i=Math.round(r/60);if(i<60)return o?`in ${i}m`:`${i}m ago`;let a=Math.round(i/60);if(a<24)return o?`in ${a}h`:`${a}h ago`;let l=Math.round(a/24);return o?`in ${l}d`:`${l}d ago`},ev=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=Math.abs(n-e),o=Math.max(0,Math.round(s/1e3));if(o<60)return`${o}s`;let r=Math.round(o/60);if(r<60)return`${r}m`;let i=Math.round(r/60);if(i<24)return`${i}h`;let a=Math.round(i/24);return a<30?`${a}d`:`${Math.round(a/30)}mo`},tv=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";if(n>=e)return r_(n,e);let s=e-n;if(s<60*1e3)return"due now";let o=Math.round(s/1e3);if(o<60)return"overdue by <1m";let r=Math.round(o/60);if(r<60)return`overdue by ${r}m`;let i=Math.round(r/60);return i<24?`overdue by ${i}h`:`overdue by ${Math.round(i/24)}d`},nv=(t={})=>{if(t.enabled===!1)return"disabled";if(t?.state?.runningAtMs)return"running";let e=String(t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase();return e==="error"?"error":e==="ok"?"ok":"unknown"},sv=(t="")=>t==="ok"?"bg-green-500":t==="error"?"bg-red-500":t==="running"?"bg-yellow-400":"bg-gray-500",Oe=t=>Zt(Number(t||0)),ct=t=>gn(Number(t||0)),rn=(t={})=>{let e=t?.usage||{},s=[e?.input_tokens,e?.inputTokens,e?.output_tokens,e?.outputTokens,e?.cache_read_tokens,e?.cacheReadTokens,e?.cache_write_tokens,e?.cacheWriteTokens].reduce((r,i)=>{let a=Number(i);return!Number.isFinite(a)||a<0?r:r+a},0);if(s>0)return s;let o=[e?.total_tokens,e?.totalTokens,t?.total_tokens,t?.totalTokens];for(let r of o){let i=Number(r);if(Number.isFinite(i)&&i>=0)return i}return 0},$n=(t={})=>{let e=t?.usage||{},n=[t?.estimatedCost,t?.estimated_cost,e?.estimatedCost,e?.estimated_cost,e?.totalCost,e?.total_cost,e?.costUsd,e?.cost];for(let s of n){let o=Number(s);if(Number.isFinite(o)&&o>=0)return o}return null},i_=(t={})=>{let e=t?.state||{};try{return String(JSON.stringify(e)||"").toUpperCase().includes("HEARTBEAT_OK")}catch{return!1}},a_=({jobId:t="",bulkRunsByJobId:e={}}={})=>{let n=String(t||"").trim();if(!n)return!1;let s=Array.isArray(e?.[n]?.entries)?e[n].entries:[];if(s.length===0)return!1;let o=s.reduce((i,a)=>Number(a?.ts||0)>Number(i?.ts||0)?a:i);return[o?.summary,o?.result?.summary,o?.payload?.summary].some(i=>String(i||"").toUpperCase().includes("HEARTBEAT_OK"))},l_=({job:t={},jobId:e="",bulkRunsByJobId:n={}}={})=>{let s=String(e||"").trim(),o=Array.isArray(n?.[s]?.entries)?n[s].entries:[],r=o.length>0?o.reduce((a,l)=>Number(l?.ts||0)>Number(a?.ts||0)?l:a):null;return String(r?.status||t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase()},ov=(t=[],e={})=>{let n=[];return t.forEach(s=>{let o=String(s?.id||""),r=String(s?.payload?.message||"").toLowerCase();String(s?.delivery?.mode||"").toLowerCase()==="none"&&(r.includes("message tool")||r.includes("send to telegram"))&&n.push({tone:"warning",jobId:String(s?.id||""),title:`${s.name||s.id}: delivery mismatch`,body:"Job uses delivery.mode=none but prompt asks to send via message tool."}),Number(s?.state?.consecutiveErrors||0)>=2&&n.push({tone:"error",jobId:String(s?.id||""),title:`${s.name||s.id}: repeated errors`,body:`Consecutive errors: ${Number(s?.state?.consecutiveErrors||0)}.`});let a=l_({job:s,jobId:o,bulkRunsByJobId:e});s?.state?.lastDelivered===!1&&String(s?.state?.lastDeliveryStatus||"").trim().toLowerCase()==="not-delivered"&&a!=="ok"&&!i_(s)&&!a_({jobId:o,bulkRunsByJobId:e})&&n.push({tone:"warning",jobId:o,title:`${s.name||s.id}: not delivered`,body:"Latest run completed but was not delivered."})}),n.slice(0,8)},rv=(t=[])=>t.filter(n=>n?.enabled!==!1).map(n=>Number(n?.state?.nextRunAtMs||0)).filter(n=>Number.isFinite(n)&&n>0).sort((n,s)=>n-s)[0]||null;var wl=P.bind(T),c_=["daily","weekly","monthly","other"],d_={daily:"Daily",weekly:"Weekly",monthly:"Monthly",other:"Other"},u_=60,ns=(t="")=>{let e=Number.parseInt(String(t||"").trim(),10);return Number.isFinite(e)?e:null},er=t=>{if(!Number.isFinite(t))return null;let e=t===7?0:t;return e>=0&&e<=6?e:null},p_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return null;let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=[];return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ns(r[1])),l=er(ns(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.push(c);else{for(let c=a;c<=6;c+=1)s.push(c);for(let c=0;c<=l;c+=1)s.push(c)}return}let i=er(ns(o));i!=null&&s.push(i)}),s.length===0?null:Math.min(...s)},f_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return[];let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=new Set;return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ns(r[1])),l=er(ns(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.add(c);else{for(let c=a;c<=6;c+=1)s.add(c);for(let c=0;c<=l;c+=1)s.add(c)}return}let i=er(ns(o));i!=null&&s.add(i)}),[...s].sort((o,r)=>o-r)},av=(t="")=>{let e=f_(t);return e.length!==5?!1:e.join(",")==="1,2,3,4,5"},h_=({minuteField:t="",hourField:e=""})=>{let n=ns(t),s=ns(e);return n==null||s==null||n<0||n>59||s<0||s>23?null:s*u_+n},m_=(t={})=>{let n=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(n.length<5)return null;let[s,o,r,i,a]=n;return{minuteField:s,hourField:o,dayOfMonthField:r,monthField:i,dayOfWeekField:a}},iv=(t={},e="other")=>{let n=t?.schedule||{},s=String(n?.kind||"").trim().toLowerCase(),o=m_(n),r=o?h_({minuteField:o.minuteField,hourField:o.hourField}):null,i=String(t?.name||t?.id||"").toLowerCase();if(e==="daily"){if(s==="every"){let a=Number(n?.everyMs||Number.MAX_SAFE_INTEGER);return{groupRank:0,primary:Number.isFinite(a)?a:Number.MAX_SAFE_INTEGER,secondary:i}}if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&o.dayOfWeekField==="*"&&r!=null)return{groupRank:1,primary:r,secondary:i};if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&av(o.dayOfWeekField)&&r!=null)return{groupRank:2,primary:r,secondary:i}}if(e==="weekly"&&o){let a=p_(o.dayOfWeekField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}if(e==="monthly"&&o){let a=ns(o.dayOfMonthField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}return{groupRank:99,primary:Number.MAX_SAFE_INTEGER,secondary:Number.MAX_SAFE_INTEGER,tertiary:i}},kl=(t,e)=>t===e?0:t>e?1:-1,Sl=(t=[],e="other")=>[...t].sort((n,s)=>{let o=iv(n,e),r=iv(s,e),i=kl(o.groupRank,r.groupRank);if(i!==0)return i;let a=kl(o.primary,r.primary);if(a!==0)return a;let l=kl(o.secondary,r.secondary);return l!==0?l:kl(o.tertiary,r.tertiary)}),g_=(t={})=>{if(String(t?.kind||"").trim().toLowerCase()==="every"){let o=Number(t?.everyMs||0);if(Number.isFinite(o)&&o>0){if(o<=1440*60*1e3)return"daily";if(o<=10080*60*1e3)return"weekly";if(o<=744*60*60*1e3)return"monthly"}return"other"}let s=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(s.length>=5){let[,,o,r,i]=s;if(o==="*"&&r==="*"&&i==="*")return"daily";if(o==="*"&&r==="*"&&i!=="*")return av(i)?"daily":"weekly";if(o!=="*"&&r==="*")return"monthly"}return"other"},lv=({jobs:t=[],selectedRouteKey:e=Ss,onSelectAllJobs:n=()=>{},onSelectJob:s=()=>{}})=>{let o=te(null),[r,i]=y("");R(()=>{let d=window.requestAnimationFrame(()=>{o.current?.focus()});return()=>{window.cancelAnimationFrame(d)}},[]);let a=String(r||"").trim().toLowerCase(),l=W(()=>a?t.filter(d=>{let u=String(d?.name||"").toLowerCase(),p=String(d?.id||"").toLowerCase();return u.includes(a)||p.includes(a)}):t,[t,a]),c=W(()=>{let d={daily:[],weekly:[],monthly:[],other:[]};return l.forEach(u=>{let p=g_(u?.schedule);d[p]?d[p].push(u):d.other.push(u)}),{daily:Sl(d.daily,"daily"),weekly:Sl(d.weekly,"weekly"),monthly:Sl(d.monthly,"monthly"),other:Sl(d.other,"other")}},[l]);return wl` +`;var _s="__all__",n_={0:"Sun",1:"Mon",2:"Tue",3:"Wed",4:"Thu",5:"Fri",6:"Sat",7:"Sun"},s_=({hourField:t,minuteField:e})=>{let n=Number.parseInt(String(t||""),10),s=Number.parseInt(String(e||""),10);if(!Number.isFinite(n)||!Number.isFinite(s))return"";let o=(n%24+24)%24,r=o>=12?"pm":"am",i=o%12===0?12:o%12,a=String(s).padStart(2,"0");return`${i}:${a}${r}`},Zy=t=>{let e=Number.parseInt(String(t||""),10);if(!Number.isFinite(e))return"";let n=(e%24+24)%24,s=n>=12?"pm":"am";return`${n%12===0?12:n%12}${s}`},o_=(t="")=>{let e=String(t||"").match(/^(\d{1,2})-(\d{1,2})$/);if(!e)return"";let n=Zy(e[1]),s=Zy(e[2]);return!n||!s?"":`${n}-${s}`},Yy=(t="")=>{let e=String(t||"").trim().split(/\s+/);if(e.length<5)return"";let[n,s,o,r,i]=e;if(o==="*"&&r==="*"&&i==="*"&&s==="*"&&/^\*\/\d+$/.test(n))return`Every ${n.slice(2)}m`;if(o==="*"&&r==="*"&&i==="*"&&n==="0"&&/^\*\/\d+$/.test(s))return`Every ${s.slice(2)}h`;let a=s_({hourField:s,minuteField:n});if(o==="*"&&r==="*"&&i==="1-5"&&/^\*\/\d+$/.test(n)&&/^\d{1,2}-\d{1,2}$/.test(s)){let l=n.slice(2),c=o_(s);if(c)return`Every ${l}m, ${c} weekdays`}if(o==="*"&&r==="*"&&i==="1-5"&&a)return`Weekdays at ${a}`;if(o==="*"&&r==="*"&&/^([0-7])(,[0-7])*$/.test(i)&&a)return`Every ${i.split(",").map(c=>n_[c]||c).join(", ")} at ${a}`;if(o==="*"&&r==="*"&&i==="*"&&a)return`Daily at ${a}`;if(/^\d{1,2}$/.test(o)&&r==="*"&&i==="*"&&a){let l=Number.parseInt(o,10);if(Number.isFinite(l)&&l>=1&&l<=31)return`Monthly on day ${l} at ${a}`}return""},Xy=(t="")=>String(t||"").trim().toLowerCase(),r_=()=>{try{return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone||""}catch{return""}},Qy=({scheduleTimeZone:t="",includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""})=>{let o=Xy(t);if(!o)return!1;if(e)return!0;if(!n)return!1;let r=Xy(s||r_());return r?r!==o:!0},Qo=(t={},{includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""}={})=>{let o=String(t?.kind||"").trim();if(o==="every"){let i=Number(t?.everyMs||0);return i>0?`Every ${bn(i)}`:"Every interval"}if(o==="at")return`At ${es(t?.at,{fallback:"scheduled time"})}`;if(o==="cron"){let i=String(t?.expr||"").trim();if(!i)return"Cron";let a=Yy(i),l=String(t?.tz||"").trim(),c=Qy({scheduleTimeZone:l,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return a?c?`${a} (${l})`:a:c?`${i} (${l})`:i}let r=String(t?.expr||t?.cron||t?.cronExpr||"").trim();if(r){let i=Yy(r),a=String(t?.tz||t?.timezone||"").trim(),l=Qy({scheduleTimeZone:a,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return i?l?`${i} (${a})`:i:l?`${r} (${a})`:r}return"Unknown schedule"},i_=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=n-e,o=s>0,r=Math.round(Math.abs(s)/1e3);if(r<60)return o?"in <1m":"just now";let i=Math.round(r/60);if(i<60)return o?`in ${i}m`:`${i}m ago`;let a=Math.round(i/60);if(a<24)return o?`in ${a}h`:`${a}h ago`;let l=Math.round(a/24);return o?`in ${l}d`:`${l}d ago`},ev=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=Math.abs(n-e),o=Math.max(0,Math.round(s/1e3));if(o<60)return`${o}s`;let r=Math.round(o/60);if(r<60)return`${r}m`;let i=Math.round(r/60);if(i<24)return`${i}h`;let a=Math.round(i/24);return a<30?`${a}d`:`${Math.round(a/30)}mo`},tv=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";if(n>=e)return i_(n,e);let s=e-n;if(s<60*1e3)return"due now";let o=Math.round(s/1e3);if(o<60)return"overdue by <1m";let r=Math.round(o/60);if(r<60)return`overdue by ${r}m`;let i=Math.round(r/60);return i<24?`overdue by ${i}h`:`overdue by ${Math.round(i/24)}d`},nv=(t={})=>{if(t.enabled===!1)return"disabled";if(t?.state?.runningAtMs)return"running";let e=String(t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase();return e==="error"?"error":e==="ok"?"ok":"unknown"},sv=(t="")=>t==="ok"?"bg-green-500":t==="error"?"bg-red-500":t==="running"?"bg-yellow-400":"bg-gray-500",Ne=t=>Zt(Number(t||0)),ct=t=>gn(Number(t||0)),rn=(t={})=>{let e=t?.usage||{},s=[e?.input_tokens,e?.inputTokens,e?.output_tokens,e?.outputTokens,e?.cache_read_tokens,e?.cacheReadTokens,e?.cache_write_tokens,e?.cacheWriteTokens].reduce((r,i)=>{let a=Number(i);return!Number.isFinite(a)||a<0?r:r+a},0);if(s>0)return s;let o=[e?.total_tokens,e?.totalTokens,t?.total_tokens,t?.totalTokens];for(let r of o){let i=Number(r);if(Number.isFinite(i)&&i>=0)return i}return 0},$n=(t={})=>{let e=t?.usage||{},n=[t?.estimatedCost,t?.estimated_cost,e?.estimatedCost,e?.estimated_cost,e?.totalCost,e?.total_cost,e?.costUsd,e?.cost];for(let s of n){let o=Number(s);if(Number.isFinite(o)&&o>=0)return o}return null},a_=(t={})=>{let e=t?.state||{};try{return String(JSON.stringify(e)||"").toUpperCase().includes("HEARTBEAT_OK")}catch{return!1}},l_=({jobId:t="",bulkRunsByJobId:e={}}={})=>{let n=String(t||"").trim();if(!n)return!1;let s=Array.isArray(e?.[n]?.entries)?e[n].entries:[];if(s.length===0)return!1;let o=s.reduce((i,a)=>Number(a?.ts||0)>Number(i?.ts||0)?a:i);return[o?.summary,o?.result?.summary,o?.payload?.summary].some(i=>String(i||"").toUpperCase().includes("HEARTBEAT_OK"))},c_=({job:t={},jobId:e="",bulkRunsByJobId:n={}}={})=>{let s=String(e||"").trim(),o=Array.isArray(n?.[s]?.entries)?n[s].entries:[],r=o.length>0?o.reduce((a,l)=>Number(l?.ts||0)>Number(a?.ts||0)?l:a):null;return String(r?.status||t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase()},ov=(t=[],e={})=>{let n=[];return t.forEach(s=>{let o=String(s?.id||""),r=String(s?.payload?.message||"").toLowerCase();String(s?.delivery?.mode||"").toLowerCase()==="none"&&(r.includes("message tool")||r.includes("send to telegram"))&&n.push({tone:"warning",jobId:String(s?.id||""),title:`${s.name||s.id}: delivery mismatch`,body:"Job uses delivery.mode=none but prompt asks to send via message tool."}),Number(s?.state?.consecutiveErrors||0)>=2&&n.push({tone:"error",jobId:String(s?.id||""),title:`${s.name||s.id}: repeated errors`,body:`Consecutive errors: ${Number(s?.state?.consecutiveErrors||0)}.`});let a=c_({job:s,jobId:o,bulkRunsByJobId:e});s?.state?.lastDelivered===!1&&String(s?.state?.lastDeliveryStatus||"").trim().toLowerCase()==="not-delivered"&&a!=="ok"&&!a_(s)&&!l_({jobId:o,bulkRunsByJobId:e})&&n.push({tone:"warning",jobId:o,title:`${s.name||s.id}: not delivered`,body:"Latest run completed but was not delivered."})}),n.slice(0,8)},rv=(t=[])=>t.filter(n=>n?.enabled!==!1).map(n=>Number(n?.state?.nextRunAtMs||0)).filter(n=>Number.isFinite(n)&&n>0).sort((n,s)=>n-s)[0]||null;var wl=P.bind(T),d_=["daily","weekly","monthly","other"],u_={daily:"Daily",weekly:"Weekly",monthly:"Monthly",other:"Other"},p_=60,ss=(t="")=>{let e=Number.parseInt(String(t||"").trim(),10);return Number.isFinite(e)?e:null},er=t=>{if(!Number.isFinite(t))return null;let e=t===7?0:t;return e>=0&&e<=6?e:null},f_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return null;let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=[];return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ss(r[1])),l=er(ss(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.push(c);else{for(let c=a;c<=6;c+=1)s.push(c);for(let c=0;c<=l;c+=1)s.push(c)}return}let i=er(ss(o));i!=null&&s.push(i)}),s.length===0?null:Math.min(...s)},h_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return[];let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=new Set;return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ss(r[1])),l=er(ss(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.add(c);else{for(let c=a;c<=6;c+=1)s.add(c);for(let c=0;c<=l;c+=1)s.add(c)}return}let i=er(ss(o));i!=null&&s.add(i)}),[...s].sort((o,r)=>o-r)},av=(t="")=>{let e=h_(t);return e.length!==5?!1:e.join(",")==="1,2,3,4,5"},m_=({minuteField:t="",hourField:e=""})=>{let n=ss(t),s=ss(e);return n==null||s==null||n<0||n>59||s<0||s>23?null:s*p_+n},g_=(t={})=>{let n=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(n.length<5)return null;let[s,o,r,i,a]=n;return{minuteField:s,hourField:o,dayOfMonthField:r,monthField:i,dayOfWeekField:a}},iv=(t={},e="other")=>{let n=t?.schedule||{},s=String(n?.kind||"").trim().toLowerCase(),o=g_(n),r=o?m_({minuteField:o.minuteField,hourField:o.hourField}):null,i=String(t?.name||t?.id||"").toLowerCase();if(e==="daily"){if(s==="every"){let a=Number(n?.everyMs||Number.MAX_SAFE_INTEGER);return{groupRank:0,primary:Number.isFinite(a)?a:Number.MAX_SAFE_INTEGER,secondary:i}}if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&o.dayOfWeekField==="*"&&r!=null)return{groupRank:1,primary:r,secondary:i};if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&av(o.dayOfWeekField)&&r!=null)return{groupRank:2,primary:r,secondary:i}}if(e==="weekly"&&o){let a=f_(o.dayOfWeekField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}if(e==="monthly"&&o){let a=ss(o.dayOfMonthField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}return{groupRank:99,primary:Number.MAX_SAFE_INTEGER,secondary:Number.MAX_SAFE_INTEGER,tertiary:i}},kl=(t,e)=>t===e?0:t>e?1:-1,Sl=(t=[],e="other")=>[...t].sort((n,s)=>{let o=iv(n,e),r=iv(s,e),i=kl(o.groupRank,r.groupRank);if(i!==0)return i;let a=kl(o.primary,r.primary);if(a!==0)return a;let l=kl(o.secondary,r.secondary);return l!==0?l:kl(o.tertiary,r.tertiary)}),b_=(t={})=>{if(String(t?.kind||"").trim().toLowerCase()==="every"){let o=Number(t?.everyMs||0);if(Number.isFinite(o)&&o>0){if(o<=1440*60*1e3)return"daily";if(o<=10080*60*1e3)return"weekly";if(o<=744*60*60*1e3)return"monthly"}return"other"}let s=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(s.length>=5){let[,,o,r,i]=s;if(o==="*"&&r==="*"&&i==="*")return"daily";if(o==="*"&&r==="*"&&i!=="*")return av(i)?"daily":"weekly";if(o!=="*"&&r==="*")return"monthly"}return"other"},lv=({jobs:t=[],selectedRouteKey:e=_s,onSelectAllJobs:n=()=>{},onSelectJob:s=()=>{}})=>{let o=te(null),[r,i]=y("");L(()=>{let d=window.requestAnimationFrame(()=>{o.current?.focus()});return()=>{window.cancelAnimationFrame(d)}},[]);let a=String(r||"").trim().toLowerCase(),l=F(()=>a?t.filter(d=>{let u=String(d?.name||"").toLowerCase(),p=String(d?.id||"").toLowerCase();return u.includes(a)||p.includes(a)}):t,[t,a]),c=F(()=>{let d={daily:[],weekly:[],monthly:[],other:[]};return l.forEach(u=>{let p=b_(u?.schedule);d[p]?d[p].push(u):d.other.push(u)}),{daily:Sl(d.daily,"daily"),weekly:Sl(d.weekly,"weekly"),monthly:Sl(d.monthly,"monthly"),other:Sl(d.other,"other")}},[l]);return wl`
            + Topic + Thread ID @@ -8361,13 +8361,13 @@ ${t} ${$.map(([S,C])=>Mt`
            ${C.name}${S}${C.name}${S} - ${le.map(J=>{let oe=String(i||"")===String(J.id||""),ce=dw(J);return wt` + ${re.map(J=>{let ie=String(i||"")===String(J.id||""),de=fw(J);return wt` `})} - ${ie.length?wt` + ${oe.length?wt` - ${O?wt` + ${D?wt`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${C?.id?`Run #${C.id} summary`:"Run summary"}

            - ${O} + ${D}

            `:null} @@ -5855,11 +5931,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `:null}
            - <${hw} - cards=${X} + <${bw} + cards=${Z} busyCardId=${d} onAskAgentFix=${f} - onUpdateStatus=${V} + onUpdateStatus=${j} onOpenFile=${e} changedPaths=${s?.changeSummary?.changedPaths||[]} showRunMeta=${i==="all"} @@ -5868,12 +5944,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null} - ${!j&&!ue?wt` + ${!V&&!ce?wt`
            - <${l3} /> + <${f3} />

            Workspace health review @@ -5885,9 +5961,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s

            - <${Z} - onClick=${me} - disabled=${!E} + <${Y} + onClick=${ge} + disabled=${!O} loading=${!!s?.runInProgress} size="lg" idleLabel="Run Drift Doctor" @@ -5902,21 +5978,21 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null} - <${gw} + <${yw} visible=${!!p} card=${p} onClose=${()=>f(null)} onComplete=${async()=>{await n.refresh(),await r.refresh(),await _.refresh()}} />
            - `};var c3=P.bind(T),df=({onNavigateToBrowseFile:t=()=>{}})=>c3` + `};var h3=P.bind(T),df=({onNavigateToBrowseFile:t=()=>{}})=>h3`
            - <${xw} + <${$w} isActive=${!0} onOpenFile=${(e,n={})=>{let s=`workspace/${String(e||"").trim().replace(/^workspace\//,"")}`;t(s,{view:"edit",...n.line?{line:n.line}:{},...n.lineEnd?{lineEnd:n.lineEnd}:{}})}} />
            -`;var d3=P.bind(T),zn=({header:t,children:e})=>d3` +`;var m3=P.bind(T),Un=({header:t,children:e})=>m3`
            @@ -5929,7 +6005,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            -`;var Ue=P.bind(T),yw={ai:"AI Provider Keys",github:"GitHub",channels:"Channels",tools:"Tools",custom:"Custom"},u3=["ai","github","channels","tools","custom"],p3=new Set(["OPENAI_API_KEY","GEMINI_API_KEY"]),ww={Embeddings:{Icon:_a,label:"Memory embeddings"},Image:{Icon:nb,label:"Image generation"},TTS:{Icon:sb,label:"Text to speech"},STT:{Icon:ob,label:"Speech to text"}},vw=t=>t.trim().toUpperCase().replace(/[^A-Z0-9_]/g,"_"),f3=/^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/,h3=t=>{let e=String(t||"").trim();if(e.length<2)return e;let n=e.startsWith('"'),s=e.endsWith('"');if(n&&s)return e.slice(1,-1);let o=e.startsWith("'"),r=e.endsWith("'");return o&&r?e.slice(1,-1):e},gc=(t="")=>f3.test(String(t||"").trim().toUpperCase()),$w=t=>JSON.stringify((t||[]).map(e=>({key:String(e?.key||""),value:String(e?.value||"")})).sort((e,n)=>e.key.localeCompare(n.key))),m3=t=>{let e=Array.isArray(t)?[...t]:[],n=e.filter(o=>(o?.group||"custom")==="custom").sort((o,r)=>String(o?.key||"").localeCompare(String(r?.key||""))),s=0;return e.map(o=>{if((o?.group||"custom")!=="custom")return o;let r=n[s];return s+=1,r})},g3={ANTHROPIC_API_KEY:Ue`from${" "} +`;var Ue=P.bind(T),ww={ai:"AI Provider Keys",github:"GitHub",channels:"Channels",tools:"Tools",custom:"Custom"},g3=["ai","github","channels","tools","custom"],b3=new Set(["OPENAI_API_KEY","GEMINI_API_KEY"]),Cw={Embeddings:{Icon:_a,label:"Memory embeddings"},Image:{Icon:sb,label:"Image generation"},TTS:{Icon:ob,label:"Text to speech"},STT:{Icon:rb,label:"Speech to text"}},kw=t=>t.trim().toUpperCase().replace(/[^A-Z0-9_]/g,"_"),x3=/^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/,y3=t=>{let e=String(t||"").trim();if(e.length<2)return e;let n=e.startsWith('"'),s=e.endsWith('"');if(n&&s)return e.slice(1,-1);let o=e.startsWith("'"),r=e.endsWith("'");return o&&r?e.slice(1,-1):e},gc=(t="")=>x3.test(String(t||"").trim().toUpperCase()),Sw=t=>JSON.stringify((t||[]).map(e=>({key:String(e?.key||""),value:String(e?.value||"")})).sort((e,n)=>e.key.localeCompare(n.key))),v3=t=>{let e=Array.isArray(t)?[...t]:[],n=e.filter(o=>(o?.group||"custom")==="custom").sort((o,r)=>String(o?.key||"").localeCompare(String(r?.key||""))),s=0;return e.map(o=>{if((o?.group||"custom")!=="custom")return o;let r=n[s];return s+=1,r})},$3={ANTHROPIC_API_KEY:Ue`from${" "} {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s class="hover:underline" style="color: var(--accent-link)" >brave.com/search/api${" "} — free tier available`},b3=t=>g3[t.key]||t.hint||"",x3=t=>(Array.isArray(t?.features)?t.features:[]).filter(e=>!!ww[e]),y3=t=>{let e=[],n=[];return(t||[]).forEach(s=>{let o=!!String(s?.value||"").trim();if(p3.has(s?.key)||o){e.push(s);return}n.push(s)}),{visible:e,hidden:n}},v3=({feature:t})=>{let e=ww[t];if(!e)return null;let{Icon:n,label:s}=e;return Ue` + >${" "} — free tier available`},w3=t=>$3[t.key]||t.hint||"",k3=t=>(Array.isArray(t?.features)?t.features:[]).filter(e=>!!Cw[e]),S3=t=>{let e=[],n=[];return(t||[]).forEach(s=>{let o=!!String(s?.value||"").trim();if(b3.has(s?.key)||o){e.push(s);return}n.push(s)}),{visible:e,hidden:n}},C3=({feature:t})=>{let e=Cw[t];if(!e)return null;let{Icon:n,label:s}=e;return Ue` <${yt} text=${s} widthClass="w-auto" tooltipClassName="whitespace-nowrap"> {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s <${n} className="w-3.5 h-3.5" /> - `},$3=({envVar:t,onChange:e,onDelete:n,disabled:s})=>{let o=b3(t),r=x3(t);return Ue` + `},_3=({envVar:t,onChange:e,onDelete:n,disabled:s})=>{let o=w3(t),r=k3(t);return Ue`
            @@ -6050,7 +6126,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            ${r.length>0?Ue`
            - ${r.map(i=>Ue`<${v3} key=${i} feature=${i} />`)} + ${r.map(i=>Ue`<${C3} key=${i} feature=${i} />`)}
            `:null}
            @@ -6075,20 +6151,20 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${o?Ue`

            ${o}

            `:null}
            - `},kw=({onRestartRequired:t=()=>{}})=>{let[e,n]=y([]),[s,o]=y(()=>new Set),[r,i]=y([]),[a,l]=y(0),[c,d]=y(!1),[u,p]=y(!1),[f,g]=y(!1),[m,h]=y(""),b=ee("[]"),{data:x,error:v,loading:w,refresh:$}=ct("/api/env",Vr,{maxAgeMs:3e4}),S=q(j=>{if(!j)return;let X=m3(j.vars||[]);b.current=$w(X),n(X),i([]),o(new Set(j.reservedKeys||[])),j.restartRequired&&t(!0)},[t]),C=q(async()=>{try{let j=await $({force:!0});S(j)}catch(j){console.error("Failed to load env vars:",j)}},[S,$]);L(()=>{x&&(c||u||S(x))},[S,c,x,u]),L(()=>{v&&console.error("Failed to load env vars:",v)},[v]),L(()=>{d($w(e)!==b.current)},[e]);let _=(j,X)=>{n(de=>de.map(le=>le.key===j?{...le,value:X}:le))},k=j=>{n(X=>X.filter(de=>de.key!==j)),i(X=>X.filter(de=>de!==j))},A=async()=>{if(!u){p(!0);try{let j=e.filter(ie=>ie.editable&&!gc(ie?.key)).map(ie=>({key:ie.key,value:ie.value})),de=!!(await dd(j))?.restartRequired;de&&t(!0),N(de?"Environment variables saved. Restart gateway to apply.":"Environment variables saved","success");let le=await $({force:!0});S(le),l(ie=>ie+1),d(!1)}catch(j){N("Failed to save: "+j.message,"error")}finally{p(!1)}}},[O,D]=y(""),z=j=>{let X=j.split(` -`).map(le=>le.trim()).filter(Boolean).filter(le=>!le.startsWith("#")),de=[];for(let le of X){let ie=le.indexOf("=");ie>0&&de.push({key:le.slice(0,ie).trim(),value:h3(le.slice(ie+1))})}return de},E=j=>{let X=0,de=[],le=[],ie=[];return n(ne=>{let xe=[...ne];for(let{key:he,value:ue}of j){let me=vw(he);if(!me)continue;if(gc(me)){le.push(me);continue}if(s.has(me)){de.push(me);continue}let V=xe.find(J=>J.key===me);V?V.value=ue:(xe.push({key:me,value:ue,label:me,group:"custom",hint:"",source:"env_file",editable:!0}),ie.push(me)),X++}return xe}),ie.length&&i(ne=>[...ne,...ie]),{added:X,blocked:de,managedChannelKeys:le}},M=(j,X)=>{let de=(j.clipboardData||window.clipboardData).getData("text"),le=z(de);if(le.length>1){j.preventDefault();let{added:ie,blocked:ne,managedChannelKeys:xe}=E(le);if(h(""),D(""),ne.length){let he=Array.from(new Set(ne));N(`Reserved vars can't be added: ${he.join(", ")}`,"error")}if(xe.length){let he=Array.from(new Set(xe));N(`Channel tokens are managed from Channels: ${he.join(", ")}`,"error")}ie&&N(`Added ${ie} variable${ie!==1?"s":""}`,"success");return}if(le.length===1){j.preventDefault(),h(le[0].key),D(le[0].value);return}},R=j=>{let X=z(j);if(X.length===1){h(X[0].key),D(X[0].value);return}h(j)},B=j=>{let X=z(j);if(X.length===1){h(X[0].key),D(X[0].value);return}D(j)},I=()=>{let j=vw(m);if(j){if(gc(j)){N(`Channel tokens are managed from Channels: ${j}`,"error");return}if(s.has(j)){N(`Reserved var can't be added: ${j}`,"error");return}E([{key:j,value:O}]),h(""),D("")}},U=e.filter(j=>!gc(j?.key)),W={};for(let j of U){let X=j.group||"custom";W[X]||(W[X]=[]),W[X].push(j)}if(W.custom?.length){let j=new Set(r),X=W.custom.filter(le=>!j.has(le.key)).sort((le,ie)=>String(le?.key||"").localeCompare(String(ie?.key||""))),de=W.custom.filter(le=>j.has(le.key));W.custom=[...X,...de]}let G=y3(W.ai||[]),se=j=>j.map(X=>Ue`<${$3} - key=${`${a}:${X.key}`} - envVar=${X} + `},_w=({onRestartRequired:t=()=>{}})=>{let[e,n]=y([]),[s,o]=y(()=>new Set),[r,i]=y([]),[a,l]=y(0),[c,d]=y(!1),[u,p]=y(!1),[f,g]=y(!1),[m,h]=y(""),b=ee("[]"),{data:x,error:v,loading:w,refresh:$}=ct("/api/env",Vr,{maxAgeMs:3e4}),S=q(V=>{if(!V)return;let Z=v3(V.vars||[]);b.current=Sw(Z),n(Z),i([]),o(new Set(V.reservedKeys||[])),V.restartRequired&&t(!0)},[t]),C=q(async()=>{try{let V=await $({force:!0});S(V)}catch(V){console.error("Failed to load env vars:",V)}},[S,$]);L(()=>{x&&(c||u||S(x))},[S,c,x,u]),L(()=>{v&&console.error("Failed to load env vars:",v)},[v]),L(()=>{d(Sw(e)!==b.current)},[e]);let _=(V,Z)=>{n(ue=>ue.map(re=>re.key===V?{...re,value:Z}:re))},k=V=>{n(Z=>Z.filter(ue=>ue.key!==V)),i(Z=>Z.filter(ue=>ue!==V))},A=async()=>{if(!u){p(!0);try{let V=e.filter(oe=>oe.editable&&!gc(oe?.key)).map(oe=>({key:oe.key,value:oe.value})),ue=!!(await dd(V))?.restartRequired;ue&&t(!0),I(ue?"Environment variables saved. Restart gateway to apply.":"Environment variables saved","success");let re=await $({force:!0});S(re),l(oe=>oe+1),d(!1)}catch(V){I("Failed to save: "+V.message,"error")}finally{p(!1)}}},[D,N]=y(""),U=V=>{let Z=V.split(` +`).map(re=>re.trim()).filter(Boolean).filter(re=>!re.startsWith("#")),ue=[];for(let re of Z){let oe=re.indexOf("=");oe>0&&ue.push({key:re.slice(0,oe).trim(),value:y3(re.slice(oe+1))})}return ue},O=V=>{let Z=0,ue=[],re=[],oe=[];return n(ne=>{let me=[...ne];for(let{key:pe,value:ce}of V){let ge=kw(pe);if(!ge)continue;if(gc(ge)){re.push(ge);continue}if(s.has(ge)){ue.push(ge);continue}let j=me.find(J=>J.key===ge);j?j.value=ce:(me.push({key:ge,value:ce,label:ge,group:"custom",hint:"",source:"env_file",editable:!0}),oe.push(ge)),Z++}return me}),oe.length&&i(ne=>[...ne,...oe]),{added:Z,blocked:ue,managedChannelKeys:re}},M=(V,Z)=>{let ue=(V.clipboardData||window.clipboardData).getData("text"),re=U(ue);if(re.length>1){V.preventDefault();let{added:oe,blocked:ne,managedChannelKeys:me}=O(re);if(h(""),N(""),ne.length){let pe=Array.from(new Set(ne));I(`Reserved vars can't be added: ${pe.join(", ")}`,"error")}if(me.length){let pe=Array.from(new Set(me));I(`Channel tokens are managed from Channels: ${pe.join(", ")}`,"error")}oe&&I(`Added ${oe} variable${oe!==1?"s":""}`,"success");return}if(re.length===1){V.preventDefault(),h(re[0].key),N(re[0].value);return}},R=V=>{let Z=U(V);if(Z.length===1){h(Z[0].key),N(Z[0].value);return}h(V)},B=V=>{let Z=U(V);if(Z.length===1){h(Z[0].key),N(Z[0].value);return}N(V)},E=()=>{let V=kw(m);if(V){if(gc(V)){I(`Channel tokens are managed from Channels: ${V}`,"error");return}if(s.has(V)){I(`Reserved var can't be added: ${V}`,"error");return}O([{key:V,value:D}]),h(""),N("")}},G=e.filter(V=>!gc(V?.key)),H={};for(let V of G){let Z=V.group||"custom";H[Z]||(H[Z]=[]),H[Z].push(V)}if(H.custom?.length){let V=new Set(r),Z=H.custom.filter(re=>!V.has(re.key)).sort((re,oe)=>String(re?.key||"").localeCompare(String(oe?.key||""))),ue=H.custom.filter(re=>V.has(re.key));H.custom=[...Z,...ue]}let z=S3(H.ai||[]),te=V=>V.map(Z=>Ue`<${_3} + key=${`${a}:${Z.key}`} + envVar=${Z} onChange=${_} onDelete=${k} disabled=${u} - />`),K=j=>{let X=W[j]||[];if(!X.length)return null;if(j==="ai"){let{visible:de,hidden:le}=G,ie=f&&le.length>0;return Ue` + />`),K=V=>{let Z=H[V]||[];if(!Z.length)return null;if(V==="ai"){let{visible:ue,hidden:re}=z,oe=f&&re.length>0;return Ue`

            - ${yw[j]||j} + ${ww[V]||V}

            -
            ${se(de)}
            - ${le.length>0?Ue` +
            ${te(ue)}
            + ${re.length>0?Ue`
            `:null} - ${ie?Ue`
            - ${se(le)} + ${oe?Ue`
            + ${te(re)}
            `:null}
            `}return Ue`

            - ${yw[j]||j} + ${ww[V]||V}

            -
            ${se(X)}
            +
            ${te(Z)}
            `};return w&&!e.length?Ue` - <${zn} - header=${Ue`<${Ne} title="Envars" />`} + <${Un} + header=${Ue`<${Pe} title="Envars" />`} >
            Loading environment variables...
            - + `:Ue` - <${zn} + <${Un} header=${Ue` - <${Ne} + <${Pe} title="Envars" actions=${Ue` - <${Qn} visible=${c}> - <${Z} + <${es} visible=${c}> + <${Y} onClick=${C} disabled=${u} tone="secondary" @@ -6136,7 +6212,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s idleLabel="Cancel" className="text-xs" /> - <${Z} + <${Y} onClick=${A} disabled=${u} loading=${u} @@ -6147,12 +6223,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s loadingLabel="Saving…" className="text-xs" /> - + `} /> `} > - ${u3.filter(j=>W[j]?.length).map(j=>K(j))} + ${g3.filter(V=>H[V]?.length).map(V=>K(V))}
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s type="text" value=${m} placeholder="KEY" - onInput=${j=>R(j.target.value)} - onPaste=${j=>M(j,"key")} - onKeyDown=${j=>j.key==="Enter"&&I()} + onInput=${V=>R(V.target.value)} + onPaste=${V=>M(V,"key")} + onKeyDown=${V=>V.key==="Enter"&&E()} class="w-full bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono uppercase" />
            B(j.target.value)} - onPaste=${j=>M(j,"val")} - onKeyDown=${j=>j.key==="Enter"&&I()} + onInput=${V=>B(V.target.value)} + onPaste=${V=>M(V,"val")} + onKeyDown=${V=>V.key==="Enter"&&E()} class="flex-1 bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono" />
            - - `};var w3=P.bind(T),uf=({onRestartRequired:t=()=>{}})=>w3` - <${kw} onRestartRequired=${t} /> -`;var Qt=P.bind(T),k3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},S3=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",O=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(I=!1)=>{try{let U=await n(I);if(!R)return;c(U.currentVersion||e||null),u(U.latestVersion||null),f(!!U.hasUpdate),m(U.ok?"":U.error||"")}catch(U){if(!R)return;m(U.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let I=await n(!0);if(!R)return;c(I.currentVersion||e||null),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let D=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),m("");try{let I=R?await s():await n(!0);c(I.currentVersion||l),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||""),R?I.ok?I.updated||I.restarting?N(I.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${I.currentVersion}`,"success"):N(`Already at latest ${t} version`,"success"):N(I.error||`${t} update failed`,"error"):I.hasUpdate&&I.latestVersion?N(`${t} update available: ${I.latestVersion}`,"warning"):N(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!I?.ok,result:I})}catch(I){m(I.message||(R?`Could not update ${t}`:"Could not check updates")),N(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:I})}finally{a(!1)}}},z=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}D()}},E=()=>{v(!1),D()},M=_?i||o:i;return Qt` + + `};var A3=P.bind(T),uf=({onRestartRequired:t=()=>{}})=>A3` + <${_w} onRestartRequired=${t} /> +`;var en=P.bind(T),M3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},T3=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",D=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(E=!1)=>{try{let G=await n(E);if(!R)return;c(G.currentVersion||e||null),u(G.latestVersion||null),f(!!G.hasUpdate),m(G.ok?"":G.error||"")}catch(G){if(!R)return;m(G.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let E=await n(!0);if(!R)return;c(E.currentVersion||e||null),u(E.latestVersion||null),f(!!E.hasUpdate),m(E.ok?"":E.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let N=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),m("");try{let E=R?await s():await n(!0);c(E.currentVersion||l),u(E.latestVersion||null),f(!!E.hasUpdate),m(E.ok?"":E.error||""),R?E.ok?E.updated||E.restarting?I(E.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${E.currentVersion}`,"success"):I(`Already at latest ${t} version`,"success"):I(E.error||`${t} update failed`,"error"):E.hasUpdate&&E.latestVersion?I(`${t} update available: ${E.latestVersion}`,"warning"):I(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!E?.ok,result:E})}catch(E){m(E.message||(R?`Could not update ${t}`:"Could not check updates")),I(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:E})}finally{a(!1)}}},U=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}N()}},O=()=>{v(!1),N()},M=_?i||o:i;return en`

            ${t}${" "}${l?`${l}`:"..."}

            - ${g&&S&&Qt`
            ${g}
            `}
            - ${S&&C&&Qt` + ${S&&C&&en` {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >View changelog `} - ${O?Qt` + ${D?en` <${tn} - onClick=${z} + onClick=${U} loading=${M} warning=${_} idleLabel=${_?k:"Check updates"} loadingLabel=${_?"Updating...":"Checking..."} className="hidden md:inline-flex" /> - `:Qt` + `:en` <${tn} - onClick=${z} + onClick=${U} loading=${M} warning=${_} idleLabel=${_?k:"Check updates"} @@ -6242,7 +6318,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `}
            - ${O&&Qt` + ${D&&en`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >View changelog <${tn} - onClick=${z} + onClick=${U} loading=${M} warning=${_} idleLabel=${k} @@ -6270,9 +6346,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s cancelLabel="Cancel" confirmTone="warning" onCancel=${()=>v(!1)} - onConfirm=${E} + onConfirm=${O} /> - `},bc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ma})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,w=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),Qt`
            + `},bc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ma})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,w=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),en`
            @@ -6283,9 +6359,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >
            - ${!n&&f?Qt` + ${!n&&f?en` Uptime: ${k3($)}Uptime: ${M3($)} `:null} <${tn} @@ -6299,7 +6375,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            - ${r?Qt` + ${r?en` - `:Qt` + `:en`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${b}
            `} - ${i?Qt` + ${i?en`
            - ${v?Qt` + ${v?en` <${tn} onClick=${r} warning=${!1} @@ -6330,7 +6406,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s loadingLabel="Inspect" className="w-full justify-center" /> - `:w?Qt` + `:w?en` <${tn} onClick=${i} loading=${x} @@ -6339,7 +6415,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s loadingLabel="Repairing..." className="w-full justify-center" /> - `:Qt``} @@ -6348,7 +6424,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            - <${S3} + <${T3} label="OpenClaw" currentVersion=${e} fetchVersion=${mm} @@ -6357,7 +6433,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onActionComplete=${c} />
            -
            `};var $r=P.bind(T),C3={webchat:"Browser",cli:"CLI"},_3=t=>C3[t.clientMode]||t.clientId||"Device",M3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},A3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=_3(t),a=M3(t);return s==="approve"?$r` +
            `};var $r=P.bind(T),P3={webchat:"Browser",cli:"CLI"},R3=t=>P3[t.clientMode]||t.clientId||"Device",L3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},E3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=R3(t),a=L3(t);return s==="approve"?$r`
            Approved ${i} @@ -6372,14 +6448,14 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${a&&$r`${a}`}
            - <${Z} + <${Y} onClick=${()=>r("approve")} tone="success" size="sm" idleLabel="Approve" className="font-medium px-3 py-1.5" /> - <${Z} + <${Y} onClick=${()=>r("reject")} tone="secondary" size="sm" @@ -6390,9 +6466,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `},xc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r`

            Pending device pairings

            - ${t.map(s=>$r`<${A3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} -
            `;var fo=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],T3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Sw(t){return`https://console.developers.google.com/apis/api/${T3[t]||""}/overview`}function Cw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return fo`
            - ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=fo``:f&&(f.status==="ok"?g=fo`API ✓`:f.status==="not_enabled"?g=fo`Enable API`:f.status==="error"&&(g=fo`Enable API`)),fo` + ${t.map(s=>$r`<${E3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} +
            `;var fo=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],I3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Aw(t){return`https://console.developers.google.com/apis/api/${I3[t]||""}/overview`}function Mw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return fo`
            + ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=fo``:f&&(f.status==="ok"?g=fo`API ✓`:f.status==="not_enabled"?g=fo`Enable API`:f.status==="error"&&(g=fo`Enable API`)),fo`
            ${d.icon} ${d.label}
            @@ -6410,7 +6486,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${o?"Show fewer services":`Show more services (${Vi.length-a})`} `:null} -
            `}function _w(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Mw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let I=await B.text(),U=JSON.parse(I),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&f(W.client_secret)}catch{b("Invalid JSON file")}},D=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},z=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr` +
            `}function Tw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Pw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},D=async R=>{let B=R.target.files[0];if(B)try{let E=await B.text(),G=JSON.parse(E),H=G.installed||G.web||G;H.client_id&&u(H.client_id),H.client_secret&&f(H.client_secret)}catch{b("Invalid JSON file")}},N=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},U=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,O=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${S?"Copied":"Copy"}
            - `;return kr` <${De} + `;return kr` <${Te} visible=${t} onClose=${e} closeOnOverlayClick=${!1} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" > - <${Ne} + <${Pe} title=${s} actions=${kr` `} /> @@ -6471,15 +6547,15 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s > @@ -6610,7 +6686,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s type="file" ref=${_} accept=".json" - onchange=${O} + onchange=${D} class="hidden" />
            - <${Z} - onClick=${D} + <${Y} + onClick=${N} disabled=${x} loading=${x} tone="primary" @@ -6673,14 +6749,14 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
            ${h?kr`
            ${h}
            `:null} - `};var Aw=P.bind(T),P3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Tw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Aw` + `};var Rw=P.bind(T),D3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Lw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Rw`
            Gmail watch requires gmail:read. Add it in permissions above, then update permissions.
            - `;let a=P3({watchStatus:e,busy:n}),l=!!e?.enabled;return Aw` + `;let a=D3({watchStatus:e,busy:n}),l=!!e?.enabled;return Rw`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onClick=${c=>c.stopPropagation()} onKeyDown=${c=>c.stopPropagation()} > - <${ae} tone=${a.tone}>${a.label} + <${le} tone=${a.tone}>${a.label} <${vn} checked=${l} disabled=${n} @@ -6709,7 +6785,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
            - `};var ji=P.bind(T),R3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Pw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!R3(o,r);return ji` + `};var ji=P.bind(T),N3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Ew=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!N3(o,r);return ji`
            - ${e?ji`<${ae} tone="neutral">Personal`:null} - <${ae} tone=${t.authenticated?"success":"warning"}> + ${e?ji`<${le} tone="neutral">Personal`:null} + <${le} tone=${t.authenticated?"success":"warning"}> ${t.authenticated?"Connected":"Awaiting sign-in"} - + ${n?"\u25BE":"\u25B8"}
            @@ -6740,7 +6816,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${a?"Checking APIs...":"\u21BB Check APIs"} `:null} - <${Cw} + <${Mw} scopes=${o} onToggle=${w=>l?.(t.id,w)} apiStatus=${t.authenticated?i:{}} @@ -6759,7 +6835,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s Configure - <${Tw} + <${Lw} account=${t} watchStatus=${f} busy=${g} @@ -6799,13 +6875,13 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `:null} - `};var pf=P.bind(T),Rw=({visible:t,onClose:e,onSubmit:n,loading:s=!1,defaultEmail:o="",title:r="Add Company Account"})=>{let[i,a]=y(""),[l,c]=y("");if(L(()=>{t&&(a(String(o||"")),c(""))},[t,o]),!t)return null;let d=async()=>{c("");let u=String(i||"").trim();if(!u){c("Email is required");return}await n?.({email:u,setError:c})};return pf`<${De} + `};var pf=P.bind(T),Iw=({visible:t,onClose:e,onSubmit:n,loading:s=!1,defaultEmail:o="",title:r="Add Company Account"})=>{let[i,a]=y(""),[l,c]=y("");if(L(()=>{t&&(a(String(o||"")),c(""))},[t,o]),!t)return null;let d=async()=>{c("");let u=String(i||"").trim();if(!u){c("Email is required");return}await n?.({email:u,setError:c})};return pf`<${Te} visible=${t} onClose=${e} closeOnOverlayClick=${!1} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-md w-full space-y-4" > - <${Ne} + <${Pe} title=${r} actions=${pf` `} /> @@ -6837,7 +6913,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${l?pf`
            ${l}
            `:null}
            - <${Z} + <${Y} onClick=${d} disabled=${s} loading=${s} @@ -6848,7 +6924,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s className="w-full px-4 py-2 rounded-lg text-sm" />
            - `};var Lw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Wh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Ew=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Gh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await Jh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Zh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Yh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await qh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),L3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Iw=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],E3=Iw.slice(0,3),yc=Is,vc=(t="",e=()=>{})=>mt` + `};var Dw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Wh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Nw=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Gh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await Jh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Zh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Yh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await qh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),O3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Ow=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],B3=Ow.slice(0,3),yc=Ds,vc=(t="",e=()=>{})=>mt`
                 
            -`,Dw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:O,error:D}=yr({enabled:t,filter:pc}),{sessions:z,destinationSessionKey:E,setDestinationSessionKey:M,selectedDestination:R}=Ds({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,I=!!(String(n?.projectId||"").trim()||B),U=u||!I&&!m,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,se=G?E3:Iw,K=se.length,j=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async he=>{if(await L3(he)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:j,projectId:String(c||"").trim(),destination:R}),x(!0),l(he=>Math.min(he+1,K-1))}catch(he){g(he.message||"Could not finish setup")}},ne=async()=>{if(!s){if(U){if(!X)return;g("");try{await r({client:j,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,K-1))}},xe=async()=>{if(!(v||$))try{w(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ue,sessionKey:k}),S(!0),N("Message sent to your agent","success")}catch(he){N(he.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` - <${De} +`,Bw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:D,error:N}=yr({enabled:t,filter:pc}),{sessions:U,destinationSessionKey:O,setDestinationSessionKey:M,selectedDestination:R}=Ns({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,E=!!(String(n?.projectId||"").trim()||B),G=u||!E&&!m,H=String(c||"").trim()||String(n?.projectId||"").trim()||"",z=C,te=z?B3:Ow,K=te.length,V=String(e?.client||n?.client||"default").trim()||"default",Z=F(()=>G?String(c||"").trim().length>0:!0,[G,c]),ue=q(async pe=>{if(await O3(pe)){I("Copied to clipboard","success");return}I("Could not copy text","error")},[]),re=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),oe=async()=>{try{g(""),await i({client:V,projectId:String(c||"").trim(),destination:R}),x(!0),l(pe=>Math.min(pe+1,K-1))}catch(pe){g(pe.message||"Could not finish setup")}},ne=async()=>{if(!s){if(G){if(!Z)return;g("");try{await r({client:V,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(pe){g(pe.message||"Could not save project id");return}return}l(pe=>Math.min(pe+1,K-1))}},me=async()=>{if(!(v||$))try{w(!0);let ce=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ce,sessionKey:k}),S(!0),I("Message sent to your agent","success")}catch(pe){I(pe.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` + <${Te} visible=${t} onClose=${o} closeOnOverlayClick=${!1} @@ -6879,24 +6955,24 @@ ${t} - <${nt} className="w-3.5 h-3.5 text-body" /> + <${Ze} className="w-3.5 h-3.5 text-body" />
            Gmail Pub / Sub Setup
            - ${se.map((he,ue)=>mt` + ${te.map((pe,ce)=>mt`
            `)}
            - <${Ne} - title=${`Step ${a+1} of ${K}: ${se[a]}`} + <${Pe} + title=${`Step ${a+1} of ${K}: ${te[a]}`} actions=${null} /> ${f?mt`
            ${f}
            `:null} - ${U?mt` + ${G?mt`
            @@ -6917,16 +6993,16 @@ ${t}d(he.target.value)} + oninput=${pe=>d(pe.target.value)} class="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none" placeholder="my-gcp-project" />
            `:null} - ${!U&&a===0?mt` + ${!G&&a===0?mt`
            - Using project ${W}. + Using project ${H}.
            If gcloud is not installed on your computer, @@ -6942,35 +7018,35 @@ ${t}
            ${vc(`gcloud auth login -gcloud config set project ${W}`,()=>de(`gcloud auth login -gcloud config set project ${W}`))} +gcloud config set project ${H}`,()=>ue(`gcloud auth login +gcloud config set project ${H}`))} `:null} - ${!U&&a===1?vc(B?.enableApis||"",()=>de(B?.enableApis||"")):null} - ${!U&&a===2?mt` + ${!G&&a===1?vc(B?.enableApis||"",()=>ue(B?.enableApis||"")):null} + ${!G&&a===2?mt` ${vc(`${B?.createTopic||""} -${B?.grantPublisher||""}`.trim(),()=>de(`${B?.createTopic||""} +${B?.grantPublisher||""}`.trim(),()=>ue(`${B?.createTopic||""} ${B?.grantPublisher||""}`.trim()))} `:null} - ${!G&&!U&&a===3?mt` - ${vc(B?.createSubscription||"",()=>de(B?.createSubscription||""))} + ${!z&&!G&&a===3?mt` + ${vc(B?.createSubscription||"",()=>ue(B?.createSubscription||""))}
            <${vr} label="Deliver to" - sessions=${z} - selectedSessionKey=${E} + sessions=${U} + selectedSessionKey=${O} onChangeSessionKey=${M} - disabled=${G||O||s} - loading=${O} - error=${D} + disabled=${z||D||s} + loading=${D} + error=${N} allowNone=${!0} noneValue=${yc} noneLabel="Default" loadingLabel="Loading sessions..." - helperText=${G?"This Gmail webhook has already been created. To edit delivery routing, ask your agent.":null} + helperText=${z?"This Gmail webhook has already been created. To edit delivery routing, ask your agent.":null} selectClassName="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed" helperClassName="text-xs text-fg-muted" statusClassName="text-[11px] text-fg-muted" @@ -6978,7 +7054,7 @@ ${B?.grantPublisher||""}`.trim()))} />
            `:null} - ${!G&&a===4?mt` + ${!z&&a===4?mt`
            @@ -6995,21 +7071,21 @@ ${B?.grantPublisher||""}`.trim()))}
            - <${Z} - onClick=${xe} + <${Y} + onClick=${me} disabled=${!k||$} loading=${v} idleLabel=${$?"Sent":"Send to Agent"} @@ -7019,33 +7095,33 @@ ${B?.grantPublisher||""}`.trim()))} className="h-[34px] px-3" />
            - ${O?mt`
            + ${D?mt`
            Loading sessions...
            `:null} - ${D?mt`
            - ${D} + ${N?mt`
            + ${N}
            `:null}
            `:null}
            - ${a===0?mt`${U?mt`
            `:mt`
            `:mt``}`:mt`<${Z} - onClick=${()=>l(he=>Math.max(he-1,0))} + `}`:mt`<${Y} + onClick=${()=>l(pe=>Math.max(pe-1,0))} disabled=${s} idleLabel="Back" tone="secondary" size="md" className="w-full justify-center" />`} - ${!G&&a===K-2?mt`<${Z} - onClick=${ie} + ${!z&&a===K-2?mt`<${Y} + onClick=${oe} disabled=${!1} loading=${s} idleLabel="Enable watch" @@ -7053,14 +7129,14 @@ ${B?.grantPublisher||""}`.trim()))} tone="primary" size="md" className="w-full justify-center" - />`:a`:a`:mt`<${Z} + />`:mt`<${Y} onClick=${o} disabled=${s||v} idleLabel="Done" @@ -7069,13 +7145,13 @@ ${B?.grantPublisher||""}`.trim()))} className="w-full justify-center" />`}
            - - `};var Ns=P.bind(T),I3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,D3="/assets/icons/google_icon.svg",Nw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Lw({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[b,x]=y(!1),[v,w]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[$,S]=y(!1),[C,_]=y(!1),[k,A]=y(""),[O,D]=y({visible:!1,accountId:""}),{loading:z,watchByAccountId:E,clientConfigByClient:M,busyByAccountId:R,savingClient:B,refresh:I,saveClientSetup:U,startWatchForAccount:W,stopWatchForAccount:G}=Ew({gatewayStatus:t,accounts:s}),se=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),j=q(Q=>s.find(pe=>pe.id===Q)||null,[s]),X=q(Q=>{let pe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(te=>({...te,[Q.id]:[...pe]})),d(te=>{let Ee=te[Q.id];return!Ee||!I3(Ee,pe)?{...te,[Q.id]:[...pe]}:te})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(pe=>!pe.authenticated)?.id||"";l(pe=>pe&&s.some(te=>te.id===pe)?pe:Q),s.forEach(pe=>X(pe))},[s,X]);let de=q(Q=>{let pe=j(Q);if(!pe)return;let te=c[Q]||pe.activeScopes||wr();if(!te.length){window.alert("Select at least one service");return}let Ee=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(te.join(","))}&_ts=${Date.now()}`,ke=window.open(Ee,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=Ee)},[j,c]),le=(Q,pe)=>{d(te=>({...te,[Q]:_w(te[Q]||[],pe)}))},ie=q(async Q=>{g(pe=>{let te={...pe};return delete te[Q],te}),h({[Q]:!0});try{let pe=await jh(Q);pe.results&&g(te=>({...te,[Q]:pe.results}))}finally{h(pe=>{if(!pe[Q])return pe;let te={...pe};return delete te[Q],te})}},[]);L(()=>{let Q=async pe=>{if(pe.data?.google==="success"){N("\u2713 Google account connected","success");let te=String(pe.data?.accountId||"").trim();g({}),await i(),await I(),te&&await ie(te)}else pe.data?.google==="error"&&N(`\u2717 Google auth failed: ${pe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[ie,i,I]),L(()=>{!a||!j(a)?.authenticated||m[a]||f[a]||ie(a)},[s,f,m,a,j,ie]);let ne=async Q=>{let pe=await Kh(Q);if(!pe.ok){N(`Failed to disconnect: ${pe.error||"unknown"}`,"error");return}N("Google account disconnected","success"),g(te=>{let Ee={...te};return delete Ee[Q],Ee}),await i(),await I()},xe=({accountId:Q="",client:pe="default",personal:te=!1,title:Ee="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:H=te?"personal":"workspace",initialValues:re={}})=>{w({visible:!0,accountId:Q,client:pe,personal:te,title:Ee,submitLabel:ke,defaultInstrType:H,initialValues:re})},he=()=>{w(Q=>({...Q,visible:!1}))},ue=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&de(Q.id)},me=async({email:Q,setError:pe})=>{_(!0);try{let te=await Uh({email:Q,client:"default",personal:!1,services:wr()});if(!te.ok){pe?.(te.error||"Could not add account");return}S(!1),te.accountId&&l(te.accountId),await i(),te.accountId&&de(te.accountId)}finally{_(!1)}},V=()=>{if(x(!1),K&&r){S(!0);return}xe({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{x(!1),xe({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},oe=async Q=>{let pe=j(Q);if(!pe)return;let te=$c(pe),Ee=te?"personal":pe.client||"default",ke={};try{let H=await Vh({accountId:pe.id,client:Ee});H?.ok&&(ke={clientId:String(H.clientId||""),clientSecret:String(H.clientSecret||"")})}catch{N("Could not load saved client credentials","warning")}xe({accountId:pe.id,client:Ee,personal:te,title:`Edit Credentials (${pe.email})`,submitLabel:"Save Credentials",defaultInstrType:te?"personal":"workspace",initialValues:{email:pe.email,...ke}})},ce=Q=>{D({visible:!0,accountId:String(Q||"")})},be=()=>{D({visible:!1,accountId:""})},Ve=async Q=>{let pe=j(Q);if(!pe)return;let te=String(pe.client||"default").trim()||"default",Ee=M.get(te);if(!Ee?.configured||!Ee?.webhookExists){ce(Q);return}try{(await W(Q))?.restartRequired&&e(!0),N("Gmail watch enabled","success")}catch(ke){N(ke.message||"Could not enable Gmail watch","error")}},et=async Q=>{try{await G(Q),N("Gmail watch disabled","info")}catch(pe){N(pe.message||"Could not disable Gmail watch","error")}},kt=async({client:Q,projectId:pe,destination:te=null})=>{let Ee=String(O.accountId||"").trim();Ee&&(await U({client:Q,projectId:pe,regeneratePushToken:!1}),await W(Ee,{destination:te}),N("Gmail setup complete and watch enabled","success"))},Ot=()=>Ns` + + `};var Os=P.bind(T),F3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,H3="/assets/icons/google_icon.svg",Fw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Dw({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[b,x]=y(!1),[v,w]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[$,S]=y(!1),[C,_]=y(!1),[k,A]=y(""),[D,N]=y({visible:!1,accountId:""}),{loading:U,watchByAccountId:O,clientConfigByClient:M,busyByAccountId:R,savingClient:B,refresh:E,saveClientSetup:G,startWatchForAccount:H,stopWatchForAccount:z}=Nw({gatewayStatus:t,accounts:s}),te=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),V=q(Q=>s.find(fe=>fe.id===Q)||null,[s]),Z=q(Q=>{let fe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(se=>({...se,[Q.id]:[...fe]})),d(se=>{let De=se[Q.id];return!De||!F3(De,fe)?{...se,[Q.id]:[...fe]}:se})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(fe=>!fe.authenticated)?.id||"";l(fe=>fe&&s.some(se=>se.id===fe)?fe:Q),s.forEach(fe=>Z(fe))},[s,Z]);let ue=q(Q=>{let fe=V(Q);if(!fe)return;let se=c[Q]||fe.activeScopes||wr();if(!se.length){window.alert("Select at least one service");return}let De=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(se.join(","))}&_ts=${Date.now()}`,ke=window.open(De,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=De)},[V,c]),re=(Q,fe)=>{d(se=>({...se,[Q]:Tw(se[Q]||[],fe)}))},oe=q(async Q=>{g(fe=>{let se={...fe};return delete se[Q],se}),h({[Q]:!0});try{let fe=await jh(Q);fe.results&&g(se=>({...se,[Q]:fe.results}))}finally{h(fe=>{if(!fe[Q])return fe;let se={...fe};return delete se[Q],se})}},[]);L(()=>{let Q=async fe=>{if(fe.data?.google==="success"){I("\u2713 Google account connected","success");let se=String(fe.data?.accountId||"").trim();g({}),await i(),await E(),se&&await oe(se)}else fe.data?.google==="error"&&I(`\u2717 Google auth failed: ${fe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[oe,i,E]),L(()=>{!a||!V(a)?.authenticated||m[a]||f[a]||oe(a)},[s,f,m,a,V,oe]);let ne=async Q=>{let fe=await Kh(Q);if(!fe.ok){I(`Failed to disconnect: ${fe.error||"unknown"}`,"error");return}I("Google account disconnected","success"),g(se=>{let De={...se};return delete De[Q],De}),await i(),await E()},me=({accountId:Q="",client:fe="default",personal:se=!1,title:De="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:W=se?"personal":"workspace",initialValues:ae={}})=>{w({visible:!0,accountId:Q,client:fe,personal:se,title:De,submitLabel:ke,defaultInstrType:W,initialValues:ae})},pe=()=>{w(Q=>({...Q,visible:!1}))},ce=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&ue(Q.id)},ge=async({email:Q,setError:fe})=>{_(!0);try{let se=await Uh({email:Q,client:"default",personal:!1,services:wr()});if(!se.ok){fe?.(se.error||"Could not add account");return}S(!1),se.accountId&&l(se.accountId),await i(),se.accountId&&ue(se.accountId)}finally{_(!1)}},j=()=>{if(x(!1),K&&r){S(!0);return}me({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{x(!1),me({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},ie=async Q=>{let fe=V(Q);if(!fe)return;let se=$c(fe),De=se?"personal":fe.client||"default",ke={};try{let W=await Vh({accountId:fe.id,client:De});W?.ok&&(ke={clientId:String(W.clientId||""),clientSecret:String(W.clientSecret||"")})}catch{I("Could not load saved client credentials","warning")}me({accountId:fe.id,client:De,personal:se,title:`Edit Credentials (${fe.email})`,submitLabel:"Save Credentials",defaultInstrType:se?"personal":"workspace",initialValues:{email:fe.email,...ke}})},de=Q=>{N({visible:!0,accountId:String(Q||"")})},be=()=>{N({visible:!1,accountId:""})},Ve=async Q=>{let fe=V(Q);if(!fe)return;let se=String(fe.client||"default").trim()||"default",De=M.get(se);if(!De?.configured||!De?.webhookExists){de(Q);return}try{(await H(Q))?.restartRequired&&e(!0),I("Gmail watch enabled","success")}catch(ke){I(ke.message||"Could not enable Gmail watch","error")}},tt=async Q=>{try{await z(Q),I("Gmail watch disabled","info")}catch(fe){I(fe.message||"Could not disable Gmail watch","error")}},kt=async({client:Q,projectId:fe,destination:se=null})=>{let De=String(D.accountId||"").trim();De&&(await G({client:Q,projectId:fe,regeneratePushToken:!1}),await H(De,{destination:se}),I("Gmail setup complete and watch enabled","success"))},Ot=()=>Os`
            Google logo
            - <${Z} - onClick=${V} + <${Y} + onClick=${j} tone="primary" size="sm" idleLabel="Add Company Account" className="w-full font-medium" /> - <${Z} + <${Y} onClick=${J} tone="secondary" size="sm" @@ -7104,11 +7180,11 @@ ${B?.grantPublisher||""}`.trim()))} />
            - `;return Ns` + `;return Os`

            Google Accounts

            - ${s.length?Ns` + ${s.length?Os`
            <${pt} open=${b} @@ -7116,63 +7192,63 @@ ${B?.grantPublisher||""}`.trim()))} title="Add Google account" onClose=${()=>x(!1)} onToggle=${()=>x(Q=>!Q)} - renderTrigger=${({onToggle:Q,ariaLabel:pe,title:te})=>Ns` - <${Z} + renderTrigger=${({onToggle:Q,ariaLabel:fe,title:se})=>Os` + <${Y} onClick=${Q} tone="subtle" size="sm" idleLabel="+ Add Account" - ariaLabel=${pe} - title=${te} + ariaLabel=${fe} + title=${se} /> `} > - <${We} onClick=${V}> + <${Oe} onClick=${j}> Company account - - ${se?null:Ns` - <${We} onClick=${J}> + + ${te?null:Os` + <${Oe} onClick=${J}> Personal account - + `}
            `:null}
            - ${o?Ns`
            + ${o?Os`
            Loading... -
            `:s.length?Ns` +
            `:s.length?Os`
            - ${s.map(Q=>Ns`<${Pw} + ${s.map(Q=>Os`<${Ew} key=${Q.id} account=${Q} personal=${$c(Q)} expanded=${a===Q.id} - onToggleExpanded=${pe=>l(te=>te===pe?"":pe)} + onToggleExpanded=${fe=>l(se=>se===fe?"":fe)} scopes=${c[Q.id]||Q.activeScopes||wr()} savedScopes=${u[Q.id]||Q.activeScopes||wr()} apiStatus=${f[Q.id]||{}} checkingApis=${a===Q.id&&!!m[Q.id]} - onToggleScope=${le} - onCheckApis=${ie} - onUpdatePermissions=${pe=>de(pe)} - onEditCredentials=${oe} - onDisconnect=${pe=>A(pe)} - gmailWatchStatus=${E.get(Q.id)||null} + onToggleScope=${re} + onCheckApis=${oe} + onUpdatePermissions=${fe=>ue(fe)} + onEditCredentials=${ie} + onDisconnect=${fe=>A(fe)} + gmailWatchStatus=${O.get(Q.id)||null} gmailWatchBusy=${!!R[Q.id]} onEnableGmailWatch=${Ve} - onDisableGmailWatch=${et} - onOpenGmailSetup=${ce} + onDisableGmailWatch=${tt} + onOpenGmailSetup=${de} onOpenGmailWebhook=${n} />`)}
            `:Ot()}
            - <${Mw} + <${Pw} visible=${v.visible} - onClose=${he} - onSaved=${ue} + onClose=${pe} + onSaved=${ce} title=${v.title} submitLabel=${v.submitLabel} defaultInstrType=${v.defaultInstrType} @@ -7182,21 +7258,21 @@ ${B?.grantPublisher||""}`.trim()))} initialValues=${v.initialValues} /> - <${Rw} + <${Iw} visible=${$} onClose=${()=>S(!1)} - onSubmit=${me} + onSubmit=${ge} loading=${C} title="Add Company Account" /> - <${Dw} - visible=${O.visible} - account=${j(O.accountId)} - clientConfig=${M.get(String(j(O.accountId)?.client||"default").trim()||"default")||null} - saving=${B||z} + <${Bw} + visible=${D.visible} + account=${V(D.accountId)} + clientConfig=${M.get(String(V(D.accountId)?.client||"default").trim()||"default")||null} + saving=${B||U} onClose=${be} - onSaveSetup=${U} + onSaveSetup=${G} onFinish=${kt} /> @@ -7209,19 +7285,19 @@ ${B?.grantPublisher||""}`.trim()))} onCancel=${()=>A("")} onConfirm=${async()=>{let Q=k;A(""),await ne(Q)}} /> - `};var wc=P.bind(T),N3=(t,e)=>t.find(n=>n.key===e)?.value||"",O3=(t,e)=>{for(let n of t.providers)if((Mn[n]||[]).some(r=>!!N3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Ow=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc` + `};var wc=P.bind(T),W3=(t,e)=>t.find(n=>n.key===e)?.value||"",V3=(t,e)=>{for(let n of t.providers)if((An[n]||[]).some(r=>!!W3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Hw=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc`

            Features

            - ${gg.map(r=>{let i=O3(r,s);return wc` + ${bg.map(r=>{let i=V3(r,s);return wc`
            ${r.label} ${i.active?wc` - ${zs[i.provider]||i.provider} + ${Us[i.provider]||i.provider} - <${ae} tone="success">Enabled + <${le} tone="success">Enabled `:wc` @@ -7230,27 +7306,27 @@ ${B?.grantPublisher||""}`.trim()))} onclick=${a=>{a.preventDefault(),t?.("envars")}} class="text-xs px-2 py-1 rounded-lg ac-btn-ghost" >Add provider - <${ae} tone=${r.hasDefault?"neutral":"danger"}>Disabled + <${le} tone=${r.hasDefault?"neutral":"danger"}>Disabled `}
            `})}
            - `};var B3=P.bind(T),Bw=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>fc(t,e)?B3` + `};var j3=P.bind(T),Ww=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>fc(t,e)?j3`

            Drift Doctor

            -

            ${rw(t)}

            +

            ${lw(t)}

            - <${Z} + <${Y} onClick=${s} tone="secondary" idleLabel="Dismiss for 1 week" /> - <${Z} + <${Y} onClick=${n} tone="warning" idleLabel="Open Drift Doctor" @@ -7258,7 +7334,7 @@ ${B?.grantPublisher||""}`.trim()))}
            - `:null;var kc="0 * * * *",Fw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(kc),[g,m]=y(!1),[h,b]=y(kc),[x,v]=y(!1),[w,$]=y(!1),[S,C]=y(!1),_=ee(null),k=t,A=e,O=n,D=k?.gateway??null,z=k?.channels??null,E=k?.repo||null,M=k?.syncCron||null,R=k?.openclawVersion||null,B=bs.some(ue=>{let me=z?.[ue];if(!me)return!1;let V=me.accounts&&typeof me.accounts=="object"?me.accounts:{};return Object.keys(V).length>0?Object.values(V).some(J=>J&&J.status!=="paired"):me.status!=="paired"}),I=Re(async()=>(await Co()).pending||[],3e3,{enabled:B&&D==="running",cacheKey:"/api/pairings"}),U=I.data||[],W=D==="running"&&(w||!S),G=Re(async()=>{let ue=await ga();return C(ue?.cliAutoApproveComplete===!0),ue.pending||[]},5e3,{enabled:W,cacheKey:"/api/devices"}),se=G.data||[];L(()=>{o&&(I.refresh(),W&&G.refresh())},[G.refresh,o,s,I.refresh,w,W]),L(()=>{if(!r||!o)return;s(),I.refresh(),W&&G.refresh();let ue=setTimeout(()=>{s(),I.refresh(),W&&G.refresh()},1200),me=setTimeout(()=>{s(),I.refresh(),W&&G.refresh()},3500);return()=>{clearTimeout(ue),clearTimeout(me)}},[G.refresh,o,s,I.refresh,r,w,W]),L(()=>{M&&(u(M.enabled!==!1),f(M.schedule||kc),b(M.enabled===!1?"disabled":M.schedule||kc))},[M?.enabled,M?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),I.refresh(),setTimeout(()=>{s(),I.refresh()},700),setTimeout(()=>{s(),I.refresh()},1800)},j=async({enabled:ue=d,schedule:me=p}={})=>{if(!g){m(!0);try{let V=await ym({enabled:ue,schedule:me});if(!V.ok)throw new Error(V.error||"Could not save sync settings");N("Sync schedule updated","success"),s()}catch(V){N(V.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:z,dashboardLoading:i,devicePending:se,doctorStatus:O,gatewayStatus:D,hasUnpaired:B,openclawVersion:R,pending:U,pairingStatusRefreshing:x,repairingWatchdog:l,repo:E,savingSyncCron:g,syncCron:M,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ue,me,V="")=>{await _o(ue,me,V),K()},handleDeviceApprove:async ue=>{await ba(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleDeviceReject:async ue=>{await xa(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){$(!0),a(!0);try{let ue=await hm();console.log("[dashboard] response:",JSON.stringify(ue)),window.open(ue.url||"/openclaw","_blank")}catch(ue){console.error("[dashboard] error:",ue),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ue,me,V="")=>{try{await Mo(ue,me,V),K()}catch(J){N(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ue=>{b(ue);let me=ue!=="disabled",V=me?ue:p;u(me),f(V),await j({enabled:me,schedule:V})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ue=await ha();if(!ue.ok)throw new Error(ue.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ue){N(ue.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),Hw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:x,actions:v}=Fw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc` + `:null;var kc="0 * * * *",Vw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(kc),[g,m]=y(!1),[h,b]=y(kc),[x,v]=y(!1),[w,$]=y(!1),[S,C]=y(!1),_=ee(null),k=t,A=e,D=n,N=k?.gateway??null,U=k?.channels??null,O=k?.repo||null,M=k?.syncCron||null,R=k?.openclawVersion||null,B=xs.some(ce=>{let ge=U?.[ce];if(!ge)return!1;let j=ge.accounts&&typeof ge.accounts=="object"?ge.accounts:{};return Object.keys(j).length>0?Object.values(j).some(J=>J&&J.status!=="paired"):ge.status!=="paired"}),E=Ee(async()=>(await Co()).pending||[],3e3,{enabled:B&&N==="running",cacheKey:"/api/pairings"}),G=E.data||[],H=N==="running"&&(w||!S),z=Ee(async()=>{let ce=await ga();return C(ce?.cliAutoApproveComplete===!0),ce.pending||[]},5e3,{enabled:H,cacheKey:"/api/devices"}),te=z.data||[];L(()=>{o&&(E.refresh(),H&&z.refresh())},[z.refresh,o,s,E.refresh,w,H]),L(()=>{if(!r||!o)return;s(),E.refresh(),H&&z.refresh();let ce=setTimeout(()=>{s(),E.refresh(),H&&z.refresh()},1200),ge=setTimeout(()=>{s(),E.refresh(),H&&z.refresh()},3500);return()=>{clearTimeout(ce),clearTimeout(ge)}},[z.refresh,o,s,E.refresh,r,w,H]),L(()=>{M&&(u(M.enabled!==!1),f(M.schedule||kc),b(M.enabled===!1?"disabled":M.schedule||kc))},[M?.enabled,M?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),E.refresh(),setTimeout(()=>{s(),E.refresh()},700),setTimeout(()=>{s(),E.refresh()},1800)},V=async({enabled:ce=d,schedule:ge=p}={})=>{if(!g){m(!0);try{let j=await ym({enabled:ce,schedule:ge});if(!j.ok)throw new Error(j.error||"Could not save sync settings");I("Sync schedule updated","success"),s()}catch(j){I(j.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:U,dashboardLoading:i,devicePending:te,doctorStatus:D,gatewayStatus:N,hasUnpaired:B,openclawVersion:R,pending:G,pairingStatusRefreshing:x,repairingWatchdog:l,repo:O,savingSyncCron:g,syncCron:M,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ce,ge,j="")=>{await _o(ce,ge,j),K()},handleDeviceApprove:async ce=>{await ba(ce),setTimeout(z.refresh,500),setTimeout(z.refresh,2e3)},handleDeviceReject:async ce=>{await xa(ce),setTimeout(z.refresh,500),setTimeout(z.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){$(!0),a(!0);try{let ce=await hm();console.log("[dashboard] response:",JSON.stringify(ce)),window.open(ce.url||"/openclaw","_blank")}catch(ce){console.error("[dashboard] error:",ce),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ce,ge,j="")=>{try{await Ao(ce,ge,j),K()}catch(J){I(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ce=>{b(ce);let ge=ce!=="disabled",j=ge?ce:p;u(ge),f(j),await V({enabled:ge,schedule:j})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ce=await ha();if(!ce.ok)throw new Error(ce.error||"Repair failed");I("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ce){I(ce.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),jw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:x,actions:v}=Vw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc`
            <${bc} status=${x.gatewayStatus} @@ -7273,7 +7349,7 @@ ${B?.grantPublisher||""}`.trim()))} onOpenclawVersionActionComplete=${g} onOpenclawUpdate=${m} /> - <${Bw} + <${Ww} doctorStatus=${x.doctorStatus} dismissedUntilMs=${o} onOpenDoctor=${()=>i("doctor")} @@ -7281,7 +7357,7 @@ ${B?.grantPublisher||""}`.trim()))} /> <${nl} channelsSection=${Sc` - <${Cb} + <${Mb} channels=${x.channels} agents=${s} onNavigate=${a} @@ -7299,8 +7375,8 @@ ${B?.grantPublisher||""}`.trim()))} /> `} /> - <${Ow} onSwitchTab=${i} /> - <${Nw} + <${Hw} onSwitchTab=${i} /> + <${Fw} gatewayStatus=${x.gatewayStatus} onRestartRequired=${h} onOpenGmailWebhook=${l} @@ -7370,9 +7446,9 @@ ${B?.grantPublisher||""}`.trim()))} />
            - `};var F3=P.bind(T),ff=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSetLocation:i=()=>{},onNavigate:a=()=>{},restartingGateway:l=!1,onRestartGateway:c=()=>{},restartSignal:d=0,openclawUpdateInProgress:u=!1,onOpenclawVersionActionComplete:p=()=>{},onOpenclawUpdate:f=()=>{},onRestartRequired:g=()=>{},onDismissDoctorWarning:m=()=>{}})=>F3` + `};var z3=P.bind(T),ff=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSetLocation:i=()=>{},onNavigate:a=()=>{},restartingGateway:l=!1,onRestartGateway:c=()=>{},restartSignal:d=0,openclawUpdateInProgress:u=!1,onOpenclawVersionActionComplete:p=()=>{},onOpenclawUpdate:f=()=>{},onRestartRequired:g=()=>{},onDismissDoctorWarning:m=()=>{}})=>z3`
            - <${Hw} + <${jw} statusData=${t} watchdogData=${e} doctorStatusData=${n} @@ -7393,11 +7469,11 @@ ${B?.grantPublisher||""}`.trim()))} onDismissDoctorWarning=${m} />
            -`;var Nt=P.bind(T),H3={anthropic:{label:"Anthropic",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-ant-api03-...",url:"https://console.anthropic.com",field:"key"},{id:"token",label:"Setup Token",profileSuffix:"manual",placeholder:"sk-ant-oat01-...",hint:"From claude setup-token (uses your Claude subscription)",field:"token"}]},openai:{label:"OpenAI",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-...",url:"https://platform.openai.com",field:"key"}]},"openai-codex":{label:"OpenAI Codex",modes:[{id:"oauth",label:"Codex OAuth",isCodexOauth:!0}]},google:{label:"Gemini",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"AI...",url:"https://aistudio.google.com",field:"key"}]}},W3={id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"...",field:"key"},V3=t=>{let e=Mn[t]||[];return e.length===0?[W3]:e.map(n=>({id:"api_key",label:n.label||"API Key",profileSuffix:"default",placeholder:n.placeholder||"...",hint:n.hint,url:n.url,field:"key"}))},j3=t=>H3[t]||{label:zs[t]||t,modes:V3(t)},zi=(t,e)=>`${t.provider||e}:${t.profileSuffix||"default"}`,Cc=t=>String(t?.key||t?.token||t?.access||"").trim(),z3=({codexStatus:t,onRefreshCodex:e})=>{let[n,s]=y(!1),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(!1),d=ee(null);L(()=>()=>{d.current&&clearInterval(d.current)},[]),L(()=>{let g=async m=>{m.data?.codex==="success"?(N("Codex connected","success"),s(!1),r(!1),await e()):m.data?.codex==="error"&&N(`Codex auth failed: ${m.data.message||"unknown error"}`,"error")};return window.addEventListener("message",g),()=>window.removeEventListener("message",g)},[e]);let u=()=>{s(!0),r(!0);let g=window.open("/auth/codex/start","codex-auth","popup=yes,width=640,height=780");if(!g||g.closed){r(!1),window.location.href="/auth/codex/start";return}d.current&&clearInterval(d.current),d.current=setInterval(()=>{g.closed&&(clearInterval(d.current),d.current=null,r(!1))},500)},p=async()=>{if(!(!i.trim()||l)){c(!0);try{let g=await Wr(i.trim());if(!g.ok)throw new Error(g.error||"Codex OAuth exchange failed");a(""),N("Codex connected","success"),s(!1),r(!1),await e()}catch(g){N(g.message||"Codex OAuth exchange failed","error")}finally{c(!1)}}},f=async()=>{let g=await Hr();if(!g.ok){N(g.error||"Failed to disconnect Codex","error");return}N("Codex disconnected","success"),s(!1),r(!1),a(""),await e()};return Nt` +`;var Nt=P.bind(T),U3={anthropic:{label:"Anthropic",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-ant-api03-...",url:"https://console.anthropic.com",field:"key"},{id:"token",label:"Setup Token",profileSuffix:"manual",placeholder:"sk-ant-oat01-...",hint:"From claude setup-token (uses your Claude subscription)",field:"token"}]},openai:{label:"OpenAI",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-...",url:"https://platform.openai.com",field:"key"}]},"openai-codex":{label:"OpenAI Codex",modes:[{id:"oauth",label:"Codex OAuth",isCodexOauth:!0}]},google:{label:"Gemini",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"AI...",url:"https://aistudio.google.com",field:"key"}]}},K3={id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"...",field:"key"},G3=t=>{let e=An[t]||[];return e.length===0?[K3]:e.map(n=>({id:"api_key",label:n.label||"API Key",profileSuffix:"default",placeholder:n.placeholder||"...",hint:n.hint,url:n.url,field:"key"}))},q3=t=>U3[t]||{label:Us[t]||t,modes:G3(t)},zi=(t,e)=>`${t.provider||e}:${t.profileSuffix||"default"}`,Cc=t=>String(t?.key||t?.token||t?.access||"").trim(),J3=({codexStatus:t,onRefreshCodex:e})=>{let[n,s]=y(!1),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(!1),d=ee(null);L(()=>()=>{d.current&&clearInterval(d.current)},[]),L(()=>{let g=async m=>{m.data?.codex==="success"?(I("Codex connected","success"),s(!1),r(!1),await e()):m.data?.codex==="error"&&I(`Codex auth failed: ${m.data.message||"unknown error"}`,"error")};return window.addEventListener("message",g),()=>window.removeEventListener("message",g)},[e]);let u=()=>{s(!0),r(!0);let g=window.open("/auth/codex/start","codex-auth","popup=yes,width=640,height=780");if(!g||g.closed){r(!1),window.location.href="/auth/codex/start";return}d.current&&clearInterval(d.current),d.current=setInterval(()=>{g.closed&&(clearInterval(d.current),d.current=null,r(!1))},500)},p=async()=>{if(!(!i.trim()||l)){c(!0);try{let g=await Wr(i.trim());if(!g.ok)throw new Error(g.error||"Codex OAuth exchange failed");a(""),I("Codex connected","success"),s(!1),r(!1),await e()}catch(g){I(g.message||"Codex OAuth exchange failed","error")}finally{c(!1)}}},f=async()=>{let g=await Hr();if(!g.ok){I(g.error||"Failed to disconnect Codex","error");return}I("Codex disconnected","success"),s(!1),r(!1),a(""),await e()};return Nt`
            Codex OAuth - ${t.connected?Nt`<${ae} tone="success">Connected`:Nt`<${ae} tone="warning">Not connected`} + ${t.connected?Nt`<${le} tone="success">Connected`:Nt`<${le} tone="warning">Not connected`}
            ${t.connected?Nt`
            @@ -7448,7 +7524,7 @@ ${B?.grantPublisher||""}`.trim()))} placeholder="http://localhost:1455/auth/callback?code=...&state=..." class="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted" /> - <${Z} + <${Y} onClick=${p} disabled=${!i.trim()||l} loading=${l} @@ -7460,11 +7536,11 @@ ${B?.grantPublisher||""}`.trim()))} /> `:null}
            - `},Ww=({provider:t,authProfiles:e,authOrder:n,codexStatus:s,onEditProfile:o,onEditAuthOrder:r,getProfileValue:i,getEffectiveOrder:a,onRefreshCodex:l})=>{let c=j3(t),d=c.modes.filter($=>!$.isCodexOauth),u=d.length>1,p=c.modes.some($=>$.isCodexOauth),f=a(t),g=f?.[0]||null,m=n[t]||null,h=d.some($=>{let S=zi($,t),C=e.find(k=>k.id===S)||null,_=i(S);return Cc(_)!==Cc(C)}),b=JSON.stringify(f||null)!==JSON.stringify(m),x=h||b,v=d.some($=>{let S=zi($,t),C=i(S);return!!(C?.key||C?.token||C?.access)})||t==="openai-codex"&&!!s?.connected,w=$=>{let S=zi($,t),C=d.map(k=>zi(k,t)),_=[S,...C.filter(k=>k!==S)];r(t,_)};return Nt` + `},zw=({provider:t,authProfiles:e,authOrder:n,codexStatus:s,onEditProfile:o,onEditAuthOrder:r,getProfileValue:i,getEffectiveOrder:a,onRefreshCodex:l})=>{let c=q3(t),d=c.modes.filter($=>!$.isCodexOauth),u=d.length>1,p=c.modes.some($=>$.isCodexOauth),f=a(t),g=f?.[0]||null,m=n[t]||null,h=d.some($=>{let S=zi($,t),C=e.find(k=>k.id===S)||null,_=i(S);return Cc(_)!==Cc(C)}),b=JSON.stringify(f||null)!==JSON.stringify(m),x=h||b,v=d.some($=>{let S=zi($,t),C=i(S);return!!(C?.key||C?.token||C?.access)})||t==="openai-codex"&&!!s?.connected,w=$=>{let S=zi($,t),C=d.map(k=>zi(k,t)),_=[S,...C.filter(k=>k!==S)];r(t,_)};return Nt`

            ${c.label}

            - ${p&&d.length===0?null:x?Nt`<${ae} tone="warning">Unsaved`:v?Nt`<${ae} tone="success">Connected`:Nt`<${ae} tone="warning">Not configured`} + ${p&&d.length===0?null:x?Nt`<${le} tone="warning">Unsaved`:v?Nt`<${le} tone="success">Connected`:Nt`<${le} tone="warning">Not configured`}
            ${d.map($=>{let S=zi($,t),C=$.provider||t,_=i(S),k=_?.[$.field]||"",A=!u||g===S||!g&&$===d[0];return Nt`
            @@ -7472,7 +7548,7 @@ ${B?.grantPublisher||""}`.trim()))} - ${u&&A?Nt`<${ae} tone="cyan">Primary`:null} + ${u&&A?Nt`<${le} tone="cyan">Primary`:null} ${u&&!A&&k?Nt`
            <${Rt} value=${k} - onInput=${O=>{let D=O.target.value,z={type:$.id,provider:C,[$.field]:D};_?.expires&&(z.expires=_.expires),o(S,z);let E=e.find(R=>R.id===S)||null;Cc(z)===Cc(E)&&u?r(t,m):u&&D&&!A&&w($)}} + onInput=${D=>{let N=D.target.value,U={type:$.id,provider:C,[$.field]:N};_?.expires&&(U.expires=_.expires),o(S,U);let O=e.find(R=>R.id===S)||null;Cc(U)===Cc(O)&&u?r(t,m):u&&N&&!A&&w($)}} placeholder=${$.placeholder||""} isSecret=${!0} inputClass="flex-1 w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" @@ -7499,16 +7575,16 @@ ${B?.grantPublisher||""}`.trim()))} `})} ${c.modes.some($=>$.isCodexOauth)?Nt`
            - <${z3} + <${J3} codexStatus=${s} onRefreshCodex=${l} />
            `:null}
            - `};var Mt=P.bind(T),U3=t=>{let e=new Set;for(let n of Object.keys(t)){let s=ri(n);s&&e.add(s)}return[...e]},K3=["anthropic","openai","openai-codex",...jr.filter(t=>!["anthropic","openai"].includes(t))],Vw=({onRestartRequired:t=()=>{},agentId:e,embedded:n=!1})=>{let{catalog:s,primary:o,configuredModels:r,authProfiles:i,authOrder:a,codexStatus:l,loading:c,saving:d,ready:u,error:p,isDirty:f,addModel:g,removeModel:m,setPrimaryModel:h,editProfile:b,editAuthOrder:x,getProfileValue:v,getEffectiveOrder:w,cancelChanges:$,saveAll:S,refreshCodexStatus:C}=rl(e),_=F(()=>new Set(Object.keys(r)),[r]),k=F(()=>Lo(s),[s]),A=F(()=>k.filter(I=>!_.has(I.key)),[k,_]),O=F(()=>[...s].filter(I=>!_.has(I.key)).sort((I,U)=>{let W=jo(vs(I))-jo(vs(U));return W!==0?W:String(I.label||I.key).localeCompare(String(U.label||U.key))}),[s,_]),D=F(()=>U3(r),[r]),z=F(()=>{let I=[];for(let U of K3)D.includes(U)&&I.push(U);for(let U of D)I.includes(U)||I.push(U);return I},[D]),E=F(()=>sl({authProfiles:i,codexStatus:l}),[i,l]),M=F(()=>Object.keys(r).map(I=>{let U=s.find(se=>se.key===I),W=ri(I),G=!!E[W];return{key:I,label:U?.label||I,isPrimary:I===o,hasAuth:G}}),[r,s,o,E]),R=Mt` - <${Qn} visible=${f}> - <${Z} + `};var At=P.bind(T),Z3=t=>{let e=new Set;for(let n of Object.keys(t)){let s=ri(n);s&&e.add(s)}return[...e]},Y3=["anthropic","openai","openai-codex",...jr.filter(t=>!["anthropic","openai"].includes(t))],Uw=({onRestartRequired:t=()=>{},agentId:e,embedded:n=!1})=>{let{catalog:s,primary:o,configuredModels:r,authProfiles:i,authOrder:a,codexStatus:l,loading:c,saving:d,ready:u,error:p,isDirty:f,addModel:g,removeModel:m,setPrimaryModel:h,editProfile:b,editAuthOrder:x,getProfileValue:v,getEffectiveOrder:w,cancelChanges:$,saveAll:S,refreshCodexStatus:C}=rl(e),_=F(()=>new Set(Object.keys(r)),[r]),k=F(()=>Lo(s),[s]),A=F(()=>k.filter(E=>!_.has(E.key)),[k,_]),D=F(()=>[...s].filter(E=>!_.has(E.key)).sort((E,G)=>{let H=jo($s(E))-jo($s(G));return H!==0?H:String(E.label||E.key).localeCompare(String(G.label||G.key))}),[s,_]),N=F(()=>Z3(r),[r]),U=F(()=>{let E=[];for(let G of Y3)N.includes(G)&&E.push(G);for(let G of N)E.includes(G)||E.push(G);return E},[N]),O=F(()=>sl({authProfiles:i,codexStatus:l}),[i,l]),M=F(()=>Object.keys(r).map(E=>{let G=s.find(te=>te.key===E),H=ri(E),z=!!O[H];return{key:E,label:G?.label||E,isPrimary:E===o,hasAuth:z}}),[r,s,o,O]),R=At` + <${es} visible=${f}> + <${Y} onClick=${$} disabled=${d} tone="secondary" @@ -7516,7 +7592,7 @@ ${B?.grantPublisher||""}`.trim()))} idleLabel="Cancel" className="text-xs" /> - <${Z} + <${Y} onClick=${S} disabled=${d} loading=${d} @@ -7527,48 +7603,48 @@ ${B?.grantPublisher||""}`.trim()))} loadingLabel="Saving…" className="text-xs" /> - - `;if(!u){let I=Mt` + + `;if(!u){let E=At`
            <${Ce} className="h-4 w-4" /> Loading model settings...
            - `;return n?I:Mt` - <${zn} - header=${Mt`<${Ne} title="Models" />`} + `;return n?E:At` + <${Un} + header=${At`<${Pe} title="Models" />`} > - ${I} - - `}let B=Mt` + ${E} + + `}let B=At`

            Available Models

            - ${M.length===0?Mt`

            + ${M.length===0?At`

            No models configured. Add a model below. -

            `:Mt` +

            `:At`
            - ${M.map(I=>Mt` + ${M.map(E=>At`
            ${I.label}${E.label} - ${I.isPrimary?Mt`<${ae} tone="cyan">Primary`:I.hasAuth?Mt` + ${E.isPrimary?At`<${le} tone="cyan">Primary`:E.hasAuth?At` - `:Mt`<${ae} tone="warning">Needs auth`} + `:At`<${le} tone="warning">Needs auth`}
            - ${z.length>0?Mt` + ${U.length>0?At`

            Provider Authentication

            - ${z.map(I=>Mt` - <${Ww} - provider=${I} + ${U.map(E=>At` + <${zw} + provider=${E} authProfiles=${i} authOrder=${a} codexStatus=${l} @@ -7613,22 +7689,22 @@ ${B?.grantPublisher||""}`.trim()))} `)}
            `:null} - `;return n?Mt` + `;return n?At`
            ${R}
            ${B}
            - `:Mt` - <${zn} - header=${Mt`<${Ne} title="Models" actions=${R} />`} + `:At` + <${Un} + header=${At`<${Pe} title="Models" actions=${R} />`} > ${B} - - `};var G3=P.bind(T),hf=({onRestartRequired:t=()=>{}})=>G3` - <${Vw} onRestartRequired=${t} /> -`;var q3=1e4,jw=({enabled:t=!0}={})=>{let e=Re(async()=>{let n=await Pm(),s=Array.isArray(n?.nodes)?n.nodes:[],o=Array.isArray(n?.pending)?n.pending:[];return{nodes:s,pending:o}},q3,{enabled:t,cacheKey:"/api/nodes"});return{nodes:Array.isArray(e.data?.nodes)?e.data.nodes:[],pending:Array.isArray(e.data?.pending)?e.data.pending:[],loading:e.data===null&&!e.error,error:e.error?String(e.error.message||"Could not load nodes"):"",refresh:e.refresh}};var zw=()=>{let t=jw({enabled:!0}),[e,n]=y(!1),[s,o]=y(!1),{data:r,error:i}=ct("/api/nodes/connect-info",ya,{maxAgeMs:6e4}),a=Array.isArray(t.nodes)?t.nodes.filter(c=>c?.paired!==!1):[];L(()=>{i&&N(i.message||"Could not load node connect command","error")},[i]);let l=q(async()=>{if(!s){o(!0);try{await t.refresh()}finally{o(!1)}}},[t.refresh,s]);return{state:{wizardVisible:e,nodes:a,pending:t.pending,loadingNodes:t.loading,refreshingNodes:s,nodesError:t.error,connectInfo:r},actions:{openWizard:()=>n(!0),closeWizard:()=>n(!1),refreshNodes:l}}};var J3=35e3,Z3=1e4,Kw="nodesBrowserAttachStateByNode",Y3=/selected page has been closed/i,X3=async(t,e=J3)=>{let n=null;try{return await Promise.race([t,new Promise((s,o)=>{n=setTimeout(()=>{o(new Error("Browser check timed out"))},e)})])}finally{n&&clearTimeout(n)}},Uw=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},Q3=t=>{let e=String(t?.message||"Could not check node browser status").trim();return Y3.test(e)?"Selected Chrome page was closed. Click Attach to reconnect.":e},eR=()=>{let e=Fe()?.[Kw];return!e||typeof e!="object"||Array.isArray(e)?{}:e},tR=(t={})=>{vx(e=>({...e&&typeof e=="object"?e:{},[Kw]:t&&typeof t=="object"?t:{}}))},Gw=({nodes:t=[],onRefreshNodes:e=async()=>{}}={})=>{let[n,s]=y({}),[o,r]=y({}),[i,a]=y(""),[l,c]=y(()=>eR()),[d,u]=y(""),[p,f]=y(null),[g,m]=y(""),h=ee(0),b=ee(""),x=async(k,{successMessage:A="Connection command copied",errorMessage:O="Could not copy connection command"}={})=>{if(await Ho(k)){N(A,"success");return}N(O,"error")},v=q(async(k,{silent:A=!1}={})=>{let O=String(k||"").trim();if(!(!O||b.current)){b.current=O,A||a(O),r(D=>({...D,[O]:""}));try{let D=await X3(Em(O,"user")),z=D?.status&&typeof D.status=="object"?D.status:null;s(E=>({...E,[O]:z}))}catch(D){let z=Q3(D);s(E=>({...E,[O]:null})),r(E=>({...E,[O]:z})),A||N(z,"error")}finally{b.current="",A||a("")}}},[]),w=q((k,A)=>{let O=String(k||"").trim();O&&c(D=>{let z={...D&&typeof D=="object"?D:{},[O]:A===!0};return tR(z),z})},[]),$=q(async k=>{let A=String(k||"").trim();A&&(w(A,!0),await v(A))},[v,w]),S=q(k=>{let A=String(k||"").trim();A&&(w(A,!1),s(O=>{let D={...O||{}};return delete D[A],D}),r(O=>{let D={...O||{}};return delete D[A],D}))},[w]),C=q(k=>{let A=String(k||"").trim();A&&u(O=>O===A?"":A)},[]),_=q(async()=>{let k=String(p?.nodeId||"").trim();if(!(!k||g)){m(k);try{await Rm(k),S(k),N("Device removed","success"),f(null),u(""),await e()}catch(A){N(A.message||"Could not remove node","error")}finally{m("")}}},[S,e,p,g]);return L(()=>{if(i)return;let k=t.map(A=>({nodeId:String(A?.nodeId||"").trim(),connected:A?.connected===!0,browserCapable:Uw(A)})).find(A=>!(!A.nodeId||!A.connected||!A.browserCapable||l?.[A.nodeId]!==!0||n?.[A.nodeId]||o?.[A.nodeId]))?.nodeId;k&&v(k,{silent:!0})},[l,o,n,i,v,t]),L(()=>{if(i)return;let k=t.map(z=>({nodeId:String(z?.nodeId||"").trim(),connected:z?.connected===!0,browserCapable:Uw(z),browserRunning:n?.[String(z?.nodeId||"").trim()]?.running===!0})).filter(z=>z.nodeId&&z.connected&&z.browserCapable&&l?.[z.nodeId]===!0&&z.browserRunning).map(z=>z.nodeId);if(!k.length)return;let A=!0,D=setInterval(async()=>{if(!A||b.current)return;let z=h.current%k.length;h.current+=1;let E=k[z];await v(E,{silent:!0})},Z3);return()=>{A=!1,clearInterval(D)}},[l,n,i,v,t]),{browserStatusByNodeId:n,browserErrorByNodeId:o,checkingBrowserNodeId:i,browserAttachStateByNodeId:l,menuOpenNodeId:d,removeDialogNode:p,removingNodeId:g,handleCopyText:x,handleCheckNodeBrowser:v,handleAttachNodeBrowser:$,handleDetachNodeBrowser:S,handleOpenNodeMenu:C,handleRemoveNode:_,setMenuOpenNodeId:u,setRemoveDialogNode:f}};var ot=P.bind(T),nR=t=>String(t||"").replace(/"/g,'\\"'),qw=({node:t,connectInfo:e,maskToken:n=!1})=>{let s=String(e?.gatewayHost||"").trim()||"localhost",o=Number(e?.gatewayPort)||3e3,r=String(e?.gatewayToken||"").trim(),i=e?.tls===!0?"--tls":"",a=String(t?.displayName||t?.nodeId||"My Node").trim(),l=n?"****":r;return[l?`OPENCLAW_GATEWAY_TOKEN=${l}`:"","openclaw node run",`--host ${s}`,`--port ${o}`,i,`--display-name "${nR(a)}"`].filter(Boolean).join(" ")},sR=t=>t?.connected?ot`<${ae} tone="success">Connected`:t?.paired?ot`<${ae} tone="warning">Disconnected`:ot`<${ae} tone="danger">Pending approval`,oR=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},rR=t=>t.running?"success":"warning",iR=t=>t.running?"Attached":"Not connected",Jw=({nodes:t=[],pending:e=[],loading:n=!1,error:s="",connectInfo:o=null,onRefreshNodes:r=async()=>{}})=>{let i=Gw({nodes:t,onRefreshNodes:r}),{browserStatusByNodeId:a,browserErrorByNodeId:l,checkingBrowserNodeId:c,browserAttachStateByNodeId:d,menuOpenNodeId:u,removeDialogNode:p,removingNodeId:f,handleCopyText:g,handleCheckNodeBrowser:m,handleAttachNodeBrowser:h,handleDetachNodeBrowser:b,handleOpenNodeMenu:x,handleRemoveNode:v,setMenuOpenNodeId:w,setRemoveDialogNode:$}=i;return ot` + + `};var X3=P.bind(T),hf=({onRestartRequired:t=()=>{}})=>X3` + <${Uw} onRestartRequired=${t} /> +`;var Q3=1e4,Kw=({enabled:t=!0}={})=>{let e=Ee(async()=>{let n=await Pm(),s=Array.isArray(n?.nodes)?n.nodes:[],o=Array.isArray(n?.pending)?n.pending:[];return{nodes:s,pending:o}},Q3,{enabled:t,cacheKey:"/api/nodes"});return{nodes:Array.isArray(e.data?.nodes)?e.data.nodes:[],pending:Array.isArray(e.data?.pending)?e.data.pending:[],loading:e.data===null&&!e.error,error:e.error?String(e.error.message||"Could not load nodes"):"",refresh:e.refresh}};var Gw=()=>{let t=Kw({enabled:!0}),[e,n]=y(!1),[s,o]=y(!1),{data:r,error:i}=ct("/api/nodes/connect-info",ya,{maxAgeMs:6e4}),a=Array.isArray(t.nodes)?t.nodes.filter(c=>c?.paired!==!1):[];L(()=>{i&&I(i.message||"Could not load node connect command","error")},[i]);let l=q(async()=>{if(!s){o(!0);try{await t.refresh()}finally{o(!1)}}},[t.refresh,s]);return{state:{wizardVisible:e,nodes:a,pending:t.pending,loadingNodes:t.loading,refreshingNodes:s,nodesError:t.error,connectInfo:r},actions:{openWizard:()=>n(!0),closeWizard:()=>n(!1),refreshNodes:l}}};var eR=35e3,tR=1e4,Jw="nodesBrowserAttachStateByNode",nR=/selected page has been closed/i,sR=async(t,e=eR)=>{let n=null;try{return await Promise.race([t,new Promise((s,o)=>{n=setTimeout(()=>{o(new Error("Browser check timed out"))},e)})])}finally{n&&clearTimeout(n)}},qw=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},oR=t=>{let e=String(t?.message||"Could not check node browser status").trim();return nR.test(e)?"Selected Chrome page was closed. Click Attach to reconnect.":e},rR=()=>{let e=He()?.[Jw];return!e||typeof e!="object"||Array.isArray(e)?{}:e},iR=(t={})=>{kx(e=>({...e&&typeof e=="object"?e:{},[Jw]:t&&typeof t=="object"?t:{}}))},Zw=({nodes:t=[],onRefreshNodes:e=async()=>{}}={})=>{let[n,s]=y({}),[o,r]=y({}),[i,a]=y(""),[l,c]=y(()=>rR()),[d,u]=y(""),[p,f]=y(null),[g,m]=y(""),h=ee(0),b=ee(""),x=async(k,{successMessage:A="Connection command copied",errorMessage:D="Could not copy connection command"}={})=>{if(await Ho(k)){I(A,"success");return}I(D,"error")},v=q(async(k,{silent:A=!1}={})=>{let D=String(k||"").trim();if(!(!D||b.current)){b.current=D,A||a(D),r(N=>({...N,[D]:""}));try{let N=await sR(Em(D,"user")),U=N?.status&&typeof N.status=="object"?N.status:null;s(O=>({...O,[D]:U}))}catch(N){let U=oR(N);s(O=>({...O,[D]:null})),r(O=>({...O,[D]:U})),A||I(U,"error")}finally{b.current="",A||a("")}}},[]),w=q((k,A)=>{let D=String(k||"").trim();D&&c(N=>{let U={...N&&typeof N=="object"?N:{},[D]:A===!0};return iR(U),U})},[]),$=q(async k=>{let A=String(k||"").trim();A&&(w(A,!0),await v(A))},[v,w]),S=q(k=>{let A=String(k||"").trim();A&&(w(A,!1),s(D=>{let N={...D||{}};return delete N[A],N}),r(D=>{let N={...D||{}};return delete N[A],N}))},[w]),C=q(k=>{let A=String(k||"").trim();A&&u(D=>D===A?"":A)},[]),_=q(async()=>{let k=String(p?.nodeId||"").trim();if(!(!k||g)){m(k);try{await Rm(k),S(k),I("Device removed","success"),f(null),u(""),await e()}catch(A){I(A.message||"Could not remove node","error")}finally{m("")}}},[S,e,p,g]);return L(()=>{if(i)return;let k=t.map(A=>({nodeId:String(A?.nodeId||"").trim(),connected:A?.connected===!0,browserCapable:qw(A)})).find(A=>!(!A.nodeId||!A.connected||!A.browserCapable||l?.[A.nodeId]!==!0||n?.[A.nodeId]||o?.[A.nodeId]))?.nodeId;k&&v(k,{silent:!0})},[l,o,n,i,v,t]),L(()=>{if(i)return;let k=t.map(U=>({nodeId:String(U?.nodeId||"").trim(),connected:U?.connected===!0,browserCapable:qw(U),browserRunning:n?.[String(U?.nodeId||"").trim()]?.running===!0})).filter(U=>U.nodeId&&U.connected&&U.browserCapable&&l?.[U.nodeId]===!0&&U.browserRunning).map(U=>U.nodeId);if(!k.length)return;let A=!0,N=setInterval(async()=>{if(!A||b.current)return;let U=h.current%k.length;h.current+=1;let O=k[U];await v(O,{silent:!0})},tR);return()=>{A=!1,clearInterval(N)}},[l,n,i,v,t]),{browserStatusByNodeId:n,browserErrorByNodeId:o,checkingBrowserNodeId:i,browserAttachStateByNodeId:l,menuOpenNodeId:d,removeDialogNode:p,removingNodeId:g,handleCopyText:x,handleCheckNodeBrowser:v,handleAttachNodeBrowser:$,handleDetachNodeBrowser:S,handleOpenNodeMenu:C,handleRemoveNode:_,setMenuOpenNodeId:u,setRemoveDialogNode:f}};var ot=P.bind(T),aR=t=>String(t||"").replace(/"/g,'\\"'),Yw=({node:t,connectInfo:e,maskToken:n=!1})=>{let s=String(e?.gatewayHost||"").trim()||"localhost",o=Number(e?.gatewayPort)||3e3,r=String(e?.gatewayToken||"").trim(),i=e?.tls===!0?"--tls":"",a=String(t?.displayName||t?.nodeId||"My Node").trim(),l=n?"****":r;return[l?`OPENCLAW_GATEWAY_TOKEN=${l}`:"","openclaw node run",`--host ${s}`,`--port ${o}`,i,`--display-name "${aR(a)}"`].filter(Boolean).join(" ")},lR=t=>t?.connected?ot`<${le} tone="success">Connected`:t?.paired?ot`<${le} tone="warning">Disconnected`:ot`<${le} tone="danger">Pending approval`,cR=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},dR=t=>t.running?"success":"warning",uR=t=>t.running?"Attached":"Not connected",Xw=({nodes:t=[],pending:e=[],loading:n=!1,error:s="",connectInfo:o=null,onRefreshNodes:r=async()=>{}})=>{let i=Zw({nodes:t,onRefreshNodes:r}),{browserStatusByNodeId:a,browserErrorByNodeId:l,checkingBrowserNodeId:c,browserAttachStateByNodeId:d,menuOpenNodeId:u,removeDialogNode:p,removingNodeId:f,handleCopyText:g,handleCheckNodeBrowser:m,handleAttachNodeBrowser:h,handleDetachNodeBrowser:b,handleOpenNodeMenu:x,handleRemoveNode:v,setMenuOpenNodeId:w,setRemoveDialogNode:$}=i;return ot`
            ${e.length?ot`
            `:t.length?ot`
            - ${t.map(S=>{let C=String(S?.nodeId||"").trim(),_=a[C]||null,k=l[C]||"",A=c===C,O=S?.connected&&oR(S)&&C,D=d?.[C]===!0,z=!!_||!!k,E=_?.running===!0,M=D&&!z&&!A,R=O&&D&&!A&&z&&!E;return ot` + ${t.map(S=>{let C=String(S?.nodeId||"").trim(),_=a[C]||null,k=l[C]||"",A=c===C,D=S?.connected&&cR(S)&&C,N=d?.[C]===!0,U=!!_||!!k,O=_?.running===!0,M=N&&!U&&!A,R=D&&N&&!A&&U&&!O;return ot`
            @@ -7669,7 +7745,7 @@ ${B?.grantPublisher||""}`.trim()))} class="shrink-0 inline-flex items-center gap-1 text-[11px] text-fg-muted hover:text-body" onclick=${()=>g(C,{successMessage:"Device ID copied",errorMessage:"Could not copy device ID"})} > - <${Us} + <${Ks} className="w-3.5 h-3.5" /> Copy device id @@ -7678,7 +7754,7 @@ ${B?.grantPublisher||""}`.trim()))}
            - ${sR(S)} + ${lR(S)} ${S?.paired?ot` <${pt} open=${u===C} @@ -7687,12 +7763,12 @@ ${B?.grantPublisher||""}`.trim()))} onClose=${()=>w("")} onToggle=${()=>x(C)} > - <${We} + <${Oe} className="text-status-error hover:text-status-error" onClick=${()=>{w(""),$(S)}} > Remove device - + `:null}
            @@ -7713,7 +7789,7 @@ ${B?.grantPublisher||""}`.trim()))} >
            - ${O?ot` + ${D?ot`
            Browser
            - ${D?ot` + ${N?ot`
            @@ -7746,8 +7822,8 @@ ${B?.grantPublisher||""}`.trim()))}
            ${_?ot` - <${ae} tone=${rR(_)} - >${iR(_)}${uR(_)} `:null} @@ -7761,8 +7837,8 @@ ${B?.grantPublisher||""}`.trim()))} className="h-3.5 w-3.5" /> `:null} - ${O&&!D?ot` - <${Z} + ${D&&!N?ot` + <${Y} onClick=${()=>h(C)} idleLabel="Attach" tone="primary" @@ -7770,14 +7846,14 @@ ${B?.grantPublisher||""}`.trim()))} /> `:null} ${R?ot` - <${Z} + <${Y} onClick=${()=>m(C)} idleLabel="Check" tone="secondary" size="sm" /> `:null} - ${O&&D&&!A?ot` + ${D&&N&&!A?ot`
            - `};var Yw=3e3,Xw=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[b,x]=y([]),[v,w]=y(!1),$=ee(!1);L(()=>{t&&(i(0),g(""),h(!1),w(!1))},[t]),L(()=>{t&&(d(!0),ya().then(E=>{l(E||null)}).catch(E=>{N(E.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=F(()=>{let E=new Set,M=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||E.has(B)||R?.paired!==!1&&(E.add(B),M.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return M},[e]),C=F(()=>S.find(E=>E.nodeId===String(f||"").trim())||null,[S,f]),_=F(()=>{if(!a)return"";let E=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",I=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${E}`,`--port ${M}`,B.trim(),I?`--display-name "${I}"`:""].filter(Boolean).join(" ")},[a,u]),k=q(async()=>{if(!$.current){$.current=!0;try{await n();let E=await ga(),M=Array.isArray(E?.pending)?E.pending:[];x(M)}finally{$.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let E=!0,M=async()=>{if(E)try{await k()}catch{}};M();let R=setInterval(M,Yw);return()=>{E=!1,clearInterval(R)}},[k,r,t]),L(()=>{if(!t||r!==1)return;let E=S.some(B=>B.nodeId===String(f||"").trim()),M=String(u||"").trim().toLowerCase(),R=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||S[0];R&&(E&&String(f||"").trim()===R.nodeId||g(R.nodeId))},[u,S,f,r,t]);let A=q(async E=>{try{await ba(E),N("Pairing approved","success"),w(!0),await k()}catch(M){N(M.message||"Could not approve pairing","error")}},[k]),O=q(async E=>{try{await xa(E),N("Pairing rejected","info"),await k()}catch(M){N(M.message||"Could not reject pairing","error")}},[k]),D=q(async()=>{let E=String(f||"").trim();if(!E||m)return!1;h(!0);try{return await Lm(E),s(!0),N("Gateway routing now points to the selected node","success"),!0}catch(M){return N(M.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),z=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:S,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:Yw,handleDeviceApprove:A,handleDeviceReject:O,applyGatewayNodeRouting:D,completeWizard:z}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],Qw=({command:t="",onCopy:e=()=>{}})=>an` + `};var e1=3e3,t1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[b,x]=y([]),[v,w]=y(!1),$=ee(!1);L(()=>{t&&(i(0),g(""),h(!1),w(!1))},[t]),L(()=>{t&&(d(!0),ya().then(O=>{l(O||null)}).catch(O=>{I(O.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=F(()=>{let O=new Set,M=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||O.has(B)||R?.paired!==!1&&(O.add(B),M.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return M},[e]),C=F(()=>S.find(O=>O.nodeId===String(f||"").trim())||null,[S,f]),_=F(()=>{if(!a)return"";let O=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",E=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${O}`,`--port ${M}`,B.trim(),E?`--display-name "${E}"`:""].filter(Boolean).join(" ")},[a,u]),k=q(async()=>{if(!$.current){$.current=!0;try{await n();let O=await ga(),M=Array.isArray(O?.pending)?O.pending:[];x(M)}finally{$.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let O=!0,M=async()=>{if(O)try{await k()}catch{}};M();let R=setInterval(M,e1);return()=>{O=!1,clearInterval(R)}},[k,r,t]),L(()=>{if(!t||r!==1)return;let O=S.some(B=>B.nodeId===String(f||"").trim()),M=String(u||"").trim().toLowerCase(),R=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||S[0];R&&(O&&String(f||"").trim()===R.nodeId||g(R.nodeId))},[u,S,f,r,t]);let A=q(async O=>{try{await ba(O),I("Pairing approved","success"),w(!0),await k()}catch(M){I(M.message||"Could not approve pairing","error")}},[k]),D=q(async O=>{try{await xa(O),I("Pairing rejected","info"),await k()}catch(M){I(M.message||"Could not reject pairing","error")}},[k]),N=q(async()=>{let O=String(f||"").trim();if(!O||m)return!1;h(!0);try{return await Lm(O),s(!0),I("Gateway routing now points to the selected node","success"),!0}catch(M){return I(M.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),U=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:S,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:e1,handleDeviceApprove:A,handleDeviceReject:D,applyGatewayNodeRouting:N,completeWizard:U}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],n1=({command:t="",onCopy:e=()=>{}})=>an`
            -        <${Us} className="w-3.5 h-3.5" />
            +        <${Ks} className="w-3.5 h-3.5" />
                     Copy
                   
                 
            -`,e1=async(t,e="text")=>{if(await Ho(t)){N("Copied to clipboard","success");return}N(`Could not copy ${e}`,"error")},t1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}})=>{let r=Xw({visible:t,nodes:e,refreshNodes:n,onRestartRequired:s,onClose:o}),i=r.step===Ui.length-1;return an` - <${De} +`,s1=async(t,e="text")=>{if(await Ho(t)){I("Copied to clipboard","success");return}I(`Could not copy ${e}`,"error")},o1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}})=>{let r=t1({visible:t,nodes:e,refreshNodes:n,onRestartRequired:s,onClose:o}),i=r.step===Ui.length-1;return an` + <${Te} visible=${t} onClose=${o} closeOnOverlayClick=${!1} @@ -7963,7 +8039,7 @@ ${t} - <${nt} className="w-3.5 h-3.5 text-body" /> + <${Ze} className="w-3.5 h-3.5 text-body" />
            Node Setup Wizard
            @@ -7983,7 +8059,7 @@ ${t} Install OpenClaw on the machine you want to connect as a node.
            - ${Qw({command:"npm install -g openclaw",onCopy:()=>e1("npm install -g openclaw","command")})} + ${n1({command:"npm install -g openclaw",onCopy:()=>s1("npm install -g openclaw","command")})}
            Requires Node.js 22+.
            `:null} @@ -8005,7 +8081,7 @@ ${t} ${r.loadingConnectInfo?an`
            Loading command... -
            `:Qw({command:r.connectCommand||"Could not build connect command.",onCopy:()=>e1(r.connectCommand||"","command")})} +
            `:n1({command:r.connectCommand||"Could not build connect command.",onCopy:()=>s1(r.connectCommand||"","command")})}
            ${r.devicePending.length?an` <${xc} @@ -8033,7 +8109,7 @@ ${t} ${r.step===0?an`
            `:an` - <${Z} + <${Y} onClick=${()=>r.setStep(Math.max(0,r.step-1))} idleLabel="Back" tone="secondary" @@ -8042,7 +8118,7 @@ ${t} `} ${i?an` - <${Z} + <${Y} onClick=${async()=>{await r.applyGatewayNodeRouting()&&(r.completeWizard(),Promise.resolve(n()).catch(()=>{}))}} loading=${r.configuring} idleLabel=${r.canFinish?"Finish":"Awaiting pairing"} @@ -8053,7 +8129,7 @@ ${t} `:an` - <${Z} + <${Y} onClick=${()=>r.setStep(Math.min(Ui.length-1,r.step+1))} idleLabel="Next" tone="primary" @@ -8062,13 +8138,13 @@ ${t} `}
            - - `};var n1=P.bind(T),s1=({onRestartRequired:t=()=>{}})=>{let{state:e,actions:n}=zw();return n1` + + `};var r1=P.bind(T),i1=({onRestartRequired:t=()=>{}})=>{let{state:e,actions:n}=Gw();return r1`
            - <${Ne} + <${Pe} title="Nodes" - actions=${n1` - <${Z} + actions=${r1` + <${Y} onClick=${n.refreshNodes} loading=${e.refreshingNodes} loadingMode="inline" @@ -8076,7 +8152,7 @@ ${t} - <${Z} + <${Y} onClick=${n.openWizard} idleLabel="Connect Node" tone="primary" @@ -8085,7 +8161,7 @@ ${t} - <${Jw} + <${Xw} nodes=${e.nodes} pending=${e.pending} loading=${e.loadingNodes} @@ -8094,9 +8170,9 @@ ${t} - <${Zw} /> + <${Qw} /> - <${t1} + <${o1} visible=${e.wizardVisible} nodes=${e.nodes} refreshNodes=${n.refreshNodes} @@ -8104,38 +8180,38 @@ ${t}
            - `};var dR=P.bind(T),mf=({onRestartRequired:t=()=>{}})=>dR` + `};var mR=P.bind(T),mf=({onRestartRequired:t=()=>{}})=>mR`
            - <${s1} onRestartRequired=${t} /> + <${i1} onRestartRequired=${t} />
            -`;var NU=P.bind(T);var OU=Object.values(Mn).flat().map(t=>t.key).filter((t,e,n)=>n.indexOf(t)===e);var jU=P.bind(T);var Ki=({to:t})=>{let[,e]=ca();return L(()=>{e(t)},[t,e]),null};var Gi=(t,e)=>{let n=String(e||"").trim();n&&t.set("accountId",n)},o1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/bot${n}`)).json()},r1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/workspace${n}`)).json()},i1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/workspace/reset${n}`,{method:"POST"})).json()},a1=async(t,{accountId:e=""}={})=>(await Y("/api/telegram/groups/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groupId:t,accountId:e})})).json(),_c=async(t,{accountId:e=""}={})=>{let n=new URLSearchParams;Gi(n,e);let s=n.toString()?`?${n.toString()}`:"";return(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics${s}`)).json()},Mc=async(t,e,{accountId:n=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/bulk`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topics:e,accountId:n})})).json(),Ac=async(t,e,{accountId:n=""}={})=>{let s=new URLSearchParams;Gi(s,n);let o=s.toString()?`?${s.toString()}`:"";return(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${e}${o}`,{method:"DELETE"})).json()},l1=async(t,e,n,{accountId:s=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,accountId:s})})).json(),c1=async(t,e,{accountId:n=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,accountId:n})})).json();var At=P.bind(T),d1=({currentStep:t,steps:e})=>At` +`;var JU=P.bind(T);var ZU=Object.values(An).flat().map(t=>t.key).filter((t,e,n)=>n.indexOf(t)===e);var nK=P.bind(T);var Ki=({to:t})=>{let[,e]=ca();return L(()=>{e(t)},[t,e]),null};var Gi=(t,e)=>{let n=String(e||"").trim();n&&t.set("accountId",n)},a1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/bot${n}`)).json()},l1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/workspace${n}`)).json()},c1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/workspace/reset${n}`,{method:"POST"})).json()},d1=async(t,{accountId:e=""}={})=>(await X("/api/telegram/groups/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groupId:t,accountId:e})})).json(),_c=async(t,{accountId:e=""}={})=>{let n=new URLSearchParams;Gi(n,e);let s=n.toString()?`?${n.toString()}`:"";return(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics${s}`)).json()},Ac=async(t,e,{accountId:n=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/bulk`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topics:e,accountId:n})})).json(),Mc=async(t,e,{accountId:n=""}={})=>{let s=new URLSearchParams;Gi(s,n);let o=s.toString()?`?${s.toString()}`:"";return(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${e}${o}`,{method:"DELETE"})).json()},u1=async(t,e,n,{accountId:s=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,accountId:s})})).json(),p1=async(t,e,{accountId:n=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,accountId:n})})).json();var Mt=P.bind(T),f1=({currentStep:t,steps:e})=>Mt`
            - ${e.map((n,s)=>At` + ${e.map((n,s)=>Mt`
            `)}
            -`,u1=({accountId:t,botInfo:e,setBotInfo:n,onNext:s})=>{let[o,r]=y(!1),[i,a]=y(null),l=async()=>{r(!0),a(null);try{let c=await o1({accountId:t});if(!c.ok)throw new Error(c.error);n(c.bot)}catch(c){a(c.message)}r(!1)};return L(()=>{e||l()},[]),At` +`,h1=({accountId:t,botInfo:e,setBotInfo:n,onNext:s})=>{let[o,r]=y(!1),[i,a]=y(null),l=async()=>{r(!0),a(null);try{let c=await a1({accountId:t});if(!c.ok)throw new Error(c.error);n(c.bot)}catch(c){a(c.message)}r(!1)};return L(()=>{e||l()},[]),Mt`

            Verify Bot Setup

            - ${e&&At` + ${e&&Mt`
            @${e.username} - <${ae} tone="success">Connected + <${le} tone="success">Connected

            ${e.first_name}

            `} - ${i&&At` + ${i&&Mt`

            ${i}

            `} - ${!e&&!o&&!i&&At`

            Checking bot token...

            `} + ${!e&&!o&&!i&&Mt`

            Checking bot token...

            `}

            @@ -8168,7 +8244,7 @@ ${t}

            - `},p1=({onNext:t,onBack:e})=>At` + `},m1=({onNext:t,onBack:e})=>Mt`

            Create a Telegram Group

            @@ -8236,7 +8312,7 @@ ${t}
            -`,f1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],x=async()=>{let $=u.trim();if($){g(!0),l(null);try{let S=await a1($,{accountId:t});if(!S.ok)throw new Error(S.error);n($),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),w=async()=>{if(!(!v||m)){l(null),h(!0);try{let $=String(r||"").trim(),S=await c1(e,{...$?{userId:$}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch($){l($.message)}h(!1)}};return At` +`,g1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],x=async()=>{let $=u.trim();if($){g(!0),l(null);try{let S=await d1($,{accountId:t});if(!S.ok)throw new Error(S.error);n($),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),w=async()=>{if(!(!v||m)){l(null),h(!0);try{let $=String(r||"").trim(),S=await p1(e,{...$?{userId:$}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch($){l($.message)}h(!1)}};return Mt`

            Verify Group

            @@ -8264,7 +8340,7 @@ ${t} - <${Z} + <${Y} onClick=${x} disabled=${!u.trim()||f} loading=${f} @@ -8276,16 +8352,16 @@ ${t}
            - ${a&&At` + ${a&&Mt`

            ${a}

            `} - ${s&&At` + ${s&&Mt`
            ${s.chat.title} - <${ae} tone="success">Verified + <${le} tone="success">Verified
            Topics: ${s.chat.isForum?"ON":"OFF"} @@ -8293,7 +8369,7 @@ ${t}
            `} - ${s&&b.length===0&&At` + ${s&&b.length===0&&Mt`

            Your Telegram User ID

            `} - ${b.length>0&&At` + ${b.length>0&&Mt`
            @@ -8316,7 +8392,7 @@ ${t}
              - ${b.map($=>At`
            • ${$}
            • `)} + ${b.map($=>Mt`
            • ${$}
            • `)}

            Once fixed, hit Verify again.

            @@ -8338,11 +8414,11 @@ ${t}
            - `},h1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,b]=y(null),x=async()=>{let S=await _c(e,{accountId:t});S.ok&&s(S.topics)};L(()=>{x()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),f(null);try{let _=await Mc(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(A=>!A.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await x(),N(`Created topic: ${S}`,"success")}catch(_){f(_.message)}u(!1)}},w=async(S,C)=>{m(S);try{let _=await Ac(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await x(),_.removedFromRegistryOnly?N(`Removed stale topic from registry: ${C}`,"success"):N(`Deleted topic: ${C}`,"success")}catch(_){N(`Failed to delete: ${_.message}`,"error")}m(null)},$=Object.entries(n||{});return At` + `},b1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,b]=y(null),x=async()=>{let S=await _c(e,{accountId:t});S.ok&&s(S.topics)};L(()=>{x()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),f(null);try{let _=await Ac(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(A=>!A.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await x(),I(`Created topic: ${S}`,"success")}catch(_){f(_.message)}u(!1)}},w=async(S,C)=>{m(S);try{let _=await Mc(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await x(),_.removedFromRegistryOnly?I(`Removed stale topic from registry: ${C}`,"success"):I(`Deleted topic: ${C}`,"success")}catch(_){I(`Failed to delete: ${_.message}`,"error")}m(null)},$=Object.entries(n||{});return Mt`

            Create Topics

            - ${$.length>0&&At` + ${$.length>0&&Mt`
            @@ -8359,7 +8435,7 @@ ${t}
            ${C.name} ${S} @@ -8546,47 +8622,47 @@ ${t}
            0?4:3}> + 0?4:3}>
            k(oe.target.value)} - onKeyDown=${oe=>{oe.key==="Enter"&&le(V),oe.key==="Escape"&&de()}} + onInput=${ie=>k(ie.target.value)} + onKeyDown=${ie=>{ie.key==="Enter"&&re(j),ie.key==="Escape"&&ue()}} class="w-full bg-field border border-border rounded-lg px-2 py-1.5 text-xs text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" />
            - <${Y} + <${Z} onClick=${c} - disabled=${w} + disabled=${$} tone="secondary" size="md" idleLabel=${l} /> - <${Y} + <${Z} onClick=${S} disabled=${!m||h||!!b||!String(x||"").trim()} - loading=${w} + loading=${$} tone="primary" size="md" idleLabel=${i} loadingLabel=${a} />
            - - `};var p3=P.bind(T),yw=({visible:t=!1,card:e=null,onClose:n=()=>{},onComplete:s=()=>{}})=>{let o=async({selectedSession:r,message:i})=>{if(!e?.id)return!1;try{await nm({cardId:e.id,sessionId:r?.sessionId||"",replyChannel:r?.replyChannel||"",replyTo:r?.replyTo||"",prompt:i});try{await pa({cardId:e.id,status:"fixed"}),I("Doctor fix request sent and finding marked fixed","success")}catch(a){I(a.message||"Doctor fix request sent, but could not mark the finding fixed","warning")}return await s(),!0}catch(a){return I(a.message||"Could not send Doctor fix request","error"),!1}};return p3` - <${mc} + + `};var b3=P.bind(T),Sw=({visible:t=!1,card:e=null,onClose:n=()=>{},onComplete:s=()=>{}})=>{let o=async({selectedSession:r,message:i})=>{if(!e?.id)return!1;try{await om({cardId:e.id,sessionId:r?.sessionId||"",replyChannel:r?.replyChannel||"",replyTo:r?.replyTo||"",prompt:i});try{await ma({cardId:e.id,status:"fixed"}),I("Doctor fix request sent and finding marked fixed","success")}catch(a){I(a.message||"Doctor fix request sent, but could not mark the finding fixed","warning")}return await s(),!0}catch(a){return I(a.message||"Could not send Doctor fix request","error"),!1}};return b3` + <${yc} visible=${t} title="Ask agent to fix" messageLabel="Instructions" @@ -5734,7 +5741,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onClose=${n} onSubmit=${o} /> - `};var wt=P.bind(T),cf=15e3,vw=2e3,f3=()=>wt` + `};var St=P.bind(T),uf=15e3,Cw=2e3,x3=()=>St` {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s d="M8 20V14H16V20H19V4H5V20H8ZM10 20H14V16H10V20ZM21 20H23V22H1V20H3V3C3 2.44772 3.44772 2 4 2H20C20.5523 2 21 2.44772 21 3V20ZM11 8V6H13V8H15V10H13V12H11V10H9V8H11Z" > -`,$w=({isActive:t=!1,onOpenFile:e=()=>{}})=>{let n=Ee(ua,cf,{enabled:t}),s=n.data?.status||null,o=s?.runInProgress?vw:cf,r=Ee(()=>em(10),o,{enabled:t}),[i,a]=y("all"),[l,c]=y("open"),[d,u]=y(0),[p,f]=y(null),[g,m]=y(""),h=r.data?.runs||[],b=String(s?.activeRunId||""),x=String(i||""),w=x!==""&&x!=="all"&&!h.some(J=>String(J.id||"")===x)&&(g===x||s?.runInProgress&&b===x)?{id:Number(x||0),status:"running",summary:"",priorityCounts:{P0:0,P1:0,P2:0},statusCounts:{open:0,dismissed:0,fixed:0}}:null,$=w?[w,...h]:h,S=i!=="all"&&!!b&&String(i||"")===b,C=i==="all"?null:$.find(J=>String(J.id||"")===String(i||""))||null,_=Ee(()=>tm({runId:i||"all"}),s?.runInProgress||C?.status==="running"?vw:cf,{enabled:t}),k=_.data?.cards||[];L(()=>{t&&(n.refresh(),r.refresh())},[t]),L(()=>{if(!h.length){if(g&&x===g||S&&s?.runInProgress)return;i!=="all"&&a("all");return}i==="all"||h.some(ie=>String(ie.id||"")===String(i||""))||S&&s?.runInProgress||a("all")},[h,x,i,S,g,s?.runInProgress]),L(()=>{if(!g)return;if(i!==g){a(g);return}let J=h.some(de=>String(de.id||"")===String(g||"")),ie=!!b&&b===g&&!!s?.runInProgress;!J&&!ie||m("")},[b,s?.runInProgress,g,h,i]),L(()=>{_.refresh()},[i]);let A=C?.status==="running"||S&&s?.runInProgress,D=F(()=>A?"":C?.summary||"",[C,A]),N=F(()=>hw(),[]),U=F(()=>uw(s?.changeSummary||null),[s]),O=F(()=>s?.runInProgress||s?.needsInitialRun?!0:Number(s?.changeSummary?.changedFilesCount||0)>0,[s]),M=O?"":"No workspace changes since the last completed Drift Doctor run.",R=F(()=>fc(s,0),[s]),B=F(()=>cw(s),[s]),E=F(()=>dw(s),[s]),G=F(()=>hc(s),[s]),H=!!s?.lastRunAt,z=h.length>0,te=r.data!==null||r.error!==null,K=_.data!==null||_.error!==null,V=!te||z&&!K,Z=F(()=>l==="all"?k:k.filter(J=>String(J?.status||"open").trim().toLowerCase()===l),[k,l]),ue=F(()=>k.filter(J=>String(J?.status||"open").trim().toLowerCase()==="open"),[k]),re=F(()=>$.slice(0,2),[$]),oe=F(()=>$.slice(2),[$]),ne=F(()=>i==="all"?"":oe.some(J=>String(J.id||"")===String(i||""))?String(i||""):"",[oe,i]),me=(J=!1)=>["inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",J?"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]":"border-border bg-field text-body hover:border-fg-muted hover:text-bright"].join(" "),pe=(J="neutral")=>J==="success"?"bg-green-400":J==="warning"?"bg-yellow-400":J==="danger"?"bg-red-400":J==="cyan"?"ac-status-dot ac-status-dot--info":"bg-gray-500",ce=!V&&(h.length>0||!!g||!!b||!!s?.runInProgress),ge=async()=>{try{let J=await Qh();if(I(J?.reusedPreviousRun?"No workspace changes since the last scan; reused previous findings":"Doctor run started","success"),J?.runId){let ie=String(J.runId);m(ie),a(ie)}n.refresh(),r.refresh(),_.refresh(),setTimeout(n.refresh,1200),setTimeout(r.refresh,1200),setTimeout(_.refresh,1200)}catch(J){I(J.message||"Could not start Doctor run","error")}},j=async(J,ie)=>{if(!(!J?.id||d))try{u(J.id),await pa({cardId:J.id,status:ie}),I("Doctor card updated","success"),await _.refresh(),await r.refresh(),await n.refresh()}catch(de){I(de.message||"Could not update Doctor card","error")}finally{u(0)}};return wt` +`,_w=({isActive:t=!1,onOpenFile:e=()=>{}})=>{let n=Re(ha,uf,{enabled:t}),s=n.data?.status||null,o=s?.runInProgress?Cw:uf,r=Re(()=>nm(10),o,{enabled:t}),[i,a]=y("all"),[l,c]=y("open"),[d,u]=y(0),[p,f]=y(null),[g,m]=y(""),h=r.data?.runs||[],b=String(s?.activeRunId||""),x=String(i||""),$=x!==""&&x!=="all"&&!h.some(q=>String(q.id||"")===x)&&(g===x||s?.runInProgress&&b===x)?{id:Number(x||0),status:"running",summary:"",priorityCounts:{P0:0,P1:0,P2:0},statusCounts:{open:0,dismissed:0,fixed:0}}:null,w=$?[$,...h]:h,S=i!=="all"&&!!b&&String(i||"")===b,C=i==="all"?null:w.find(q=>String(q.id||"")===String(i||""))||null,_=Re(()=>sm({runId:i||"all"}),s?.runInProgress||C?.status==="running"?Cw:uf,{enabled:t}),k=_.data?.cards||[];R(()=>{t&&(n.refresh(),r.refresh())},[t]),R(()=>{if(!h.length){if(g&&x===g||S&&s?.runInProgress)return;i!=="all"&&a("all");return}i==="all"||h.some(ae=>String(ae.id||"")===String(i||""))||S&&s?.runInProgress||a("all")},[h,x,i,S,g,s?.runInProgress]),R(()=>{if(!g)return;if(i!==g){a(g);return}let q=h.some(fe=>String(fe.id||"")===String(g||"")),ae=!!b&&b===g&&!!s?.runInProgress;!q&&!ae||m("")},[b,s?.runInProgress,g,h,i]),R(()=>{_.refresh()},[i]);let A=C?.status==="running"||S&&s?.runInProgress,D=W(()=>A?"":C?.summary||"",[C,A]),O=W(()=>yw(),[]),z=W(()=>gw(s?.changeSummary||null),[s]),N=W(()=>s?.runInProgress||s?.needsInitialRun?!0:Number(s?.changeSummary?.changedFilesCount||0)>0,[s]),M=N?"":"No workspace changes since the last completed Drift Doctor run.",L=W(()=>bc(s,0),[s]),B=W(()=>hw(s),[s]),E=W(()=>mw(s),[s]),U=W(()=>xc(s),[s]),F=!!s?.lastRunAt,K=h.length>0,re=r.data!==null||r.error!==null,Y=_.data!==null||_.error!==null,j=!re||K&&!Y,J=W(()=>l==="all"?k:k.filter(q=>String(q?.status||"open").trim().toLowerCase()===l),[k,l]),pe=W(()=>k.filter(q=>String(q?.status||"open").trim().toLowerCase()==="open"),[k]),le=W(()=>w.slice(0,2),[w]),ie=W(()=>w.slice(2),[w]),se=W(()=>i==="all"?"":ie.some(q=>String(q.id||"")===String(i||""))?String(i||""):"",[ie,i]),xe=(q=!1)=>["inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",q?"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]":"border-border bg-field text-body hover:border-fg-muted hover:text-bright"].join(" "),he=(q="neutral")=>q==="success"?"bg-green-400":q==="warning"?"bg-yellow-400":q==="danger"?"bg-red-400":q==="cyan"?"ac-status-dot ac-status-dot--info":"bg-gray-500",ue=!j&&(h.length>0||!!g||!!b||!!s?.runInProgress),be=async()=>{try{let q=await tm();if(I(q?.reusedPreviousRun?"No workspace changes since the last scan; reused previous findings":"Doctor run started","success"),q?.runId){let ae=String(q.runId);m(ae),a(ae)}n.refresh(),r.refresh(),_.refresh(),setTimeout(n.refresh,1200),setTimeout(r.refresh,1200),setTimeout(_.refresh,1200)}catch(q){I(q.message||"Could not start Doctor run","error")}},V=async(q,ae)=>{if(!(!q?.id||d))try{u(q.id),await ma({cardId:q.id,status:ae}),I("Doctor card updated","success"),await _.refresh(),await r.refresh(),await n.refresh()}catch(fe){I(fe.message||"Could not update Doctor card","error")}finally{u(0)}};return St`
            - ${ce?wt` - <${Pe} + ${ue?St` + <${De} title="Drift Doctor" - actions=${wt` - <${Y} - onClick=${ge} - disabled=${!O} + actions=${St` + <${Z} + onClick=${be} + disabled=${!N} loading=${!!s?.runInProgress} idleLabel="Run Drift Doctor" loadingLabel="Running..." @@ -5762,33 +5769,33 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `} /> `:null} - ${V?wt` + ${j?St`
            - <${Ce} className="h-4 w-4" /> + <${Ae} className="h-4 w-4" /> Loading Drift Doctor...
            `:null} - ${!V&&z?wt` + ${!j&&K?St`
            - <${mw} cards=${ue} /> + <${vw} cards=${pe} />
            - ${H?wt` + ${F?St`
            Last run ·${" "} - ${Wo(s?.lastRunAt,{fallback:"Never"})} + ${Ho(s?.lastRunAt,{fallback:"Never"})} - ${U} + ${z}
            - ${B?wt` + ${B?St`
            @@ -5796,27 +5803,27 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ⚠️ ${E}
            - ${G.map(J=>wt` + ${U.map(q=>St`
            - ${J.size} + ${q.size} - ${J.statusText} + ${q.statusText}
            @@ -5830,7 +5837,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null} `:null} - ${R?wt` + ${L?St`
            @@ -5842,7 +5849,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null} - ${ce?wt` + ${ue?St`

            Findings

            @@ -5850,46 +5857,46 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            - ${re.map(J=>{let ie=String(i||"")===String(J.id||""),de=fw(J);return wt` + ${le.map(q=>{let ae=String(i||"")===String(q.id||""),fe=xw(q);return St` `})} - ${oe.length?wt` + ${ie.length?St`
            - ${D?wt` + ${D?St`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s

            `:null} - ${A?wt` + ${A?St`
            - <${Ce} className="h-3.5 w-3.5" /> + <${Ae} className="h-3.5 w-3.5" /> Run in progress. Findings will appear when analysis completes. @@ -5931,11 +5938,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null}
            - <${bw} - cards=${Z} + <${ww} + cards=${J} busyCardId=${d} onAskAgentFix=${f} - onUpdateStatus=${j} + onUpdateStatus=${V} onOpenFile=${e} changedPaths=${s?.changeSummary?.changedPaths||[]} showRunMeta=${i==="all"} @@ -5944,12 +5951,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null} - ${!V&&!ce?wt` + ${!j&&!ue?St`
            - <${f3} /> + <${x3} />

            Workspace health review @@ -5961,9 +5968,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s

            - <${Y} - onClick=${ge} - disabled=${!O} + <${Z} + onClick=${be} + disabled=${!N} loading=${!!s?.runInProgress} size="lg" idleLabel="Run Drift Doctor" @@ -5978,21 +5985,21 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null} - <${yw} + <${Sw} visible=${!!p} card=${p} onClose=${()=>f(null)} onComplete=${async()=>{await n.refresh(),await r.refresh(),await _.refresh()}} />
            - `};var h3=P.bind(T),df=({onNavigateToBrowseFile:t=()=>{}})=>h3` + `};var y3=P.bind(T),pf=({onNavigateToBrowseFile:t=()=>{}})=>y3`
            - <${$w} + <${_w} isActive=${!0} onOpenFile=${(e,n={})=>{let s=`workspace/${String(e||"").trim().replace(/^workspace\//,"")}`;t(s,{view:"edit",...n.line?{line:n.line}:{},...n.lineEnd?{lineEnd:n.lineEnd}:{}})}} />
            -`;var m3=P.bind(T),Un=({header:t,children:e})=>m3` +`;var v3=P.bind(T),Un=({header:t,children:e})=>v3`
            @@ -6005,29 +6012,29 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            -`;var Ue=P.bind(T),ww={ai:"AI Provider Keys",github:"GitHub",channels:"Channels",tools:"Tools",custom:"Custom"},g3=["ai","github","channels","tools","custom"],b3=new Set(["OPENAI_API_KEY","GEMINI_API_KEY"]),Cw={Embeddings:{Icon:_a,label:"Memory embeddings"},Image:{Icon:sb,label:"Image generation"},TTS:{Icon:ob,label:"Text to speech"},STT:{Icon:rb,label:"Speech to text"}},kw=t=>t.trim().toUpperCase().replace(/[^A-Z0-9_]/g,"_"),x3=/^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/,y3=t=>{let e=String(t||"").trim();if(e.length<2)return e;let n=e.startsWith('"'),s=e.endsWith('"');if(n&&s)return e.slice(1,-1);let o=e.startsWith("'"),r=e.endsWith("'");return o&&r?e.slice(1,-1):e},gc=(t="")=>x3.test(String(t||"").trim().toUpperCase()),Sw=t=>JSON.stringify((t||[]).map(e=>({key:String(e?.key||""),value:String(e?.value||"")})).sort((e,n)=>e.key.localeCompare(n.key))),v3=t=>{let e=Array.isArray(t)?[...t]:[],n=e.filter(o=>(o?.group||"custom")==="custom").sort((o,r)=>String(o?.key||"").localeCompare(String(r?.key||""))),s=0;return e.map(o=>{if((o?.group||"custom")!=="custom")return o;let r=n[s];return s+=1,r})},$3={ANTHROPIC_API_KEY:Ue`from${" "} +`;var Ge=P.bind(T),Aw={ai:"AI Provider Keys",github:"GitHub",channels:"Channels",tools:"Tools",custom:"Custom"},$3=["ai","github","channels","tools","custom"],w3=new Set(["OPENAI_API_KEY","GEMINI_API_KEY"]),Pw={Embeddings:{Icon:Pa,label:"Memory embeddings"},Image:{Icon:rb,label:"Image generation"},TTS:{Icon:ib,label:"Text to speech"},STT:{Icon:ab,label:"Speech to text"}},Mw=t=>t.trim().toUpperCase().replace(/[^A-Z0-9_]/g,"_"),k3=/^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/,S3=t=>{let e=String(t||"").trim();if(e.length<2)return e;let n=e.startsWith('"'),s=e.endsWith('"');if(n&&s)return e.slice(1,-1);let o=e.startsWith("'"),r=e.endsWith("'");return o&&r?e.slice(1,-1):e},vc=(t="")=>k3.test(String(t||"").trim().toUpperCase()),Tw=t=>JSON.stringify((t||[]).map(e=>({key:String(e?.key||""),value:String(e?.value||"")})).sort((e,n)=>e.key.localeCompare(n.key))),C3=t=>{let e=Array.isArray(t)?[...t]:[],n=e.filter(o=>(o?.group||"custom")==="custom").sort((o,r)=>String(o?.key||"").localeCompare(String(r?.key||""))),s=0;return e.map(o=>{if((o?.group||"custom")!=="custom")return o;let r=n[s];return s+=1,r})},_3={ANTHROPIC_API_KEY:Ge`from${" "} console.anthropic.com`,ANTHROPIC_TOKEN:Ue`from - claude setup-token`,OPENAI_API_KEY:Ue`from${" "} + >`,ANTHROPIC_TOKEN:Ge`from + claude setup-token`,OPENAI_API_KEY:Ge`from${" "} platform.openai.com`,GEMINI_API_KEY:Ue`from${" "} + >`,GEMINI_API_KEY:Ge`from${" "} aistudio.google.com`,ELEVENLABS_API_KEY:Ue`from${" "} + >`,ELEVENLABS_API_KEY:Ge`from${" "} {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >elevenlabs.io${" "} · ${" "} XI_API_KEY also - supported`,GITHUB_WORKSPACE_REPO:Ue`use + supported`,GITHUB_WORKSPACE_REPO:Ge`use owner/repo or https://github.com/owner/repo`,TELEGRAM_BOT_TOKEN:Ue`from${" "} + >`,TELEGRAM_BOT_TOKEN:Ge`from${" "} {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s class="hover:underline" style="color: var(--accent-link)" >full guide`,DISCORD_BOT_TOKEN:Ue`from${" "} + >`,DISCORD_BOT_TOKEN:Ge`from${" "} {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s class="hover:underline" style="color: var(--accent-link)" >full guide`,MISTRAL_API_KEY:Ue`from${" "} + >`,MISTRAL_API_KEY:Ge`from${" "} console.mistral.ai`,VOYAGE_API_KEY:Ue`from${" "} + >`,VOYAGE_API_KEY:Ge`from${" "} dash.voyageai.com`,GROQ_API_KEY:Ue`from${" "} + >`,GROQ_API_KEY:Ge`from${" "} console.groq.com`,DEEPGRAM_API_KEY:Ue`from${" "} + >`,DEEPGRAM_API_KEY:Ge`from${" "} console.deepgram.com`,BRAVE_API_KEY:Ue`from${" "} + >`,BRAVE_API_KEY:Ge`from${" "} brave.com/search/api${" "} — free tier available`},w3=t=>$3[t.key]||t.hint||"",k3=t=>(Array.isArray(t?.features)?t.features:[]).filter(e=>!!Cw[e]),S3=t=>{let e=[],n=[];return(t||[]).forEach(s=>{let o=!!String(s?.value||"").trim();if(b3.has(s?.key)||o){e.push(s);return}n.push(s)}),{visible:e,hidden:n}},C3=({feature:t})=>{let e=Cw[t];if(!e)return null;let{Icon:n,label:s}=e;return Ue` - <${yt} text=${s} widthClass="w-auto" tooltipClassName="whitespace-nowrap"> + >${" "} — free tier available`},A3=t=>_3[t.key]||t.hint||"",M3=t=>(Array.isArray(t?.features)?t.features:[]).filter(e=>!!Pw[e]),T3=t=>{let e=[],n=[];return(t||[]).forEach(s=>{let o=!!String(s?.value||"").trim();if(w3.has(s?.key)||o){e.push(s);return}n.push(s)}),{visible:e,hidden:n}},P3=({feature:t})=>{let e=Pw[t];if(!e)return null;let{Icon:n,label:s}=e;return Ge` + <${$t} text=${s} widthClass="w-auto" tooltipClassName="whitespace-nowrap"> {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s > <${n} className="w-3.5 h-3.5" /> - - `},_3=({envVar:t,onChange:e,onDelete:n,disabled:s})=>{let o=w3(t),r=k3(t);return Ue` + + `},R3=({envVar:t,onChange:e,onDelete:n,disabled:s})=>{let o=A3(t),r=M3(t);return Ge`
            @@ -6124,15 +6131,15 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s /> ${t.key}
            - ${r.length>0?Ue` + ${r.length>0?Ge`
            - ${r.map(i=>Ue`<${C3} key=${i} feature=${i} />`)} + ${r.map(i=>Ge`<${P3} key=${i} feature=${i} />`)}
            `:null}
            - <${Rt} + <${Et} value=${t.value} onInput=${i=>e(t.key,i.target.value)} placeholder=${t.value?"":"not set"} @@ -6140,7 +6147,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s inputClass="flex-1 min-w-0 bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono" disabled=${s} /> - ${t.group==="custom"?Ue``:null}
            - ${o?Ue`

            ${o}

            `:null} + ${o?Ge`

            ${o}

            `:null}
            - `},_w=({onRestartRequired:t=()=>{}})=>{let[e,n]=y([]),[s,o]=y(()=>new Set),[r,i]=y([]),[a,l]=y(0),[c,d]=y(!1),[u,p]=y(!1),[f,g]=y(!1),[m,h]=y(""),b=ee("[]"),{data:x,error:v,loading:w,refresh:$}=ct("/api/env",Vr,{maxAgeMs:3e4}),S=q(V=>{if(!V)return;let Z=v3(V.vars||[]);b.current=Sw(Z),n(Z),i([]),o(new Set(V.reservedKeys||[])),V.restartRequired&&t(!0)},[t]),C=q(async()=>{try{let V=await $({force:!0});S(V)}catch(V){console.error("Failed to load env vars:",V)}},[S,$]);L(()=>{x&&(c||u||S(x))},[S,c,x,u]),L(()=>{v&&console.error("Failed to load env vars:",v)},[v]),L(()=>{d(Sw(e)!==b.current)},[e]);let _=(V,Z)=>{n(ue=>ue.map(re=>re.key===V?{...re,value:Z}:re))},k=V=>{n(Z=>Z.filter(ue=>ue.key!==V)),i(Z=>Z.filter(ue=>ue!==V))},A=async()=>{if(!u){p(!0);try{let V=e.filter(oe=>oe.editable&&!gc(oe?.key)).map(oe=>({key:oe.key,value:oe.value})),ue=!!(await dd(V))?.restartRequired;ue&&t(!0),I(ue?"Environment variables saved. Restart gateway to apply.":"Environment variables saved","success");let re=await $({force:!0});S(re),l(oe=>oe+1),d(!1)}catch(V){I("Failed to save: "+V.message,"error")}finally{p(!1)}}},[D,N]=y(""),U=V=>{let Z=V.split(` -`).map(re=>re.trim()).filter(Boolean).filter(re=>!re.startsWith("#")),ue=[];for(let re of Z){let oe=re.indexOf("=");oe>0&&ue.push({key:re.slice(0,oe).trim(),value:y3(re.slice(oe+1))})}return ue},O=V=>{let Z=0,ue=[],re=[],oe=[];return n(ne=>{let me=[...ne];for(let{key:pe,value:ce}of V){let ge=kw(pe);if(!ge)continue;if(gc(ge)){re.push(ge);continue}if(s.has(ge)){ue.push(ge);continue}let j=me.find(J=>J.key===ge);j?j.value=ce:(me.push({key:ge,value:ce,label:ge,group:"custom",hint:"",source:"env_file",editable:!0}),oe.push(ge)),Z++}return me}),oe.length&&i(ne=>[...ne,...oe]),{added:Z,blocked:ue,managedChannelKeys:re}},M=(V,Z)=>{let ue=(V.clipboardData||window.clipboardData).getData("text"),re=U(ue);if(re.length>1){V.preventDefault();let{added:oe,blocked:ne,managedChannelKeys:me}=O(re);if(h(""),N(""),ne.length){let pe=Array.from(new Set(ne));I(`Reserved vars can't be added: ${pe.join(", ")}`,"error")}if(me.length){let pe=Array.from(new Set(me));I(`Channel tokens are managed from Channels: ${pe.join(", ")}`,"error")}oe&&I(`Added ${oe} variable${oe!==1?"s":""}`,"success");return}if(re.length===1){V.preventDefault(),h(re[0].key),N(re[0].value);return}},R=V=>{let Z=U(V);if(Z.length===1){h(Z[0].key),N(Z[0].value);return}h(V)},B=V=>{let Z=U(V);if(Z.length===1){h(Z[0].key),N(Z[0].value);return}N(V)},E=()=>{let V=kw(m);if(V){if(gc(V)){I(`Channel tokens are managed from Channels: ${V}`,"error");return}if(s.has(V)){I(`Reserved var can't be added: ${V}`,"error");return}O([{key:V,value:D}]),h(""),N("")}},G=e.filter(V=>!gc(V?.key)),H={};for(let V of G){let Z=V.group||"custom";H[Z]||(H[Z]=[]),H[Z].push(V)}if(H.custom?.length){let V=new Set(r),Z=H.custom.filter(re=>!V.has(re.key)).sort((re,oe)=>String(re?.key||"").localeCompare(String(oe?.key||""))),ue=H.custom.filter(re=>V.has(re.key));H.custom=[...Z,...ue]}let z=S3(H.ai||[]),te=V=>V.map(Z=>Ue`<${_3} - key=${`${a}:${Z.key}`} - envVar=${Z} + `},Rw=({onRestartRequired:t=()=>{}})=>{let[e,n]=y([]),[s,o]=y(()=>new Set),[r,i]=y([]),[a,l]=y(0),[c,d]=y(!1),[u,p]=y(!1),[f,g]=y(!1),[m,h]=y(""),b=te("[]"),{data:x,error:v,loading:$,refresh:w}=pt("/api/env",Ur,{maxAgeMs:3e4}),S=G(j=>{if(!j)return;let J=C3(j.vars||[]);b.current=Tw(J),n(J),i([]),o(new Set(j.reservedKeys||[])),j.restartRequired&&t(!0)},[t]),C=G(async()=>{try{let j=await w({force:!0});S(j)}catch(j){console.error("Failed to load env vars:",j)}},[S,w]);R(()=>{x&&(c||u||S(x))},[S,c,x,u]),R(()=>{v&&console.error("Failed to load env vars:",v)},[v]),R(()=>{d(Tw(e)!==b.current)},[e]);let _=(j,J)=>{n(pe=>pe.map(le=>le.key===j?{...le,value:J}:le))},k=j=>{n(J=>J.filter(pe=>pe.key!==j)),i(J=>J.filter(pe=>pe!==j))},A=async()=>{if(!u){p(!0);try{let j=e.filter(ie=>ie.editable&&!vc(ie?.key)).map(ie=>({key:ie.key,value:ie.value})),pe=!!(await ud(j))?.restartRequired;pe&&t(!0),I(pe?"Environment variables saved. Restart gateway to apply.":"Environment variables saved","success");let le=await w({force:!0});S(le),l(ie=>ie+1),d(!1)}catch(j){I("Failed to save: "+j.message,"error")}finally{p(!1)}}},[D,O]=y(""),z=j=>{let J=j.split(` +`).map(le=>le.trim()).filter(Boolean).filter(le=>!le.startsWith("#")),pe=[];for(let le of J){let ie=le.indexOf("=");ie>0&&pe.push({key:le.slice(0,ie).trim(),value:S3(le.slice(ie+1))})}return pe},N=j=>{let J=0,pe=[],le=[],ie=[];return n(se=>{let xe=[...se];for(let{key:he,value:ue}of j){let be=Mw(he);if(!be)continue;if(vc(be)){le.push(be);continue}if(s.has(be)){pe.push(be);continue}let V=xe.find(q=>q.key===be);V?V.value=ue:(xe.push({key:be,value:ue,label:be,group:"custom",hint:"",source:"env_file",editable:!0}),ie.push(be)),J++}return xe}),ie.length&&i(se=>[...se,...ie]),{added:J,blocked:pe,managedChannelKeys:le}},M=(j,J)=>{let pe=(j.clipboardData||window.clipboardData).getData("text"),le=z(pe);if(le.length>1){j.preventDefault();let{added:ie,blocked:se,managedChannelKeys:xe}=N(le);if(h(""),O(""),se.length){let he=Array.from(new Set(se));I(`Reserved vars can't be added: ${he.join(", ")}`,"error")}if(xe.length){let he=Array.from(new Set(xe));I(`Channel tokens are managed from Channels: ${he.join(", ")}`,"error")}ie&&I(`Added ${ie} variable${ie!==1?"s":""}`,"success");return}if(le.length===1){j.preventDefault(),h(le[0].key),O(le[0].value);return}},L=j=>{let J=z(j);if(J.length===1){h(J[0].key),O(J[0].value);return}h(j)},B=j=>{let J=z(j);if(J.length===1){h(J[0].key),O(J[0].value);return}O(j)},E=()=>{let j=Mw(m);if(j){if(vc(j)){I(`Channel tokens are managed from Channels: ${j}`,"error");return}if(s.has(j)){I(`Reserved var can't be added: ${j}`,"error");return}N([{key:j,value:D}]),h(""),O("")}},U=e.filter(j=>!vc(j?.key)),F={};for(let j of U){let J=j.group||"custom";F[J]||(F[J]=[]),F[J].push(j)}if(F.custom?.length){let j=new Set(r),J=F.custom.filter(le=>!j.has(le.key)).sort((le,ie)=>String(le?.key||"").localeCompare(String(ie?.key||""))),pe=F.custom.filter(le=>j.has(le.key));F.custom=[...J,...pe]}let K=T3(F.ai||[]),re=j=>j.map(J=>Ge`<${R3} + key=${`${a}:${J.key}`} + envVar=${J} onChange=${_} onDelete=${k} disabled=${u} - />`),K=V=>{let Z=H[V]||[];if(!Z.length)return null;if(V==="ai"){let{visible:ue,hidden:re}=z,oe=f&&re.length>0;return Ue` + />`),Y=j=>{let J=F[j]||[];if(!J.length)return null;if(j==="ai"){let{visible:pe,hidden:le}=K,ie=f&&le.length>0;return Ge`

            - ${ww[V]||V} + ${Aw[j]||j}

            -
            ${te(ue)}
            - ${re.length>0?Ue` +
            ${re(pe)}
            + ${le.length>0?Ge`
            `:null} - ${oe?Ue`
            - ${te(re)} + ${ie?Ge`
            + ${re(le)}
            `:null}
            - `}return Ue` + `}return Ge`

            - ${ww[V]||V} + ${Aw[j]||j}

            -
            ${te(Z)}
            +
            ${re(J)}
            - `};return w&&!e.length?Ue` + `};return $&&!e.length?Ge` <${Un} - header=${Ue`<${Pe} title="Envars" />`} + header=${Ge`<${De} title="Envars" />`} >
            Loading environment variables...
            - `:Ue` + `:Ge` <${Un} - header=${Ue` - <${Pe} + header=${Ge` + <${De} title="Envars" - actions=${Ue` - <${es} visible=${c}> - <${Y} + actions=${Ge` + <${ts} visible=${c}> + <${Z} onClick=${C} disabled=${u} tone="secondary" @@ -6212,7 +6219,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s idleLabel="Cancel" className="text-xs" /> - <${Y} + <${Z} onClick=${A} disabled=${u} loading=${u} @@ -6223,12 +6230,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s loadingLabel="Saving…" className="text-xs" /> - + `} /> `} > - ${g3.filter(V=>H[V]?.length).map(V=>K(V))} + ${$3.filter(j=>F[j]?.length).map(j=>Y(j))}
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s type="text" value=${m} placeholder="KEY" - onInput=${V=>R(V.target.value)} - onPaste=${V=>M(V,"key")} - onKeyDown=${V=>V.key==="Enter"&&E()} + onInput=${j=>L(j.target.value)} + onPaste=${j=>M(j,"key")} + onKeyDown=${j=>j.key==="Enter"&&E()} class="w-full bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono uppercase" />
            @@ -6258,9 +6265,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s type="text" value=${D} placeholder="value" - onInput=${V=>B(V.target.value)} - onPaste=${V=>M(V,"val")} - onKeyDown=${V=>V.key==="Enter"&&E()} + onInput=${j=>B(j.target.value)} + onPaste=${j=>M(j,"val")} + onKeyDown=${j=>j.key==="Enter"&&E()} class="flex-1 bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono" />
            - `};var A3=P.bind(T),uf=({onRestartRequired:t=()=>{}})=>A3` - <${_w} onRestartRequired=${t} /> -`;var en=P.bind(T),M3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},T3=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",D=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(E=!1)=>{try{let G=await n(E);if(!R)return;c(G.currentVersion||e||null),u(G.latestVersion||null),f(!!G.hasUpdate),m(G.ok?"":G.error||"")}catch(G){if(!R)return;m(G.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let E=await n(!0);if(!R)return;c(E.currentVersion||e||null),u(E.latestVersion||null),f(!!E.hasUpdate),m(E.ok?"":E.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let N=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),m("");try{let E=R?await s():await n(!0);c(E.currentVersion||l),u(E.latestVersion||null),f(!!E.hasUpdate),m(E.ok?"":E.error||""),R?E.ok?E.updated||E.restarting?I(E.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${E.currentVersion}`,"success"):I(`Already at latest ${t} version`,"success"):I(E.error||`${t} update failed`,"error"):E.hasUpdate&&E.latestVersion?I(`${t} update available: ${E.latestVersion}`,"warning"):I(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!E?.ok,result:E})}catch(E){m(E.message||(R?`Could not update ${t}`:"Could not check updates")),I(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:E})}finally{a(!1)}}},U=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}N()}},O=()=>{v(!1),N()},M=_?i||o:i;return en` + `};var L3=P.bind(T),ff=({onRestartRequired:t=()=>{}})=>L3` + <${Rw} onRestartRequired=${t} /> +`;var en=P.bind(T),E3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},I3=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),$=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),w=(()=>{if(!$)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=$||p,C=w||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",D=S&&C;R(()=>{c(e||null)},[e]),R(()=>{let L=!0;return(async(E=!1)=>{try{let U=await n(E);if(!L)return;c(U.currentVersion||e||null),u(U.latestVersion||null),f(!!U.hasUpdate),m(U.ok?"":U.error||"")}catch(U){if(!L)return;m(U.message||"Could not check updates")}})(!1),()=>{L=!1}},[e,n]),R(()=>{if(o)return()=>{};let L=!0,B=setTimeout(async()=>{try{let E=await n(!0);if(!L)return;c(E.currentVersion||e||null),u(E.latestVersion||null),f(!!E.hasUpdate),m(E.ok?"":E.error||"")}catch{}},1200);return()=>{L=!1,clearTimeout(B)}},[o,e,n]),R(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let O=async()=>{let L=!!S;if(!(_?i||o:i)){a(!0),m("");try{let E=L?await s():await n(!0);c(E.currentVersion||l),u(E.latestVersion||null),f(!!E.hasUpdate),m(E.ok?"":E.error||""),L?E.ok?E.updated||E.restarting?I(E.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${E.currentVersion}`,"success"):I(`Already at latest ${t} version`,"success"):I(E.error||`${t} update failed`,"error"):E.hasUpdate&&E.latestVersion?I(`${t} update available: ${E.latestVersion}`,"warning"):I(`${t} is up to date`,"success"),await r({type:L?"update":"check",ok:!!E?.ok,result:E})}catch(E){m(E.message||(L?`Could not update ${t}`:"Could not check updates")),I(L?`Could not update ${t}`:"Could not check updates","error"),await r({type:L?"update":"check",ok:!1,error:E})}finally{a(!1)}}},z=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}O()}},N=()=>{v(!1),O()},M=_?i||o:i;return en`

            @@ -6300,7 +6307,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `} ${D?en` <${tn} - onClick=${U} + onClick=${z} loading=${M} warning=${_} idleLabel=${_?k:"Check updates"} @@ -6309,7 +6316,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s /> `:en` <${tn} - onClick=${U} + onClick=${z} loading=${M} warning=${_} idleLabel=${_?k:"Check updates"} @@ -6329,7 +6336,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >View changelog <${tn} - onClick=${U} + onClick=${z} loading=${M} warning=${_} idleLabel=${k} @@ -6338,7 +6345,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />

            `} - <${st} + <${rt} visible=${x} title="Update without changelog?" message="Are you sure you want to update without viewing the changelog?" @@ -6346,9 +6353,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s cancelLabel="Cancel" confirmTone="warning" onCancel=${()=>v(!1)} - onConfirm=${O} + onConfirm=${N} /> - `},bc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ma})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,w=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),en`
            + `},$c=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ya})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,$=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",w=W(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return R(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),en`
            @@ -6361,7 +6368,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            ${!n&&f?en` Uptime: ${M3($)}Uptime: ${E3(w)} `:null} <${tn} @@ -6406,7 +6413,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s loadingLabel="Inspect" className="w-full justify-center" /> - `:w?en` + `:$?en` <${tn} onClick=${i} loading=${x} @@ -6424,16 +6431,16 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            - <${T3} + <${I3} label="OpenClaw" currentVersion=${e} - fetchVersion=${mm} + fetchVersion=${gm} applyUpdate=${d} updateInProgress=${l} onActionComplete=${c} />
            -
            `};var $r=P.bind(T),P3={webchat:"Browser",cli:"CLI"},R3=t=>P3[t.clientMode]||t.clientId||"Device",L3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},E3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=R3(t),a=L3(t);return s==="approve"?$r` +
            `};var $r=P.bind(T),D3={webchat:"Browser",cli:"CLI"},O3=t=>D3[t.clientMode]||t.clientId||"Device",N3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},B3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=O3(t),a=N3(t);return s==="approve"?$r`
            Approved ${i} @@ -6448,14 +6455,14 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${a&&$r`${a}`}
            - <${Y} + <${Z} onClick=${()=>r("approve")} tone="success" size="sm" idleLabel="Approve" className="font-medium px-3 py-1.5" /> - <${Y} + <${Z} onClick=${()=>r("reject")} tone="secondary" size="sm" @@ -6463,12 +6470,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s className="font-medium px-3 py-1.5" />
            -
            `},xc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r` +
            `},wc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r`

            Pending device pairings

            - ${t.map(s=>$r`<${E3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} -
            `;var fo=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],I3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Aw(t){return`https://console.developers.google.com/apis/api/${I3[t]||""}/overview`}function Mw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return fo`
            - ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=fo``:f&&(f.status==="ok"?g=fo`API ✓`:f.status==="not_enabled"?g=fo`Enable API`:f.status==="error"&&(g=fo`Enable API`)),fo` + ${t.map(s=>$r`<${B3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} +
            `;var fo=P.bind(T),Ui=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],F3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Lw(t){return`https://console.developers.google.com/apis/api/${F3[t]||""}/overview`}function Ew({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Ui.length>a,c=o?Ui:Ui.slice(0,a);return fo`
            + ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=fo``:f&&(f.status==="ok"?g=fo`API ✓`:f.status==="not_enabled"?g=fo`Enable API`:f.status==="error"&&(g=fo`Enable API`)),fo`
            ${d.icon} ${d.label}
            @@ -6483,17 +6490,17 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onclick=${()=>r(d=>!d)} class="ml-3 text-xs text-fg-muted hover:text-body" > - ${o?"Show fewer services":`Show more services (${Vi.length-a})`} + ${o?"Show fewer services":`Show more services (${Ui.length-a})`} `:null} -
            `}function Tw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Pw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},D=async R=>{let B=R.target.files[0];if(B)try{let E=await B.text(),G=JSON.parse(E),H=G.installed||G.web||G;H.client_id&&u(H.client_id),H.client_secret&&f(H.client_secret)}catch{b("Invalid JSON file")}},N=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},U=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,O=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr` +
            `}function Iw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Ui)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Dw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[$,w]=y(r),[S,C]=y(!1),_=te(null);if(R(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),w(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},D=async L=>{let B=L.target.files[0];if(B)try{let E=await B.text(),U=JSON.parse(E),F=U.installed||U.web||U;F.client_id&&u(F.client_id),F.client_secret&&f(F.client_secret)}catch{b("Invalid JSON file")}},O=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let L=await Kh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});L.ok?(e(),n?.(L.account)):b(L.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},z=L=>`flex-1 text-center border-0 cursor-pointer transition-colors ${$===L?"":"hover:text-white"}`,N=L=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+($===L?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr`
            R.target.select()} - onclick=${R=>R.target.select()} + onFocus=${L=>L.target.select()} + onclick=${L=>L.target.select()} class="flex-1 min-w-0 bg-field border border-border rounded px-2 py-1 text-body text-xs focus:outline-none focus:border-fg-muted" />
            - `;return kr` <${Te} + `;return kr` <${Ie} visible=${t} onClose=${e} closeOnOverlayClick=${!1} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" > - <${Pe} + <${De} title=${s} actions=${kr` `} /> @@ -6546,21 +6553,21 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s style="border: 1px solid var(--border); border-radius: 6px; background: rgba(255,255,255,0.02)" >
            - ${w==="personal"?kr` + ${$==="personal"?kr`
            1. @@ -6704,9 +6711,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            - <${Rt} + <${Et} value=${d} - onInput=${R=>u(R.target.value)} + onInput=${L=>u(L.target.value)} placeholder="xxxx.apps.googleusercontent.com" inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" /> @@ -6715,9 +6722,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s - <${Rt} + <${Et} value=${p} - onInput=${R=>f(R.target.value)} + onInput=${L=>f(L.target.value)} placeholder="GOCSPX-..." inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" /> @@ -6729,7 +6736,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s m(R.target.value)} + onInput=${L=>m(L.target.value)} placeholder="you@gmail.com" class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" /> @@ -6737,8 +6744,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            - <${Y} - onClick=${N} + <${Z} + onClick=${O} disabled=${x} loading=${x} tone="primary" @@ -6749,14 +6756,14 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
            ${h?kr`
            ${h}
            `:null} - `};var Rw=P.bind(T),D3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Lw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Rw` + `};var Ow=P.bind(T),W3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Nw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Ow`
            Gmail watch requires gmail:read. Add it in permissions above, then update permissions.
            - `;let a=D3({watchStatus:e,busy:n}),l=!!e?.enabled;return Rw` + `;let a=W3({watchStatus:e,busy:n}),l=!!e?.enabled;return Ow`
            {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onClick=${c=>c.stopPropagation()} onKeyDown=${c=>c.stopPropagation()} > - <${le} tone=${a.tone}>${a.label} + <${de} tone=${a.tone}>${a.label} <${vn} checked=${l} disabled=${n} @@ -6785,7 +6792,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
            - `};var ji=P.bind(T),N3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Ew=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!N3(o,r);return ji` + `};var Ki=P.bind(T),H3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Bw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!H3(o,r);return Ki`
            - ${e?ji`<${le} tone="neutral">Personal`:null} - <${le} tone=${t.authenticated?"success":"warning"}> + ${e?Ki`<${de} tone="neutral">Personal`:null} + <${de} tone=${t.authenticated?"success":"warning"}> ${t.authenticated?"Connected":"Awaiting sign-in"} - + ${n?"\u25BE":"\u25B8"}
            - ${n?ji` + ${n?Ki`
            Select permissions - ${t.authenticated?ji``:null}
            - <${Mw} + <${Ew} scopes=${o} - onToggle=${w=>l?.(t.id,w)} + onToggle=${$=>l?.(t.id,$)} apiStatus=${t.authenticated?i:{}} loading=${t.authenticated&&a} /> - ${t.authenticated?ji` + ${t.authenticated?Ki`
            @@ -6835,7 +6842,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s Configure
            - <${Lw} + <${Nw} account=${t} watchStatus=${f} busy=${g} @@ -6875,22 +6882,22 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `:null}
            - `};var pf=P.bind(T),Iw=({visible:t,onClose:e,onSubmit:n,loading:s=!1,defaultEmail:o="",title:r="Add Company Account"})=>{let[i,a]=y(""),[l,c]=y("");if(L(()=>{t&&(a(String(o||"")),c(""))},[t,o]),!t)return null;let d=async()=>{c("");let u=String(i||"").trim();if(!u){c("Email is required");return}await n?.({email:u,setError:c})};return pf`<${Te} + `};var hf=P.bind(T),Fw=({visible:t,onClose:e,onSubmit:n,loading:s=!1,defaultEmail:o="",title:r="Add Company Account"})=>{let[i,a]=y(""),[l,c]=y("");if(R(()=>{t&&(a(String(o||"")),c(""))},[t,o]),!t)return null;let d=async()=>{c("");let u=String(i||"").trim();if(!u){c("Email is required");return}await n?.({email:u,setError:c})};return hf`<${Ie} visible=${t} onClose=${e} closeOnOverlayClick=${!1} panelClassName="bg-modal border border-border rounded-xl p-6 max-w-md w-full space-y-4" > - <${Pe} + <${De} title=${r} - actions=${pf` + actions=${hf` `} /> @@ -6910,10 +6917,10 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted" />
            - ${l?pf`
            ${l}
            `:null} + ${l?hf`
            ${l}
            `:null}
            - <${Y} + <${Z} onClick=${d} disabled=${s} loading=${s} @@ -6924,7 +6931,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s className="w-full px-4 py-2 rounded-lg text-sm" />
            - `};var Dw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Wh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Nw=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Gh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await Jh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Zh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Yh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await qh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),O3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Ow=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],B3=Ow.slice(0,3),yc=Ds,vc=(t="",e=()=>{})=>mt` + `};var Ww=({gatewayStatus:t})=>{let e=te(!1),{data:n,loading:s,refresh:o}=pt("/api/google/accounts",jh,{maxAgeMs:3e4}),r=W(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=G(async()=>o({force:!0}),[o]);return R(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Hw=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=W(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=pt("/api/gmail/config",Jh,{enabled:t==="running",maxAgeMs:3e4}),d=G(async()=>c({force:!0}),[c]);R(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=W(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=W(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s($=>{let w=String(x||"");if(!w)return $;if(v)return{...$,[w]:!0};if(!$[w])return $;let S={...$};return delete S[w],S})},g=G(async(x,{destination:v=null}={})=>{let $=String(x||"");f($,!0);try{let w=await Yh($,{destination:v});return await d(),w}finally{f($,!1)}},[d]),m=G(async x=>{let v=String(x||"");f(v,!0);try{await Xh(v),await d()}finally{f(v,!1)}},[d]),h=G(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Qh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=G(async({client:x="default",projectId:v="",regeneratePushToken:$=!1}={})=>{r(!0);try{let w=await Zh({client:x,projectId:v,regeneratePushToken:$});return await d(),w}catch(w){throw String(w?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):w}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var xt=P.bind(T),V3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Vw=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],j3=Vw.slice(0,3),kc=Os,Sc=(t="",e=()=>{})=>xt`
                 
            -`,Bw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:D,error:N}=yr({enabled:t,filter:pc}),{sessions:U,destinationSessionKey:O,setDestinationSessionKey:M,selectedDestination:R}=Ns({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,E=!!(String(n?.projectId||"").trim()||B),G=u||!E&&!m,H=String(c||"").trim()||String(n?.projectId||"").trim()||"",z=C,te=z?B3:Ow,K=te.length,V=String(e?.client||n?.client||"default").trim()||"default",Z=F(()=>G?String(c||"").trim().length>0:!0,[G,c]),ue=q(async pe=>{if(await O3(pe)){I("Copied to clipboard","success");return}I("Could not copy text","error")},[]),re=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),oe=async()=>{try{g(""),await i({client:V,projectId:String(c||"").trim(),destination:R}),x(!0),l(pe=>Math.min(pe+1,K-1))}catch(pe){g(pe.message||"Could not finish setup")}},ne=async()=>{if(!s){if(G){if(!Z)return;g("");try{await r({client:V,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(pe){g(pe.message||"Could not save project id");return}return}l(pe=>Math.min(pe+1,K-1))}},me=async()=>{if(!(v||$))try{w(!0);let ce=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ce,sessionKey:k}),S(!0),I("Message sent to your agent","success")}catch(pe){I(pe.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` - <${Te} +`,jw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,$]=y(!1),[w,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:D,error:O}=yr({enabled:t,filter:gc}),{sessions:z,destinationSessionKey:N,setDestinationSessionKey:M,selectedDestination:L}=Ns({enabled:t,resetKey:String(e?.id||"")});R(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),$(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,E=!!(String(n?.projectId||"").trim()||B),U=u||!E&&!m,F=String(c||"").trim()||String(n?.projectId||"").trim()||"",K=C,re=K?j3:Vw,Y=re.length,j=String(e?.client||n?.client||"default").trim()||"default",J=W(()=>U?String(c||"").trim().length>0:!0,[U,c]),pe=G(async he=>{if(await V3(he)){I("Copied to clipboard","success");return}I("Could not copy text","error")},[]),le=G(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:j,projectId:String(c||"").trim(),destination:L}),x(!0),l(he=>Math.min(he+1,Y-1))}catch(he){g(he.message||"Could not finish setup")}},se=async()=>{if(!s){if(U){if(!J)return;g("");try{await r({client:j,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,Y-1))}},xe=async()=>{if(!(v||w))try{$(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await ga({message:ue,sessionKey:k}),S(!0),I("Message sent to your agent","success")}catch(he){I(he.message||"Could not send message to agent","error")}finally{$(!1)}};return xt` + <${Ie} visible=${t} onClose=${o} closeOnOverlayClick=${!1} @@ -6955,24 +6962,24 @@ ${t} - <${Ze} className="w-3.5 h-3.5 text-body" /> + <${Qe} className="w-3.5 h-3.5 text-body" />
            Gmail Pub / Sub Setup
            - ${te.map((pe,ce)=>mt` + ${re.map((he,ue)=>xt`
            `)}
            - <${Pe} - title=${`Step ${a+1} of ${K}: ${te[a]}`} + <${De} + title=${`Step ${a+1} of ${Y}: ${re[a]}`} actions=${null} /> - ${f?mt`
            ${f}
            `:null} - ${G?mt` + ${f?xt`
            ${f}
            `:null} + ${U?xt`
            @@ -6993,16 +7000,16 @@ ${t}d(pe.target.value)} + oninput=${he=>d(he.target.value)} class="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none" placeholder="my-gcp-project" />
            `:null} - ${!G&&a===0?mt` + ${!U&&a===0?xt`
            - Using project ${H}. + Using project ${F}.
            If gcloud is not installed on your computer, @@ -7017,36 +7024,36 @@ ${t}
            - ${vc(`gcloud auth login -gcloud config set project ${H}`,()=>ue(`gcloud auth login -gcloud config set project ${H}`))} + ${Sc(`gcloud auth login +gcloud config set project ${F}`,()=>pe(`gcloud auth login +gcloud config set project ${F}`))} `:null} - ${!G&&a===1?vc(B?.enableApis||"",()=>ue(B?.enableApis||"")):null} - ${!G&&a===2?mt` - ${vc(`${B?.createTopic||""} + ${!U&&a===1?Sc(B?.enableApis||"",()=>pe(B?.enableApis||"")):null} + ${!U&&a===2?xt` + ${Sc(`${B?.createTopic||""} -${B?.grantPublisher||""}`.trim(),()=>ue(`${B?.createTopic||""} +${B?.grantPublisher||""}`.trim(),()=>pe(`${B?.createTopic||""} ${B?.grantPublisher||""}`.trim()))} `:null} - ${!z&&!G&&a===3?mt` - ${vc(B?.createSubscription||"",()=>ue(B?.createSubscription||""))} + ${!K&&!U&&a===3?xt` + ${Sc(B?.createSubscription||"",()=>pe(B?.createSubscription||""))}
            <${vr} label="Deliver to" - sessions=${U} - selectedSessionKey=${O} + sessions=${z} + selectedSessionKey=${N} onChangeSessionKey=${M} - disabled=${z||D||s} + disabled=${K||D||s} loading=${D} - error=${N} + error=${O} allowNone=${!0} - noneValue=${yc} + noneValue=${kc} noneLabel="Default" loadingLabel="Loading sessions..." - helperText=${z?"This Gmail webhook has already been created. To edit delivery routing, ask your agent.":null} + helperText=${K?"This Gmail webhook has already been created. To edit delivery routing, ask your agent.":null} selectClassName="w-full bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed" helperClassName="text-xs text-fg-muted" statusClassName="text-[11px] text-fg-muted" @@ -7054,7 +7061,7 @@ ${B?.grantPublisher||""}`.trim()))} />
            `:null} - ${!z&&a===4?mt` + ${!K&&a===4?xt`
            @@ -7070,58 +7077,58 @@ ${B?.grantPublisher||""}`.trim()))}
            - <${Y} - onClick=${me} - disabled=${!k||$} + <${Z} + onClick=${xe} + disabled=${!k||w} loading=${v} - idleLabel=${$?"Sent":"Send to Agent"} + idleLabel=${w?"Sent":"Send to Agent"} loadingLabel="Sending..." tone="primary" size="sm" className="h-[34px] px-3" />
            - ${D?mt`
            + ${D?xt`
            Loading sessions...
            `:null} - ${N?mt`
            - ${N} + ${O?xt`
            + ${O}
            `:null}
            `:null}
            - ${a===0?mt`${G?mt`
            `:mt`
            `:xt``}`:mt`<${Y} - onClick=${()=>l(pe=>Math.max(pe-1,0))} + `}`:xt`<${Z} + onClick=${()=>l(he=>Math.max(he-1,0))} disabled=${s} idleLabel="Back" tone="secondary" size="md" className="w-full justify-center" />`} - ${!z&&a===K-2?mt`<${Y} - onClick=${oe} + ${!K&&a===Y-2?xt`<${Z} + onClick=${ie} disabled=${!1} loading=${s} idleLabel="Enable watch" @@ -7129,14 +7136,14 @@ ${B?.grantPublisher||""}`.trim()))} tone="primary" size="md" className="w-full justify-center" - />`:a`:a`:mt`<${Y} + />`:xt`<${Z} onClick=${o} disabled=${s||v} idleLabel="Done" @@ -7145,13 +7152,13 @@ ${B?.grantPublisher||""}`.trim()))} className="w-full justify-center" />`} - - `};var Os=P.bind(T),F3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,H3="/assets/icons/google_icon.svg",Fw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Dw({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[b,x]=y(!1),[v,w]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[$,S]=y(!1),[C,_]=y(!1),[k,A]=y(""),[D,N]=y({visible:!1,accountId:""}),{loading:U,watchByAccountId:O,clientConfigByClient:M,busyByAccountId:R,savingClient:B,refresh:E,saveClientSetup:G,startWatchForAccount:H,stopWatchForAccount:z}=Nw({gatewayStatus:t,accounts:s}),te=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),V=q(Q=>s.find(fe=>fe.id===Q)||null,[s]),Z=q(Q=>{let fe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(se=>({...se,[Q.id]:[...fe]})),d(se=>{let De=se[Q.id];return!De||!F3(De,fe)?{...se,[Q.id]:[...fe]}:se})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(fe=>!fe.authenticated)?.id||"";l(fe=>fe&&s.some(se=>se.id===fe)?fe:Q),s.forEach(fe=>Z(fe))},[s,Z]);let ue=q(Q=>{let fe=V(Q);if(!fe)return;let se=c[Q]||fe.activeScopes||wr();if(!se.length){window.alert("Select at least one service");return}let De=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(se.join(","))}&_ts=${Date.now()}`,ke=window.open(De,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=De)},[V,c]),re=(Q,fe)=>{d(se=>({...se,[Q]:Tw(se[Q]||[],fe)}))},oe=q(async Q=>{g(fe=>{let se={...fe};return delete se[Q],se}),h({[Q]:!0});try{let fe=await jh(Q);fe.results&&g(se=>({...se,[Q]:fe.results}))}finally{h(fe=>{if(!fe[Q])return fe;let se={...fe};return delete se[Q],se})}},[]);L(()=>{let Q=async fe=>{if(fe.data?.google==="success"){I("\u2713 Google account connected","success");let se=String(fe.data?.accountId||"").trim();g({}),await i(),await E(),se&&await oe(se)}else fe.data?.google==="error"&&I(`\u2717 Google auth failed: ${fe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[oe,i,E]),L(()=>{!a||!V(a)?.authenticated||m[a]||f[a]||oe(a)},[s,f,m,a,V,oe]);let ne=async Q=>{let fe=await Kh(Q);if(!fe.ok){I(`Failed to disconnect: ${fe.error||"unknown"}`,"error");return}I("Google account disconnected","success"),g(se=>{let De={...se};return delete De[Q],De}),await i(),await E()},me=({accountId:Q="",client:fe="default",personal:se=!1,title:De="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:W=se?"personal":"workspace",initialValues:ae={}})=>{w({visible:!0,accountId:Q,client:fe,personal:se,title:De,submitLabel:ke,defaultInstrType:W,initialValues:ae})},pe=()=>{w(Q=>({...Q,visible:!1}))},ce=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&ue(Q.id)},ge=async({email:Q,setError:fe})=>{_(!0);try{let se=await Uh({email:Q,client:"default",personal:!1,services:wr()});if(!se.ok){fe?.(se.error||"Could not add account");return}S(!1),se.accountId&&l(se.accountId),await i(),se.accountId&&ue(se.accountId)}finally{_(!1)}},j=()=>{if(x(!1),K&&r){S(!0);return}me({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{x(!1),me({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},ie=async Q=>{let fe=V(Q);if(!fe)return;let se=$c(fe),De=se?"personal":fe.client||"default",ke={};try{let W=await Vh({accountId:fe.id,client:De});W?.ok&&(ke={clientId:String(W.clientId||""),clientSecret:String(W.clientSecret||"")})}catch{I("Could not load saved client credentials","warning")}me({accountId:fe.id,client:De,personal:se,title:`Edit Credentials (${fe.email})`,submitLabel:"Save Credentials",defaultInstrType:se?"personal":"workspace",initialValues:{email:fe.email,...ke}})},de=Q=>{N({visible:!0,accountId:String(Q||"")})},be=()=>{N({visible:!1,accountId:""})},Ve=async Q=>{let fe=V(Q);if(!fe)return;let se=String(fe.client||"default").trim()||"default",De=M.get(se);if(!De?.configured||!De?.webhookExists){de(Q);return}try{(await H(Q))?.restartRequired&&e(!0),I("Gmail watch enabled","success")}catch(ke){I(ke.message||"Could not enable Gmail watch","error")}},tt=async Q=>{try{await z(Q),I("Gmail watch disabled","info")}catch(fe){I(fe.message||"Could not disable Gmail watch","error")}},kt=async({client:Q,projectId:fe,destination:se=null})=>{let De=String(D.accountId||"").trim();De&&(await G({client:Q,projectId:fe,regeneratePushToken:!1}),await H(De,{destination:se}),I("Gmail setup complete and watch enabled","success"))},Ot=()=>Os` + + `};var Bs=P.bind(T),z3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),Cc=(t={})=>!!t.personal,U3="/assets/icons/google_icon.svg",zw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Ww({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[b,x]=y(!1),[v,$]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[w,S]=y(!1),[C,_]=y(!1),[k,A]=y(""),[D,O]=y({visible:!1,accountId:""}),{loading:z,watchByAccountId:N,clientConfigByClient:M,busyByAccountId:L,savingClient:B,refresh:E,saveClientSetup:U,startWatchForAccount:F,stopWatchForAccount:K}=Hw({gatewayStatus:t,accounts:s}),re=W(()=>s.some(Q=>Cc(Q)),[s]),Y=W(()=>s.some(Q=>!Cc(Q)),[s]),j=G(Q=>s.find(ce=>ce.id===Q)||null,[s]),J=G(Q=>{let ce=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(ee=>({...ee,[Q.id]:[...ce]})),d(ee=>{let Ee=ee[Q.id];return!Ee||!z3(Ee,ce)?{...ee,[Q.id]:[...ce]}:ee})},[]);R(()=>{if(!s.length){l("");return}let Q=s.find(ce=>!ce.authenticated)?.id||"";l(ce=>ce&&s.some(ee=>ee.id===ce)?ce:Q),s.forEach(ce=>J(ce))},[s,J]);let pe=G(Q=>{let ce=j(Q);if(!ce)return;let ee=c[Q]||ce.activeScopes||wr();if(!ee.length){window.alert("Select at least one service");return}let Ee=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(ee.join(","))}&_ts=${Date.now()}`,ke=window.open(Ee,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=Ee)},[j,c]),le=(Q,ce)=>{d(ee=>({...ee,[Q]:Iw(ee[Q]||[],ce)}))},ie=G(async Q=>{g(ce=>{let ee={...ce};return delete ee[Q],ee}),h({[Q]:!0});try{let ce=await Uh(Q);ce.results&&g(ee=>({...ee,[Q]:ce.results}))}finally{h(ce=>{if(!ce[Q])return ce;let ee={...ce};return delete ee[Q],ee})}},[]);R(()=>{let Q=async ce=>{if(ce.data?.google==="success"){I("\u2713 Google account connected","success");let ee=String(ce.data?.accountId||"").trim();g({}),await i(),await E(),ee&&await ie(ee)}else ce.data?.google==="error"&&I(`\u2717 Google auth failed: ${ce.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[ie,i,E]),R(()=>{!a||!j(a)?.authenticated||m[a]||f[a]||ie(a)},[s,f,m,a,j,ie]);let se=async Q=>{let ce=await qh(Q);if(!ce.ok){I(`Failed to disconnect: ${ce.error||"unknown"}`,"error");return}I("Google account disconnected","success"),g(ee=>{let Ee={...ee};return delete Ee[Q],Ee}),await i(),await E()},xe=({accountId:Q="",client:ce="default",personal:ee=!1,title:Ee="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:H=ee?"personal":"workspace",initialValues:oe={}})=>{$({visible:!0,accountId:Q,client:ce,personal:ee,title:Ee,submitLabel:ke,defaultInstrType:H,initialValues:oe})},he=()=>{$(Q=>({...Q,visible:!1}))},ue=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&pe(Q.id)},be=async({email:Q,setError:ce})=>{_(!0);try{let ee=await Gh({email:Q,client:"default",personal:!1,services:wr()});if(!ee.ok){ce?.(ee.error||"Could not add account");return}S(!1),ee.accountId&&l(ee.accountId),await i(),ee.accountId&&pe(ee.accountId)}finally{_(!1)}},V=()=>{if(x(!1),Y&&r){S(!0);return}xe({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},q=()=>{x(!1),xe({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},ae=async Q=>{let ce=j(Q);if(!ce)return;let ee=Cc(ce),Ee=ee?"personal":ce.client||"default",ke={};try{let H=await zh({accountId:ce.id,client:Ee});H?.ok&&(ke={clientId:String(H.clientId||""),clientSecret:String(H.clientSecret||"")})}catch{I("Could not load saved client credentials","warning")}xe({accountId:ce.id,client:Ee,personal:ee,title:`Edit Credentials (${ce.email})`,submitLabel:"Save Credentials",defaultInstrType:ee?"personal":"workspace",initialValues:{email:ce.email,...ke}})},fe=Q=>{O({visible:!0,accountId:String(Q||"")})},ne=()=>{O({visible:!1,accountId:""})},ve=async Q=>{let ce=j(Q);if(!ce)return;let ee=String(ce.client||"default").trim()||"default",Ee=M.get(ee);if(!Ee?.configured||!Ee?.webhookExists){fe(Q);return}try{(await F(Q))?.restartRequired&&e(!0),I("Gmail watch enabled","success")}catch(ke){I(ke.message||"Could not enable Gmail watch","error")}},ge=async Q=>{try{await K(Q),I("Gmail watch disabled","info")}catch(ce){I(ce.message||"Could not disable Gmail watch","error")}},Ne=async({client:Q,projectId:ce,destination:ee=null})=>{let Ee=String(D.accountId||"").trim();Ee&&(await U({client:Q,projectId:ce,regeneratePushToken:!1}),await F(Ee,{destination:ee}),I("Gmail setup complete and watch enabled","success"))},qe=()=>Bs`
            Google logo
            - <${Y} - onClick=${j} + <${Z} + onClick=${V} tone="primary" size="sm" idleLabel="Add Company Account" className="w-full font-medium" /> - <${Y} - onClick=${J} + <${Z} + onClick=${q} tone="secondary" size="sm" idleLabel="Add Personal Account" @@ -7180,75 +7187,75 @@ ${B?.grantPublisher||""}`.trim()))} />
            - `;return Os` + `;return Bs`

            Google Accounts

            - ${s.length?Os` + ${s.length?Bs`
            - <${pt} + <${mt} open=${b} ariaLabel="Add Google account" title="Add Google account" onClose=${()=>x(!1)} onToggle=${()=>x(Q=>!Q)} - renderTrigger=${({onToggle:Q,ariaLabel:fe,title:se})=>Os` - <${Y} + renderTrigger=${({onToggle:Q,ariaLabel:ce,title:ee})=>Bs` + <${Z} onClick=${Q} tone="subtle" size="sm" idleLabel="+ Add Account" - ariaLabel=${fe} - title=${se} + ariaLabel=${ce} + title=${ee} /> `} > - <${Oe} onClick=${j}> + <${We} onClick=${V}> Company account - - ${te?null:Os` - <${Oe} onClick=${J}> + + ${re?null:Bs` + <${We} onClick=${q}> Personal account - + `} - +
            `:null}
            - ${o?Os`
            + ${o?Bs`
            Loading... -
            `:s.length?Os` +
            `:s.length?Bs`
            - ${s.map(Q=>Os`<${Ew} + ${s.map(Q=>Bs`<${Bw} key=${Q.id} account=${Q} - personal=${$c(Q)} + personal=${Cc(Q)} expanded=${a===Q.id} - onToggleExpanded=${fe=>l(se=>se===fe?"":fe)} + onToggleExpanded=${ce=>l(ee=>ee===ce?"":ce)} scopes=${c[Q.id]||Q.activeScopes||wr()} savedScopes=${u[Q.id]||Q.activeScopes||wr()} apiStatus=${f[Q.id]||{}} checkingApis=${a===Q.id&&!!m[Q.id]} - onToggleScope=${re} - onCheckApis=${oe} - onUpdatePermissions=${fe=>ue(fe)} - onEditCredentials=${ie} - onDisconnect=${fe=>A(fe)} - gmailWatchStatus=${O.get(Q.id)||null} - gmailWatchBusy=${!!R[Q.id]} - onEnableGmailWatch=${Ve} - onDisableGmailWatch=${tt} - onOpenGmailSetup=${de} + onToggleScope=${le} + onCheckApis=${ie} + onUpdatePermissions=${ce=>pe(ce)} + onEditCredentials=${ae} + onDisconnect=${ce=>A(ce)} + gmailWatchStatus=${N.get(Q.id)||null} + gmailWatchBusy=${!!L[Q.id]} + onEnableGmailWatch=${ve} + onDisableGmailWatch=${ge} + onOpenGmailSetup=${fe} onOpenGmailWebhook=${n} />`)}
            - `:Ot()} + `:qe()}
            - <${Pw} + <${Dw} visible=${v.visible} - onClose=${pe} - onSaved=${ce} + onClose=${he} + onSaved=${ue} title=${v.title} submitLabel=${v.submitLabel} defaultInstrType=${v.defaultInstrType} @@ -7258,75 +7265,75 @@ ${B?.grantPublisher||""}`.trim()))} initialValues=${v.initialValues} /> - <${Iw} - visible=${$} + <${Fw} + visible=${w} onClose=${()=>S(!1)} - onSubmit=${ge} + onSubmit=${be} loading=${C} title="Add Company Account" /> - <${Bw} + <${jw} visible=${D.visible} - account=${V(D.accountId)} - clientConfig=${M.get(String(V(D.accountId)?.client||"default").trim()||"default")||null} - saving=${B||U} - onClose=${be} - onSaveSetup=${G} - onFinish=${kt} + account=${j(D.accountId)} + clientConfig=${M.get(String(j(D.accountId)?.client||"default").trim()||"default")||null} + saving=${B||z} + onClose=${ne} + onSaveSetup=${U} + onFinish=${Ne} /> - <${st} + <${rt} visible=${!!k} title="Disconnect Google account?" message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect." confirmLabel="Disconnect" cancelLabel="Cancel" onCancel=${()=>A("")} - onConfirm=${async()=>{let Q=k;A(""),await ne(Q)}} + onConfirm=${async()=>{let Q=k;A(""),await se(Q)}} /> - `};var wc=P.bind(T),W3=(t,e)=>t.find(n=>n.key===e)?.value||"",V3=(t,e)=>{for(let n of t.providers)if((An[n]||[]).some(r=>!!W3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Hw=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc` + `};var _c=P.bind(T),K3=(t,e)=>t.find(n=>n.key===e)?.value||"",G3=(t,e)=>{for(let n of t.providers)if((An[n]||[]).some(r=>!!K3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Uw=({onSwitchTab:t})=>{let{data:e,loading:n}=pt("/api/env",Ur,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:_c`

            Features

            - ${bg.map(r=>{let i=V3(r,s);return wc` + ${yg.map(r=>{let i=G3(r,s);return _c`
            ${r.label} - ${i.active?wc` + ${i.active?_c` - ${Us[i.provider]||i.provider} + ${Ks[i.provider]||i.provider} - <${le} tone="success">Enabled + <${de} tone="success">Enabled - `:wc` + `:_c` {a.preventDefault(),t?.("envars")}} class="text-xs px-2 py-1 rounded-lg ac-btn-ghost" >Add provider - <${le} tone=${r.hasDefault?"neutral":"danger"}>Disabled + <${de} tone=${r.hasDefault?"neutral":"danger"}>Disabled `}
            `})}
            - `};var j3=P.bind(T),Ww=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>fc(t,e)?j3` + `};var q3=P.bind(T),Kw=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>bc(t,e)?q3`

            Drift Doctor

            -

            ${lw(t)}

            +

            ${fw(t)}

            - <${Y} + <${Z} onClick=${s} tone="secondary" idleLabel="Dismiss for 1 week" /> - <${Y} + <${Z} onClick=${n} tone="warning" idleLabel="Open Drift Doctor" @@ -7334,9 +7341,9 @@ ${B?.grantPublisher||""}`.trim()))}
            - `:null;var kc="0 * * * *",Vw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(kc),[g,m]=y(!1),[h,b]=y(kc),[x,v]=y(!1),[w,$]=y(!1),[S,C]=y(!1),_=ee(null),k=t,A=e,D=n,N=k?.gateway??null,U=k?.channels??null,O=k?.repo||null,M=k?.syncCron||null,R=k?.openclawVersion||null,B=xs.some(ce=>{let ge=U?.[ce];if(!ge)return!1;let j=ge.accounts&&typeof ge.accounts=="object"?ge.accounts:{};return Object.keys(j).length>0?Object.values(j).some(J=>J&&J.status!=="paired"):ge.status!=="paired"}),E=Ee(async()=>(await Co()).pending||[],3e3,{enabled:B&&N==="running",cacheKey:"/api/pairings"}),G=E.data||[],H=N==="running"&&(w||!S),z=Ee(async()=>{let ce=await ga();return C(ce?.cliAutoApproveComplete===!0),ce.pending||[]},5e3,{enabled:H,cacheKey:"/api/devices"}),te=z.data||[];L(()=>{o&&(E.refresh(),H&&z.refresh())},[z.refresh,o,s,E.refresh,w,H]),L(()=>{if(!r||!o)return;s(),E.refresh(),H&&z.refresh();let ce=setTimeout(()=>{s(),E.refresh(),H&&z.refresh()},1200),ge=setTimeout(()=>{s(),E.refresh(),H&&z.refresh()},3500);return()=>{clearTimeout(ce),clearTimeout(ge)}},[z.refresh,o,s,E.refresh,r,w,H]),L(()=>{M&&(u(M.enabled!==!1),f(M.schedule||kc),b(M.enabled===!1?"disabled":M.schedule||kc))},[M?.enabled,M?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),E.refresh(),setTimeout(()=>{s(),E.refresh()},700),setTimeout(()=>{s(),E.refresh()},1800)},V=async({enabled:ce=d,schedule:ge=p}={})=>{if(!g){m(!0);try{let j=await ym({enabled:ce,schedule:ge});if(!j.ok)throw new Error(j.error||"Could not save sync settings");I("Sync schedule updated","success"),s()}catch(j){I(j.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:U,dashboardLoading:i,devicePending:te,doctorStatus:D,gatewayStatus:N,hasUnpaired:B,openclawVersion:R,pending:G,pairingStatusRefreshing:x,repairingWatchdog:l,repo:O,savingSyncCron:g,syncCron:M,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ce,ge,j="")=>{await _o(ce,ge,j),K()},handleDeviceApprove:async ce=>{await ba(ce),setTimeout(z.refresh,500),setTimeout(z.refresh,2e3)},handleDeviceReject:async ce=>{await xa(ce),setTimeout(z.refresh,500),setTimeout(z.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){$(!0),a(!0);try{let ce=await hm();console.log("[dashboard] response:",JSON.stringify(ce)),window.open(ce.url||"/openclaw","_blank")}catch(ce){console.error("[dashboard] error:",ce),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ce,ge,j="")=>{try{await Ao(ce,ge,j),K()}catch(J){I(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ce=>{b(ce);let ge=ce!=="disabled",j=ge?ce:p;u(ge),f(j),await V({enabled:ge,schedule:j})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ce=await ha();if(!ce.ok)throw new Error(ce.error||"Repair failed");I("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ce){I(ce.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),jw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:x,actions:v}=Vw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc` + `:null;var Ac="0 * * * *",Gw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(Ac),[g,m]=y(!1),[h,b]=y(Ac),[x,v]=y(!1),[$,w]=y(!1),[S,C]=y(!1),_=te(null),k=t,A=e,D=n,O=k?.gateway??null,z=k?.channels??null,N=k?.repo||null,M=k?.syncCron||null,L=k?.openclawVersion||null,B=ys.some(ue=>{let be=z?.[ue];if(!be)return!1;let V=be.accounts&&typeof be.accounts=="object"?be.accounts:{};return Object.keys(V).length>0?Object.values(V).some(q=>q&&q.status!=="paired"):be.status!=="paired"}),E=Re(async()=>(await Co()).pending||[],3e3,{enabled:B&&O==="running",cacheKey:"/api/pairings"}),U=E.data||[],F=O==="running"&&($||!S),K=Re(async()=>{let ue=await va();return C(ue?.cliAutoApproveComplete===!0),ue.pending||[]},5e3,{enabled:F,cacheKey:"/api/devices"}),re=K.data||[];R(()=>{o&&(E.refresh(),F&&K.refresh())},[K.refresh,o,s,E.refresh,$,F]),R(()=>{if(!r||!o)return;s(),E.refresh(),F&&K.refresh();let ue=setTimeout(()=>{s(),E.refresh(),F&&K.refresh()},1200),be=setTimeout(()=>{s(),E.refresh(),F&&K.refresh()},3500);return()=>{clearTimeout(ue),clearTimeout(be)}},[K.refresh,o,s,E.refresh,r,$,F]),R(()=>{M&&(u(M.enabled!==!1),f(M.schedule||Ac),b(M.enabled===!1?"disabled":M.schedule||Ac))},[M?.enabled,M?.schedule]),R(()=>()=>{_.current&&clearTimeout(_.current)},[]);let Y=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),E.refresh(),setTimeout(()=>{s(),E.refresh()},700),setTimeout(()=>{s(),E.refresh()},1800)},j=async({enabled:ue=d,schedule:be=p}={})=>{if(!g){m(!0);try{let V=await vm({enabled:ue,schedule:be});if(!V.ok)throw new Error(V.error||"Could not save sync settings");I("Sync schedule updated","success"),s()}catch(V){I(V.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:z,dashboardLoading:i,devicePending:re,doctorStatus:D,gatewayStatus:O,hasUnpaired:B,openclawVersion:L,pending:U,pairingStatusRefreshing:x,repairingWatchdog:l,repo:N,savingSyncCron:g,syncCron:M,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ue,be,V="")=>{await _o(ue,be,V),Y()},handleDeviceApprove:async ue=>{await $a(ue),setTimeout(K.refresh,500),setTimeout(K.refresh,2e3)},handleDeviceReject:async ue=>{await wa(ue),setTimeout(K.refresh,500),setTimeout(K.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){w(!0),a(!0);try{let ue=await mm();console.log("[dashboard] response:",JSON.stringify(ue)),window.open(ue.url||"/openclaw","_blank")}catch(ue){console.error("[dashboard] error:",ue),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ue,be,V="")=>{try{await Ao(ue,be,V),Y()}catch(q){I(q.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ue=>{b(ue);let be=ue!=="disabled",V=be?ue:p;u(be),f(V),await j({enabled:be,schedule:V})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ue=await xa();if(!ue.ok)throw new Error(ue.error||"Repair failed");I("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ue){I(ue.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sr=P.bind(T),J3=()=>{typeof window>"u"||window.dispatchEvent(new CustomEvent("alphaclaw:open-whatsapp-qr"))},qw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:x,actions:v}=Gw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p}),$=x.channels?.whatsapp||null,w=$?.accounts&&typeof $.accounts=="object"?$.accounts:{},S=Object.keys(w).length>0?Object.values(w).some(_=>_&&_.status!=="paired"):String($?.status||"").trim()==="configured",C=x.hasUnpaired&&!x.pairingStatusRefreshing&&Array.isArray(x.pending)&&x.pending.length===0&&S;return Sr`
            - <${bc} + <${$c} status=${x.gatewayStatus} openclawVersion=${x.openclawVersion} restarting=${d} @@ -7349,40 +7356,63 @@ ${B?.grantPublisher||""}`.trim()))} onOpenclawVersionActionComplete=${g} onOpenclawUpdate=${m} /> - <${Ww} + <${Kw} doctorStatus=${x.doctorStatus} dismissedUntilMs=${o} onOpenDoctor=${()=>i("doctor")} onDismiss=${b} /> - <${nl} - channelsSection=${Sc` - <${Mb} + <${il} + channelsSection=${Sr` + <${Eb} channels=${x.channels} agents=${s} onNavigate=${a} onRefreshStatuses=${r} + onRestartGateway=${u} /> `} - pairingsSection=${Sc` - <${tl} - pending=${x.pending} - channels=${x.channels} - visible=${x.hasUnpaired} - statusRefreshing=${x.pairingStatusRefreshing} - onApprove=${v.handleApprove} - onReject=${v.handleReject} - /> + pairingsSection=${Sr` + ${C?Sr` +
            +

            Pending Pairings

            +
            + +

            WhatsApp needs to be linked

            +

            Scan the QR code to finish pairing this channel.

            + <${Z} + onClick=${J3} + tone="primary" + size="sm" + idleLabel="Open QR Code" + /> +
            +
            + `:Sr` + <${rl} + pending=${x.pending} + channels=${x.channels} + visible=${x.hasUnpaired} + statusRefreshing=${x.pairingStatusRefreshing} + onApprove=${v.handleApprove} + onReject=${v.handleReject} + /> + `} `} /> - <${Hw} onSwitchTab=${i} /> - <${Fw} + <${Uw} onSwitchTab=${i} /> + <${zw} gatewayStatus=${x.gatewayStatus} onRestartRequired=${h} onOpenGmailWebhook=${l} /> - ${x.repo&&Sc` + ${x.repo&&Sr`
            @@ -7407,7 +7437,7 @@ ${B?.grantPublisher||""}`.trim()))}
            - <${Ca} + <${Ta} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted" />
            @@ -7439,16 +7469,16 @@ ${B?.grantPublisher||""}`.trim()))} loadingLabel="Opening..." />
            - <${xc} + <${wc} pending=${x.devicePending} onApprove=${v.handleDeviceApprove} onReject=${v.handleDeviceReject} />
            - `};var z3=P.bind(T),ff=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSetLocation:i=()=>{},onNavigate:a=()=>{},restartingGateway:l=!1,onRestartGateway:c=()=>{},restartSignal:d=0,openclawUpdateInProgress:u=!1,onOpenclawVersionActionComplete:p=()=>{},onOpenclawUpdate:f=()=>{},onRestartRequired:g=()=>{},onDismissDoctorWarning:m=()=>{}})=>z3` + `};var Z3=P.bind(T),mf=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSetLocation:i=()=>{},onNavigate:a=()=>{},restartingGateway:l=!1,onRestartGateway:c=()=>{},restartSignal:d=0,openclawUpdateInProgress:u=!1,onOpenclawVersionActionComplete:p=()=>{},onOpenclawUpdate:f=()=>{},onRestartRequired:g=()=>{},onDismissDoctorWarning:m=()=>{}})=>Z3`
            - <${jw} + <${qw} statusData=${t} watchdogData=${e} doctorStatusData=${n} @@ -7469,11 +7499,11 @@ ${B?.grantPublisher||""}`.trim()))} onDismissDoctorWarning=${m} />
            -`;var Nt=P.bind(T),U3={anthropic:{label:"Anthropic",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-ant-api03-...",url:"https://console.anthropic.com",field:"key"},{id:"token",label:"Setup Token",profileSuffix:"manual",placeholder:"sk-ant-oat01-...",hint:"From claude setup-token (uses your Claude subscription)",field:"token"}]},openai:{label:"OpenAI",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-...",url:"https://platform.openai.com",field:"key"}]},"openai-codex":{label:"OpenAI Codex",modes:[{id:"oauth",label:"Codex OAuth",isCodexOauth:!0}]},google:{label:"Gemini",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"AI...",url:"https://aistudio.google.com",field:"key"}]}},K3={id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"...",field:"key"},G3=t=>{let e=An[t]||[];return e.length===0?[K3]:e.map(n=>({id:"api_key",label:n.label||"API Key",profileSuffix:"default",placeholder:n.placeholder||"...",hint:n.hint,url:n.url,field:"key"}))},q3=t=>U3[t]||{label:Us[t]||t,modes:G3(t)},zi=(t,e)=>`${t.provider||e}:${t.profileSuffix||"default"}`,Cc=t=>String(t?.key||t?.token||t?.access||"").trim(),J3=({codexStatus:t,onRefreshCodex:e})=>{let[n,s]=y(!1),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(!1),d=ee(null);L(()=>()=>{d.current&&clearInterval(d.current)},[]),L(()=>{let g=async m=>{m.data?.codex==="success"?(I("Codex connected","success"),s(!1),r(!1),await e()):m.data?.codex==="error"&&I(`Codex auth failed: ${m.data.message||"unknown error"}`,"error")};return window.addEventListener("message",g),()=>window.removeEventListener("message",g)},[e]);let u=()=>{s(!0),r(!0);let g=window.open("/auth/codex/start","codex-auth","popup=yes,width=640,height=780");if(!g||g.closed){r(!1),window.location.href="/auth/codex/start";return}d.current&&clearInterval(d.current),d.current=setInterval(()=>{g.closed&&(clearInterval(d.current),d.current=null,r(!1))},500)},p=async()=>{if(!(!i.trim()||l)){c(!0);try{let g=await Wr(i.trim());if(!g.ok)throw new Error(g.error||"Codex OAuth exchange failed");a(""),I("Codex connected","success"),s(!1),r(!1),await e()}catch(g){I(g.message||"Codex OAuth exchange failed","error")}finally{c(!1)}}},f=async()=>{let g=await Hr();if(!g.ok){I(g.error||"Failed to disconnect Codex","error");return}I("Codex disconnected","success"),s(!1),r(!1),a(""),await e()};return Nt` +`;var Nt=P.bind(T),Y3={anthropic:{label:"Anthropic",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-ant-api03-...",url:"https://console.anthropic.com",field:"key"},{id:"token",label:"Setup Token",profileSuffix:"manual",placeholder:"sk-ant-oat01-...",hint:"From claude setup-token (uses your Claude subscription)",field:"token"}]},openai:{label:"OpenAI",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"sk-...",url:"https://platform.openai.com",field:"key"}]},"openai-codex":{label:"OpenAI Codex",modes:[{id:"oauth",label:"Codex OAuth",isCodexOauth:!0}]},google:{label:"Gemini",modes:[{id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"AI...",url:"https://aistudio.google.com",field:"key"}]}},X3={id:"api_key",label:"API Key",profileSuffix:"default",placeholder:"...",field:"key"},Q3=t=>{let e=An[t]||[];return e.length===0?[X3]:e.map(n=>({id:"api_key",label:n.label||"API Key",profileSuffix:"default",placeholder:n.placeholder||"...",hint:n.hint,url:n.url,field:"key"}))},eR=t=>Y3[t]||{label:Ks[t]||t,modes:Q3(t)},Gi=(t,e)=>`${t.provider||e}:${t.profileSuffix||"default"}`,Mc=t=>String(t?.key||t?.token||t?.access||"").trim(),tR=({codexStatus:t,onRefreshCodex:e})=>{let[n,s]=y(!1),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(!1),d=te(null);R(()=>()=>{d.current&&clearInterval(d.current)},[]),R(()=>{let g=async m=>{m.data?.codex==="success"?(I("Codex connected","success"),s(!1),r(!1),await e()):m.data?.codex==="error"&&I(`Codex auth failed: ${m.data.message||"unknown error"}`,"error")};return window.addEventListener("message",g),()=>window.removeEventListener("message",g)},[e]);let u=()=>{s(!0),r(!0);let g=window.open("/auth/codex/start","codex-auth","popup=yes,width=640,height=780");if(!g||g.closed){r(!1),window.location.href="/auth/codex/start";return}d.current&&clearInterval(d.current),d.current=setInterval(()=>{g.closed&&(clearInterval(d.current),d.current=null,r(!1))},500)},p=async()=>{if(!(!i.trim()||l)){c(!0);try{let g=await zr(i.trim());if(!g.ok)throw new Error(g.error||"Codex OAuth exchange failed");a(""),I("Codex connected","success"),s(!1),r(!1),await e()}catch(g){I(g.message||"Codex OAuth exchange failed","error")}finally{c(!1)}}},f=async()=>{let g=await jr();if(!g.ok){I(g.error||"Failed to disconnect Codex","error");return}I("Codex disconnected","success"),s(!1),r(!1),a(""),await e()};return Nt`
            Codex OAuth - ${t.connected?Nt`<${le} tone="success">Connected`:Nt`<${le} tone="warning">Not connected`} + ${t.connected?Nt`<${de} tone="success">Connected`:Nt`<${de} tone="warning">Not connected`}
            ${t.connected?Nt`
            @@ -7524,7 +7554,7 @@ ${B?.grantPublisher||""}`.trim()))} placeholder="http://localhost:1455/auth/callback?code=...&state=..." class="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted" /> - <${Y} + <${Z} onClick=${p} disabled=${!i.trim()||l} loading=${l} @@ -7536,63 +7566,63 @@ ${B?.grantPublisher||""}`.trim()))} /> `:null}
            - `},zw=({provider:t,authProfiles:e,authOrder:n,codexStatus:s,onEditProfile:o,onEditAuthOrder:r,getProfileValue:i,getEffectiveOrder:a,onRefreshCodex:l})=>{let c=q3(t),d=c.modes.filter($=>!$.isCodexOauth),u=d.length>1,p=c.modes.some($=>$.isCodexOauth),f=a(t),g=f?.[0]||null,m=n[t]||null,h=d.some($=>{let S=zi($,t),C=e.find(k=>k.id===S)||null,_=i(S);return Cc(_)!==Cc(C)}),b=JSON.stringify(f||null)!==JSON.stringify(m),x=h||b,v=d.some($=>{let S=zi($,t),C=i(S);return!!(C?.key||C?.token||C?.access)})||t==="openai-codex"&&!!s?.connected,w=$=>{let S=zi($,t),C=d.map(k=>zi(k,t)),_=[S,...C.filter(k=>k!==S)];r(t,_)};return Nt` + `},Jw=({provider:t,authProfiles:e,authOrder:n,codexStatus:s,onEditProfile:o,onEditAuthOrder:r,getProfileValue:i,getEffectiveOrder:a,onRefreshCodex:l})=>{let c=eR(t),d=c.modes.filter(w=>!w.isCodexOauth),u=d.length>1,p=c.modes.some(w=>w.isCodexOauth),f=a(t),g=f?.[0]||null,m=n[t]||null,h=d.some(w=>{let S=Gi(w,t),C=e.find(k=>k.id===S)||null,_=i(S);return Mc(_)!==Mc(C)}),b=JSON.stringify(f||null)!==JSON.stringify(m),x=h||b,v=d.some(w=>{let S=Gi(w,t),C=i(S);return!!(C?.key||C?.token||C?.access)})||t==="openai-codex"&&!!s?.connected,$=w=>{let S=Gi(w,t),C=d.map(k=>Gi(k,t)),_=[S,...C.filter(k=>k!==S)];r(t,_)};return Nt`

            ${c.label}

            - ${p&&d.length===0?null:x?Nt`<${le} tone="warning">Unsaved`:v?Nt`<${le} tone="success">Connected`:Nt`<${le} tone="warning">Not configured`} + ${p&&d.length===0?null:x?Nt`<${de} tone="warning">Unsaved`:v?Nt`<${de} tone="success">Connected`:Nt`<${de} tone="warning">Not configured`}
            - ${d.map($=>{let S=zi($,t),C=$.provider||t,_=i(S),k=_?.[$.field]||"",A=!u||g===S||!g&&$===d[0];return Nt` + ${d.map(w=>{let S=Gi(w,t),C=w.provider||t,_=i(S),k=_?.[w.field]||"",A=!u||g===S||!g&&w===d[0];return Nt`
            ${w.label} - ${u&&A?Nt`<${le} tone="cyan">Primary`:null} + ${u&&A?Nt`<${de} tone="cyan">Primary`:null} ${u&&!A&&k?Nt``:null} - ${$.url&&!k?Nt`Get`:null}
            - <${Rt} + <${Et} value=${k} - onInput=${D=>{let N=D.target.value,U={type:$.id,provider:C,[$.field]:N};_?.expires&&(U.expires=_.expires),o(S,U);let O=e.find(R=>R.id===S)||null;Cc(U)===Cc(O)&&u?r(t,m):u&&N&&!A&&w($)}} - placeholder=${$.placeholder||""} + onInput=${D=>{let O=D.target.value,z={type:w.id,provider:C,[w.field]:O};_?.expires&&(z.expires=_.expires),o(S,z);let N=e.find(L=>L.id===S)||null;Mc(z)===Mc(N)&&u?r(t,m):u&&O&&!A&&$(w)}} + placeholder=${w.placeholder||""} isSecret=${!0} inputClass="flex-1 w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" /> - ${$.hint?Nt`

            ${$.hint}

            `:null} + ${w.hint?Nt`

            ${w.hint}

            `:null}
            `})} - ${c.modes.some($=>$.isCodexOauth)?Nt` + ${c.modes.some(w=>w.isCodexOauth)?Nt`
            - <${J3} + <${tR} codexStatus=${s} onRefreshCodex=${l} />
            `:null}
            - `};var At=P.bind(T),Z3=t=>{let e=new Set;for(let n of Object.keys(t)){let s=ri(n);s&&e.add(s)}return[...e]},Y3=["anthropic","openai","openai-codex",...jr.filter(t=>!["anthropic","openai"].includes(t))],Uw=({onRestartRequired:t=()=>{},agentId:e,embedded:n=!1})=>{let{catalog:s,primary:o,configuredModels:r,authProfiles:i,authOrder:a,codexStatus:l,loading:c,saving:d,ready:u,error:p,isDirty:f,addModel:g,removeModel:m,setPrimaryModel:h,editProfile:b,editAuthOrder:x,getProfileValue:v,getEffectiveOrder:w,cancelChanges:$,saveAll:S,refreshCodexStatus:C}=rl(e),_=F(()=>new Set(Object.keys(r)),[r]),k=F(()=>Lo(s),[s]),A=F(()=>k.filter(E=>!_.has(E.key)),[k,_]),D=F(()=>[...s].filter(E=>!_.has(E.key)).sort((E,G)=>{let H=jo($s(E))-jo($s(G));return H!==0?H:String(E.label||E.key).localeCompare(String(G.label||G.key))}),[s,_]),N=F(()=>Z3(r),[r]),U=F(()=>{let E=[];for(let G of Y3)N.includes(G)&&E.push(G);for(let G of N)E.includes(G)||E.push(G);return E},[N]),O=F(()=>sl({authProfiles:i,codexStatus:l}),[i,l]),M=F(()=>Object.keys(r).map(E=>{let G=s.find(te=>te.key===E),H=ri(E),z=!!O[H];return{key:E,label:G?.label||E,isPrimary:E===o,hasAuth:z}}),[r,s,o,O]),R=At` - <${es} visible=${f}> - <${Y} - onClick=${$} + `};var Tt=P.bind(T),nR=t=>{let e=new Set;for(let n of Object.keys(t)){let s=li(n);s&&e.add(s)}return[...e]},sR=["anthropic","openai","openai-codex",...Kr.filter(t=>!["anthropic","openai"].includes(t))],Zw=({onRestartRequired:t=()=>{},agentId:e,embedded:n=!1})=>{let{catalog:s,primary:o,configuredModels:r,authProfiles:i,authOrder:a,codexStatus:l,loading:c,saving:d,ready:u,error:p,isDirty:f,addModel:g,removeModel:m,setPrimaryModel:h,editProfile:b,editAuthOrder:x,getProfileValue:v,getEffectiveOrder:$,cancelChanges:w,saveAll:S,refreshCodexStatus:C}=cl(e),_=W(()=>new Set(Object.keys(r)),[r]),k=W(()=>Lo(s),[s]),A=W(()=>k.filter(E=>!_.has(E.key)),[k,_]),D=W(()=>[...s].filter(E=>!_.has(E.key)).sort((E,U)=>{let F=jo(ws(E))-jo(ws(U));return F!==0?F:String(E.label||E.key).localeCompare(String(U.label||U.key))}),[s,_]),O=W(()=>nR(r),[r]),z=W(()=>{let E=[];for(let U of sR)O.includes(U)&&E.push(U);for(let U of O)E.includes(U)||E.push(U);return E},[O]),N=W(()=>al({authProfiles:i,codexStatus:l}),[i,l]),M=W(()=>Object.keys(r).map(E=>{let U=s.find(re=>re.key===E),F=li(E),K=!!N[F];return{key:E,label:U?.label||E,isPrimary:E===o,hasAuth:K}}),[r,s,o,N]),L=Tt` + <${ts} visible=${f}> + <${Z} + onClick=${w} disabled=${d} tone="secondary" size="sm" idleLabel="Cancel" className="text-xs" /> - <${Y} + <${Z} onClick=${S} disabled=${d} loading=${d} @@ -7603,30 +7633,30 @@ ${B?.grantPublisher||""}`.trim()))} loadingLabel="Saving…" className="text-xs" /> - - `;if(!u){let E=At` + + `;if(!u){let E=Tt`
            - <${Ce} className="h-4 w-4" /> + <${Ae} className="h-4 w-4" /> Loading model settings...
            - `;return n?E:At` + `;return n?E:Tt` <${Un} - header=${At`<${Pe} title="Models" />`} + header=${Tt`<${De} title="Models" />`} > ${E} - `}let B=At` + `}let B=Tt`

            Available Models

            - ${M.length===0?At`

            + ${M.length===0?Tt`

            No models configured. Add a model below. -

            `:At` +

            `:Tt`
            - ${M.map(E=>At` + ${M.map(E=>Tt`
            @@ -7634,14 +7664,14 @@ ${B?.grantPublisher||""}`.trim()))} ${E.label} - ${E.isPrimary?At`<${le} tone="cyan">Primary`:E.hasAuth?At` + ${E.isPrimary?Tt`<${de} tone="cyan">Primary`:E.hasAuth?Tt` - `:At`<${le} tone="warning">Needs auth`} + `:Tt`<${de} tone="warning">Needs auth`}
            - ${U.length>0?At` + ${z.length>0?Tt`

            Provider Authentication

            - ${U.map(E=>At` - <${zw} + ${z.map(E=>Tt` + <${Jw} provider=${E} authProfiles=${i} authOrder=${a} @@ -7683,30 +7713,30 @@ ${B?.grantPublisher||""}`.trim()))} onEditProfile=${b} onEditAuthOrder=${x} getProfileValue=${v} - getEffectiveOrder=${w} + getEffectiveOrder=${$} onRefreshCodex=${C} /> `)}
            `:null} - `;return n?At` + `;return n?Tt`
            - ${R} + ${L}
            ${B}
            - `:At` + `:Tt` <${Un} - header=${At`<${Pe} title="Models" actions=${R} />`} + header=${Tt`<${De} title="Models" actions=${L} />`} > ${B} - `};var X3=P.bind(T),hf=({onRestartRequired:t=()=>{}})=>X3` - <${Uw} onRestartRequired=${t} /> -`;var Q3=1e4,Kw=({enabled:t=!0}={})=>{let e=Ee(async()=>{let n=await Pm(),s=Array.isArray(n?.nodes)?n.nodes:[],o=Array.isArray(n?.pending)?n.pending:[];return{nodes:s,pending:o}},Q3,{enabled:t,cacheKey:"/api/nodes"});return{nodes:Array.isArray(e.data?.nodes)?e.data.nodes:[],pending:Array.isArray(e.data?.pending)?e.data.pending:[],loading:e.data===null&&!e.error,error:e.error?String(e.error.message||"Could not load nodes"):"",refresh:e.refresh}};var Gw=()=>{let t=Kw({enabled:!0}),[e,n]=y(!1),[s,o]=y(!1),{data:r,error:i}=ct("/api/nodes/connect-info",ya,{maxAgeMs:6e4}),a=Array.isArray(t.nodes)?t.nodes.filter(c=>c?.paired!==!1):[];L(()=>{i&&I(i.message||"Could not load node connect command","error")},[i]);let l=q(async()=>{if(!s){o(!0);try{await t.refresh()}finally{o(!1)}}},[t.refresh,s]);return{state:{wizardVisible:e,nodes:a,pending:t.pending,loadingNodes:t.loading,refreshingNodes:s,nodesError:t.error,connectInfo:r},actions:{openWizard:()=>n(!0),closeWizard:()=>n(!1),refreshNodes:l}}};var eR=35e3,tR=1e4,Jw="nodesBrowserAttachStateByNode",nR=/selected page has been closed/i,sR=async(t,e=eR)=>{let n=null;try{return await Promise.race([t,new Promise((s,o)=>{n=setTimeout(()=>{o(new Error("Browser check timed out"))},e)})])}finally{n&&clearTimeout(n)}},qw=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},oR=t=>{let e=String(t?.message||"Could not check node browser status").trim();return nR.test(e)?"Selected Chrome page was closed. Click Attach to reconnect.":e},rR=()=>{let e=He()?.[Jw];return!e||typeof e!="object"||Array.isArray(e)?{}:e},iR=(t={})=>{kx(e=>({...e&&typeof e=="object"?e:{},[Jw]:t&&typeof t=="object"?t:{}}))},Zw=({nodes:t=[],onRefreshNodes:e=async()=>{}}={})=>{let[n,s]=y({}),[o,r]=y({}),[i,a]=y(""),[l,c]=y(()=>rR()),[d,u]=y(""),[p,f]=y(null),[g,m]=y(""),h=ee(0),b=ee(""),x=async(k,{successMessage:A="Connection command copied",errorMessage:D="Could not copy connection command"}={})=>{if(await Ho(k)){I(A,"success");return}I(D,"error")},v=q(async(k,{silent:A=!1}={})=>{let D=String(k||"").trim();if(!(!D||b.current)){b.current=D,A||a(D),r(N=>({...N,[D]:""}));try{let N=await sR(Em(D,"user")),U=N?.status&&typeof N.status=="object"?N.status:null;s(O=>({...O,[D]:U}))}catch(N){let U=oR(N);s(O=>({...O,[D]:null})),r(O=>({...O,[D]:U})),A||I(U,"error")}finally{b.current="",A||a("")}}},[]),w=q((k,A)=>{let D=String(k||"").trim();D&&c(N=>{let U={...N&&typeof N=="object"?N:{},[D]:A===!0};return iR(U),U})},[]),$=q(async k=>{let A=String(k||"").trim();A&&(w(A,!0),await v(A))},[v,w]),S=q(k=>{let A=String(k||"").trim();A&&(w(A,!1),s(D=>{let N={...D||{}};return delete N[A],N}),r(D=>{let N={...D||{}};return delete N[A],N}))},[w]),C=q(k=>{let A=String(k||"").trim();A&&u(D=>D===A?"":A)},[]),_=q(async()=>{let k=String(p?.nodeId||"").trim();if(!(!k||g)){m(k);try{await Rm(k),S(k),I("Device removed","success"),f(null),u(""),await e()}catch(A){I(A.message||"Could not remove node","error")}finally{m("")}}},[S,e,p,g]);return L(()=>{if(i)return;let k=t.map(A=>({nodeId:String(A?.nodeId||"").trim(),connected:A?.connected===!0,browserCapable:qw(A)})).find(A=>!(!A.nodeId||!A.connected||!A.browserCapable||l?.[A.nodeId]!==!0||n?.[A.nodeId]||o?.[A.nodeId]))?.nodeId;k&&v(k,{silent:!0})},[l,o,n,i,v,t]),L(()=>{if(i)return;let k=t.map(U=>({nodeId:String(U?.nodeId||"").trim(),connected:U?.connected===!0,browserCapable:qw(U),browserRunning:n?.[String(U?.nodeId||"").trim()]?.running===!0})).filter(U=>U.nodeId&&U.connected&&U.browserCapable&&l?.[U.nodeId]===!0&&U.browserRunning).map(U=>U.nodeId);if(!k.length)return;let A=!0,N=setInterval(async()=>{if(!A||b.current)return;let U=h.current%k.length;h.current+=1;let O=k[U];await v(O,{silent:!0})},tR);return()=>{A=!1,clearInterval(N)}},[l,n,i,v,t]),{browserStatusByNodeId:n,browserErrorByNodeId:o,checkingBrowserNodeId:i,browserAttachStateByNodeId:l,menuOpenNodeId:d,removeDialogNode:p,removingNodeId:g,handleCopyText:x,handleCheckNodeBrowser:v,handleAttachNodeBrowser:$,handleDetachNodeBrowser:S,handleOpenNodeMenu:C,handleRemoveNode:_,setMenuOpenNodeId:u,setRemoveDialogNode:f}};var ot=P.bind(T),aR=t=>String(t||"").replace(/"/g,'\\"'),Yw=({node:t,connectInfo:e,maskToken:n=!1})=>{let s=String(e?.gatewayHost||"").trim()||"localhost",o=Number(e?.gatewayPort)||3e3,r=String(e?.gatewayToken||"").trim(),i=e?.tls===!0?"--tls":"",a=String(t?.displayName||t?.nodeId||"My Node").trim(),l=n?"****":r;return[l?`OPENCLAW_GATEWAY_TOKEN=${l}`:"","openclaw node run",`--host ${s}`,`--port ${o}`,i,`--display-name "${aR(a)}"`].filter(Boolean).join(" ")},lR=t=>t?.connected?ot`<${le} tone="success">Connected`:t?.paired?ot`<${le} tone="warning">Disconnected`:ot`<${le} tone="danger">Pending approval`,cR=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},dR=t=>t.running?"success":"warning",uR=t=>t.running?"Attached":"Not connected",Xw=({nodes:t=[],pending:e=[],loading:n=!1,error:s="",connectInfo:o=null,onRefreshNodes:r=async()=>{}})=>{let i=Zw({nodes:t,onRefreshNodes:r}),{browserStatusByNodeId:a,browserErrorByNodeId:l,checkingBrowserNodeId:c,browserAttachStateByNodeId:d,menuOpenNodeId:u,removeDialogNode:p,removingNodeId:f,handleCopyText:g,handleCheckNodeBrowser:m,handleAttachNodeBrowser:h,handleDetachNodeBrowser:b,handleOpenNodeMenu:x,handleRemoveNode:v,setMenuOpenNodeId:w,setRemoveDialogNode:$}=i;return ot` + `};var oR=P.bind(T),gf=({onRestartRequired:t=()=>{}})=>oR` + <${Zw} onRestartRequired=${t} /> +`;var rR=1e4,Yw=({enabled:t=!0}={})=>{let e=Re(async()=>{let n=await Rm(),s=Array.isArray(n?.nodes)?n.nodes:[],o=Array.isArray(n?.pending)?n.pending:[];return{nodes:s,pending:o}},rR,{enabled:t,cacheKey:"/api/nodes"});return{nodes:Array.isArray(e.data?.nodes)?e.data.nodes:[],pending:Array.isArray(e.data?.pending)?e.data.pending:[],loading:e.data===null&&!e.error,error:e.error?String(e.error.message||"Could not load nodes"):"",refresh:e.refresh}};var Xw=()=>{let t=Yw({enabled:!0}),[e,n]=y(!1),[s,o]=y(!1),{data:r,error:i}=pt("/api/nodes/connect-info",ka,{maxAgeMs:6e4}),a=Array.isArray(t.nodes)?t.nodes.filter(c=>c?.paired!==!1):[];R(()=>{i&&I(i.message||"Could not load node connect command","error")},[i]);let l=G(async()=>{if(!s){o(!0);try{await t.refresh()}finally{o(!1)}}},[t.refresh,s]);return{state:{wizardVisible:e,nodes:a,pending:t.pending,loadingNodes:t.loading,refreshingNodes:s,nodesError:t.error,connectInfo:r},actions:{openWizard:()=>n(!0),closeWizard:()=>n(!1),refreshNodes:l}}};var iR=35e3,aR=1e4,e1="nodesBrowserAttachStateByNode",lR=/selected page has been closed/i,cR=async(t,e=iR)=>{let n=null;try{return await Promise.race([t,new Promise((s,o)=>{n=setTimeout(()=>{o(new Error("Browser check timed out"))},e)})])}finally{n&&clearTimeout(n)}},Qw=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},dR=t=>{let e=String(t?.message||"Could not check node browser status").trim();return lR.test(e)?"Selected Chrome page was closed. Click Attach to reconnect.":e},uR=()=>{let e=je()?.[e1];return!e||typeof e!="object"||Array.isArray(e)?{}:e},pR=(t={})=>{Mx(e=>({...e&&typeof e=="object"?e:{},[e1]:t&&typeof t=="object"?t:{}}))},t1=({nodes:t=[],onRefreshNodes:e=async()=>{}}={})=>{let[n,s]=y({}),[o,r]=y({}),[i,a]=y(""),[l,c]=y(()=>uR()),[d,u]=y(""),[p,f]=y(null),[g,m]=y(""),h=te(0),b=te(""),x=async(k,{successMessage:A="Connection command copied",errorMessage:D="Could not copy connection command"}={})=>{if(await Wo(k)){I(A,"success");return}I(D,"error")},v=G(async(k,{silent:A=!1}={})=>{let D=String(k||"").trim();if(!(!D||b.current)){b.current=D,A||a(D),r(O=>({...O,[D]:""}));try{let O=await cR(Im(D,"user")),z=O?.status&&typeof O.status=="object"?O.status:null;s(N=>({...N,[D]:z}))}catch(O){let z=dR(O);s(N=>({...N,[D]:null})),r(N=>({...N,[D]:z})),A||I(z,"error")}finally{b.current="",A||a("")}}},[]),$=G((k,A)=>{let D=String(k||"").trim();D&&c(O=>{let z={...O&&typeof O=="object"?O:{},[D]:A===!0};return pR(z),z})},[]),w=G(async k=>{let A=String(k||"").trim();A&&($(A,!0),await v(A))},[v,$]),S=G(k=>{let A=String(k||"").trim();A&&($(A,!1),s(D=>{let O={...D||{}};return delete O[A],O}),r(D=>{let O={...D||{}};return delete O[A],O}))},[$]),C=G(k=>{let A=String(k||"").trim();A&&u(D=>D===A?"":A)},[]),_=G(async()=>{let k=String(p?.nodeId||"").trim();if(!(!k||g)){m(k);try{await Lm(k),S(k),I("Device removed","success"),f(null),u(""),await e()}catch(A){I(A.message||"Could not remove node","error")}finally{m("")}}},[S,e,p,g]);return R(()=>{if(i)return;let k=t.map(A=>({nodeId:String(A?.nodeId||"").trim(),connected:A?.connected===!0,browserCapable:Qw(A)})).find(A=>!(!A.nodeId||!A.connected||!A.browserCapable||l?.[A.nodeId]!==!0||n?.[A.nodeId]||o?.[A.nodeId]))?.nodeId;k&&v(k,{silent:!0})},[l,o,n,i,v,t]),R(()=>{if(i)return;let k=t.map(z=>({nodeId:String(z?.nodeId||"").trim(),connected:z?.connected===!0,browserCapable:Qw(z),browserRunning:n?.[String(z?.nodeId||"").trim()]?.running===!0})).filter(z=>z.nodeId&&z.connected&&z.browserCapable&&l?.[z.nodeId]===!0&&z.browserRunning).map(z=>z.nodeId);if(!k.length)return;let A=!0,O=setInterval(async()=>{if(!A||b.current)return;let z=h.current%k.length;h.current+=1;let N=k[z];await v(N,{silent:!0})},aR);return()=>{A=!1,clearInterval(O)}},[l,n,i,v,t]),{browserStatusByNodeId:n,browserErrorByNodeId:o,checkingBrowserNodeId:i,browserAttachStateByNodeId:l,menuOpenNodeId:d,removeDialogNode:p,removingNodeId:g,handleCopyText:x,handleCheckNodeBrowser:v,handleAttachNodeBrowser:w,handleDetachNodeBrowser:S,handleOpenNodeMenu:C,handleRemoveNode:_,setMenuOpenNodeId:u,setRemoveDialogNode:f}};var it=P.bind(T),fR=t=>String(t||"").replace(/"/g,'\\"'),n1=({node:t,connectInfo:e,maskToken:n=!1})=>{let s=String(e?.gatewayHost||"").trim()||"localhost",o=Number(e?.gatewayPort)||3e3,r=String(e?.gatewayToken||"").trim(),i=e?.tls===!0?"--tls":"",a=String(t?.displayName||t?.nodeId||"My Node").trim(),l=n?"****":r;return[l?`OPENCLAW_GATEWAY_TOKEN=${l}`:"","openclaw node run",`--host ${s}`,`--port ${o}`,i,`--display-name "${fR(a)}"`].filter(Boolean).join(" ")},hR=t=>t?.connected?it`<${de} tone="success">Connected`:t?.paired?it`<${de} tone="warning">Disconnected`:it`<${de} tone="danger">Pending approval`,mR=t=>{let e=Array.isArray(t?.caps)?t.caps:[],n=Array.isArray(t?.commands)?t.commands:[];return e.includes("browser")||n.includes("browser.proxy")},gR=t=>t.running?"success":"warning",bR=t=>t.running?"Attached":"Not connected",s1=({nodes:t=[],pending:e=[],loading:n=!1,error:s="",connectInfo:o=null,onRefreshNodes:r=async()=>{}})=>{let i=t1({nodes:t,onRefreshNodes:r}),{browserStatusByNodeId:a,browserErrorByNodeId:l,checkingBrowserNodeId:c,browserAttachStateByNodeId:d,menuOpenNodeId:u,removeDialogNode:p,removingNodeId:f,handleCopyText:g,handleCheckNodeBrowser:m,handleAttachNodeBrowser:h,handleDetachNodeBrowser:b,handleOpenNodeMenu:x,handleRemoveNode:v,setMenuOpenNodeId:$,setRemoveDialogNode:w}=i;return it`
            - ${e.length?ot` + ${e.length?it`
            @@ -7714,22 +7744,22 @@ ${B?.grantPublisher||""}`.trim()))} waiting for approval.
            `:null} - ${n?ot` + ${n?it`
            - <${Ce} className="h-4 w-4" /> + <${Ae} className="h-4 w-4" /> Loading nodes...
            - `:s?ot` + `:s?it`
            ${s}
            - `:t.length?ot` + `:t.length?it`
            - ${t.map(S=>{let C=String(S?.nodeId||"").trim(),_=a[C]||null,k=l[C]||"",A=c===C,D=S?.connected&&cR(S)&&C,N=d?.[C]===!0,U=!!_||!!k,O=_?.running===!0,M=N&&!U&&!A,R=D&&N&&!A&&U&&!O;return ot` + ${t.map(S=>{let C=String(S?.nodeId||"").trim(),_=a[C]||null,k=l[C]||"",A=c===C,D=S?.connected&&mR(S)&&C,O=d?.[C]===!0,z=!!_||!!k,N=_?.running===!0,M=O&&!z&&!A,L=D&&O&&!A&&z&&!N;return it`
            @@ -7739,13 +7769,13 @@ ${B?.grantPublisher||""}`.trim()))}
            ${S?.displayName||S?.nodeId||"Unnamed node"}
            - ${C?ot` + ${C?it`
            - ${lR(S)} - ${S?.paired?ot` - <${pt} + ${hR(S)} + ${S?.paired?it` + <${mt} open=${u===C} ariaLabel="Open node actions" title="Open node actions" - onClose=${()=>w("")} + onClose=${()=>$("")} onToggle=${()=>x(C)} > - <${Oe} + <${We} className="text-status-error hover:text-status-error" - onClick=${()=>{w(""),$(S)}} + onClick=${()=>{$(""),w(S)}} > Remove device - - + + `:null}
            @@ -7789,7 +7819,7 @@ ${B?.grantPublisher||""}`.trim()))} >
            - ${D?ot` + ${D?it`
            Browser
            - ${N?ot` + ${O?it`
            profile: user
            - `:ot` + `:it`
            @@ -7820,40 +7850,40 @@ ${B?.grantPublisher||""}`.trim()))} `}
            - ${_?ot` + ${_?it` - <${le} tone=${dR(_)} - >${uR(_)}${bR(_)} `:null} - ${M?ot` - <${Ce} + ${M?it` + <${Ae} className="h-3.5 w-3.5" /> `:null} - ${A?ot` - <${Ce} + ${A?it` + <${Ae} className="h-3.5 w-3.5" /> `:null} - ${D&&!N?ot` - <${Y} + ${D&&!O?it` + <${Z} onClick=${()=>h(C)} idleLabel="Attach" tone="primary" size="sm" /> `:null} - ${R?ot` - <${Y} + ${L?it` + <${Z} onClick=${()=>m(C)} idleLabel="Check" tone="secondary" size="sm" /> `:null} - ${D&&N&&!A?ot` + ${D&&O&&!A?it`
            - ${_?ot` + ${_?it`
            @@ -7886,7 +7916,7 @@ ${B?.grantPublisher||""}`.trim()))}
            `:null} - ${k?ot`
            ${k} @@ -7894,7 +7924,7 @@ ${B?.grantPublisher||""}`.trim()))}
            `:null} - ${S?.paired&&!S?.connected&&o?ot` + ${S?.paired&&!S?.connected&&o?it`
            @@ -7905,15 +7935,15 @@ ${B?.grantPublisher||""}`.trim()))} - <${Y} - onClick=${()=>g(Yw({node:S,connectInfo:o,maskToken:!1}),{successMessage:"Connection command copied",errorMessage:"Could not copy connection command"})} + <${Z} + onClick=${()=>g(n1({node:S,connectInfo:o,maskToken:!1}),{successMessage:"Connection command copied",errorMessage:"Could not copy connection command"})} tone="secondary" size="sm" iconOnly=${!0} - idleIcon=${Ks} + idleIcon=${Gs} idleIconClassName="w-3.5 h-3.5" ariaLabel="Copy reconnect command" title="Copy reconnect command" @@ -7924,12 +7954,12 @@ ${B?.grantPublisher||""}`.trim()))}
            `})} - `:ot` + `:it`
            - <${Pa} className="h-12 w-12 text-cyan-400" /> + <${Ia} className="h-12 w-12 text-cyan-400" />

            No connected nodes yet @@ -7943,7 +7973,7 @@ ${B?.grantPublisher||""}`.trim()))}

            `}
            - <${st} + <${rt} visible=${!!p} title="Remove device?" message=${p?.connected?"This device is currently connected. Removing it will disconnect and remove the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later.":"This removes the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."} @@ -7952,10 +7982,10 @@ ${B?.grantPublisher||""}`.trim()))} confirmTone="warning" confirmLoading=${!!f} confirmDisabled=${!!f} - onCancel=${()=>{f||$(null)}} + onCancel=${()=>{f||w(null)}} onConfirm=${v} /> - `};var pR=P.bind(T),fR="https://github.com/openclaw/openclaw/releases/tag/v2026.3.13",hR=`Release reference: [OpenClaw 2026.3.13](${fR}) + `};var xR=P.bind(T),yR="https://github.com/openclaw/openclaw/releases/tag/v2026.3.13",vR=`Release reference: [OpenClaw 2026.3.13](${yR}) ## Requirements @@ -7995,7 +8025,7 @@ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**. | Config validation error on existing-session | Do not define a custom existing-session profile. Use \`defaultProfile: "user"\`. | | EADDRINUSE on port 9222 | Quit Chrome launched with \`--remote-debugging-port\` and relaunch normally. | | Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. | -| \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`,Qw=()=>{let t=F(()=>Ie.parse(hR,{gfm:!0,breaks:!0}),[]);return pR` +| \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`,o1=()=>{let t=W(()=>Fe.parse(vR,{gfm:!0,breaks:!0}),[]);return xR`
            @@ -8007,7 +8037,7 @@ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**. dangerouslySetInnerHTML=${{__html:t}} >
            - `};var e1=3e3,t1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[b,x]=y([]),[v,w]=y(!1),$=ee(!1);L(()=>{t&&(i(0),g(""),h(!1),w(!1))},[t]),L(()=>{t&&(d(!0),ya().then(O=>{l(O||null)}).catch(O=>{I(O.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=F(()=>{let O=new Set,M=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||O.has(B)||R?.paired!==!1&&(O.add(B),M.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return M},[e]),C=F(()=>S.find(O=>O.nodeId===String(f||"").trim())||null,[S,f]),_=F(()=>{if(!a)return"";let O=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",E=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${O}`,`--port ${M}`,B.trim(),E?`--display-name "${E}"`:""].filter(Boolean).join(" ")},[a,u]),k=q(async()=>{if(!$.current){$.current=!0;try{await n();let O=await ga(),M=Array.isArray(O?.pending)?O.pending:[];x(M)}finally{$.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let O=!0,M=async()=>{if(O)try{await k()}catch{}};M();let R=setInterval(M,e1);return()=>{O=!1,clearInterval(R)}},[k,r,t]),L(()=>{if(!t||r!==1)return;let O=S.some(B=>B.nodeId===String(f||"").trim()),M=String(u||"").trim().toLowerCase(),R=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||S[0];R&&(O&&String(f||"").trim()===R.nodeId||g(R.nodeId))},[u,S,f,r,t]);let A=q(async O=>{try{await ba(O),I("Pairing approved","success"),w(!0),await k()}catch(M){I(M.message||"Could not approve pairing","error")}},[k]),D=q(async O=>{try{await xa(O),I("Pairing rejected","info"),await k()}catch(M){I(M.message||"Could not reject pairing","error")}},[k]),N=q(async()=>{let O=String(f||"").trim();if(!O||m)return!1;h(!0);try{return await Lm(O),s(!0),I("Gateway routing now points to the selected node","success"),!0}catch(M){return I(M.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),U=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:S,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:e1,handleDeviceApprove:A,handleDeviceReject:D,applyGatewayNodeRouting:N,completeWizard:U}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],n1=({command:t="",onCopy:e=()=>{}})=>an` + `};var r1=3e3,i1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[b,x]=y([]),[v,$]=y(!1),w=te(!1);R(()=>{t&&(i(0),g(""),h(!1),$(!1))},[t]),R(()=>{t&&(d(!0),ka().then(N=>{l(N||null)}).catch(N=>{I(N.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=W(()=>{let N=new Set,M=[];for(let L of e){let B=String(L?.nodeId||"").trim();!B||N.has(B)||L?.paired!==!1&&(N.add(B),M.push({nodeId:B,displayName:String(L?.displayName||L?.name||B),connected:L?.connected===!0}))}return M},[e]),C=W(()=>S.find(N=>N.nodeId===String(f||"").trim())||null,[S,f]),_=W(()=>{if(!a)return"";let N=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,L=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",E=String(u||"").trim().replace(/"/g,'\\"');return[L?`OPENCLAW_GATEWAY_TOKEN=${L}`:"","openclaw node run",`--host ${N}`,`--port ${M}`,B.trim(),E?`--display-name "${E}"`:""].filter(Boolean).join(" ")},[a,u]),k=G(async()=>{if(!w.current){w.current=!0;try{await n();let N=await va(),M=Array.isArray(N?.pending)?N.pending:[];x(M)}finally{w.current=!1}}},[n]);R(()=>{if(!t||r!==1)return;let N=!0,M=async()=>{if(N)try{await k()}catch{}};M();let L=setInterval(M,r1);return()=>{N=!1,clearInterval(L)}},[k,r,t]),R(()=>{if(!t||r!==1)return;let N=S.some(B=>B.nodeId===String(f||"").trim()),M=String(u||"").trim().toLowerCase(),L=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||S[0];L&&(N&&String(f||"").trim()===L.nodeId||g(L.nodeId))},[u,S,f,r,t]);let A=G(async N=>{try{await $a(N),I("Pairing approved","success"),$(!0),await k()}catch(M){I(M.message||"Could not approve pairing","error")}},[k]),D=G(async N=>{try{await wa(N),I("Pairing rejected","info"),await k()}catch(M){I(M.message||"Could not reject pairing","error")}},[k]),O=G(async()=>{let N=String(f||"").trim();if(!N||m)return!1;h(!0);try{return await Em(N),s(!0),I("Gateway routing now points to the selected node","success"),!0}catch(M){return I(M.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),z=G(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:S,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:r1,handleDeviceApprove:A,handleDeviceReject:D,applyGatewayNodeRouting:O,completeWizard:z}};var an=P.bind(T),qi=["Install OpenClaw CLI","Connect Node"],a1=({command:t="",onCopy:e=()=>{}})=>an`
            -        <${Ks} className="w-3.5 h-3.5" />
            +        <${Gs} className="w-3.5 h-3.5" />
                     Copy
                   
                 
            -`,s1=async(t,e="text")=>{if(await Ho(t)){I("Copied to clipboard","success");return}I(`Could not copy ${e}`,"error")},o1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}})=>{let r=t1({visible:t,nodes:e,refreshNodes:n,onRestartRequired:s,onClose:o}),i=r.step===Ui.length-1;return an` - <${Te} +`,l1=async(t,e="text")=>{if(await Wo(t)){I("Copied to clipboard","success");return}I(`Could not copy ${e}`,"error")},c1=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}})=>{let r=i1({visible:t,nodes:e,refreshNodes:n,onRestartRequired:s,onClose:o}),i=r.step===qi.length-1;return an` + <${Ie} visible=${t} onClose=${o} closeOnOverlayClick=${!1} @@ -8039,12 +8069,12 @@ ${t} - <${Ze} className="w-3.5 h-3.5 text-body" /> + <${Qe} className="w-3.5 h-3.5 text-body" />
            Node Setup Wizard
            - ${Ui.map((a,l)=>an` + ${qi.map((a,l)=>an`

            - Step ${r.step+1} of ${Ui.length}: ${Ui[r.step]} + Step ${r.step+1} of ${qi.length}: ${qi[r.step]}

            ${r.step===0?an`
            Install OpenClaw on the machine you want to connect as a node.
            - ${n1({command:"npm install -g openclaw",onCopy:()=>s1("npm install -g openclaw","command")})} + ${a1({command:"npm install -g openclaw",onCopy:()=>l1("npm install -g openclaw","command")})}
            Requires Node.js 22+.
            `:null} @@ -8081,10 +8111,10 @@ ${t} ${r.loadingConnectInfo?an`
            Loading command... -
            `:n1({command:r.connectCommand||"Could not build connect command.",onCopy:()=>s1(r.connectCommand||"","command")})} +
            `:a1({command:r.connectCommand||"Could not build connect command.",onCopy:()=>l1(r.connectCommand||"","command")})}
            ${r.devicePending.length?an` - <${xc} + <${wc} pending=${r.devicePending} onApprove=${r.handleDeviceApprove} onReject=${r.handleDeviceReject} @@ -8109,7 +8139,7 @@ ${t} ${r.step===0?an`
            `:an` - <${Y} + <${Z} onClick=${()=>r.setStep(Math.max(0,r.step-1))} idleLabel="Back" tone="secondary" @@ -8118,7 +8148,7 @@ ${t} `} ${i?an` - <${Y} + <${Z} onClick=${async()=>{await r.applyGatewayNodeRouting()&&(r.completeWizard(),Promise.resolve(n()).catch(()=>{}))}} loading=${r.configuring} idleLabel=${r.canFinish?"Finish":"Awaiting pairing"} @@ -8129,8 +8159,8 @@ ${t} `:an` - <${Y} - onClick=${()=>r.setStep(Math.min(Ui.length-1,r.step+1))} + <${Z} + onClick=${()=>r.setStep(Math.min(qi.length-1,r.step+1))} idleLabel="Next" tone="primary" size="md" @@ -8138,13 +8168,13 @@ ${t} `} - - `};var r1=P.bind(T),i1=({onRestartRequired:t=()=>{}})=>{let{state:e,actions:n}=Gw();return r1` + + `};var d1=P.bind(T),u1=({onRestartRequired:t=()=>{}})=>{let{state:e,actions:n}=Xw();return d1`
            - <${Pe} + <${De} title="Nodes" - actions=${r1` - <${Y} + actions=${d1` + <${Z} onClick=${n.refreshNodes} loading=${e.refreshingNodes} loadingMode="inline" @@ -8152,7 +8182,7 @@ ${t} - <${Y} + <${Z} onClick=${n.openWizard} idleLabel="Connect Node" tone="primary" @@ -8161,7 +8191,7 @@ ${t} - <${Xw} + <${s1} nodes=${e.nodes} pending=${e.pending} loading=${e.loadingNodes} @@ -8170,9 +8200,9 @@ ${t} - <${Qw} /> + <${o1} /> - <${o1} + <${c1} visible=${e.wizardVisible} nodes=${e.nodes} refreshNodes=${n.refreshNodes} @@ -8180,38 +8210,38 @@ ${t}
            - `};var mR=P.bind(T),mf=({onRestartRequired:t=()=>{}})=>mR` + `};var $R=P.bind(T),bf=({onRestartRequired:t=()=>{}})=>$R`
            - <${i1} onRestartRequired=${t} /> + <${u1} onRestartRequired=${t} />
            -`;var JU=P.bind(T);var ZU=Object.values(An).flat().map(t=>t.key).filter((t,e,n)=>n.indexOf(t)===e);var nK=P.bind(T);var Ki=({to:t})=>{let[,e]=ca();return L(()=>{e(t)},[t,e]),null};var Gi=(t,e)=>{let n=String(e||"").trim();n&&t.set("accountId",n)},a1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/bot${n}`)).json()},l1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/workspace${n}`)).json()},c1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/workspace/reset${n}`,{method:"POST"})).json()},d1=async(t,{accountId:e=""}={})=>(await X("/api/telegram/groups/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groupId:t,accountId:e})})).json(),_c=async(t,{accountId:e=""}={})=>{let n=new URLSearchParams;Gi(n,e);let s=n.toString()?`?${n.toString()}`:"";return(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics${s}`)).json()},Ac=async(t,e,{accountId:n=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/bulk`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topics:e,accountId:n})})).json(),Mc=async(t,e,{accountId:n=""}={})=>{let s=new URLSearchParams;Gi(s,n);let o=s.toString()?`?${s.toString()}`:"";return(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${e}${o}`,{method:"DELETE"})).json()},u1=async(t,e,n,{accountId:s=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,accountId:s})})).json(),p1=async(t,e,{accountId:n=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,accountId:n})})).json();var Mt=P.bind(T),f1=({currentStep:t,steps:e})=>Mt` +`;var nK=P.bind(T);var sK=Object.values(An).flat().map(t=>t.key).filter((t,e,n)=>n.indexOf(t)===e);var cK=P.bind(T);var Ji=({to:t})=>{let[,e]=pa();return R(()=>{e(t)},[t,e]),null};var Zi=(t,e)=>{let n=String(e||"").trim();n&&t.set("accountId",n)},p1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Zi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/bot${n}`)).json()},f1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Zi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/workspace${n}`)).json()},h1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Zi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await X(`/api/telegram/workspace/reset${n}`,{method:"POST"})).json()},m1=async(t,{accountId:e=""}={})=>(await X("/api/telegram/groups/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groupId:t,accountId:e})})).json(),Tc=async(t,{accountId:e=""}={})=>{let n=new URLSearchParams;Zi(n,e);let s=n.toString()?`?${n.toString()}`:"";return(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics${s}`)).json()},Pc=async(t,e,{accountId:n=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/bulk`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topics:e,accountId:n})})).json(),Rc=async(t,e,{accountId:n=""}={})=>{let s=new URLSearchParams;Zi(s,n);let o=s.toString()?`?${s.toString()}`:"";return(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${e}${o}`,{method:"DELETE"})).json()},g1=async(t,e,n,{accountId:s=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,accountId:s})})).json(),b1=async(t,e,{accountId:n=""}={})=>(await X(`/api/telegram/groups/${encodeURIComponent(t)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,accountId:n})})).json();var Pt=P.bind(T),x1=({currentStep:t,steps:e})=>Pt`
            - ${e.map((n,s)=>Mt` + ${e.map((n,s)=>Pt`
            `)}
            -`,h1=({accountId:t,botInfo:e,setBotInfo:n,onNext:s})=>{let[o,r]=y(!1),[i,a]=y(null),l=async()=>{r(!0),a(null);try{let c=await a1({accountId:t});if(!c.ok)throw new Error(c.error);n(c.bot)}catch(c){a(c.message)}r(!1)};return L(()=>{e||l()},[]),Mt` +`,y1=({accountId:t,botInfo:e,setBotInfo:n,onNext:s})=>{let[o,r]=y(!1),[i,a]=y(null),l=async()=>{r(!0),a(null);try{let c=await p1({accountId:t});if(!c.ok)throw new Error(c.error);n(c.bot)}catch(c){a(c.message)}r(!1)};return R(()=>{e||l()},[]),Pt`

            Verify Bot Setup

            - ${e&&Mt` + ${e&&Pt`
            @${e.username} - <${le} tone="success">Connected + <${de} tone="success">Connected

            ${e.first_name}

            `} - ${i&&Mt` + ${i&&Pt`

            ${i}

            `} - ${!e&&!o&&!i&&Mt`

            Checking bot token...

            `} + ${!e&&!o&&!i&&Pt`

            Checking bot token...

            `}

            @@ -8244,7 +8274,7 @@ ${t}

            - `},m1=({onNext:t,onBack:e})=>Mt` + `},v1=({onNext:t,onBack:e})=>Pt`

            Create a Telegram Group

            @@ -8312,7 +8342,7 @@ ${t}
            -`,g1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],x=async()=>{let $=u.trim();if($){g(!0),l(null);try{let S=await d1($,{accountId:t});if(!S.ok)throw new Error(S.error);n($),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),w=async()=>{if(!(!v||m)){l(null),h(!0);try{let $=String(r||"").trim(),S=await p1(e,{...$?{userId:$}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch($){l($.message)}h(!1)}};return Mt` +`,$1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],x=async()=>{let w=u.trim();if(w){g(!0),l(null);try{let S=await m1(w,{accountId:t});if(!S.ok)throw new Error(S.error);n(w),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),$=async()=>{if(!(!v||m)){l(null),h(!0);try{let w=String(r||"").trim(),S=await b1(e,{...w?{userId:w}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch(w){l(w.message)}h(!1)}};return Pt`

            Verify Group

            @@ -8336,11 +8366,11 @@ ${t}p($.target.value)} + onInput=${w=>p(w.target.value)} placeholder="-100XXXXXXXXXX" class="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> - <${Y} + <${Z} onClick=${x} disabled=${!u.trim()||f} loading=${f} @@ -8352,16 +8382,16 @@ ${t}
            - ${a&&Mt` + ${a&&Pt`

            ${a}

            `} - ${s&&Mt` + ${s&&Pt`
            ${s.chat.title} - <${le} tone="success">Verified + <${de} tone="success">Verified
            Topics: ${s.chat.isForum?"ON":"OFF"} @@ -8369,13 +8399,13 @@ ${t}
            `} - ${s&&b.length===0&&Mt` + ${s&&b.length===0&&Pt`

            Your Telegram User ID

            i($.target.value)} + onInput=${w=>i(w.target.value)} placeholder="e.g. 123456789" class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> @@ -8384,7 +8414,7 @@ ${t}
            `} - ${b.length>0&&Mt` + ${b.length>0&&Pt`
            @@ -8392,7 +8422,7 @@ ${t}
              - ${b.map($=>Mt`
            • ${$}
            • `)} + ${b.map(w=>Pt`
            • ${w}
            • `)}

            Once fixed, hit Verify again.

            @@ -8406,7 +8436,7 @@ ${t}
            - `},b1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,b]=y(null),x=async()=>{let S=await _c(e,{accountId:t});S.ok&&s(S.topics)};L(()=>{x()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),f(null);try{let _=await Ac(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(A=>!A.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await x(),I(`Created topic: ${S}`,"success")}catch(_){f(_.message)}u(!1)}},w=async(S,C)=>{m(S);try{let _=await Mc(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await x(),_.removedFromRegistryOnly?I(`Removed stale topic from registry: ${C}`,"success"):I(`Deleted topic: ${C}`,"success")}catch(_){I(`Failed to delete: ${_.message}`,"error")}m(null)},$=Object.entries(n||{});return Mt` + `},w1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,b]=y(null),x=async()=>{let S=await Tc(e,{accountId:t});S.ok&&s(S.topics)};R(()=>{x()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),f(null);try{let _=await Pc(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(A=>!A.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await x(),I(`Created topic: ${S}`,"success")}catch(_){f(_.message)}u(!1)}},$=async(S,C)=>{m(S);try{let _=await Rc(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await x(),_.removedFromRegistryOnly?I(`Removed stale topic from registry: ${C}`,"success"):I(`Deleted topic: ${C}`,"success")}catch(_){I(`Failed to delete: ${_.message}`,"error")}m(null)},w=Object.entries(n||{});return Pt`

            Create Topics

            - ${$.length>0&&Mt` + ${w.length>0&&Pt`
            @@ -8435,7 +8465,7 @@ ${t}
            ${C.name} ${S} @@ -8622,47 +8652,47 @@ ${t}
            0?4:3}> + 0?4:3}>
            k(ie.target.value)} - onKeyDown=${ie=>{ie.key==="Enter"&&re(j),ie.key==="Escape"&&ue()}} + onInput=${ae=>k(ae.target.value)} + onKeyDown=${ae=>{ae.key==="Enter"&&le(V),ae.key==="Escape"&&pe()}} class="w-full bg-field border border-border rounded-lg px-2 py-1.5 text-xs text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> -`,Yo=({editorShellClassName:t="file-viewer-editor-shell",editorShellAriaHidden:e,editorLineNumbers:n,editorLineNumbersRef:s,editorLineNumberRowRefs:o,shouldUseHighlightedEditor:r,highlightedEditorLines:i,editorHighlightRef:a,editorHighlightLineRefs:l,editorTextareaRef:c,renderContent:d,handleContentInput:u,handleEditorKeyDown:p,handleEditorScroll:f,handleEditorSelectionChange:g,isEditBlocked:m,isPreviewOnly:h,textareaWrap:b="soft"})=>Zo` +`,Yo=({editorShellClassName:t="file-viewer-editor-shell",editorShellAriaHidden:e,editorLineNumbers:n,editorLineNumbersRef:s,editorLineNumberRowRefs:o,shouldUseHighlightedEditor:r,highlightedEditorLines:i,editorHighlightRef:a,editorHighlightLineRefs:l,editorTextareaRef:c,renderContent:d,handleContentInput:u,handleEditorKeyDown:p,handleEditorScroll:f,handleEditorSelectionChange:g,isEditBlocked:m,isPreviewOnly:h,textareaWrap:x="soft"})=>Zo`
            - ${n.map(x=>Zo` + ${n.map(b=>Zo`
            {o.current[x-1]=v}} + ref=${v=>{o.current[b-1]=v}} > - ${x} + ${b}
            `)}
            ${r?Zo`
            - ${i.map(x=>Zo` + ${i.map(b=>Zo`
            {l.current[x.lineNumber-1]=v}} + key=${b.lineNumber} + ref=${v=>{l.current[b.lineNumber-1]=v}} >
            `)} @@ -4113,7 +4113,7 @@ ${(n||"").split(` handleEditorSelectionChange=${g} isEditBlocked=${m} isPreviewOnly=${h} - textareaWrap=${b} + textareaWrap=${x} />
            `:Zo` @@ -4127,11 +4127,11 @@ ${(n||"").split(` handleEditorSelectionChange=${g} isEditBlocked=${m} isPreviewOnly=${h} - textareaWrap=${b} + textareaWrap=${x} /> `}
            -`;var j2=P.bind(T),Cy=({viewMode:t,previewRef:e,handlePreviewScroll:n,previewHtml:s,editorLineNumbers:o,editorLineNumbersRef:r,editorLineNumberRowRefs:i,shouldUseHighlightedEditor:a,highlightedEditorLines:l,editorHighlightRef:c,editorHighlightLineRefs:d,editorTextareaRef:u,renderContent:p,handleContentInput:f,handleEditorKeyDown:g,handleEditorScroll:m,handleEditorSelectionChange:h,isEditBlocked:b,isPreviewOnly:x})=>j2` +`;var V2=P.bind(T),Cy=({viewMode:t,previewRef:e,handlePreviewScroll:n,previewHtml:s,editorLineNumbers:o,editorLineNumbersRef:r,editorLineNumberRowRefs:i,shouldUseHighlightedEditor:a,highlightedEditorLines:l,editorHighlightRef:c,editorHighlightLineRefs:d,editorTextareaRef:u,renderContent:p,handleContentInput:f,handleEditorKeyDown:g,handleEditorScroll:m,handleEditorSelectionChange:h,isEditBlocked:x,isPreviewOnly:b})=>V2`
            -`;var _y=1e3,Ay=5e3,ul=50,pl=25e4,fl=5e3;var My=()=>{try{return String(window.localStorage.getItem(Jr)||"").trim()==="preview"?"preview":"edit"}catch{return"edit"}},Ty=()=>{try{let t=window.localStorage.getItem(Ba);if(!t)return{};let e=JSON.parse(t);return!e||typeof e!="object"?{}:e}catch{return{}}},Py=t=>{let e=String(t||"").trim();if(!e)return null;let s=Ty()[e];return!s||typeof s!="object"?null:{start:s.start,end:s.end}},du=(t,e)=>{let n=String(t||"").trim();if(!(!n||!e||typeof e!="object"))try{let s=Ty();s[n]={start:e.start,end:e.end},window.localStorage.setItem(Ba,JSON.stringify(s))}catch{}};var Ry=t=>String(t||"").split("/").map(e=>e.trim()).filter(Boolean),hl=(t,e)=>{let n=Number.parseInt(String(t??""),10);return Number.isFinite(n)?Math.max(0,Math.min(e,n)):0},ml=t=>{let e=String(t||"");return e?e.split(/\r\n|\r|\n/).length:1},gl=({contentLength:t=0,lineCount:e=1,charThreshold:n=25e4,lineThreshold:s=5e3})=>Number(t)>Number(n)||Number(e)>Number(s);var Xo=t=>{if(!t)return 0;let e=t.scrollHeight-t.clientHeight;return e<=0?0:t.scrollTop/e},ks=(t,e)=>{if(!t)return;let n=t.scrollHeight-t.clientHeight;if(n<=0){t.scrollTop=0;return}let s=Math.max(0,Math.min(1,e));t.scrollTop=n*s},Ly=({viewMode:t,setViewMode:e,previewRef:n,editorTextareaRef:s,editorLineNumbersRef:o,editorHighlightRef:r})=>{let i=ee(0),a=ee(!1);return{viewScrollRatioRef:i,isSyncingScrollRef:a,handleEditorScroll:u=>{if(a.current)return;let p=u.currentTarget.scrollTop,f=Xo(u.currentTarget);i.current=f,o.current&&(o.current.scrollTop=p,r.current&&(r.current.scrollTop=p,r.current.scrollLeft=u.currentTarget.scrollLeft),n.current&&(a.current=!0,ks(n.current,f),window.requestAnimationFrame(()=>{a.current=!1})))},handlePreviewScroll:u=>{if(a.current)return;let p=Xo(u.currentTarget);i.current=p,a.current=!0,ks(s.current,p),ks(o.current,p),ks(r.current,p),window.requestAnimationFrame(()=>{a.current=!1})},handleChangeViewMode:u=>{if(u===t)return;let p=Xo(t==="preview"?n.current:s.current);i.current=p,e(u),window.requestAnimationFrame(()=>{a.current=!0,u==="preview"?ks(n.current,p):(ks(s.current,p),ks(o.current,p),ks(r.current,p)),window.requestAnimationFrame(()=>{a.current=!1})})}}};var Ey=({hasSelectedPath:t,normalizedPath:e,isDiffView:n,isSqliteFile:s,sqliteSelectedTable:o,sqliteTableOffset:r,canEditFile:i,isFolderPath:a,loading:l,saving:c,initialContent:d,isDirty:u,setContent:p,setInitialContent:f,setFileKind:g,setImageDataUrl:m,setAudioDataUrl:h,setSqliteSummary:b,setSqliteSelectedTable:x,setSqliteTableOffset:v,setSqliteTableLoading:w,setSqliteTableError:$,setSqliteTableData:k,setError:C,setIsFolderPath:_,setExternalChangeNoticeShown:S,externalChangeNoticeShown:A,viewScrollRatioRef:O,setLoading:I})=>{let V=ee(""),E=ee(""),M=ee(!1);return L(()=>{let R=!0;return V.current="",E.current="",t?(p(""),f(""),m(""),h(""),b(null),x(""),v(0),w(!1),$(""),k(null),g("text"),C(""),_(!1),S(!1),O.current=0,n?(I(!1),V.current=e,E.current="",()=>{R=!1}):((async()=>{I(!0),C(""),_(!1);try{let D=await ud(e);if(!R)return;let U=D?.kind==="image"?"image":D?.kind==="audio"?"audio":D?.kind==="sqlite"?"sqlite":"text";if(g(U),U==="image"){m(String(D?.imageDataUrl||"")),h(""),b(null),x(""),v(0),w(!1),$(""),k(null),p(""),f(""),S(!1),O.current=0,V.current=e,E.current="";return}if(U==="audio"){h(String(D?.audioDataUrl||"")),m(""),b(null),x(""),v(0),w(!1),$(""),k(null),p(""),f(""),S(!1),O.current=0,V.current=e,E.current="";return}if(U==="sqlite"){let te=D?.sqliteSummary||null,K=te?.objects||[],z=K.find(X=>X?.type==="table")?.name||K[0]?.name||"";b(te),x(z),v(0),w(!1),$(""),k(null),m(""),h(""),p(""),f(""),S(!1),O.current=0,V.current=e,E.current="";return}m(""),h(""),b(null),x(""),v(0),w(!1),$(""),k(null);let W=D.content||"",G=zb(e);p(G||W),mn(e,!!(G&&G!==W),{dispatchEvent:te=>window.dispatchEvent(te)}),f(W),S(!1),O.current=0,V.current=e,E.current=""}catch(D){if(!R)return;g("text"),m(""),h(""),b(null),x(""),v(0),w(!1),$(""),k(null);let U=D.message||"Could not load file";if(/path is not a file/i.test(U)){p(""),f(""),_(!0),C(""),V.current=e,E.current="";return}C(U)}finally{R&&I(!1)}})(),()=>{R=!1})):(p(""),f(""),g("text"),m(""),h(""),b(null),x(""),v(0),w(!1),$(""),k(null),C(""),_(!1),O.current=0,V.current="",()=>{R=!1})},[t,e,n]),L(()=>{if(!s||!e||!o)return k(null),$(""),w(!1),()=>{};let R=!0;return(async()=>{w(!0),$("");try{let D=await hg({filePath:e,table:o,limit:ul,offset:r});if(!R)return;k(D)}catch(D){if(!R)return;$(D.message||"Could not load sqlite table")}finally{R&&w(!1)}})(),()=>{R=!1}},[s,e,o,r]),L(()=>{if(!t||a||!i||n)return()=>{};let R=async()=>{if(!(l||c)&&!M.current){M.current=!0;try{let U=(await ud(e)).content||"";if(U===d){S(!1);return}if(!u){p(U),f(U),ys(e),mn(e,!1,{dispatchEvent:W=>window.dispatchEvent(W)}),S(!1),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));return}A||(N("This file changed on disk. Save to overwrite or reload by re-opening.","error"),S(!0))}catch{}finally{M.current=!1}}},B=window.setInterval(R,Ay);return()=>{window.clearInterval(B)}},[t,a,i,n,l,c,e,d,u,A]),{loadedFilePathRef:V,restoredSelectionPathRef:E}};var Iy=({hasSelectedPath:t,isDiffView:e,isPreviewOnly:n,normalizedPath:s})=>{let[o,r]=y(!1),[i,a]=y(""),[l,c]=y(""),[d,u]=y({statusKind:"",isDeleted:!1});return L(()=>{let p=!0;return!t||!e||n?(r(!1),a(""),c(""),u({statusKind:"",isDeleted:!1}),()=>{p=!1}):((async()=>{r(!0),a("");try{let g=await fg(s);if(!p)return;c(String(g?.content||"")),u({statusKind:String(g?.statusKind||""),isDeleted:!!g?.isDeleted})}catch(g){if(!p)return;a(g.message||"Could not load diff"),u({statusKind:"",isDeleted:!1})}finally{p&&r(!1)}})(),()=>{p=!1})},[t,e,n,s]),{diffLoading:o,diffError:i,diffContent:l,diffStatus:d}};var Dy=({loadedFilePathRef:t,normalizedPath:e,canEditFile:n,hasSelectedPath:s,loading:o,content:r,initialContent:i})=>{L(()=>{if(t.current===e&&!(!n||!s||o)){if(r===i){ys(e),mn(e,!1,{dispatchEvent:a=>window.dispatchEvent(a)});return}za(e,r),mn(e,!0,{dispatchEvent:a=>window.dispatchEvent(a)})}},[n,s,o,r,i,e])};var Ny=({canEditFile:t,isPreviewOnly:e,isDiffView:n,viewMode:s,handleSave:o})=>{L(()=>{let r=i=>{(i.metaKey||i.ctrlKey)&&!i.shiftKey&&!i.altKey&&String(i.key||"").toLowerCase()==="s"&&(!t||e||n||s!=="edit"||(i.preventDefault(),o()))};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,e,n,s,o])};var z2=(t,e)=>{let n=String(t||"").split(` -`),s=Math.max(0,Math.min(e-1,n.length-1)),o=0;for(let r=0;r{let r=window.getComputedStyle(e),i=Number.parseFloat(r.lineHeight||""),a=Number.isFinite(i)&&i>0?i:20,l=Math.max(0,t*a-e.clientHeight*.4);e.scrollTop=l,n.current&&(n.current.scrollTop=l),s.current&&(s.current.scrollTop=l),o.current=Xo(e)},By=t=>{if(!t)return;let e=t.querySelectorAll(".line-highlight-flash");for(let n of e)n.classList.remove("line-highlight-flash")},U2=(t,e,n)=>{if(!t)return;By(t);let s=t.querySelectorAll("[data-line-row]"),o=Math.min(n,s.length-1);for(let r=e;r<=o;r+=1){let i=s[r];i&&i.classList.add("line-highlight-flash")}},Fy=({canEditFile:t,isEditBlocked:e,loading:n,hasSelectedPath:s,normalizedPath:o,loadedFilePathRef:r,restoredSelectionPathRef:i,viewMode:a,content:l,lineTarget:c=0,lineEndTarget:d=0,editorTextareaRef:u,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})=>{let m=ee("");L(()=>{c&&c>=1||m.current&&(By(p.current),m.current="")},[c,o,p]),L(()=>{if(e||!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(a!=="edit")return()=>{};if(!c||c<1)return()=>{};let h=d&&d>=c?d:c,b=`${o}:${c}-${h}`;if(m.current===b)return()=>{};let x=0,v=0,w=()=>{let $=u.current;if(!$){v+=1,v<6&&(x=window.requestAnimationFrame(w));return}let k=String(l||""),C=z2(k,c);$.setSelectionRange(C,C);let _=c-1,S=h-1;window.requestAnimationFrame(()=>{let A=u.current;A&&(Oy({lineIndex:_,textareaElement:A,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g}),U2(p.current,_,S))}),m.current=b,i.current=o};return x=window.requestAnimationFrame(w),()=>{x&&window.cancelAnimationFrame(x)}},[t,e,n,s,o,l,a,c,d,r,i,u,p,f,g]),L(()=>{if(e)return i.current="",()=>{};if(!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(i.current===o)return()=>{};if(a!=="edit")return()=>{};if(c&&c>=1)return()=>{};let h=Py(o);if(!h)return i.current=o,()=>{};let b=0,x=0,v=()=>{let w=u.current;if(!w){x+=1,x<6&&(b=window.requestAnimationFrame(v));return}let $=String(l||"").length,k=hl(h.start,$),C=hl(h.end,$);w.focus(),w.setSelectionRange(k,Math.max(k,C)),window.requestAnimationFrame(()=>{let _=u.current;if(!_)return;let S=String(l||""),A=hl(k,S.length),O=S.slice(0,A).split(` -`).length-1;Oy({lineIndex:O,textareaElement:_,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})}),i.current=o};return b=window.requestAnimationFrame(v),()=>{b&&window.cancelAnimationFrame(b)}},[t,e,n,s,o,l,a,c,r,i,u,p,f,g])};var bl=({enabled:t=!1,syncKey:e="",editorLineNumberRowRefs:n,editorHighlightLineRefs:s})=>{let o=q(()=>{if(!t)return;let r=n?.current||[],i=s?.current||[],a=Math.min(r.length,i.length);for(let l=0;l{o()},[o,e]),L(()=>{if(!t)return()=>{};let r=()=>o();return window.addEventListener("resize",r),()=>window.removeEventListener("resize",r)},[t,o]),{syncEditorLineNumberHeights:o}};var Hy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestClearSelection:r=()=>{},onRequestEdit:i=()=>{}})=>{let a=String(t||"").trim(),l=Xs(a),[c,d]=y(""),[u,p]=y(""),[f,g]=y("text"),[m,h]=y(""),[b,x]=y(""),[v,w]=y(null),[$,k]=y(""),[C,_]=y(0),[S,A]=y(!1),[O,I]=y(""),[V,E]=y(null),[M,R]=y(My),[B,D]=y(!1),[U,W]=y(!1),[G,te]=y(!1),[K,z]=y(!1),[X,de]=y(!1),[le,ie]=y(""),[se,xe]=y(!1),[he,ue]=y(!1),[me,j]=y(!1),[J,oe]=y(()=>new Set),ce=ee(null),be=ee(null),Ve=ee(null),et=ee(null),kt=ee([]),Ot=ee([]),Q=a.length>0,pe=f==="image",ne=f==="audio",Ee=f==="sqlite",ke=Q&&!se&&!e&&!pe&&!ne&&!Ee,H=String(n||"edit")==="diff",{viewScrollRatioRef:re,handleEditorScroll:ye,handlePreviewScroll:ve,handleChangeViewMode:Ie}=Ly({viewMode:M,setViewMode:R,previewRef:et,editorTextareaRef:Ve,editorLineNumbersRef:ce,editorHighlightRef:be}),{loadedFilePathRef:gt,restoredSelectionPathRef:Hc}=Ey({hasSelectedPath:Q,normalizedPath:a,isDiffView:H,isSqliteFile:Ee,sqliteSelectedTable:$,sqliteTableOffset:C,canEditFile:ke,isFolderPath:se,loading:B,saving:G,initialContent:u,isDirty:ke&&c!==u,setLoading:D,setContent:d,setInitialContent:p,setFileKind:g,setImageDataUrl:h,setAudioDataUrl:x,setSqliteSummary:w,setSqliteSelectedTable:k,setSqliteTableOffset:_,setSqliteTableLoading:A,setSqliteTableError:I,setSqliteTableData:E,setError:ie,setIsFolderPath:xe,setExternalChangeNoticeShown:j,externalChangeNoticeShown:me,viewScrollRatioRef:re}),{diffLoading:Wc,diffError:ea,diffContent:Vc,diffStatus:Cr}=Iy({hasSelectedPath:Q,isDiffView:H,isPreviewOnly:e,normalizedPath:a}),jc=F(()=>Ry(a),[a]),ta=gt.current===a,Kt=ta?c:"",bo=ke&&Kt!==(ta?u:""),xo=ke&&Yn(Ys,l),_r=ke&&!xo&&Yn(Ua,l),na=_r&&!J.has(l),fs=xo||na,yo=xo||_r,Ws=Q&&!se&&!e&&!H&&!K&&!G&&!yo,hs=F(()=>vy(a),[a]),Gn=hs==="markdown",vo=F(()=>ml(Kt),[Kt]),ge=F(()=>gl({contentLength:Kt.length,lineCount:vo,charThreshold:pl,lineThreshold:fl}),[Kt,vo]),bt=hs!=="plain"&&!ge,Pt=!ge,xt=F(()=>Gn?lu(Kt):{entries:[],body:Kt},[Kt,Gn]),fn=F(()=>bt?dl(Kt,hs):[],[Kt,bt,hs]),Ke=F(()=>Pt?Array.from({length:vo},(we,je)=>je+1):[],[vo,Pt]),St=F(()=>Gn?Le.parse(xt.body||"",{gfm:!0,breaks:!0}):"",[xt.body,Gn]);bl({enabled:bt&&M==="edit",syncKey:`${a}:${Kt.length}:${fn.length}`,editorLineNumberRowRefs:kt,editorHighlightLineRefs:Ot}),L(()=>{!Gn&&M!=="edit"&&R("edit")},[Gn,M]),L(()=>{oe(new Set)},[a]),L(()=>{try{window.localStorage.setItem(Jr,M)}catch{}},[M]),L(()=>{if(!B)return W(!1),()=>{};let we=window.setTimeout(()=>{W(!0)},_y);return()=>window.clearTimeout(we)},[B]),Dy({loadedFilePathRef:gt,normalizedPath:a,canEditFile:ke,hasSelectedPath:Q,loading:B,content:c,initialContent:u}),Fy({canEditFile:ke,isEditBlocked:fs,loading:B,hasSelectedPath:Q,normalizedPath:a,loadedFilePathRef:gt,restoredSelectionPathRef:Hc,viewMode:M,content:c,lineTarget:s,lineEndTarget:o,editorTextareaRef:Ve,editorLineNumbersRef:ce,editorHighlightRef:be,viewScrollRatioRef:re});let Gt=q(async()=>{if(!(!ke||G||!bo||fs)){te(!0),ie("");try{await ag(a,c),p(c),j(!1),ys(a),mn(a,!1,{dispatchEvent:we=>window.dispatchEvent(we)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:a}}))}catch(we){let je=we.message||"Could not save file";N(je,"error")}finally{te(!1)}}},[ke,G,bo,fs,a,c]),$o=q(async()=>{if(Ws){z(!0),ie("");try{let we=await wa(a),je=String(we?.path||a);j(!1),ys(a),mn(a,!1,{dispatchEvent:Ar=>window.dispatchEvent(Ar)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:je}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-deleted",{detail:{path:je}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),N("File deleted","success"),r()}catch(we){let je=we.message||"Could not delete file";ie(je),/path is not a file/i.test(je)?(N("Only files can be deleted","warning"),r()):N(je,"error")}finally{z(!1)}}},[Ws,a,r]),Vs=q(async()=>{if(!(!H||!Cr?.isDeleted||X)){de(!0);try{let we=await pg(a),je=String(we?.path||a);window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:je}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),N("File restored","success"),i(je)}catch(we){N(we.message||"Could not restore file","error")}finally{de(!1)}}},[Cr?.isDeleted,H,a,i,X]);Ny({canEditFile:ke,isPreviewOnly:e,isDiffView:H,viewMode:M,handleSave:Gt});let Ck=()=>{l&&(oe(we=>{let je=new Set(we);return je.add(l),je}),window.requestAnimationFrame(()=>{window.requestAnimationFrame(()=>{let we=Ve.current;we&&(we.disabled||we.readOnly||we.focus())})}))},jf=q((we,je=null)=>{!Q||!ke||(je&&du(a,je),za(a,we),mn(a,we!==u,{dispatchEvent:Ar=>window.dispatchEvent(Ar)}))},[Q,ke,a,u]);return{state:{hasSelectedPath:Q,isPreviewOnly:e,loading:B,saving:G,deleting:K,restoring:X,showDelayedLoadingSpinner:U,error:le,isFolderPath:se,isImageFile:pe,imageDataUrl:m,isAudioFile:ne,audioDataUrl:b,isSqliteFile:Ee,sqliteSummary:v,sqliteSelectedTable:$,sqliteTableOffset:C,sqliteTableLoading:S,sqliteTableError:O,sqliteTableData:V,isDiffView:H,diffLoading:Wc,diffError:ea,diffContent:Vc,diffStatus:Cr,isMarkdownFile:Gn,frontmatterCollapsed:he,previewHtml:St,viewMode:M,renderContent:Kt},derived:{pathSegments:jc,isDirty:bo,canEditFile:ke,canDeleteFile:Ws,isDeleteBlocked:yo,isEditBlocked:fs,isLockedFile:xo,isProtectedFile:_r,isProtectedLocked:na,shouldUseHighlightedEditor:bt,shouldRenderLineNumbers:Pt,parsedFrontmatter:xt,highlightedEditorLines:fn,editorLineNumbers:Ke},refs:{previewRef:et,editorLineNumbersRef:ce,editorLineNumberRowRefs:kt,editorHighlightRef:be,editorHighlightLineRefs:Ot,editorTextareaRef:Ve},actions:{setFrontmatterCollapsed:ue,setSqliteSelectedTable:k,setSqliteTableOffset:_,handleChangeViewMode:Ie,handleSave:Gt,handleDiscard:()=>{!ke||!bo||G||K||(d(u),ys(a),mn(a,!1,{dispatchEvent:we=>window.dispatchEvent(we)}),N("Changes discarded","info"))},handleDelete:$o,handleRestore:Vs,handleEditProtectedFile:Ck,handleContentInput:we=>{if(fs||e)return;let je=we.target.value;d(je),jf(je,{start:we.target.selectionStart,end:we.target.selectionEnd})},handleEditorKeyDown:we=>{if(we.key!=="Tab"||we.shiftKey||we.metaKey||we.ctrlKey||we.altKey||fs||e||!ke)return;let je=we.currentTarget;if(!je)return;we.preventDefault();let Ar=Number(je.selectionStart||0),_k=Number(je.selectionEnd||0);je.setRangeText(" ",Ar,_k,"end");let zf=je.value;d(zf),jf(zf,{start:je.selectionStart,end:je.selectionEnd})},handleEditorScroll:ye,handlePreviewScroll:ve,handleEditorSelectionChange:()=>{if(!Q||!ke||B)return;let we=Ve.current;we&&du(a,{start:we.selectionStart,end:we.selectionEnd})}},context:{normalizedPath:a}}};var dn=P.bind(T),Wy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestEdit:r=()=>{},onRequestClearSelection:i=()=>{}})=>{let[a,l]=y(!1),{state:c,derived:d,refs:u,actions:p,context:f}=Hy({filePath:t,isPreviewOnly:e,browseView:n,lineTarget:s,lineEndTarget:o,onRequestClearSelection:i,onRequestEdit:r});return!c.hasSelectedPath||c.isFolderPath?dn` +`;var _y=1e3,Ay=5e3,ul=50,pl=25e4,fl=5e3;var My=()=>{try{return String(window.localStorage.getItem(Jr)||"").trim()==="preview"?"preview":"edit"}catch{return"edit"}},Ty=()=>{try{let t=window.localStorage.getItem(Ba);if(!t)return{};let e=JSON.parse(t);return!e||typeof e!="object"?{}:e}catch{return{}}},Py=t=>{let e=String(t||"").trim();if(!e)return null;let s=Ty()[e];return!s||typeof s!="object"?null:{start:s.start,end:s.end}},du=(t,e)=>{let n=String(t||"").trim();if(!(!n||!e||typeof e!="object"))try{let s=Ty();s[n]={start:e.start,end:e.end},window.localStorage.setItem(Ba,JSON.stringify(s))}catch{}};var Ry=t=>String(t||"").split("/").map(e=>e.trim()).filter(Boolean),hl=(t,e)=>{let n=Number.parseInt(String(t??""),10);return Number.isFinite(n)?Math.max(0,Math.min(e,n)):0},ml=t=>{let e=String(t||"");return e?e.split(/\r\n|\r|\n/).length:1},gl=({contentLength:t=0,lineCount:e=1,charThreshold:n=25e4,lineThreshold:s=5e3})=>Number(t)>Number(n)||Number(e)>Number(s);var Xo=t=>{if(!t)return 0;let e=t.scrollHeight-t.clientHeight;return e<=0?0:t.scrollTop/e},ks=(t,e)=>{if(!t)return;let n=t.scrollHeight-t.clientHeight;if(n<=0){t.scrollTop=0;return}let s=Math.max(0,Math.min(1,e));t.scrollTop=n*s},Ly=({viewMode:t,setViewMode:e,previewRef:n,editorTextareaRef:s,editorLineNumbersRef:o,editorHighlightRef:r})=>{let i=ee(0),a=ee(!1);return{viewScrollRatioRef:i,isSyncingScrollRef:a,handleEditorScroll:u=>{if(a.current)return;let p=u.currentTarget.scrollTop,f=Xo(u.currentTarget);i.current=f,o.current&&(o.current.scrollTop=p,r.current&&(r.current.scrollTop=p,r.current.scrollLeft=u.currentTarget.scrollLeft),n.current&&(a.current=!0,ks(n.current,f),window.requestAnimationFrame(()=>{a.current=!1})))},handlePreviewScroll:u=>{if(a.current)return;let p=Xo(u.currentTarget);i.current=p,a.current=!0,ks(s.current,p),ks(o.current,p),ks(r.current,p),window.requestAnimationFrame(()=>{a.current=!1})},handleChangeViewMode:u=>{if(u===t)return;let p=Xo(t==="preview"?n.current:s.current);i.current=p,e(u),window.requestAnimationFrame(()=>{a.current=!0,u==="preview"?ks(n.current,p):(ks(s.current,p),ks(o.current,p),ks(r.current,p)),window.requestAnimationFrame(()=>{a.current=!1})})}}};var Ey=({hasSelectedPath:t,normalizedPath:e,isDiffView:n,isSqliteFile:s,sqliteSelectedTable:o,sqliteTableOffset:r,canEditFile:i,isFolderPath:a,loading:l,saving:c,initialContent:d,isDirty:u,setContent:p,setInitialContent:f,setFileKind:g,setImageDataUrl:m,setAudioDataUrl:h,setSqliteSummary:x,setSqliteSelectedTable:b,setSqliteTableOffset:v,setSqliteTableLoading:$,setSqliteTableError:w,setSqliteTableData:S,setError:C,setIsFolderPath:_,setExternalChangeNoticeShown:k,externalChangeNoticeShown:M,viewScrollRatioRef:O,setLoading:I})=>{let V=ee(""),E=ee(""),A=ee(!1);return L(()=>{let R=!0;return V.current="",E.current="",t?(p(""),f(""),m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null),g("text"),C(""),_(!1),k(!1),O.current=0,n?(I(!1),V.current=e,E.current="",()=>{R=!1}):((async()=>{I(!0),C(""),_(!1);try{let D=await ud(e);if(!R)return;let U=D?.kind==="image"?"image":D?.kind==="audio"?"audio":D?.kind==="sqlite"?"sqlite":"text";if(g(U),U==="image"){m(String(D?.imageDataUrl||"")),h(""),x(null),b(""),v(0),$(!1),w(""),S(null),p(""),f(""),k(!1),O.current=0,V.current=e,E.current="";return}if(U==="audio"){h(String(D?.audioDataUrl||"")),m(""),x(null),b(""),v(0),$(!1),w(""),S(null),p(""),f(""),k(!1),O.current=0,V.current=e,E.current="";return}if(U==="sqlite"){let te=D?.sqliteSummary||null,K=te?.objects||[],z=K.find(X=>X?.type==="table")?.name||K[0]?.name||"";x(te),b(z),v(0),$(!1),w(""),S(null),m(""),h(""),p(""),f(""),k(!1),O.current=0,V.current=e,E.current="";return}m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null);let W=D.content||"",G=zb(e);p(G||W),mn(e,!!(G&&G!==W),{dispatchEvent:te=>window.dispatchEvent(te)}),f(W),k(!1),O.current=0,V.current=e,E.current=""}catch(D){if(!R)return;g("text"),m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null);let U=D.message||"Could not load file";if(/path is not a file/i.test(U)){p(""),f(""),_(!0),C(""),V.current=e,E.current="";return}C(U)}finally{R&&I(!1)}})(),()=>{R=!1})):(p(""),f(""),g("text"),m(""),h(""),x(null),b(""),v(0),$(!1),w(""),S(null),C(""),_(!1),O.current=0,V.current="",()=>{R=!1})},[t,e,n]),L(()=>{if(!s||!e||!o)return S(null),w(""),$(!1),()=>{};let R=!0;return(async()=>{$(!0),w("");try{let D=await hg({filePath:e,table:o,limit:ul,offset:r});if(!R)return;S(D)}catch(D){if(!R)return;w(D.message||"Could not load sqlite table")}finally{R&&$(!1)}})(),()=>{R=!1}},[s,e,o,r]),L(()=>{if(!t||a||!i||n)return()=>{};let R=async()=>{if(!(l||c)&&!A.current){A.current=!0;try{let U=(await ud(e)).content||"";if(U===d){k(!1);return}if(!u){p(U),f(U),ys(e),mn(e,!1,{dispatchEvent:W=>window.dispatchEvent(W)}),k(!1),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh"));return}M||(N("This file changed on disk. Save to overwrite or reload by re-opening.","error"),k(!0))}catch{}finally{A.current=!1}}},B=window.setInterval(R,Ay);return()=>{window.clearInterval(B)}},[t,a,i,n,l,c,e,d,u,M]),{loadedFilePathRef:V,restoredSelectionPathRef:E}};var Iy=({hasSelectedPath:t,isDiffView:e,isPreviewOnly:n,normalizedPath:s})=>{let[o,r]=y(!1),[i,a]=y(""),[l,c]=y(""),[d,u]=y({statusKind:"",isDeleted:!1});return L(()=>{let p=!0;return!t||!e||n?(r(!1),a(""),c(""),u({statusKind:"",isDeleted:!1}),()=>{p=!1}):((async()=>{r(!0),a("");try{let g=await fg(s);if(!p)return;c(String(g?.content||"")),u({statusKind:String(g?.statusKind||""),isDeleted:!!g?.isDeleted})}catch(g){if(!p)return;a(g.message||"Could not load diff"),u({statusKind:"",isDeleted:!1})}finally{p&&r(!1)}})(),()=>{p=!1})},[t,e,n,s]),{diffLoading:o,diffError:i,diffContent:l,diffStatus:d}};var Dy=({loadedFilePathRef:t,normalizedPath:e,canEditFile:n,hasSelectedPath:s,loading:o,content:r,initialContent:i})=>{L(()=>{if(t.current===e&&!(!n||!s||o)){if(r===i){ys(e),mn(e,!1,{dispatchEvent:a=>window.dispatchEvent(a)});return}za(e,r),mn(e,!0,{dispatchEvent:a=>window.dispatchEvent(a)})}},[n,s,o,r,i,e])};var Ny=({canEditFile:t,isPreviewOnly:e,isDiffView:n,viewMode:s,handleSave:o})=>{L(()=>{let r=i=>{(i.metaKey||i.ctrlKey)&&!i.shiftKey&&!i.altKey&&String(i.key||"").toLowerCase()==="s"&&(!t||e||n||s!=="edit"||(i.preventDefault(),o()))};return window.addEventListener("keydown",r),()=>window.removeEventListener("keydown",r)},[t,e,n,s,o])};var j2=(t,e)=>{let n=String(t||"").split(` +`),s=Math.max(0,Math.min(e-1,n.length-1)),o=0;for(let r=0;r{let r=window.getComputedStyle(e),i=Number.parseFloat(r.lineHeight||""),a=Number.isFinite(i)&&i>0?i:20,l=Math.max(0,t*a-e.clientHeight*.4);e.scrollTop=l,n.current&&(n.current.scrollTop=l),s.current&&(s.current.scrollTop=l),o.current=Xo(e)},By=t=>{if(!t)return;let e=t.querySelectorAll(".line-highlight-flash");for(let n of e)n.classList.remove("line-highlight-flash")},z2=(t,e,n)=>{if(!t)return;By(t);let s=t.querySelectorAll("[data-line-row]"),o=Math.min(n,s.length-1);for(let r=e;r<=o;r+=1){let i=s[r];i&&i.classList.add("line-highlight-flash")}},Fy=({canEditFile:t,isEditBlocked:e,loading:n,hasSelectedPath:s,normalizedPath:o,loadedFilePathRef:r,restoredSelectionPathRef:i,viewMode:a,content:l,lineTarget:c=0,lineEndTarget:d=0,editorTextareaRef:u,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})=>{let m=ee("");L(()=>{c&&c>=1||m.current&&(By(p.current),m.current="")},[c,o,p]),L(()=>{if(e||!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(a!=="edit")return()=>{};if(!c||c<1)return()=>{};let h=d&&d>=c?d:c,x=`${o}:${c}-${h}`;if(m.current===x)return()=>{};let b=0,v=0,$=()=>{let w=u.current;if(!w){v+=1,v<6&&(b=window.requestAnimationFrame($));return}let S=String(l||""),C=j2(S,c);w.setSelectionRange(C,C);let _=c-1,k=h-1;window.requestAnimationFrame(()=>{let M=u.current;M&&(Oy({lineIndex:_,textareaElement:M,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g}),z2(p.current,_,k))}),m.current=x,i.current=o};return b=window.requestAnimationFrame($),()=>{b&&window.cancelAnimationFrame(b)}},[t,e,n,s,o,l,a,c,d,r,i,u,p,f,g]),L(()=>{if(e)return i.current="",()=>{};if(!t||n||!s)return()=>{};if(r.current!==o)return()=>{};if(i.current===o)return()=>{};if(a!=="edit")return()=>{};if(c&&c>=1)return()=>{};let h=Py(o);if(!h)return i.current=o,()=>{};let x=0,b=0,v=()=>{let $=u.current;if(!$){b+=1,b<6&&(x=window.requestAnimationFrame(v));return}let w=String(l||"").length,S=hl(h.start,w),C=hl(h.end,w);$.focus(),$.setSelectionRange(S,Math.max(S,C)),window.requestAnimationFrame(()=>{let _=u.current;if(!_)return;let k=String(l||""),M=hl(S,k.length),O=k.slice(0,M).split(` +`).length-1;Oy({lineIndex:O,textareaElement:_,editorLineNumbersRef:p,editorHighlightRef:f,viewScrollRatioRef:g})}),i.current=o};return x=window.requestAnimationFrame(v),()=>{x&&window.cancelAnimationFrame(x)}},[t,e,n,s,o,l,a,c,r,i,u,p,f,g])};var bl=({enabled:t=!1,syncKey:e="",editorLineNumberRowRefs:n,editorHighlightLineRefs:s})=>{let o=q(()=>{if(!t)return;let r=n?.current||[],i=s?.current||[],a=Math.min(r.length,i.length);for(let l=0;l{o()},[o,e]),L(()=>{if(!t)return()=>{};let r=()=>o();return window.addEventListener("resize",r),()=>window.removeEventListener("resize",r)},[t,o]),{syncEditorLineNumberHeights:o}};var Hy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestClearSelection:r=()=>{},onRequestEdit:i=()=>{}})=>{let a=String(t||"").trim(),l=Xs(a),[c,d]=y(""),[u,p]=y(""),[f,g]=y("text"),[m,h]=y(""),[x,b]=y(""),[v,$]=y(null),[w,S]=y(""),[C,_]=y(0),[k,M]=y(!1),[O,I]=y(""),[V,E]=y(null),[A,R]=y(My),[B,D]=y(!1),[U,W]=y(!1),[G,te]=y(!1),[K,z]=y(!1),[X,de]=y(!1),[le,ie]=y(""),[se,xe]=y(!1),[he,ue]=y(!1),[me,j]=y(!1),[J,oe]=y(()=>new Set),ce=ee(null),be=ee(null),Ve=ee(null),et=ee(null),kt=ee([]),Ot=ee([]),Q=a.length>0,pe=f==="image",ne=f==="audio",Ee=f==="sqlite",ke=Q&&!se&&!e&&!pe&&!ne&&!Ee,H=String(n||"edit")==="diff",{viewScrollRatioRef:re,handleEditorScroll:ye,handlePreviewScroll:ve,handleChangeViewMode:Ie}=Ly({viewMode:A,setViewMode:R,previewRef:et,editorTextareaRef:Ve,editorLineNumbersRef:ce,editorHighlightRef:be}),{loadedFilePathRef:gt,restoredSelectionPathRef:Hc}=Ey({hasSelectedPath:Q,normalizedPath:a,isDiffView:H,isSqliteFile:Ee,sqliteSelectedTable:w,sqliteTableOffset:C,canEditFile:ke,isFolderPath:se,loading:B,saving:G,initialContent:u,isDirty:ke&&c!==u,setLoading:D,setContent:d,setInitialContent:p,setFileKind:g,setImageDataUrl:h,setAudioDataUrl:b,setSqliteSummary:$,setSqliteSelectedTable:S,setSqliteTableOffset:_,setSqliteTableLoading:M,setSqliteTableError:I,setSqliteTableData:E,setError:ie,setIsFolderPath:xe,setExternalChangeNoticeShown:j,externalChangeNoticeShown:me,viewScrollRatioRef:re}),{diffLoading:Wc,diffError:ea,diffContent:Vc,diffStatus:Cr}=Iy({hasSelectedPath:Q,isDiffView:H,isPreviewOnly:e,normalizedPath:a}),jc=F(()=>Ry(a),[a]),ta=gt.current===a,Kt=ta?c:"",bo=ke&&Kt!==(ta?u:""),xo=ke&&Yn(Ys,l),_r=ke&&!xo&&Yn(Ua,l),na=_r&&!J.has(l),fs=xo||na,yo=xo||_r,Ws=Q&&!se&&!e&&!H&&!K&&!G&&!yo,hs=F(()=>vy(a),[a]),Gn=hs==="markdown",vo=F(()=>ml(Kt),[Kt]),ge=F(()=>gl({contentLength:Kt.length,lineCount:vo,charThreshold:pl,lineThreshold:fl}),[Kt,vo]),bt=hs!=="plain"&&!ge,Pt=!ge,xt=F(()=>Gn?lu(Kt):{entries:[],body:Kt},[Kt,Gn]),fn=F(()=>bt?dl(Kt,hs):[],[Kt,bt,hs]),Ke=F(()=>Pt?Array.from({length:vo},(we,je)=>je+1):[],[vo,Pt]),St=F(()=>Gn?Le.parse(xt.body||"",{gfm:!0,breaks:!0}):"",[xt.body,Gn]);bl({enabled:bt&&A==="edit",syncKey:`${a}:${Kt.length}:${fn.length}`,editorLineNumberRowRefs:kt,editorHighlightLineRefs:Ot}),L(()=>{!Gn&&A!=="edit"&&R("edit")},[Gn,A]),L(()=>{oe(new Set)},[a]),L(()=>{try{window.localStorage.setItem(Jr,A)}catch{}},[A]),L(()=>{if(!B)return W(!1),()=>{};let we=window.setTimeout(()=>{W(!0)},_y);return()=>window.clearTimeout(we)},[B]),Dy({loadedFilePathRef:gt,normalizedPath:a,canEditFile:ke,hasSelectedPath:Q,loading:B,content:c,initialContent:u}),Fy({canEditFile:ke,isEditBlocked:fs,loading:B,hasSelectedPath:Q,normalizedPath:a,loadedFilePathRef:gt,restoredSelectionPathRef:Hc,viewMode:A,content:c,lineTarget:s,lineEndTarget:o,editorTextareaRef:Ve,editorLineNumbersRef:ce,editorHighlightRef:be,viewScrollRatioRef:re});let Gt=q(async()=>{if(!(!ke||G||!bo||fs)){te(!0),ie("");try{await ag(a,c),p(c),j(!1),ys(a),mn(a,!1,{dispatchEvent:we=>window.dispatchEvent(we)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:a}}))}catch(we){let je=we.message||"Could not save file";N(je,"error")}finally{te(!1)}}},[ke,G,bo,fs,a,c]),$o=q(async()=>{if(Ws){z(!0),ie("");try{let we=await wa(a),je=String(we?.path||a);j(!1),ys(a),mn(a,!1,{dispatchEvent:Ar=>window.dispatchEvent(Ar)}),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:je}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-deleted",{detail:{path:je}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),N("File deleted","success"),r()}catch(we){let je=we.message||"Could not delete file";ie(je),/path is not a file/i.test(je)?(N("Only files can be deleted","warning"),r()):N(je,"error")}finally{z(!1)}}},[Ws,a,r]),Vs=q(async()=>{if(!(!H||!Cr?.isDeleted||X)){de(!0);try{let we=await pg(a),je=String(we?.path||a);window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:je}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),N("File restored","success"),i(je)}catch(we){N(we.message||"Could not restore file","error")}finally{de(!1)}}},[Cr?.isDeleted,H,a,i,X]);Ny({canEditFile:ke,isPreviewOnly:e,isDiffView:H,viewMode:A,handleSave:Gt});let Sk=()=>{l&&(oe(we=>{let je=new Set(we);return je.add(l),je}),window.requestAnimationFrame(()=>{window.requestAnimationFrame(()=>{let we=Ve.current;we&&(we.disabled||we.readOnly||we.focus())})}))},jf=q((we,je=null)=>{!Q||!ke||(je&&du(a,je),za(a,we),mn(a,we!==u,{dispatchEvent:Ar=>window.dispatchEvent(Ar)}))},[Q,ke,a,u]);return{state:{hasSelectedPath:Q,isPreviewOnly:e,loading:B,saving:G,deleting:K,restoring:X,showDelayedLoadingSpinner:U,error:le,isFolderPath:se,isImageFile:pe,imageDataUrl:m,isAudioFile:ne,audioDataUrl:x,isSqliteFile:Ee,sqliteSummary:v,sqliteSelectedTable:w,sqliteTableOffset:C,sqliteTableLoading:k,sqliteTableError:O,sqliteTableData:V,isDiffView:H,diffLoading:Wc,diffError:ea,diffContent:Vc,diffStatus:Cr,isMarkdownFile:Gn,frontmatterCollapsed:he,previewHtml:St,viewMode:A,renderContent:Kt},derived:{pathSegments:jc,isDirty:bo,canEditFile:ke,canDeleteFile:Ws,isDeleteBlocked:yo,isEditBlocked:fs,isLockedFile:xo,isProtectedFile:_r,isProtectedLocked:na,shouldUseHighlightedEditor:bt,shouldRenderLineNumbers:Pt,parsedFrontmatter:xt,highlightedEditorLines:fn,editorLineNumbers:Ke},refs:{previewRef:et,editorLineNumbersRef:ce,editorLineNumberRowRefs:kt,editorHighlightRef:be,editorHighlightLineRefs:Ot,editorTextareaRef:Ve},actions:{setFrontmatterCollapsed:ue,setSqliteSelectedTable:S,setSqliteTableOffset:_,handleChangeViewMode:Ie,handleSave:Gt,handleDiscard:()=>{!ke||!bo||G||K||(d(u),ys(a),mn(a,!1,{dispatchEvent:we=>window.dispatchEvent(we)}),N("Changes discarded","info"))},handleDelete:$o,handleRestore:Vs,handleEditProtectedFile:Sk,handleContentInput:we=>{if(fs||e)return;let je=we.target.value;d(je),jf(je,{start:we.target.selectionStart,end:we.target.selectionEnd})},handleEditorKeyDown:we=>{if(we.key!=="Tab"||we.shiftKey||we.metaKey||we.ctrlKey||we.altKey||fs||e||!ke)return;let je=we.currentTarget;if(!je)return;we.preventDefault();let Ar=Number(je.selectionStart||0),Ck=Number(je.selectionEnd||0);je.setRangeText(" ",Ar,Ck,"end");let zf=je.value;d(zf),jf(zf,{start:je.selectionStart,end:je.selectionEnd})},handleEditorScroll:ye,handlePreviewScroll:ve,handleEditorSelectionChange:()=>{if(!Q||!ke||B)return;let we=Ve.current;we&&du(a,{start:we.selectionStart,end:we.selectionEnd})}},context:{normalizedPath:a}}};var dn=P.bind(T),Wy=({filePath:t="",isPreviewOnly:e=!1,browseView:n="edit",lineTarget:s=0,lineEndTarget:o=0,onRequestEdit:r=()=>{},onRequestClearSelection:i=()=>{}})=>{let[a,l]=y(!1),{state:c,derived:d,refs:u,actions:p,context:f}=Hy({filePath:t,isPreviewOnly:e,browseView:n,lineTarget:s,lineEndTarget:o,onRequestClearSelection:i,onRequestEdit:r});return!c.hasSelectedPath||c.isFolderPath?dn`
            [ ]
            Browse and edit files
            Syncs to git
            @@ -4298,7 +4298,7 @@ ${(n||"").split(` onConfirm=${async()=>{await p.handleDelete(),l(!1)}} />
            - `};var K2=P.bind(T),uu=({activeBrowsePath:t="",browseView:e="edit",lineTarget:n=0,lineEndTarget:s=0,selectedBrowsePath:o="",onNavigateToBrowseFile:r=()=>{},onEditSelectedBrowseFile:i=()=>{},onClearSelection:a=()=>{}})=>K2` + `};var U2=P.bind(T),uu=({activeBrowsePath:t="",browseView:e="edit",lineTarget:n=0,lineEndTarget:s=0,selectedBrowsePath:o="",onNavigateToBrowseFile:r=()=>{},onEditSelectedBrowseFile:i=()=>{},onClearSelection:a=()=>{}})=>U2`
            <${Wy} filePath=${t} @@ -4310,7 +4310,7 @@ ${(n||"").split(` onRequestClearSelection=${a} />
            -`;var Ss="__all__",G2={0:"Sun",1:"Mon",2:"Tue",3:"Wed",4:"Thu",5:"Fri",6:"Sat",7:"Sun"},q2=({hourField:t,minuteField:e})=>{let n=Number.parseInt(String(t||""),10),s=Number.parseInt(String(e||""),10);if(!Number.isFinite(n)||!Number.isFinite(s))return"";let o=(n%24+24)%24,r=o>=12?"pm":"am",i=o%12===0?12:o%12,a=String(s).padStart(2,"0");return`${i}:${a}${r}`},Vy=t=>{let e=Number.parseInt(String(t||""),10);if(!Number.isFinite(e))return"";let n=(e%24+24)%24,s=n>=12?"pm":"am";return`${n%12===0?12:n%12}${s}`},J2=(t="")=>{let e=String(t||"").match(/^(\d{1,2})-(\d{1,2})$/);if(!e)return"";let n=Vy(e[1]),s=Vy(e[2]);return!n||!s?"":`${n}-${s}`},jy=(t="")=>{let e=String(t||"").trim().split(/\s+/);if(e.length<5)return"";let[n,s,o,r,i]=e;if(o==="*"&&r==="*"&&i==="*"&&s==="*"&&/^\*\/\d+$/.test(n))return`Every ${n.slice(2)}m`;if(o==="*"&&r==="*"&&i==="*"&&n==="0"&&/^\*\/\d+$/.test(s))return`Every ${s.slice(2)}h`;let a=q2({hourField:s,minuteField:n});if(o==="*"&&r==="*"&&i==="1-5"&&/^\*\/\d+$/.test(n)&&/^\d{1,2}-\d{1,2}$/.test(s)){let l=n.slice(2),c=J2(s);if(c)return`Every ${l}m, ${c} weekdays`}if(o==="*"&&r==="*"&&i==="1-5"&&a)return`Weekdays at ${a}`;if(o==="*"&&r==="*"&&/^([0-7])(,[0-7])*$/.test(i)&&a)return`Every ${i.split(",").map(c=>G2[c]||c).join(", ")} at ${a}`;if(o==="*"&&r==="*"&&i==="*"&&a)return`Daily at ${a}`;if(/^\d{1,2}$/.test(o)&&r==="*"&&i==="*"&&a){let l=Number.parseInt(o,10);if(Number.isFinite(l)&&l>=1&&l<=31)return`Monthly on day ${l} at ${a}`}return""},zy=(t="")=>String(t||"").trim().toLowerCase(),Z2=()=>{try{return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone||""}catch{return""}},Uy=({scheduleTimeZone:t="",includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""})=>{let o=zy(t);if(!o)return!1;if(e)return!0;if(!n)return!1;let r=zy(s||Z2());return r?r!==o:!0},Qo=(t={},{includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""}={})=>{let o=String(t?.kind||"").trim();if(o==="every"){let i=Number(t?.everyMs||0);return i>0?`Every ${bn(i)}`:"Every interval"}if(o==="at")return`At ${Xn(t?.at,{fallback:"scheduled time"})}`;if(o==="cron"){let i=String(t?.expr||"").trim();if(!i)return"Cron";let a=jy(i),l=String(t?.tz||"").trim(),c=Uy({scheduleTimeZone:l,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return a?c?`${a} (${l})`:a:c?`${i} (${l})`:i}let r=String(t?.expr||t?.cron||t?.cronExpr||"").trim();if(r){let i=jy(r),a=String(t?.tz||t?.timezone||"").trim(),l=Uy({scheduleTimeZone:a,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return i?l?`${i} (${a})`:i:l?`${r} (${a})`:r}return"Unknown schedule"},Y2=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=n-e,o=s>0,r=Math.round(Math.abs(s)/1e3);if(r<60)return o?"in <1m":"just now";let i=Math.round(r/60);if(i<60)return o?`in ${i}m`:`${i}m ago`;let a=Math.round(i/60);if(a<24)return o?`in ${a}h`:`${a}h ago`;let l=Math.round(a/24);return o?`in ${l}d`:`${l}d ago`},Ky=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=Math.abs(n-e),o=Math.max(0,Math.round(s/1e3));if(o<60)return`${o}s`;let r=Math.round(o/60);if(r<60)return`${r}m`;let i=Math.round(r/60);if(i<24)return`${i}h`;let a=Math.round(i/24);return a<30?`${a}d`:`${Math.round(a/30)}mo`},Gy=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";if(n>=e)return Y2(n,e);let s=e-n;if(s<60*1e3)return"due now";let o=Math.round(s/1e3);if(o<60)return"overdue by <1m";let r=Math.round(o/60);if(r<60)return`overdue by ${r}m`;let i=Math.round(r/60);return i<24?`overdue by ${i}h`:`overdue by ${Math.round(i/24)}d`},qy=(t={})=>{if(t.enabled===!1)return"disabled";if(t?.state?.runningAtMs)return"running";let e=String(t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase();return e==="error"?"error":e==="ok"?"ok":"unknown"},Jy=(t="")=>t==="ok"?"bg-green-500":t==="error"?"bg-red-500":t==="running"?"bg-yellow-400":"bg-gray-500",Te=t=>Jt(Number(t||0)),it=t=>gn(Number(t||0)),rn=(t={})=>{let e=t?.usage||{},s=[e?.input_tokens,e?.inputTokens,e?.output_tokens,e?.outputTokens,e?.cache_read_tokens,e?.cacheReadTokens,e?.cache_write_tokens,e?.cacheWriteTokens].reduce((r,i)=>{let a=Number(i);return!Number.isFinite(a)||a<0?r:r+a},0);if(s>0)return s;let o=[e?.total_tokens,e?.totalTokens,t?.total_tokens,t?.totalTokens];for(let r of o){let i=Number(r);if(Number.isFinite(i)&&i>=0)return i}return 0},$n=(t={})=>{let e=t?.usage||{},n=[t?.estimatedCost,t?.estimated_cost,e?.estimatedCost,e?.estimated_cost,e?.totalCost,e?.total_cost,e?.costUsd,e?.cost];for(let s of n){let o=Number(s);if(Number.isFinite(o)&&o>=0)return o}return null},X2=(t={})=>{let e=t?.state||{};try{return String(JSON.stringify(e)||"").toUpperCase().includes("HEARTBEAT_OK")}catch{return!1}},Q2=({jobId:t="",bulkRunsByJobId:e={}}={})=>{let n=String(t||"").trim();if(!n)return!1;let s=Array.isArray(e?.[n]?.entries)?e[n].entries:[];if(s.length===0)return!1;let o=s.reduce((i,a)=>Number(a?.ts||0)>Number(i?.ts||0)?a:i);return[o?.summary,o?.result?.summary,o?.payload?.summary].some(i=>String(i||"").toUpperCase().includes("HEARTBEAT_OK"))},e_=({job:t={},jobId:e="",bulkRunsByJobId:n={}}={})=>{let s=String(e||"").trim(),o=Array.isArray(n?.[s]?.entries)?n[s].entries:[],r=o.length>0?o.reduce((a,l)=>Number(l?.ts||0)>Number(a?.ts||0)?l:a):null;return String(r?.status||t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase()},Zy=(t=[],e={})=>{let n=[];return t.forEach(s=>{let o=String(s?.id||""),r=String(s?.payload?.message||"").toLowerCase();String(s?.delivery?.mode||"").toLowerCase()==="none"&&(r.includes("message tool")||r.includes("send to telegram"))&&n.push({tone:"warning",jobId:String(s?.id||""),title:`${s.name||s.id}: delivery mismatch`,body:"Job uses delivery.mode=none but prompt asks to send via message tool."}),Number(s?.state?.consecutiveErrors||0)>=2&&n.push({tone:"error",jobId:String(s?.id||""),title:`${s.name||s.id}: repeated errors`,body:`Consecutive errors: ${Number(s?.state?.consecutiveErrors||0)}.`});let a=e_({job:s,jobId:o,bulkRunsByJobId:e});s?.state?.lastDelivered===!1&&String(s?.state?.lastDeliveryStatus||"").trim().toLowerCase()==="not-delivered"&&a!=="ok"&&!X2(s)&&!Q2({jobId:o,bulkRunsByJobId:e})&&n.push({tone:"warning",jobId:o,title:`${s.name||s.id}: not delivered`,body:"Latest run completed but was not delivered."})}),n.slice(0,8)},Yy=(t=[])=>t.filter(n=>n?.enabled!==!1).map(n=>Number(n?.state?.nextRunAtMs||0)).filter(n=>Number.isFinite(n)&&n>0).sort((n,s)=>n-s)[0]||null;var xl=P.bind(T),t_=["daily","weekly","monthly","other"],n_={daily:"Daily",weekly:"Weekly",monthly:"Monthly",other:"Other"},s_=60,ts=(t="")=>{let e=Number.parseInt(String(t||"").trim(),10);return Number.isFinite(e)?e:null},er=t=>{if(!Number.isFinite(t))return null;let e=t===7?0:t;return e>=0&&e<=6?e:null},o_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return null;let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=[];return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ts(r[1])),l=er(ts(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.push(c);else{for(let c=a;c<=6;c+=1)s.push(c);for(let c=0;c<=l;c+=1)s.push(c)}return}let i=er(ts(o));i!=null&&s.push(i)}),s.length===0?null:Math.min(...s)},r_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return[];let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=new Set;return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ts(r[1])),l=er(ts(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.add(c);else{for(let c=a;c<=6;c+=1)s.add(c);for(let c=0;c<=l;c+=1)s.add(c)}return}let i=er(ts(o));i!=null&&s.add(i)}),[...s].sort((o,r)=>o-r)},Qy=(t="")=>{let e=r_(t);return e.length!==5?!1:e.join(",")==="1,2,3,4,5"},i_=({minuteField:t="",hourField:e=""})=>{let n=ts(t),s=ts(e);return n==null||s==null||n<0||n>59||s<0||s>23?null:s*s_+n},a_=(t={})=>{let n=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(n.length<5)return null;let[s,o,r,i,a]=n;return{minuteField:s,hourField:o,dayOfMonthField:r,monthField:i,dayOfWeekField:a}},Xy=(t={},e="other")=>{let n=t?.schedule||{},s=String(n?.kind||"").trim().toLowerCase(),o=a_(n),r=o?i_({minuteField:o.minuteField,hourField:o.hourField}):null,i=String(t?.name||t?.id||"").toLowerCase();if(e==="daily"){if(s==="every"){let a=Number(n?.everyMs||Number.MAX_SAFE_INTEGER);return{groupRank:0,primary:Number.isFinite(a)?a:Number.MAX_SAFE_INTEGER,secondary:i}}if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&o.dayOfWeekField==="*"&&r!=null)return{groupRank:1,primary:r,secondary:i};if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&Qy(o.dayOfWeekField)&&r!=null)return{groupRank:2,primary:r,secondary:i}}if(e==="weekly"&&o){let a=o_(o.dayOfWeekField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}if(e==="monthly"&&o){let a=ts(o.dayOfMonthField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}return{groupRank:99,primary:Number.MAX_SAFE_INTEGER,secondary:Number.MAX_SAFE_INTEGER,tertiary:i}},yl=(t,e)=>t===e?0:t>e?1:-1,vl=(t=[],e="other")=>[...t].sort((n,s)=>{let o=Xy(n,e),r=Xy(s,e),i=yl(o.groupRank,r.groupRank);if(i!==0)return i;let a=yl(o.primary,r.primary);if(a!==0)return a;let l=yl(o.secondary,r.secondary);return l!==0?l:yl(o.tertiary,r.tertiary)}),l_=(t={})=>{if(String(t?.kind||"").trim().toLowerCase()==="every"){let o=Number(t?.everyMs||0);if(Number.isFinite(o)&&o>0){if(o<=1440*60*1e3)return"daily";if(o<=10080*60*1e3)return"weekly";if(o<=744*60*60*1e3)return"monthly"}return"other"}let s=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(s.length>=5){let[,,o,r,i]=s;if(o==="*"&&r==="*"&&i==="*")return"daily";if(o==="*"&&r==="*"&&i!=="*")return Qy(i)?"daily":"weekly";if(o!=="*"&&r==="*")return"monthly"}return"other"},ev=({jobs:t=[],selectedRouteKey:e=Ss,onSelectAllJobs:n=()=>{},onSelectJob:s=()=>{}})=>{let o=ee(null),[r,i]=y("");L(()=>{let d=window.requestAnimationFrame(()=>{o.current?.focus()});return()=>{window.cancelAnimationFrame(d)}},[]);let a=String(r||"").trim().toLowerCase(),l=F(()=>a?t.filter(d=>{let u=String(d?.name||"").toLowerCase(),p=String(d?.id||"").toLowerCase();return u.includes(a)||p.includes(a)}):t,[t,a]),c=F(()=>{let d={daily:[],weekly:[],monthly:[],other:[]};return l.forEach(u=>{let p=l_(u?.schedule);d[p]?d[p].push(u):d.other.push(u)}),{daily:vl(d.daily,"daily"),weekly:vl(d.weekly,"weekly"),monthly:vl(d.monthly,"monthly"),other:vl(d.other,"other")}},[l]);return xl` +`;var Ss="__all__",K2={0:"Sun",1:"Mon",2:"Tue",3:"Wed",4:"Thu",5:"Fri",6:"Sat",7:"Sun"},G2=({hourField:t,minuteField:e})=>{let n=Number.parseInt(String(t||""),10),s=Number.parseInt(String(e||""),10);if(!Number.isFinite(n)||!Number.isFinite(s))return"";let o=(n%24+24)%24,r=o>=12?"pm":"am",i=o%12===0?12:o%12,a=String(s).padStart(2,"0");return`${i}:${a}${r}`},Vy=t=>{let e=Number.parseInt(String(t||""),10);if(!Number.isFinite(e))return"";let n=(e%24+24)%24,s=n>=12?"pm":"am";return`${n%12===0?12:n%12}${s}`},q2=(t="")=>{let e=String(t||"").match(/^(\d{1,2})-(\d{1,2})$/);if(!e)return"";let n=Vy(e[1]),s=Vy(e[2]);return!n||!s?"":`${n}-${s}`},jy=(t="")=>{let e=String(t||"").trim().split(/\s+/);if(e.length<5)return"";let[n,s,o,r,i]=e;if(o==="*"&&r==="*"&&i==="*"&&s==="*"&&/^\*\/\d+$/.test(n))return`Every ${n.slice(2)}m`;if(o==="*"&&r==="*"&&i==="*"&&n==="0"&&/^\*\/\d+$/.test(s))return`Every ${s.slice(2)}h`;let a=G2({hourField:s,minuteField:n});if(o==="*"&&r==="*"&&i==="1-5"&&/^\*\/\d+$/.test(n)&&/^\d{1,2}-\d{1,2}$/.test(s)){let l=n.slice(2),c=q2(s);if(c)return`Every ${l}m, ${c} weekdays`}if(o==="*"&&r==="*"&&i==="1-5"&&a)return`Weekdays at ${a}`;if(o==="*"&&r==="*"&&/^([0-7])(,[0-7])*$/.test(i)&&a)return`Every ${i.split(",").map(c=>K2[c]||c).join(", ")} at ${a}`;if(o==="*"&&r==="*"&&i==="*"&&a)return`Daily at ${a}`;if(/^\d{1,2}$/.test(o)&&r==="*"&&i==="*"&&a){let l=Number.parseInt(o,10);if(Number.isFinite(l)&&l>=1&&l<=31)return`Monthly on day ${l} at ${a}`}return""},zy=(t="")=>String(t||"").trim().toLowerCase(),J2=()=>{try{return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone||""}catch{return""}},Uy=({scheduleTimeZone:t="",includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""})=>{let o=zy(t);if(!o)return!1;if(e)return!0;if(!n)return!1;let r=zy(s||J2());return r?r!==o:!0},Qo=(t={},{includeTimeZone:e=!1,includeTimeZoneWhenDifferent:n=!1,clientTimeZone:s=""}={})=>{let o=String(t?.kind||"").trim();if(o==="every"){let i=Number(t?.everyMs||0);return i>0?`Every ${bn(i)}`:"Every interval"}if(o==="at")return`At ${Xn(t?.at,{fallback:"scheduled time"})}`;if(o==="cron"){let i=String(t?.expr||"").trim();if(!i)return"Cron";let a=jy(i),l=String(t?.tz||"").trim(),c=Uy({scheduleTimeZone:l,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return a?c?`${a} (${l})`:a:c?`${i} (${l})`:i}let r=String(t?.expr||t?.cron||t?.cronExpr||"").trim();if(r){let i=jy(r),a=String(t?.tz||t?.timezone||"").trim(),l=Uy({scheduleTimeZone:a,includeTimeZone:e,includeTimeZoneWhenDifferent:n,clientTimeZone:s});return i?l?`${i} (${a})`:i:l?`${r} (${a})`:r}return"Unknown schedule"},Z2=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=n-e,o=s>0,r=Math.round(Math.abs(s)/1e3);if(r<60)return o?"in <1m":"just now";let i=Math.round(r/60);if(i<60)return o?`in ${i}m`:`${i}m ago`;let a=Math.round(i/60);if(a<24)return o?`in ${a}h`:`${a}h ago`;let l=Math.round(a/24);return o?`in ${l}d`:`${l}d ago`},Ky=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";let s=Math.abs(n-e),o=Math.max(0,Math.round(s/1e3));if(o<60)return`${o}s`;let r=Math.round(o/60);if(r<60)return`${r}m`;let i=Math.round(r/60);if(i<24)return`${i}h`;let a=Math.round(i/24);return a<30?`${a}d`:`${Math.round(a/30)}mo`},Gy=(t,e=Date.now())=>{let n=Number(t||0);if(!Number.isFinite(n)||n<=0)return"\u2014";if(n>=e)return Z2(n,e);let s=e-n;if(s<60*1e3)return"due now";let o=Math.round(s/1e3);if(o<60)return"overdue by <1m";let r=Math.round(o/60);if(r<60)return`overdue by ${r}m`;let i=Math.round(r/60);return i<24?`overdue by ${i}h`:`overdue by ${Math.round(i/24)}d`},qy=(t={})=>{if(t.enabled===!1)return"disabled";if(t?.state?.runningAtMs)return"running";let e=String(t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase();return e==="error"?"error":e==="ok"?"ok":"unknown"},Jy=(t="")=>t==="ok"?"bg-green-500":t==="error"?"bg-red-500":t==="running"?"bg-yellow-400":"bg-gray-500",Te=t=>Jt(Number(t||0)),it=t=>gn(Number(t||0)),rn=(t={})=>{let e=t?.usage||{},s=[e?.input_tokens,e?.inputTokens,e?.output_tokens,e?.outputTokens,e?.cache_read_tokens,e?.cacheReadTokens,e?.cache_write_tokens,e?.cacheWriteTokens].reduce((r,i)=>{let a=Number(i);return!Number.isFinite(a)||a<0?r:r+a},0);if(s>0)return s;let o=[e?.total_tokens,e?.totalTokens,t?.total_tokens,t?.totalTokens];for(let r of o){let i=Number(r);if(Number.isFinite(i)&&i>=0)return i}return 0},$n=(t={})=>{let e=t?.usage||{},n=[t?.estimatedCost,t?.estimated_cost,e?.estimatedCost,e?.estimated_cost,e?.totalCost,e?.total_cost,e?.costUsd,e?.cost];for(let s of n){let o=Number(s);if(Number.isFinite(o)&&o>=0)return o}return null},Y2=(t={})=>{let e=t?.state||{};try{return String(JSON.stringify(e)||"").toUpperCase().includes("HEARTBEAT_OK")}catch{return!1}},X2=({jobId:t="",bulkRunsByJobId:e={}}={})=>{let n=String(t||"").trim();if(!n)return!1;let s=Array.isArray(e?.[n]?.entries)?e[n].entries:[];if(s.length===0)return!1;let o=s.reduce((i,a)=>Number(a?.ts||0)>Number(i?.ts||0)?a:i);return[o?.summary,o?.result?.summary,o?.payload?.summary].some(i=>String(i||"").toUpperCase().includes("HEARTBEAT_OK"))},Q2=({job:t={},jobId:e="",bulkRunsByJobId:n={}}={})=>{let s=String(e||"").trim(),o=Array.isArray(n?.[s]?.entries)?n[s].entries:[],r=o.length>0?o.reduce((a,l)=>Number(l?.ts||0)>Number(a?.ts||0)?l:a):null;return String(r?.status||t?.state?.lastStatus||t?.state?.lastRunStatus||"").trim().toLowerCase()},Zy=(t=[],e={})=>{let n=[];return t.forEach(s=>{let o=String(s?.id||""),r=String(s?.payload?.message||"").toLowerCase();String(s?.delivery?.mode||"").toLowerCase()==="none"&&(r.includes("message tool")||r.includes("send to telegram"))&&n.push({tone:"warning",jobId:String(s?.id||""),title:`${s.name||s.id}: delivery mismatch`,body:"Job uses delivery.mode=none but prompt asks to send via message tool."}),Number(s?.state?.consecutiveErrors||0)>=2&&n.push({tone:"error",jobId:String(s?.id||""),title:`${s.name||s.id}: repeated errors`,body:`Consecutive errors: ${Number(s?.state?.consecutiveErrors||0)}.`});let a=Q2({job:s,jobId:o,bulkRunsByJobId:e});s?.state?.lastDelivered===!1&&String(s?.state?.lastDeliveryStatus||"").trim().toLowerCase()==="not-delivered"&&a!=="ok"&&!Y2(s)&&!X2({jobId:o,bulkRunsByJobId:e})&&n.push({tone:"warning",jobId:o,title:`${s.name||s.id}: not delivered`,body:"Latest run completed but was not delivered."})}),n.slice(0,8)},Yy=(t=[])=>t.filter(n=>n?.enabled!==!1).map(n=>Number(n?.state?.nextRunAtMs||0)).filter(n=>Number.isFinite(n)&&n>0).sort((n,s)=>n-s)[0]||null;var xl=P.bind(T),e_=["daily","weekly","monthly","other"],t_={daily:"Daily",weekly:"Weekly",monthly:"Monthly",other:"Other"},n_=60,ts=(t="")=>{let e=Number.parseInt(String(t||"").trim(),10);return Number.isFinite(e)?e:null},er=t=>{if(!Number.isFinite(t))return null;let e=t===7?0:t;return e>=0&&e<=6?e:null},s_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return null;let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=[];return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ts(r[1])),l=er(ts(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.push(c);else{for(let c=a;c<=6;c+=1)s.push(c);for(let c=0;c<=l;c+=1)s.push(c)}return}let i=er(ts(o));i!=null&&s.push(i)}),s.length===0?null:Math.min(...s)},o_=(t="")=>{let e=String(t||"").trim().toLowerCase();if(!e||e==="*")return[];let n=e.split(",").map(o=>o.trim()).filter(Boolean),s=new Set;return n.forEach(o=>{let r=o.match(/^(\d{1,2})-(\d{1,2})$/);if(r){let a=er(ts(r[1])),l=er(ts(r[2]));if(a==null||l==null)return;if(a<=l)for(let c=a;c<=l;c+=1)s.add(c);else{for(let c=a;c<=6;c+=1)s.add(c);for(let c=0;c<=l;c+=1)s.add(c)}return}let i=er(ts(o));i!=null&&s.add(i)}),[...s].sort((o,r)=>o-r)},Qy=(t="")=>{let e=o_(t);return e.length!==5?!1:e.join(",")==="1,2,3,4,5"},r_=({minuteField:t="",hourField:e=""})=>{let n=ts(t),s=ts(e);return n==null||s==null||n<0||n>59||s<0||s>23?null:s*n_+n},i_=(t={})=>{let n=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(n.length<5)return null;let[s,o,r,i,a]=n;return{minuteField:s,hourField:o,dayOfMonthField:r,monthField:i,dayOfWeekField:a}},Xy=(t={},e="other")=>{let n=t?.schedule||{},s=String(n?.kind||"").trim().toLowerCase(),o=i_(n),r=o?r_({minuteField:o.minuteField,hourField:o.hourField}):null,i=String(t?.name||t?.id||"").toLowerCase();if(e==="daily"){if(s==="every"){let a=Number(n?.everyMs||Number.MAX_SAFE_INTEGER);return{groupRank:0,primary:Number.isFinite(a)?a:Number.MAX_SAFE_INTEGER,secondary:i}}if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&o.dayOfWeekField==="*"&&r!=null)return{groupRank:1,primary:r,secondary:i};if(o&&o.dayOfMonthField==="*"&&o.monthField==="*"&&Qy(o.dayOfWeekField)&&r!=null)return{groupRank:2,primary:r,secondary:i}}if(e==="weekly"&&o){let a=s_(o.dayOfWeekField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}if(e==="monthly"&&o){let a=ts(o.dayOfMonthField);return{groupRank:0,primary:a??Number.MAX_SAFE_INTEGER,secondary:r??Number.MAX_SAFE_INTEGER,tertiary:i}}return{groupRank:99,primary:Number.MAX_SAFE_INTEGER,secondary:Number.MAX_SAFE_INTEGER,tertiary:i}},yl=(t,e)=>t===e?0:t>e?1:-1,vl=(t=[],e="other")=>[...t].sort((n,s)=>{let o=Xy(n,e),r=Xy(s,e),i=yl(o.groupRank,r.groupRank);if(i!==0)return i;let a=yl(o.primary,r.primary);if(a!==0)return a;let l=yl(o.secondary,r.secondary);return l!==0?l:yl(o.tertiary,r.tertiary)}),a_=(t={})=>{if(String(t?.kind||"").trim().toLowerCase()==="every"){let o=Number(t?.everyMs||0);if(Number.isFinite(o)&&o>0){if(o<=1440*60*1e3)return"daily";if(o<=10080*60*1e3)return"weekly";if(o<=744*60*60*1e3)return"monthly"}return"other"}let s=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(s.length>=5){let[,,o,r,i]=s;if(o==="*"&&r==="*"&&i==="*")return"daily";if(o==="*"&&r==="*"&&i!=="*")return Qy(i)?"daily":"weekly";if(o!=="*"&&r==="*")return"monthly"}return"other"},ev=({jobs:t=[],selectedRouteKey:e=Ss,onSelectAllJobs:n=()=>{},onSelectJob:s=()=>{}})=>{let o=ee(null),[r,i]=y("");L(()=>{let d=window.requestAnimationFrame(()=>{o.current?.focus()});return()=>{window.cancelAnimationFrame(d)}},[]);let a=String(r||"").trim().toLowerCase(),l=F(()=>a?t.filter(d=>{let u=String(d?.name||"").toLowerCase(),p=String(d?.id||"").toLowerCase();return u.includes(a)||p.includes(a)}):t,[t,a]),c=F(()=>{let d={daily:[],weekly:[],monthly:[],other:[]};return l.forEach(u=>{let p=a_(u?.schedule);d[p]?d[p].push(u):d.other.push(u)}),{daily:vl(d.daily,"daily"),weekly:vl(d.weekly,"weekly"),monthly:vl(d.monthly,"monthly"),other:vl(d.other,"other")}},[l]);return xl`
            `};var $r=P.bind(T),A3={webchat:"Browser",cli:"CLI"},M3=t=>A3[t.clientMode]||t.clientId||"Device",T3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},P3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=M3(t),a=T3(t);return s==="approve"?$r` +
            `};var $r=P.bind(T),_3={webchat:"Browser",cli:"CLI"},A3=t=>_3[t.clientMode]||t.clientId||"Device",M3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},T3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=A3(t),a=M3(t);return s==="approve"?$r`
            Approved ${i} @@ -6390,8 +6390,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
            `},xc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r`

            Pending device pairings

            - ${t.map(s=>$r`<${P3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} -
            `;var ho=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],R3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Sw(t){return`https://console.developers.google.com/apis/api/${R3[t]||""}/overview`}function Cw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return ho`
            + ${t.map(s=>$r`<${T3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} +
            `;var ho=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],P3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Sw(t){return`https://console.developers.google.com/apis/api/${P3[t]||""}/overview`}function Cw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return ho`
            ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=ho``:f&&(f.status==="ok"?g=ho`API ✓`:f.status==="not_enabled"?g=ho`Enable API`:f.status==="error"&&(g=ho`Enable API`)),ho`
            ${d.icon} ${d.label} @@ -6410,22 +6410,22 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${o?"Show fewer services":`Show more services (${Vi.length-a})`} `:null} -
            `}function _w(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Aw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[k,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let S=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(S),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let D=await B.text(),U=JSON.parse(D),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&f(W.client_secret)}catch{b("Invalid JSON file")}},I=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},V=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr` +
            `}function _w(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Aw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,x]=y(""),[b,v]=y(!1),[$,w]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),w(r),x(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,M=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{x("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let D=await B.text(),U=JSON.parse(D),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&f(W.client_secret)}catch{x("Invalid JSON file")}},I=async()=>{if(x(""),!d||!p||!g){x("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await zh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):x(R.error||"Failed to save credentials")}catch{x("Request failed")}finally{v(!1)}},V=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${$===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+($===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),A=()=>kr`
            R.target.select()} onclick=${R=>R.target.select()} class="flex-1 min-w-0 bg-field border border-border rounded px-2 py-1 text-body text-xs focus:outline-none focus:border-fg-muted" />
            `;return kr` <${De} @@ -6470,21 +6470,21 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s style="border: 1px solid var(--border); border-radius: 6px; background: rgba(255,255,255,0.02)" >
            - ${w==="personal"?kr` + ${$==="personal"?kr`
            1. @@ -6533,7 +6533,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >${" "}→ Create OAuth 2.0 Client ID (Web application)
            2. - Add redirect URI:${M()} + Add redirect URI:${A()}
            3. Copy Client ID + Secret (or download credentials JSON) @@ -6585,7 +6585,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >${" "}→ Create OAuth 2.0 Client ID (Web application)
            4. - Add redirect URI:${M()} + Add redirect URI:${A()}
            5. Copy Client ID + Secret (or download credentials JSON) @@ -6663,8 +6663,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
              <${Z} onClick=${I} - disabled=${x} - loading=${x} + disabled=${b} + loading=${b} tone="primary" size="lg" idleLabel=${o} @@ -6673,14 +6673,14 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
              ${h?kr`
              ${h}
              `:null} - `};var Mw=P.bind(T),L3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Tw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Mw` + `};var Mw=P.bind(T),R3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Tw=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Mw`
              Gmail watch requires gmail:read. Add it in permissions above, then update permissions.
              - `;let a=L3({watchStatus:e,busy:n}),l=!!e?.enabled;return Mw` + `;let a=R3({watchStatus:e,busy:n}),l=!!e?.enabled;return Mw`
              {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
            - `};var ji=P.bind(T),E3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Pw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!E3(o,r);return ji` + `};var ji=P.bind(T),L3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Pw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:x,onOpenGmailWebhook:b})=>{let v=!L3(o,r);return ji`
            <${Cw} scopes=${o} - onToggle=${w=>l?.(t.id,w)} + onToggle=${$=>l?.(t.id,$)} apiStatus=${t.authenticated?i:{}} loading=${t.authenticated&&a} /> @@ -6753,7 +6753,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s Incoming events
            @@ -6848,7 +6848,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s className="w-full px-4 py-2 rounded-lg text-sm" /> - `};var Lw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Wh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Ew=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Gh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let k={...w};return delete k[$],k})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await Jh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Zh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Yh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await qh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),I3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Iw=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],D3=Iw.slice(0,3),yc=Ns,vc=(t="",e=()=>{})=>mt` + `};var Lw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Wh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Ew=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(b=>String(b?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Gh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let b=new Map;for(let v of a?.accounts||[])b.set(String(v.accountId||""),v);return b},[a]),p=F(()=>{let b=new Map;for(let v of a?.clients||[])b.set(String(v.client||"default"),v);return b},[a]),f=(b,v)=>{s($=>{let w=String(b||"");if(!w)return $;if(v)return{...$,[w]:!0};if(!$[w])return $;let S={...$};return delete S[w],S})},g=q(async(b,{destination:v=null}={})=>{let $=String(b||"");f($,!0);try{let w=await Jh($,{destination:v});return await d(),w}finally{f($,!1)}},[d]),m=q(async b=>{let v=String(b||"");f(v,!0);try{await Zh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(b="")=>{let v=String(b||"");v&&f(v,!0);try{await Yh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),x=q(async({client:b="default",projectId:v="",regeneratePushToken:$=!1}={})=>{r(!0);try{let w=await qh({client:b,projectId:v,regeneratePushToken:$});return await d(),w}catch(w){throw String(w?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):w}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:x,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),E3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Iw=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],I3=Iw.slice(0,3),yc=Ns,vc=(t="",e=()=>{})=>mt`
                 
            -`,Dw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,k]=y(!1),[C,_]=y(!1),{selectedSessionKey:S,setSelectedSessionKey:A,loading:O,error:I}=yr({enabled:t,filter:pc}),{sessions:V,destinationSessionKey:E,setDestinationSessionKey:M,selectedDestination:R}=Os({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),k(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,D=!!(String(n?.projectId||"").trim()||B),U=u||!D&&!m,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,te=G?D3:Iw,K=te.length,z=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async he=>{if(await I3(he)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:z,projectId:String(c||"").trim(),destination:R}),x(!0),l(he=>Math.min(he+1,K-1))}catch(he){g(he.message||"Could not finish setup")}},se=async()=>{if(!s){if(U){if(!X)return;g("");try{await r({client:z,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,K-1))}},xe=async()=>{if(!(v||$))try{w(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ue,sessionKey:S}),k(!0),N("Message sent to your agent","success")}catch(he){N(he.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` +`,Dw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[x,b]=y(!1),[v,$]=y(!1),[w,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:M,loading:O,error:I}=yr({enabled:t,filter:pc}),{sessions:V,destinationSessionKey:E,setDestinationSessionKey:A,selectedDestination:R}=Os({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),b(!1),$(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,D=!!(String(n?.projectId||"").trim()||B),U=u||!D&&!m,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,te=G?I3:Iw,K=te.length,z=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async he=>{if(await E3(he)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:z,projectId:String(c||"").trim(),destination:R}),b(!0),l(he=>Math.min(he+1,K-1))}catch(he){g(he.message||"Could not finish setup")}},se=async()=>{if(!s){if(U){if(!X)return;g("");try{await r({client:z,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,K-1))}},xe=async()=>{if(!(v||w))try{$(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ue,sessionKey:k}),S(!0),N("Message sent to your agent","success")}catch(he){N(he.message||"Could not send message to agent","error")}finally{$(!1)}};return mt` <${De} visible=${t} onClose=${o} @@ -6962,7 +6962,7 @@ ${B?.grantPublisher||""}`.trim()))} label="Deliver to" sessions=${V} selectedSessionKey=${E} - onChangeSessionKey=${M} + onChangeSessionKey=${A} disabled=${G||O||s} loading=${O} error=${I} @@ -6994,12 +6994,12 @@ ${B?.grantPublisher||""}`.trim()))}
            <${Z} onClick=${xe} - disabled=${!S||$} + disabled=${!k||w} loading=${v} - idleLabel=${$?"Sent":"Send to Agent"} + idleLabel=${w?"Sent":"Send to Agent"} loadingLabel="Sending..." tone="primary" size="sm" @@ -7070,12 +7070,12 @@ ${B?.grantPublisher||""}`.trim()))} />`}
            - `};var Bs=P.bind(T),N3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,O3="/assets/icons/google_icon.svg",Nw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Lw({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[b,x]=y(!1),[v,w]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[$,k]=y(!1),[C,_]=y(!1),[S,A]=y(""),[O,I]=y({visible:!1,accountId:""}),{loading:V,watchByAccountId:E,clientConfigByClient:M,busyByAccountId:R,savingClient:B,refresh:D,saveClientSetup:U,startWatchForAccount:W,stopWatchForAccount:G}=Ew({gatewayStatus:t,accounts:s}),te=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),z=q(Q=>s.find(pe=>pe.id===Q)||null,[s]),X=q(Q=>{let pe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(ne=>({...ne,[Q.id]:[...pe]})),d(ne=>{let Ee=ne[Q.id];return!Ee||!N3(Ee,pe)?{...ne,[Q.id]:[...pe]}:ne})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(pe=>!pe.authenticated)?.id||"";l(pe=>pe&&s.some(ne=>ne.id===pe)?pe:Q),s.forEach(pe=>X(pe))},[s,X]);let de=q(Q=>{let pe=z(Q);if(!pe)return;let ne=c[Q]||pe.activeScopes||wr();if(!ne.length){window.alert("Select at least one service");return}let Ee=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(ne.join(","))}&_ts=${Date.now()}`,ke=window.open(Ee,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=Ee)},[z,c]),le=(Q,pe)=>{d(ne=>({...ne,[Q]:_w(ne[Q]||[],pe)}))},ie=q(async Q=>{g(pe=>{let ne={...pe};return delete ne[Q],ne}),h({[Q]:!0});try{let pe=await jh(Q);pe.results&&g(ne=>({...ne,[Q]:pe.results}))}finally{h(pe=>{if(!pe[Q])return pe;let ne={...pe};return delete ne[Q],ne})}},[]);L(()=>{let Q=async pe=>{if(pe.data?.google==="success"){N("\u2713 Google account connected","success");let ne=String(pe.data?.accountId||"").trim();g({}),await i(),await D(),ne&&await ie(ne)}else pe.data?.google==="error"&&N(`\u2717 Google auth failed: ${pe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[ie,i,D]),L(()=>{!a||!z(a)?.authenticated||m[a]||f[a]||ie(a)},[s,f,m,a,z,ie]);let se=async Q=>{let pe=await Kh(Q);if(!pe.ok){N(`Failed to disconnect: ${pe.error||"unknown"}`,"error");return}N("Google account disconnected","success"),g(ne=>{let Ee={...ne};return delete Ee[Q],Ee}),await i(),await D()},xe=({accountId:Q="",client:pe="default",personal:ne=!1,title:Ee="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:H=ne?"personal":"workspace",initialValues:re={}})=>{w({visible:!0,accountId:Q,client:pe,personal:ne,title:Ee,submitLabel:ke,defaultInstrType:H,initialValues:re})},he=()=>{w(Q=>({...Q,visible:!1}))},ue=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&de(Q.id)},me=async({email:Q,setError:pe})=>{_(!0);try{let ne=await Uh({email:Q,client:"default",personal:!1,services:wr()});if(!ne.ok){pe?.(ne.error||"Could not add account");return}k(!1),ne.accountId&&l(ne.accountId),await i(),ne.accountId&&de(ne.accountId)}finally{_(!1)}},j=()=>{if(x(!1),K&&r){k(!0);return}xe({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{x(!1),xe({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},oe=async Q=>{let pe=z(Q);if(!pe)return;let ne=$c(pe),Ee=ne?"personal":pe.client||"default",ke={};try{let H=await Vh({accountId:pe.id,client:Ee});H?.ok&&(ke={clientId:String(H.clientId||""),clientSecret:String(H.clientSecret||"")})}catch{N("Could not load saved client credentials","warning")}xe({accountId:pe.id,client:Ee,personal:ne,title:`Edit Credentials (${pe.email})`,submitLabel:"Save Credentials",defaultInstrType:ne?"personal":"workspace",initialValues:{email:pe.email,...ke}})},ce=Q=>{I({visible:!0,accountId:String(Q||"")})},be=()=>{I({visible:!1,accountId:""})},Ve=async Q=>{let pe=z(Q);if(!pe)return;let ne=String(pe.client||"default").trim()||"default",Ee=M.get(ne);if(!Ee?.configured||!Ee?.webhookExists){ce(Q);return}try{(await W(Q))?.restartRequired&&e(!0),N("Gmail watch enabled","success")}catch(ke){N(ke.message||"Could not enable Gmail watch","error")}},et=async Q=>{try{await G(Q),N("Gmail watch disabled","info")}catch(pe){N(pe.message||"Could not disable Gmail watch","error")}},kt=async({client:Q,projectId:pe,destination:ne=null})=>{let Ee=String(O.accountId||"").trim();Ee&&(await U({client:Q,projectId:pe,regeneratePushToken:!1}),await W(Ee,{destination:ne}),N("Gmail setup complete and watch enabled","success"))},Ot=()=>Bs` + `};var Bs=P.bind(T),D3=(t=[],e=[])=>t.length!==e.length||t.some(n=>!e.includes(n)),$c=(t={})=>!!t.personal,N3="/assets/icons/google_icon.svg",Nw=({gatewayStatus:t,onRestartRequired:e=()=>{},onOpenGmailWebhook:n=()=>{}})=>{let{accounts:s,loading:o,hasCompanyCredentials:r,refreshAccounts:i}=Lw({gatewayStatus:t}),[a,l]=y(""),[c,d]=y({}),[u,p]=y({}),[f,g]=y({}),[m,h]=y({}),[x,b]=y(!1),[v,$]=y({visible:!1,accountId:"",client:"default",personal:!1,title:"Connect Google Workspace",submitLabel:"Connect Google",defaultInstrType:"workspace",initialValues:{}}),[w,S]=y(!1),[C,_]=y(!1),[k,M]=y(""),[O,I]=y({visible:!1,accountId:""}),{loading:V,watchByAccountId:E,clientConfigByClient:A,busyByAccountId:R,savingClient:B,refresh:D,saveClientSetup:U,startWatchForAccount:W,stopWatchForAccount:G}=Ew({gatewayStatus:t,accounts:s}),te=F(()=>s.some(Q=>$c(Q)),[s]),K=F(()=>s.some(Q=>!$c(Q)),[s]),z=q(Q=>s.find(pe=>pe.id===Q)||null,[s]),X=q(Q=>{let pe=Array.isArray(Q.activeScopes)&&Q.activeScopes.length?Q.activeScopes:Array.isArray(Q.services)&&Q.services.length?Q.services:wr();p(ne=>({...ne,[Q.id]:[...pe]})),d(ne=>{let Ee=ne[Q.id];return!Ee||!D3(Ee,pe)?{...ne,[Q.id]:[...pe]}:ne})},[]);L(()=>{if(!s.length){l("");return}let Q=s.find(pe=>!pe.authenticated)?.id||"";l(pe=>pe&&s.some(ne=>ne.id===pe)?pe:Q),s.forEach(pe=>X(pe))},[s,X]);let de=q(Q=>{let pe=z(Q);if(!pe)return;let ne=c[Q]||pe.activeScopes||wr();if(!ne.length){window.alert("Select at least one service");return}let Ee=`/auth/google/start?accountId=${encodeURIComponent(Q)}&services=${encodeURIComponent(ne.join(","))}&_ts=${Date.now()}`,ke=window.open(Ee,`google-auth-${Q}`,"popup=yes,width=500,height=700");(!ke||ke.closed)&&(window.location.href=Ee)},[z,c]),le=(Q,pe)=>{d(ne=>({...ne,[Q]:_w(ne[Q]||[],pe)}))},ie=q(async Q=>{g(pe=>{let ne={...pe};return delete ne[Q],ne}),h({[Q]:!0});try{let pe=await jh(Q);pe.results&&g(ne=>({...ne,[Q]:pe.results}))}finally{h(pe=>{if(!pe[Q])return pe;let ne={...pe};return delete ne[Q],ne})}},[]);L(()=>{let Q=async pe=>{if(pe.data?.google==="success"){N("\u2713 Google account connected","success");let ne=String(pe.data?.accountId||"").trim();g({}),await i(),await D(),ne&&await ie(ne)}else pe.data?.google==="error"&&N(`\u2717 Google auth failed: ${pe.data.message||"unknown"}`,"error")};return window.addEventListener("message",Q),()=>window.removeEventListener("message",Q)},[ie,i,D]),L(()=>{!a||!z(a)?.authenticated||m[a]||f[a]||ie(a)},[s,f,m,a,z,ie]);let se=async Q=>{let pe=await Kh(Q);if(!pe.ok){N(`Failed to disconnect: ${pe.error||"unknown"}`,"error");return}N("Google account disconnected","success"),g(ne=>{let Ee={...ne};return delete Ee[Q],Ee}),await i(),await D()},xe=({accountId:Q="",client:pe="default",personal:ne=!1,title:Ee="Connect Google Workspace",submitLabel:ke="Connect Google",defaultInstrType:H=ne?"personal":"workspace",initialValues:re={}})=>{$({visible:!0,accountId:Q,client:pe,personal:ne,title:Ee,submitLabel:ke,defaultInstrType:H,initialValues:re})},he=()=>{$(Q=>({...Q,visible:!1}))},ue=async Q=>{Q?.id&&l(Q.id),await i(),Q?.id&&de(Q.id)},me=async({email:Q,setError:pe})=>{_(!0);try{let ne=await Uh({email:Q,client:"default",personal:!1,services:wr()});if(!ne.ok){pe?.(ne.error||"Could not add account");return}S(!1),ne.accountId&&l(ne.accountId),await i(),ne.accountId&&de(ne.accountId)}finally{_(!1)}},j=()=>{if(b(!1),K&&r){S(!0);return}xe({client:"default",personal:!1,title:"Add Company Account",submitLabel:"Save Credentials",defaultInstrType:"workspace"})},J=()=>{b(!1),xe({client:"personal",personal:!0,title:"Add Personal Account",submitLabel:"Save Credentials",defaultInstrType:"personal"})},oe=async Q=>{let pe=z(Q);if(!pe)return;let ne=$c(pe),Ee=ne?"personal":pe.client||"default",ke={};try{let H=await Vh({accountId:pe.id,client:Ee});H?.ok&&(ke={clientId:String(H.clientId||""),clientSecret:String(H.clientSecret||"")})}catch{N("Could not load saved client credentials","warning")}xe({accountId:pe.id,client:Ee,personal:ne,title:`Edit Credentials (${pe.email})`,submitLabel:"Save Credentials",defaultInstrType:ne?"personal":"workspace",initialValues:{email:pe.email,...ke}})},ce=Q=>{I({visible:!0,accountId:String(Q||"")})},be=()=>{I({visible:!1,accountId:""})},Ve=async Q=>{let pe=z(Q);if(!pe)return;let ne=String(pe.client||"default").trim()||"default",Ee=A.get(ne);if(!Ee?.configured||!Ee?.webhookExists){ce(Q);return}try{(await W(Q))?.restartRequired&&e(!0),N("Gmail watch enabled","success")}catch(ke){N(ke.message||"Could not enable Gmail watch","error")}},et=async Q=>{try{await G(Q),N("Gmail watch disabled","info")}catch(pe){N(pe.message||"Could not disable Gmail watch","error")}},kt=async({client:Q,projectId:pe,destination:ne=null})=>{let Ee=String(O.accountId||"").trim();Ee&&(await U({client:Q,projectId:pe,regeneratePushToken:!1}),await W(Ee,{destination:ne}),N("Gmail setup complete and watch enabled","success"))},Ot=()=>Bs`
            Google logo <${pt} - open=${b} + open=${x} ariaLabel="Add Google account" title="Add Google account" - onClose=${()=>x(!1)} - onToggle=${()=>x(Q=>!Q)} + onClose=${()=>b(!1)} + onToggle=${()=>b(Q=>!Q)} renderTrigger=${({onToggle:Q,ariaLabel:pe,title:ne})=>Bs` <${Z} onClick=${Q} @@ -7157,7 +7157,7 @@ ${B?.grantPublisher||""}`.trim()))} onCheckApis=${ie} onUpdatePermissions=${pe=>de(pe)} onEditCredentials=${oe} - onDisconnect=${pe=>A(pe)} + onDisconnect=${pe=>M(pe)} gmailWatchStatus=${E.get(Q.id)||null} gmailWatchBusy=${!!R[Q.id]} onEnableGmailWatch=${Ve} @@ -7183,8 +7183,8 @@ ${B?.grantPublisher||""}`.trim()))} /> <${Rw} - visible=${$} - onClose=${()=>k(!1)} + visible=${w} + onClose=${()=>S(!1)} onSubmit=${me} loading=${C} title="Add Company Account" @@ -7193,7 +7193,7 @@ ${B?.grantPublisher||""}`.trim()))} <${Dw} visible=${O.visible} account=${z(O.accountId)} - clientConfig=${M.get(String(z(O.accountId)?.client||"default").trim()||"default")||null} + clientConfig=${A.get(String(z(O.accountId)?.client||"default").trim()||"default")||null} saving=${B||V} onClose=${be} onSaveSetup=${U} @@ -7201,19 +7201,19 @@ ${B?.grantPublisher||""}`.trim()))} /> <${st} - visible=${!!S} + visible=${!!k} title="Disconnect Google account?" message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect." confirmLabel="Disconnect" cancelLabel="Cancel" - onCancel=${()=>A("")} - onConfirm=${async()=>{let Q=S;A(""),await se(Q)}} + onCancel=${()=>M("")} + onConfirm=${async()=>{let Q=k;M(""),await se(Q)}} /> - `};var wc=P.bind(T),B3=(t,e)=>t.find(n=>n.key===e)?.value||"",F3=(t,e)=>{for(let n of t.providers)if((An[n]||[]).some(r=>!!B3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Ow=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc` + `};var wc=P.bind(T),O3=(t,e)=>t.find(n=>n.key===e)?.value||"",B3=(t,e)=>{for(let n of t.providers)if((An[n]||[]).some(r=>!!O3(e,r.key)))return{active:!0,provider:n};return{active:!1,provider:null}},Ow=({onSwitchTab:t})=>{let{data:e,loading:n}=ct("/api/env",Vr,{maxAgeMs:3e4}),s=Array.isArray(e?.vars)?e.vars:[];return n?null:wc`

            Features

            - ${gg.map(r=>{let i=F3(r,s);return wc` + ${gg.map(r=>{let i=B3(r,s);return wc`
            ${r.label} ${i.active?wc` @@ -7237,7 +7237,7 @@ ${B?.grantPublisher||""}`.trim()))} `})}
            - `};var H3=P.bind(T),Bw=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>fc(t,e)?H3` + `};var F3=P.bind(T),Bw=({doctorStatus:t=null,dismissedUntilMs:e=0,onOpenDoctor:n=()=>{},onDismiss:s=()=>{}})=>fc(t,e)?F3`
            @@ -7258,31 +7258,31 @@ ${B?.grantPublisher||""}`.trim()))}
            - `:null;var kc="0 * * * *",Fw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(kc),[g,m]=y(!1),[h,b]=y(kc),[x,v]=y(!1),[w,$]=y(!1),[k,C]=y(!1),_=ee(null),S=t,A=e,O=n,I=S?.gateway??null,V=S?.channels??null,E=S?.repo||null,M=S?.syncCron||null,R=S?.openclawVersion||null,B=xs.some(ue=>{let me=V?.[ue];if(!me)return!1;let j=me.accounts&&typeof me.accounts=="object"?me.accounts:{};return Object.keys(j).length>0?Object.values(j).some(J=>J&&J.status!=="paired"):me.status!=="paired"}),D=Re(async()=>(await _o()).pending||[],3e3,{enabled:B&&I==="running",cacheKey:"/api/pairings"}),U=D.data||[],W=I==="running"&&(w||!k),G=Re(async()=>{let ue=await ga();return C(ue?.cliAutoApproveComplete===!0),ue.pending||[]},5e3,{enabled:W,cacheKey:"/api/devices"}),te=G.data||[];L(()=>{o&&(D.refresh(),W&&G.refresh())},[G.refresh,o,s,D.refresh,w,W]),L(()=>{if(!r||!o)return;s(),D.refresh(),W&&G.refresh();let ue=setTimeout(()=>{s(),D.refresh(),W&&G.refresh()},1200),me=setTimeout(()=>{s(),D.refresh(),W&&G.refresh()},3500);return()=>{clearTimeout(ue),clearTimeout(me)}},[G.refresh,o,s,D.refresh,r,w,W]),L(()=>{M&&(u(M.enabled!==!1),f(M.schedule||kc),b(M.enabled===!1?"disabled":M.schedule||kc))},[M?.enabled,M?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),D.refresh(),setTimeout(()=>{s(),D.refresh()},700),setTimeout(()=>{s(),D.refresh()},1800)},z=async({enabled:ue=d,schedule:me=p}={})=>{if(!g){m(!0);try{let j=await ym({enabled:ue,schedule:me});if(!j.ok)throw new Error(j.error||"Could not save sync settings");N("Sync schedule updated","success"),s()}catch(j){N(j.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:V,dashboardLoading:i,devicePending:te,doctorStatus:O,gatewayStatus:I,hasUnpaired:B,openclawVersion:R,pending:U,pairingStatusRefreshing:x,repairingWatchdog:l,repo:E,savingSyncCron:g,syncCron:M,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:A},actions:{handleApprove:async(ue,me,j="")=>{await Ao(ue,me,j),K()},handleDeviceApprove:async ue=>{await ba(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleDeviceReject:async ue=>{await xa(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){$(!0),a(!0);try{let ue=await hm();console.log("[dashboard] response:",JSON.stringify(ue)),window.open(ue.url||"/openclaw","_blank")}catch(ue){console.error("[dashboard] error:",ue),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ue,me,j="")=>{try{await Mo(ue,me,j),K()}catch(J){N(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ue=>{b(ue);let me=ue!=="disabled",j=me?ue:p;u(me),f(j),await z({enabled:me,schedule:j})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ue=await ha();if(!ue.ok)throw new Error(ue.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ue){N(ue.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),Hw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:b=()=>{}})=>{let{state:x,actions:v}=Fw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc` + `:null;var kc="0 * * * *",Fw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,onRefreshStatuses:s=()=>{},isActive:o=!1,restartSignal:r=0}={})=>{let[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(kc),[g,m]=y(!1),[h,x]=y(kc),[b,v]=y(!1),[$,w]=y(!1),[S,C]=y(!1),_=ee(null),k=t,M=e,O=n,I=k?.gateway??null,V=k?.channels??null,E=k?.repo||null,A=k?.syncCron||null,R=k?.openclawVersion||null,B=xs.some(ue=>{let me=V?.[ue];if(!me)return!1;let j=me.accounts&&typeof me.accounts=="object"?me.accounts:{};return Object.keys(j).length>0?Object.values(j).some(J=>J&&J.status!=="paired"):me.status!=="paired"}),D=Re(async()=>(await _o()).pending||[],3e3,{enabled:B&&I==="running",cacheKey:"/api/pairings"}),U=D.data||[],W=I==="running"&&($||!S),G=Re(async()=>{let ue=await ga();return C(ue?.cliAutoApproveComplete===!0),ue.pending||[]},5e3,{enabled:W,cacheKey:"/api/devices"}),te=G.data||[];L(()=>{o&&(D.refresh(),W&&G.refresh())},[G.refresh,o,s,D.refresh,$,W]),L(()=>{if(!r||!o)return;s(),D.refresh(),W&&G.refresh();let ue=setTimeout(()=>{s(),D.refresh(),W&&G.refresh()},1200),me=setTimeout(()=>{s(),D.refresh(),W&&G.refresh()},3500);return()=>{clearTimeout(ue),clearTimeout(me)}},[G.refresh,o,s,D.refresh,r,$,W]),L(()=>{A&&(u(A.enabled!==!1),f(A.schedule||kc),x(A.enabled===!1?"disabled":A.schedule||kc))},[A?.enabled,A?.schedule]),L(()=>()=>{_.current&&clearTimeout(_.current)},[]);let K=()=>{v(!0),_.current&&clearTimeout(_.current),_.current=setTimeout(()=>{v(!1),_.current=null},2800),s(),D.refresh(),setTimeout(()=>{s(),D.refresh()},700),setTimeout(()=>{s(),D.refresh()},1800)},z=async({enabled:ue=d,schedule:me=p}={})=>{if(!g){m(!0);try{let j=await ym({enabled:ue,schedule:me});if(!j.ok)throw new Error(j.error||"Could not save sync settings");N("Sync schedule updated","success"),s()}catch(j){N(j.message||"Could not save sync settings","error")}finally{m(!1)}}};return{state:{channels:V,dashboardLoading:i,devicePending:te,doctorStatus:O,gatewayStatus:I,hasUnpaired:B,openclawVersion:R,pending:U,pairingStatusRefreshing:b,repairingWatchdog:l,repo:E,savingSyncCron:g,syncCron:A,syncCronChoice:h,syncCronEnabled:d,syncCronSchedule:p,syncCronStatusText:d?"Enabled":"Disabled",watchdogStatus:M},actions:{handleApprove:async(ue,me,j="")=>{await Ao(ue,me,j),K()},handleDeviceApprove:async ue=>{await ba(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleDeviceReject:async ue=>{await xa(ue),setTimeout(G.refresh,500),setTimeout(G.refresh,2e3)},handleOpenDashboard:async()=>{if(!i){w(!0),a(!0);try{let ue=await hm();console.log("[dashboard] response:",JSON.stringify(ue)),window.open(ue.url||"/openclaw","_blank")}catch(ue){console.error("[dashboard] error:",ue),window.open("/openclaw","_blank")}finally{a(!1)}}},handleReject:async(ue,me,j="")=>{try{await Mo(ue,me,j),K()}catch(J){N(J.message||"Could not reject pairing","error")}},handleSyncCronChoiceChange:async ue=>{x(ue);let me=ue!=="disabled",j=me?ue:p;u(me),f(j),await z({enabled:me,schedule:j})},handleWatchdogRepair:async()=>{if(!l){c(!0);try{let ue=await ha();if(!ue.ok)throw new Error(ue.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{s()},800)}catch(ue){N(ue.message||"Could not run repair","error")}finally{c(!1)}}}}}};var Sc=P.bind(T),Hw=({statusData:t=null,watchdogData:e=null,doctorStatusData:n=null,agents:s=[],doctorWarningDismissedUntilMs:o=0,onRefreshStatuses:r=()=>{},onSwitchTab:i=()=>{},onNavigate:a=()=>{},onOpenGmailWebhook:l=()=>{},isActive:c=!1,restartingGateway:d=!1,onRestartGateway:u=()=>{},restartSignal:p=0,openclawUpdateInProgress:f=!1,onOpenclawVersionActionComplete:g=()=>{},onOpenclawUpdate:m=()=>{},onRestartRequired:h=()=>{},onDismissDoctorWarning:x=()=>{}})=>{let{state:b,actions:v}=Fw({statusData:t,watchdogData:e,doctorStatusData:n,onRefreshStatuses:r,isActive:c,restartSignal:p});return Sc`
            <${bc} - status=${x.gatewayStatus} - openclawVersion=${x.openclawVersion} + status=${b.gatewayStatus} + openclawVersion=${b.openclawVersion} restarting=${d} onRestart=${u} - watchdogStatus=${x.watchdogStatus} + watchdogStatus=${b.watchdogStatus} onOpenWatchdog=${()=>i("watchdog")} onRepair=${v.handleWatchdogRepair} - repairing=${x.repairingWatchdog} + repairing=${b.repairingWatchdog} openclawUpdateInProgress=${f} onOpenclawVersionActionComplete=${g} onOpenclawUpdate=${m} /> <${Bw} - doctorStatus=${x.doctorStatus} + doctorStatus=${b.doctorStatus} dismissedUntilMs=${o} onOpenDoctor=${()=>i("doctor")} - onDismiss=${b} + onDismiss=${x} /> <${nl} channelsSection=${Sc` <${Cb} - channels=${x.channels} + channels=${b.channels} agents=${s} onNavigate=${a} onRefreshStatuses=${r} @@ -7290,10 +7290,10 @@ ${B?.grantPublisher||""}`.trim()))} `} pairingsSection=${Sc` <${tl} - pending=${x.pending} - channels=${x.channels} - visible=${x.hasUnpaired} - statusRefreshing=${x.pairingStatusRefreshing} + pending=${b.pending} + channels=${b.channels} + visible=${b.hasUnpaired} + statusRefreshing=${b.pairingStatusRefreshing} onApprove=${v.handleApprove} onReject=${v.handleReject} /> @@ -7301,12 +7301,12 @@ ${B?.grantPublisher||""}`.trim()))} /> <${Ow} onSwitchTab=${i} /> <${Nw} - gatewayStatus=${x.gatewayStatus} + gatewayStatus=${b.gatewayStatus} onRestartRequired=${h} onOpenGmailWebhook=${l} /> - ${x.repo&&Sc` + ${b.repo&&Sc`
            @@ -7320,21 +7320,21 @@ ${B?.grantPublisher||""}`.trim()))} /> ${x.repo}${b.repo}
            Auto-sync
            <${Z} - onClick=${()=>g(qw({node:k,connectInfo:o,maskToken:!1}),{successMessage:"Connection command copied",errorMessage:"Could not copy connection command"})} + onClick=${()=>g(qw({node:S,connectInfo:o,maskToken:!1}),{successMessage:"Connection command copied",errorMessage:"Could not copy connection command"})} tone="secondary" size="sm" iconOnly=${!0} @@ -7876,10 +7876,10 @@ ${B?.grantPublisher||""}`.trim()))} confirmTone="warning" confirmLoading=${!!f} confirmDisabled=${!!f} - onCancel=${()=>{f||$(null)}} + onCancel=${()=>{f||w(null)}} onConfirm=${v} /> - `};var cR=P.bind(T),dR="https://github.com/openclaw/openclaw/releases/tag/v2026.3.13",uR=`Release reference: [OpenClaw 2026.3.13](${dR}) + `};var lR=P.bind(T),cR="https://github.com/openclaw/openclaw/releases/tag/v2026.3.13",dR=`Release reference: [OpenClaw 2026.3.13](${cR}) ## Requirements @@ -7919,7 +7919,7 @@ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**. | Config validation error on existing-session | Do not define a custom existing-session profile. Use \`defaultProfile: "user"\`. | | EADDRINUSE on port 9222 | Quit Chrome launched with \`--remote-debugging-port\` and relaunch normally. | | Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. | -| \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`,Zw=()=>{let t=F(()=>Le.parse(uR,{gfm:!0,breaks:!0}),[]);return cR` +| \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`,Zw=()=>{let t=F(()=>Le.parse(dR,{gfm:!0,breaks:!0}),[]);return lR`
            @@ -7931,7 +7931,7 @@ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**. dangerouslySetInnerHTML=${{__html:t}} >
            - `};var Yw=3e3,Xw=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[b,x]=y([]),[v,w]=y(!1),$=ee(!1);L(()=>{t&&(i(0),g(""),h(!1),w(!1))},[t]),L(()=>{t&&(d(!0),ya().then(E=>{l(E||null)}).catch(E=>{N(E.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let k=F(()=>{let E=new Set,M=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||E.has(B)||R?.paired!==!1&&(E.add(B),M.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return M},[e]),C=F(()=>k.find(E=>E.nodeId===String(f||"").trim())||null,[k,f]),_=F(()=>{if(!a)return"";let E=String(a.gatewayHost||"").trim()||"localhost",M=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",D=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${E}`,`--port ${M}`,B.trim(),D?`--display-name "${D}"`:""].filter(Boolean).join(" ")},[a,u]),S=q(async()=>{if(!$.current){$.current=!0;try{await n();let E=await ga(),M=Array.isArray(E?.pending)?E.pending:[];x(M)}finally{$.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let E=!0,M=async()=>{if(E)try{await S()}catch{}};M();let R=setInterval(M,Yw);return()=>{E=!1,clearInterval(R)}},[S,r,t]),L(()=>{if(!t||r!==1)return;let E=k.some(B=>B.nodeId===String(f||"").trim()),M=String(u||"").trim().toLowerCase(),R=k.find(B=>String(B?.displayName||"").trim().toLowerCase()===M)||k[0];R&&(E&&String(f||"").trim()===R.nodeId||g(R.nodeId))},[u,k,f,r,t]);let A=q(async E=>{try{await ba(E),N("Pairing approved","success"),w(!0),await S()}catch(M){N(M.message||"Could not approve pairing","error")}},[S]),O=q(async E=>{try{await xa(E),N("Pairing rejected","info"),await S()}catch(M){N(M.message||"Could not reject pairing","error")}},[S]),I=q(async()=>{let E=String(f||"").trim();if(!E||m)return!1;h(!0);try{return await Lm(E),s(!0),N("Gateway routing now points to the selected node","success"),!0}catch(M){return N(M.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),V=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:k,selectedPairedNode:C,devicePending:b,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:S,nodeDiscoveryPollIntervalMs:Yw,handleDeviceApprove:A,handleDeviceReject:O,applyGatewayNodeRouting:I,completeWizard:V}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],Qw=({command:t="",onCopy:e=()=>{}})=>an` + `};var Yw=3e3,Xw=({visible:t=!1,nodes:e=[],refreshNodes:n=async()=>{},onRestartRequired:s=()=>{},onClose:o=()=>{}}={})=>{let[r,i]=y(0),[a,l]=y(null),[c,d]=y(!1),[u,p]=y("My Mac Node"),[f,g]=y(""),[m,h]=y(!1),[x,b]=y([]),[v,$]=y(!1),w=ee(!1);L(()=>{t&&(i(0),g(""),h(!1),$(!1))},[t]),L(()=>{t&&(d(!0),ya().then(E=>{l(E||null)}).catch(E=>{N(E.message||"Could not load node connect command","error")}).finally(()=>{d(!1)}))},[t]);let S=F(()=>{let E=new Set,A=[];for(let R of e){let B=String(R?.nodeId||"").trim();!B||E.has(B)||R?.paired!==!1&&(E.add(B),A.push({nodeId:B,displayName:String(R?.displayName||R?.name||B),connected:R?.connected===!0}))}return A},[e]),C=F(()=>S.find(E=>E.nodeId===String(f||"").trim())||null,[S,f]),_=F(()=>{if(!a)return"";let E=String(a.gatewayHost||"").trim()||"localhost",A=Number(a.gatewayPort)||3e3,R=String(a.gatewayToken||"").trim(),B=a.tls===!0?" --tls":"",D=String(u||"").trim().replace(/"/g,'\\"');return[R?`OPENCLAW_GATEWAY_TOKEN=${R}`:"","openclaw node run",`--host ${E}`,`--port ${A}`,B.trim(),D?`--display-name "${D}"`:""].filter(Boolean).join(" ")},[a,u]),k=q(async()=>{if(!w.current){w.current=!0;try{await n();let E=await ga(),A=Array.isArray(E?.pending)?E.pending:[];b(A)}finally{w.current=!1}}},[n]);L(()=>{if(!t||r!==1)return;let E=!0,A=async()=>{if(E)try{await k()}catch{}};A();let R=setInterval(A,Yw);return()=>{E=!1,clearInterval(R)}},[k,r,t]),L(()=>{if(!t||r!==1)return;let E=S.some(B=>B.nodeId===String(f||"").trim()),A=String(u||"").trim().toLowerCase(),R=S.find(B=>String(B?.displayName||"").trim().toLowerCase()===A)||S[0];R&&(E&&String(f||"").trim()===R.nodeId||g(R.nodeId))},[u,S,f,r,t]);let M=q(async E=>{try{await ba(E),N("Pairing approved","success"),$(!0),await k()}catch(A){N(A.message||"Could not approve pairing","error")}},[k]),O=q(async E=>{try{await xa(E),N("Pairing rejected","info"),await k()}catch(A){N(A.message||"Could not reject pairing","error")}},[k]),I=q(async()=>{let E=String(f||"").trim();if(!E||m)return!1;h(!0);try{return await Lm(E),s(!0),N("Gateway routing now points to the selected node","success"),!0}catch(A){return N(A.message||"Could not configure gateway node routing","error"),!1}finally{h(!1)}},[m,s,f]),V=q(()=>{o()},[o]);return{step:r,setStep:i,connectInfo:a,loadingConnectInfo:c,displayName:u,setDisplayName:p,selectedNodeId:f,setSelectedNodeId:g,pairedNodes:S,selectedPairedNode:C,devicePending:x,approvedInSession:v,configuring:m,canFinish:v&&!!C?.connected,connectCommand:_,refreshNodeList:k,nodeDiscoveryPollIntervalMs:Yw,handleDeviceApprove:M,handleDeviceReject:O,applyGatewayNodeRouting:I,completeWizard:V}};var an=P.bind(T),Ui=["Install OpenClaw CLI","Connect Node"],Qw=({command:t="",onCopy:e=()=>{}})=>an`
                 
            - `};var pR=P.bind(T),mf=({onRestartRequired:t=()=>{}})=>pR` + `};var uR=P.bind(T),mf=({onRestartRequired:t=()=>{}})=>uR`
            <${s1} onRestartRequired=${t} />
            -`;var FU=P.bind(T);var HU=Object.values(An).flat().map(t=>t.key).filter((t,e,n)=>n.indexOf(t)===e);var KU=P.bind(T);var Ki=({to:t})=>{let[,e]=ca();return L(()=>{e(t)},[t,e]),null};var Gi=(t,e)=>{let n=String(e||"").trim();n&&t.set("accountId",n)},o1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/bot${n}`)).json()},r1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/workspace${n}`)).json()},i1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/workspace/reset${n}`,{method:"POST"})).json()},a1=async(t,{accountId:e=""}={})=>(await Y("/api/telegram/groups/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groupId:t,accountId:e})})).json(),_c=async(t,{accountId:e=""}={})=>{let n=new URLSearchParams;Gi(n,e);let s=n.toString()?`?${n.toString()}`:"";return(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics${s}`)).json()},Ac=async(t,e,{accountId:n=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/bulk`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topics:e,accountId:n})})).json(),Mc=async(t,e,{accountId:n=""}={})=>{let s=new URLSearchParams;Gi(s,n);let o=s.toString()?`?${s.toString()}`:"";return(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${e}${o}`,{method:"DELETE"})).json()},l1=async(t,e,n,{accountId:s=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,accountId:s})})).json(),c1=async(t,e,{accountId:n=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,accountId:n})})).json();var Mt=P.bind(T),d1=({currentStep:t,steps:e})=>Mt` +`;var BU=P.bind(T);var FU=Object.values(An).flat().map(t=>t.key).filter((t,e,n)=>n.indexOf(t)===e);var UU=P.bind(T);var Ki=({to:t})=>{let[,e]=ca();return L(()=>{e(t)},[t,e]),null};var Gi=(t,e)=>{let n=String(e||"").trim();n&&t.set("accountId",n)},o1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/bot${n}`)).json()},r1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/workspace${n}`)).json()},i1=async({accountId:t=""}={})=>{let e=new URLSearchParams;Gi(e,t);let n=e.toString()?`?${e.toString()}`:"";return(await Y(`/api/telegram/workspace/reset${n}`,{method:"POST"})).json()},a1=async(t,{accountId:e=""}={})=>(await Y("/api/telegram/groups/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groupId:t,accountId:e})})).json(),_c=async(t,{accountId:e=""}={})=>{let n=new URLSearchParams;Gi(n,e);let s=n.toString()?`?${n.toString()}`:"";return(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics${s}`)).json()},Ac=async(t,e,{accountId:n=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/bulk`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({topics:e,accountId:n})})).json(),Mc=async(t,e,{accountId:n=""}={})=>{let s=new URLSearchParams;Gi(s,n);let o=s.toString()?`?${s.toString()}`:"";return(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${e}${o}`,{method:"DELETE"})).json()},l1=async(t,e,n,{accountId:s=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/topics/${encodeURIComponent(e)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({...n,accountId:s})})).json(),c1=async(t,e,{accountId:n=""}={})=>(await Y(`/api/telegram/groups/${encodeURIComponent(t)}/configure`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...e,accountId:n})})).json();var Mt=P.bind(T),d1=({currentStep:t,steps:e})=>Mt`
            ${e.map((n,s)=>Mt`
            -`,f1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),b=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],x=async()=>{let $=u.trim();if($){g(!0),l(null);try{let k=await a1($,{accountId:t});if(!k.ok)throw new Error(k.error);n($),o(k),!String(r||"").trim()&&k.suggestedUserId&&i(String(k.suggestedUserId))}catch(k){l(k.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),w=async()=>{if(!(!v||m)){l(null),h(!0);try{let $=String(r||"").trim(),k=await c1(e,{...$?{userId:$}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!k?.ok)throw new Error(k?.error||"Failed to configure Telegram group");k.userId&&i(String(k.userId)),c()}catch($){l($.message)}h(!1)}};return Mt` +`,f1=({accountId:t,groupId:e,setGroupId:n,groupInfo:s,setGroupInfo:o,userId:r,setUserId:i,verifyGroupError:a,setVerifyGroupError:l,onNext:c,onBack:d})=>{let[u,p]=y(e||""),[f,g]=y(!1),[m,h]=y(!1),x=s?[...s.chat?.isForum?[]:["Topics are OFF. Enable Topics in Telegram group settings."],...s.bot?.isAdmin?[]:["Bot is not an admin. Promote it to admin in group members."],...s.bot?.canManageTopics?[]:["Bot is missing Manage Topics permission. Enable it in admin permissions."]]:[],b=async()=>{let w=u.trim();if(w){g(!0),l(null);try{let S=await a1(w,{accountId:t});if(!S.ok)throw new Error(S.error);n(w),o(S),!String(r||"").trim()&&S.suggestedUserId&&i(String(S.suggestedUserId))}catch(S){l(S.message),o(null)}g(!1)}},v=!!(s&&s.chat?.isForum&&s.bot?.isAdmin&&s.bot?.canManageTopics),$=async()=>{if(!(!v||m)){l(null),h(!0);try{let w=String(r||"").trim(),S=await c1(e,{...w?{userId:w}:{},groupName:s?.chat?.title||e,requireMention:!1},{accountId:t});if(!S?.ok)throw new Error(S?.error||"Failed to configure Telegram group");S.userId&&i(String(S.userId)),c()}catch(w){l(w.message)}h(!1)}};return Mt`

            Verify Group

            @@ -8260,12 +8260,12 @@ ${t}p($.target.value)} + onInput=${w=>p(w.target.value)} placeholder="-100XXXXXXXXXX" class="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> <${Z} - onClick=${x} + onClick=${b} disabled=${!u.trim()||f} loading=${f} tone="secondary" @@ -8293,13 +8293,13 @@ ${t}
            `} - ${s&&b.length===0&&Mt` + ${s&&x.length===0&&Mt`

            Your Telegram User ID

            i($.target.value)} + onInput=${w=>i(w.target.value)} placeholder="e.g. 123456789" class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" /> @@ -8308,7 +8308,7 @@ ${t}
            `} - ${b.length>0&&Mt` + ${x.length>0&&Mt`
            @@ -8316,7 +8316,7 @@ ${t}
              - ${b.map($=>Mt`
            • ${$}
            • `)} + ${x.map(w=>Mt`
            • ${w}
            • `)}

            Once fixed, hit Verify again.

            @@ -8330,7 +8330,7 @@ ${t}
            - `},h1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,b]=y(null),x=async()=>{let k=await _c(e,{accountId:t});k.ok&&s(k.topics)};L(()=>{x()},[e]);let v=async()=>{let k=i.trim(),C=l.trim();if(k){u(!0),f(null);try{let _=await Ac(e,[{name:k,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let S=_.results.filter(A=>!A.ok);if(S.length>0)throw new Error(S[0].error);a(""),c(""),await x(),N(`Created topic: ${k}`,"success")}catch(_){f(_.message)}u(!1)}},w=async(k,C)=>{m(k);try{let _=await Mc(e,k,{accountId:t});if(!_.ok)throw new Error(_.error);await x(),_.removedFromRegistryOnly?N(`Removed stale topic from registry: ${C}`,"success"):N(`Deleted topic: ${C}`,"success")}catch(_){N(`Failed to delete: ${_.message}`,"error")}m(null)},$=Object.entries(n||{});return Mt` + `},h1=({accountId:t,groupId:e,topics:n,setTopics:s,onNext:o,onBack:r})=>{let[i,a]=y(""),[l,c]=y(""),[d,u]=y(!1),[p,f]=y(null),[g,m]=y(null),[h,x]=y(null),b=async()=>{let S=await _c(e,{accountId:t});S.ok&&s(S.topics)};L(()=>{b()},[e]);let v=async()=>{let S=i.trim(),C=l.trim();if(S){u(!0),f(null);try{let _=await Ac(e,[{name:S,...C?{systemInstructions:C}:{}}],{accountId:t});if(!_.ok)throw new Error(_.results?.[0]?.error||"Failed to create topic");let k=_.results.filter(M=>!M.ok);if(k.length>0)throw new Error(k[0].error);a(""),c(""),await b(),N(`Created topic: ${S}`,"success")}catch(_){f(_.message)}u(!1)}},$=async(S,C)=>{m(S);try{let _=await Mc(e,S,{accountId:t});if(!_.ok)throw new Error(_.error);await b(),_.removedFromRegistryOnly?N(`Removed stale topic from registry: ${C}`,"success"):N(`Deleted topic: ${C}`,"success")}catch(_){N(`Failed to delete: ${_.message}`,"error")}m(null)},w=Object.entries(n||{});return Mt`

            Create Topics

            - ${$.length>0&&Mt` + ${w.length>0&&Mt`
            @@ -8359,15 +8359,15 @@ ${t}
            ${C.name}${k}${S}
            - - - - - - - - ${w.map(([S,C])=>Mt` - - - - - - `)} - -
            - Topic - - Thread ID - -
            ${C.name}${S} - -
            -
            - `} - -
            - -
            -
            - a(S.target.value)} - onKeyDown=${S=>{S.key==="Enter"&&v()}} - placeholder="Topic name" - class="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body placeholder-fg-dim focus:outline-none focus:border-fg-muted" - /> -
            -
            ${streaming @@ -1085,7 +1131,7 @@ ${JSON.stringify( !String(draft || "").trim()} onclick=${handleSend} > - ${sending || streaming ? "Sending..." : "Send"} + ${sending ? "Sending..." : "Send"}
            diff --git a/lib/server/chat-ws.js b/lib/server/chat-ws.js index 4f88255c..fe946351 100644 --- a/lib/server/chat-ws.js +++ b/lib/server/chat-ws.js @@ -354,6 +354,10 @@ const createChatWsService = ({ let gatewayConnectPromise = null; const pendingGatewayRequests = new Map(); const runTargets = new Map(); + /** While `chat.send` is in flight, agent events can arrive before we have `runId` + runTargets — match by session. */ + const pendingSessionBySessionKey = new Map(); + /** Agent events keyed by runId when the event references a run not yet in runTargets (race with chat.send response). */ + const pendingAgentEventsByRunId = new Map(); const browserRuns = new WeakMap(); const sendJson = (ws, payload = {}) => { @@ -382,6 +386,9 @@ const createChatWsService = ({ }; const clearRunTargetsForBrowser = (ws) => { + for (const [sk, pending] of pendingSessionBySessionKey.entries()) { + if (pending.ws === ws) pendingSessionBySessionKey.delete(sk); + } const runs = browserRuns.get(ws); if (!runs) return; for (const runId of runs) runTargets.delete(runId); @@ -416,6 +423,7 @@ const createChatWsService = ({ const markGatewayDisconnected = (reason = "Gateway disconnected") => { gatewaySocket = null; gatewayConnectPromise = null; + pendingAgentEventsByRunId.clear(); rejectAllGatewayRequests(reason); }; @@ -436,6 +444,17 @@ const createChatWsService = ({ sessionTarget = targetRow; } if (sessionTarget) return { runId: "", target: sessionTarget }; + const pending = pendingSessionBySessionKey.get(sessionKey); + if (pending) { + return { + runId: resolveRunIdFromPayload(payload), + target: { + ws: pending.ws, + messageId: pending.messageId, + sessionKey, + }, + }; + } } if (runTargets.size === 1) { for (const [singleRunId, singleTarget] of runTargets.entries()) { @@ -446,9 +465,60 @@ const createChatWsService = ({ }; if (eventName === "agent") { const { runId, target } = resolveTargetForPayload(); - if (!target) return; + if (!target) { + const runIdEarly = resolveRunIdFromPayload(payload); + if (runIdEarly && !runTargets.get(runIdEarly)) { + const list = pendingAgentEventsByRunId.get(runIdEarly) || []; + list.push(eventPayload); + pendingAgentEventsByRunId.set(runIdEarly, list); + } + return; + } const stream = String(payload?.stream || ""); const data = payload?.data || {}; + if (stream === "tool") { + const toolPhase = String(data?.phase || ""); + const toolName = String(data?.name || "unknown"); + const toolCallId = String(data?.toolCallId || ""); + if (toolPhase === "start") { + sendJson(target.ws, { + type: "tool", + phase: "call", + messageId: target.messageId, + sessionKey: target.sessionKey, + timestamp: Number(payload?.ts) || Date.now(), + toolCall: { + id: toolCallId, + name: toolName, + arguments: data?.args || null, + partialJson: "", + }, + toolResult: null, + rawEvent: eventPayload || null, + }); + } else if (toolPhase === "result") { + const resultText = collectTextFromUnknownShape(data?.result); + sendJson(target.ws, { + type: "tool", + phase: "result", + messageId: target.messageId, + sessionKey: target.sessionKey, + timestamp: Number(payload?.ts) || Date.now(), + toolCall: null, + toolResult: { + role: "toolResult", + toolCallId, + toolName, + content: resultText + ? [{ type: "text", text: resultText }] + : [], + isError: data?.isError === true, + }, + rawEvent: eventPayload || null, + }); + } + return; + } const toolCall = extractToolCallFromUnknownShape(payload) || extractToolCallFromUnknownShape(data); @@ -557,7 +627,7 @@ const createChatWsService = ({ }, role: "operator", scopes: kGatewayChatBridgeScopes, - caps: [], + caps: ["tool-events"], commands: [], permissions: {}, auth: { token: getGatewayToken() }, @@ -689,13 +759,21 @@ const createChatWsService = ({ }); return; } - const result = await requestGateway("chat.send", { - sessionKey, - message: content, - idempotencyKey: crypto.randomUUID(), - }); + pendingSessionBySessionKey.set(sessionKey, { ws, messageId }); + let result; + try { + result = await requestGateway("chat.send", { + sessionKey, + message: content, + idempotencyKey: crypto.randomUUID(), + }); + } catch (err) { + pendingSessionBySessionKey.delete(sessionKey); + throw err; + } const runId = String(result?.runId || "").trim(); if (!runId) { + pendingSessionBySessionKey.delete(sessionKey); sendJson(ws, { type: "error", message: "Something went wrong connecting to the agent.", @@ -706,12 +784,20 @@ const createChatWsService = ({ } runTargets.set(runId, { ws, messageId, sessionKey }); registerRunForBrowser(ws, runId); + pendingSessionBySessionKey.delete(sessionKey); sendJson(ws, { type: "started", sessionKey, runId, messageId, }); + const buffered = pendingAgentEventsByRunId.get(runId); + if (buffered && buffered.length) { + pendingAgentEventsByRunId.delete(runId); + for (const stored of buffered) { + handleGatewayEvent(stored); + } + } }; const handleStop = async ({ ws, payload }) => { From 774a0def7b0135b30c9438836a59389b1c367ac3 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 28 Mar 2026 20:25:19 -0700 Subject: [PATCH 346/417] 0.8.3-beta.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60dce5f4..aa1c69aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.3-beta.3", + "version": "0.8.3-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.3-beta.3", + "version": "0.8.3-beta.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9e416d12..0e12f605 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.3-beta.3", + "version": "0.8.3-beta.4", "publishConfig": { "access": "public" }, From 9bd4e23ce463fd1a23d4e3995695a9880afa64ae Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 28 Mar 2026 21:23:40 -0700 Subject: [PATCH 347/417] Session display refactor, chat UI polish, and Chat4Line sidebar icon. --- AGENTS.md | 6 + lib/public/css/explorer.css | 87 +++++++--- lib/public/js/app.js | 7 - .../cron-tab/cron-job-settings-card.js | 11 +- .../components/google/gmail-setup-wizard.js | 8 +- lib/public/js/components/icons.js | 13 ++ lib/public/js/components/routes/chat-route.js | 24 +-- .../js/components/session-select-field.js | 11 +- lib/public/js/components/sidebar.js | 153 +++++++++++++++--- .../webhooks/webhook-detail/index.js | 7 +- lib/public/js/lib/session-keys.js | 74 +++++++++ lib/public/js/lib/storage-keys.js | 3 +- lib/server/routes/system.js | 121 +++++--------- tests/frontend/session-keys.test.js | 91 +++++++++++ tests/server/routes-system.test.js | 64 +++++++- 15 files changed, 504 insertions(+), 176 deletions(-) create mode 100644 tests/frontend/session-keys.test.js diff --git a/AGENTS.md b/AGENTS.md index b77857b2..8a0fb99d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,12 @@ Use this format for any Telegram notices sent from AlphaClaw services (watchdog, Use these conventions for all UI work under `lib/public/js` and `lib/public/css`. +### Setup UI bundle (esbuild) + +- The browser loads the compiled bundle under `lib/public/dist/` (for example `app.bundle.js` and chunk files), produced by `scripts/build-ui.mjs` (esbuild). +- **After any UI source change** that should ship in production (`lib/public/js`, `lib/public/css`, or other inputs to the build), run **`npm run build:ui`** so `lib/public/dist/` stays in sync. Verify the app in the browser against the rebuilt bundle when the change is non-trivial. +- **`npm publish`** runs **`prepack`** → **`npm run build:ui`**, so published packages always include a fresh bundle. Local installs, Docker builds from a git checkout, or commits that include `dist/` still require **`npm run build:ui`** when you change UI sources and expect the built assets to match. + ### Component structure - Use arrow-function components and helpers. diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index c03bb1b9..6bbec04d 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -215,7 +215,6 @@ .sidebar-chat-header { display: flex; align-items: center; - justify-content: space-between; gap: 8px; padding: 12px 16px 4px; } @@ -224,38 +223,82 @@ padding: 0; } -.sidebar-chat-new-button { +.sidebar-chat-sessions-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 2px 0 8px; +} + +.sidebar-chat-agent-group { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar-chat-agent-toggle { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + margin: 0; + padding: 6px 12px 4px 10px; border: none; background: transparent; color: var(--text-muted); - font: inherit; - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; - padding: 6px; - margin: -6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + text-align: left; cursor: pointer; - opacity: 0.9; - transition: color 0.1s, opacity 0.1s; + font-family: inherit; + user-select: none; } -.sidebar-chat-new-icon { - width: 16px; - height: 16px; +.sidebar-chat-agent-toggle:hover { + color: var(--text); +} + +.sidebar-chat-agent-chevron { + display: inline-flex; + flex-shrink: 0; + transition: transform 0.15s ease; + color: var(--text-dim); +} + +.sidebar-chat-agent-chevron.is-collapsed { + transform: rotate(-90deg); +} + +.sidebar-chat-agent-chevron-icon { + width: 12px; + height: 12px; display: block; } -.sidebar-chat-new-button:hover { - color: var(--text); - opacity: 1; +.sidebar-chat-agent-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.sidebar-chat-sessions-list { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - padding: 2px 0 8px; +.sidebar-chat-agent-sessions { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0 0 4px; +} + +.sidebar-chat-session-channel-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + border-radius: 2px; + object-fit: contain; + opacity: 0.92; } .sidebar-chat-session-item { diff --git a/lib/public/js/app.js b/lib/public/js/app.js index c6661343..3b5ecfe9 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -217,13 +217,6 @@ const App = () => { setSelectedChatSessionKey(sessionKey); if (!isChatRoute) setLocation("/chat"); }} - onStartChat=${() => { - if (!isChatRoute) { - setLocation("/chat"); - return; - } - window.dispatchEvent(new Event("alphaclaw:chat-new")); - }} />
            html` `, )} diff --git a/lib/public/js/components/google/gmail-setup-wizard.js b/lib/public/js/components/google/gmail-setup-wizard.js index 87256cfa..b337127c 100644 --- a/lib/public/js/components/google/gmail-setup-wizard.js +++ b/lib/public/js/components/google/gmail-setup-wizard.js @@ -18,7 +18,10 @@ import { kNoDestinationSessionValue, useDestinationSessionSelection, } from "../../hooks/use-destination-session-selection.js"; -import { kDestinationSessionFilter } from "../../lib/session-keys.js"; +import { + getSessionDisplayLabel, + kDestinationSessionFilter, +} from "../../lib/session-keys.js"; const html = htm.bind(h); @@ -419,7 +422,8 @@ export const GmailSetupWizard = ({ ${selectableAgentSessions.map( (sessionRow) => html` `, )} diff --git a/lib/public/js/components/icons.js b/lib/public/js/components/icons.js index 488e44c4..05500ccd 100644 --- a/lib/public/js/components/icons.js +++ b/lib/public/js/components/icons.js @@ -208,6 +208,19 @@ export const ChatVoiceLineIcon = ({ className = "" }) => html` `; +export const Chat4LineIcon = ({ className = "" }) => html` + +`; + export const FileMusicLineIcon = ({ className = "" }) => html` { [messagesBySession, selectedSessionKey], ); - useEffect(() => { - const handleNewChat = () => { - if (!selectedSessionKey) return; - setMessagesBySession((currentMap) => ({ - ...currentMap, - [selectedSessionKey]: [], - })); - setDraft(""); - setDraftBySession((currentMap) => ({ - ...currentMap, - [selectedSessionKey]: "", - })); - }; - window.addEventListener(kNewChatEventName, handleNewChat); - return () => { - window.removeEventListener(kNewChatEventName, handleNewChat); - }; - }, [selectedSessionKey]); - useEffect(() => { let mounted = true; @@ -889,7 +870,8 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
            Chat
            - ${selectedSession?.label || "Pick a session in the sidebar"} + ${getSessionDisplayLabel(selectedSession) || + "Pick a session in the sidebar"}
            ${connectionError ? html`
            ${connectionError}
            ` diff --git a/lib/public/js/components/session-select-field.js b/lib/public/js/components/session-select-field.js index 78b8f4b4..2501f5d3 100644 --- a/lib/public/js/components/session-select-field.js +++ b/lib/public/js/components/session-select-field.js @@ -1,6 +1,9 @@ import { h } from "preact"; import htm from "htm"; -import { getSessionRowKey } from "../lib/session-keys.js"; +import { + getSessionDisplayLabel, + getSessionRowKey, +} from "../lib/session-keys.js"; const html = htm.bind(h); @@ -54,7 +57,11 @@ export const SessionSelectField = ({ ${sessions.map( (sessionRow) => html` `, )} diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index 0252ff5a..d95bf824 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -1,5 +1,5 @@ import { h } from "preact"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import htm from "htm"; import { AddLineIcon, @@ -7,7 +7,8 @@ import { BarChartLineIcon, Brain2LineIcon, BracesLineIcon, - ChatVoiceLineIcon, + Chat4LineIcon, + ChevronDownIcon, ComputerLineIcon, EyeLineIcon, FolderLineIcon, @@ -21,7 +22,17 @@ import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; import { UpdateActionButton } from "./update-action-button.js"; import { SidebarGitPanel } from "./sidebar-git-panel.js"; import { UpdateModal } from "./update-modal.js"; -import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js"; +import { + readUiSettings, + updateUiSettings, + writeUiSettings, +} from "../lib/ui-settings.js"; +import { + getAgentIdFromSessionKey, + getSessionChannelForIcon, + getSessionDisplayLabel, + getSessionRowKey, +} from "../lib/session-keys.js"; const html = htm.bind(h); const kBrowseBottomPanelUiSettingKey = "browseBottomPanelHeightPx"; @@ -29,6 +40,16 @@ const kBrowsePanelMinHeightPx = 120; const kBrowseBottomMinHeightPx = 120; const kBrowseResizerHeightPx = 6; const kDefaultBrowseBottomPanelHeightPx = 260; +const kChatSidebarCollapsedAgentIdsKey = "chatSidebarCollapsedAgentIds"; +const kChatChannelIconSrc = { + telegram: "/assets/icons/telegram.svg", + discord: "/assets/icons/discord.svg", + slack: "/assets/icons/slack.svg", +}; +const readChatSidebarCollapsedAgentIds = () => { + const raw = readUiSettings()[kChatSidebarCollapsedAgentIdsKey]; + return Array.isArray(raw) ? raw : []; +}; const kSidebarNavIconsById = { cron: AlarmLineIcon, usage: BarChartLineIcon, @@ -97,7 +118,6 @@ export const AppSidebar = ({ chatSessions = [], selectedChatSessionKey = "", onSelectChatSession = () => {}, - onStartChat = () => {}, }) => { const browseLayoutRef = useRef(null); const browseBottomPanelRef = useRef(null); @@ -107,6 +127,53 @@ export const AppSidebar = ({ ); const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false); + const [collapsedChatAgentIds, setCollapsedChatAgentIds] = useState(() => + readChatSidebarCollapsedAgentIds(), + ); + + const chatSessionGroups = useMemo(() => { + const rows = Array.isArray(chatSessions) ? chatSessions : []; + const order = []; + const byAgent = new Map(); + for (const row of rows) { + const aid = String( + row.agentId || + getAgentIdFromSessionKey(getSessionRowKey(row)) || + "unknown", + ); + if (!byAgent.has(aid)) { + byAgent.set(aid, { + agentId: aid, + agentLabel: String(row.agentLabel || "").trim() || aid, + sessions: [], + }); + order.push(aid); + } + byAgent.get(aid).sessions.push(row); + } + const groups = order.map((aid) => byAgent.get(aid)); + groups.sort((a, b) => { + if (a.agentId === "main" && b.agentId !== "main") return -1; + if (b.agentId === "main" && a.agentId !== "main") return 1; + return a.agentLabel.localeCompare(b.agentLabel); + }); + return groups; + }, [chatSessions]); + + const toggleChatAgentCollapsed = (agentId) => { + const id = String(agentId || ""); + setCollapsedChatAgentIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + const arr = Array.from(next); + updateUiSettings((s) => ({ + ...s, + [kChatSidebarCollapsedAgentIdsKey]: arr, + })); + return arr; + }); + }; useEffect(() => { const settings = readUiSettings(); @@ -219,7 +286,7 @@ export const AppSidebar = ({ title="Chat" onclick=${() => onSelectSidebarTab("chat")} > - <${ChatVoiceLineIcon} className="sidebar-tab-icon" /> + <${Chat4LineIcon} className="sidebar-tab-icon" />
            `, )}
            diff --git a/lib/public/js/components/webhooks/webhook-detail/index.js b/lib/public/js/components/webhooks/webhook-detail/index.js index 90ec4e5b..4613ec9c 100644 --- a/lib/public/js/components/webhooks/webhook-detail/index.js +++ b/lib/public/js/components/webhooks/webhook-detail/index.js @@ -6,7 +6,10 @@ import { Badge } from "../../badge.js"; import { ConfirmDialog } from "../../confirm-dialog.js"; import { showToast } from "../../toast.js"; import { kNoDestinationSessionValue } from "../../../hooks/use-destination-session-selection.js"; -import { getSessionRowKey } from "../../../lib/session-keys.js"; +import { + getSessionDisplayLabel, + getSessionRowKey, +} from "../../../lib/session-keys.js"; import { formatDateTime } from "../helpers.js"; import { RequestHistory } from "../request-history/index.js"; import { useWebhookDetail } from "./use-webhook-detail.js"; @@ -267,7 +270,7 @@ export const WebhookDetail = ({ (sessionRow) => html`
            - `:null);var wd=new Map,Gs=new Map,wb=()=>Date.now(),vS=(t,e)=>t?wb()-Number(t.fetchedAt||0){let e=String(t||"");return e?wd.get(e)?.data??null:null},La=(t="",e=null)=>{let n=String(t||"");return n&&wd.set(n,{data:e,fetchedAt:wb()}),e};var kb=async(t,e,{maxAgeMs:n=15e3,force:s=!1,staleWhileRevalidate:o=!0,onRevalidate:r=null}={})=>{let i=String(t||"");if(!i||typeof e!="function")return e();let a=wd.get(i);if(!s&&vS(a,n))return a.data;if(!s&&o&&a){if(!Gs.has(i)){let c=Promise.resolve().then(()=>e()).then(d=>(La(i,d),typeof r=="function"&&r(d),d)).finally(()=>{Gs.delete(i)});Gs.set(i,c)}return a.data}if(Gs.has(i))return Gs.get(i);let l=Promise.resolve().then(()=>e()).then(c=>(La(i,c),c)).finally(()=>{Gs.delete(i)});return Gs.set(i,l),l};var ct=(t,e,{enabled:n=!0,maxAgeMs:s=15e3,staleWhileRevalidate:o=!0}={})=>{let r=F(()=>String(t||""),[t]),i=F(()=>ms(r),[r]),[a,l]=y(i),[c,d]=y(i===null),[u,p]=y(null);L(()=>{l(ms(r))},[r]);let f=q(async({force:g=!1}={})=>{if(!n)return ms(r);ms(r)===null&&d(!0);try{let m=await kb(r,e,{maxAgeMs:s,force:g,staleWhileRevalidate:o,onRevalidate:h=>{l(h),p(null)}});return l(m),p(null),m}catch(m){throw p(m),m}finally{d(!1)}},[n,e,s,r,o]);return L(()=>{n&&f().catch(()=>{})},[n,f]),{data:a,error:u,loading:c,refresh:f}};var qs=({channelId:t,account:e={},providerLabel:n=""})=>{let s=t?t.charAt(0).toUpperCase()+t.slice(1):"Channel",o=String(n||"").trim()||s,r=String(e?.name||"").trim();if(r)return r;let i=String(e?.id||"").trim();return!i||i==="default"?o:`${o} ${i}`},gs=({accountId:t,boundAgentId:e})=>String(t||"").trim()==="default"&&!String(e||"").trim();var Ea=async({payload:t={},onPhase:e=()=>{}})=>{if(e("Loading..."),typeof window?.EventSource!="function")return zm(t);let n=await Um(t),s=String(n?.operationId||"").trim();if(!s)throw new Error("Could not start channel creation operation");return new Promise((o,r)=>{let i=!1,a="",l=0,c=null,d=null,u={restarting:1200},p=()=>{d&&(clearTimeout(d),d=null)},f=({phase:h="",label:b=""}={})=>{let x=String(h||"").trim(),v=String(b||"").trim();if(!v)return;let w=Number(u[a]||0),$=l>0?Date.now()-l:0;if(w>0&&x&&x!==a&&${d=null;let S=c;c=null,S&&f(S)},w-$);return}p(),c=null,e(v),a=x,l=Date.now()},g=()=>{p(),m()},m=Km({operationId:s,onMessage:h=>{let b=String(h?.event||"").trim();if(b==="phase"){f({phase:String(h?.data?.phase||"").trim(),label:String(h?.data?.label||"").trim()});return}if(b==="done"){if(i)return;i=!0,g(),o(h?.data||{});return}if(b==="error"){if(i)return;i=!0,g(),r(new Error(String(h?.data?.error||"Could not create channel")))}},onError:()=>{i||(i=!0,g(),r(new Error("Channel operation stream disconnected")))}})})};var $S=new Set(["discord","slack"]),wS=({configuredChannelMap:t,provider:e})=>{let n=t instanceof Map?t.get(String(e||"").trim()):null;return Array.isArray(n?.accounts)&&n.accounts.length>0},kS=(t="")=>$S.has(String(t||"").trim()),Ia=({configuredChannelMap:t=new Map,provider:e=""}={})=>kS(e)?wS({configuredChannelMap:t,provider:e}):!1;var SS=P.bind(T),De=({visible:t=!1,onClose:e=()=>{},closeOnOverlayClick:n=!0,closeOnEscape:s=!0,panelClassName:o="bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3",children:r=null})=>(L(()=>{if(!t||!s)return;let i=a=>{a.key==="Escape"&&e?.()};return window.addEventListener("keydown",i),()=>window.removeEventListener("keydown",i)},[t,s,e]),t?Do(SS` + `:null);var kd=new Map,Gs=new Map,kb=()=>Date.now(),vS=(t,e)=>t?kb()-Number(t.fetchedAt||0){let e=String(t||"");return e?kd.get(e)?.data??null:null},La=(t="",e=null)=>{let n=String(t||"");return n&&kd.set(n,{data:e,fetchedAt:kb()}),e};var Sb=async(t,e,{maxAgeMs:n=15e3,force:s=!1,staleWhileRevalidate:o=!0,onRevalidate:r=null}={})=>{let i=String(t||"");if(!i||typeof e!="function")return e();let a=kd.get(i);if(!s&&vS(a,n))return a.data;if(!s&&o&&a){if(!Gs.has(i)){let c=Promise.resolve().then(()=>e()).then(d=>(La(i,d),typeof r=="function"&&r(d),d)).finally(()=>{Gs.delete(i)});Gs.set(i,c)}return a.data}if(Gs.has(i))return Gs.get(i);let l=Promise.resolve().then(()=>e()).then(c=>(La(i,c),c)).finally(()=>{Gs.delete(i)});return Gs.set(i,l),l};var ct=(t,e,{enabled:n=!0,maxAgeMs:s=15e3,staleWhileRevalidate:o=!0}={})=>{let r=F(()=>String(t||""),[t]),i=F(()=>ms(r),[r]),[a,l]=y(i),[c,d]=y(i===null),[u,p]=y(null);L(()=>{l(ms(r))},[r]);let f=q(async({force:g=!1}={})=>{if(!n)return ms(r);ms(r)===null&&d(!0);try{let m=await Sb(r,e,{maxAgeMs:s,force:g,staleWhileRevalidate:o,onRevalidate:h=>{l(h),p(null)}});return l(m),p(null),m}catch(m){throw p(m),m}finally{d(!1)}},[n,e,s,r,o]);return L(()=>{n&&f().catch(()=>{})},[n,f]),{data:a,error:u,loading:c,refresh:f}};var qs=({channelId:t,account:e={},providerLabel:n=""})=>{let s=t?t.charAt(0).toUpperCase()+t.slice(1):"Channel",o=String(n||"").trim()||s,r=String(e?.name||"").trim();if(r)return r;let i=String(e?.id||"").trim();return!i||i==="default"?o:`${o} ${i}`},gs=({accountId:t,boundAgentId:e})=>String(t||"").trim()==="default"&&!String(e||"").trim();var Ea=async({payload:t={},onPhase:e=()=>{}})=>{if(e("Loading..."),typeof window?.EventSource!="function")return Um(t);let n=await Km(t),s=String(n?.operationId||"").trim();if(!s)throw new Error("Could not start channel creation operation");return new Promise((o,r)=>{let i=!1,a="",l=0,c=null,d=null,u={restarting:1200},p=()=>{d&&(clearTimeout(d),d=null)},f=({phase:h="",label:b=""}={})=>{let x=String(h||"").trim(),v=String(b||"").trim();if(!v)return;let w=Number(u[a]||0),$=l>0?Date.now()-l:0;if(w>0&&x&&x!==a&&${d=null;let S=c;c=null,S&&f(S)},w-$);return}p(),c=null,e(v),a=x,l=Date.now()},g=()=>{p(),m()},m=Gm({operationId:s,onMessage:h=>{let b=String(h?.event||"").trim();if(b==="phase"){f({phase:String(h?.data?.phase||"").trim(),label:String(h?.data?.label||"").trim()});return}if(b==="done"){if(i)return;i=!0,g(),o(h?.data||{});return}if(b==="error"){if(i)return;i=!0,g(),r(new Error(String(h?.data?.error||"Could not create channel")))}},onError:()=>{i||(i=!0,g(),r(new Error("Channel operation stream disconnected")))}})})};var $S=new Set(["discord","slack"]),wS=({configuredChannelMap:t,provider:e})=>{let n=t instanceof Map?t.get(String(e||"").trim()):null;return Array.isArray(n?.accounts)&&n.accounts.length>0},kS=(t="")=>$S.has(String(t||"").trim()),Ia=({configuredChannelMap:t=new Map,provider:e=""}={})=>kS(e)?wS({configuredChannelMap:t,provider:e}):!1;var SS=P.bind(T),De=({visible:t=!1,onClose:e=()=>{},closeOnOverlayClick:n=!0,closeOnEscape:s=!0,panelClassName:o="bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3",children:r=null})=>(L(()=>{if(!t||!s)return;let i=a=>{a.key==="Escape"&&e?.()};return window.addEventListener("keydown",i),()=>window.removeEventListener("keydown",i)},[t,s,e]),t?Do(SS`
            {n&&i.target===i.currentTarget&&e?.()}} >
            ${r}
            - `,document.body):null);var Sb=P.bind(T),Ne=({title:t="",actions:e=null,leading:n=null})=>Sb` + `,document.body):null);var Cb=P.bind(T),Ne=({title:t="",actions:e=null,leading:n=null})=>Cb`
            - ${n||Sb`

            ${t}

            `} + ${n||Cb`

            ${t}

            `}
            ${e}
            -`;var Js=P.bind(T),CS={telegram:"TELEGRAM_BOT_TOKEN",discord:"DISCORD_BOT_TOKEN",slack:"SLACK_BOT_TOKEN"},_S={slack:"SLACK_APP_TOKEN"},MS=["app_mentions:read","channels:history","channels:read","chat:write","groups:history","im:history","im:read","im:write","mpim:history","reactions:read","reactions:write","users:read"],AS="https://docs.openclaw.ai/channels/slack",TS=t=>String(t||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,""),PS=({provider:t,accountId:e})=>{let n=CS[String(t||"").trim()]||"",s=String(e||"").trim();return n?!s||s==="default"?n:`${n}_${s.replace(/-/g,"_").toUpperCase()}`:""},RS=t=>/^\*+$/.test(String(t||"").trim()),qr=({visible:t=!1,loading:e=!1,createLoadingLabel:n="Creating...",agents:s=[],existingChannels:o=[],mode:r="create",account:i=null,initialAgentId:a="",initialProvider:l="",onClose:c=()=>{},onSubmit:d=async()=>{}})=>{let u=r==="edit",[p,f]=y("telegram"),[g,m]=y(""),[h,b]=y(""),[x,v]=y(""),[w,$]=y(""),[S,C]=y(""),[_,k]=y(""),[A,O]=y(!1),[D,z]=y(!1);L(()=>{if(!t)return;let K=u?String(i?.provider||"").trim()||"telegram":bs.includes(l)?l:bs[0]||"telegram",j=Rt(K).label||"Channel",X=o.find(xe=>String(xe?.channel||"").trim()===String(K||"").trim())||null,de=Array.isArray(X?.accounts)&&X.accounts.length>0,le=u?String(i?.name||"").trim()||j:de?"":j,ie=u?String(i?.ownerAgentId||"").trim()||String(a||"").trim()||String(s[0]?.id||"").trim():String(a||"").trim()||String(s[0]?.id||"").trim();f(K),m(le);let ne=u?(()=>{let xe=String(i?.token||"").trim();return RS(xe)?"":xe})():"";b(ne),v(ne),$(""),C(ie),k(""),O(u)},[t,a,l,s,o,u,i]);let E=F(()=>o.find(K=>String(K?.channel||"").trim()===String(p||"").trim())||null,[o,p]),M=F(()=>Array.isArray(E?.accounts)&&E.accounts.length>0,[E]);L(()=>{if(A)return;let K=Rt(p).label||"Channel";if(!u&&M){m("");return}m(K)},[p,M,A,u]);let R=String(p||"").trim()==="discord"||String(p||"").trim()==="slack",B=String(p||"").trim()==="slack",I=F(()=>u?String(i?.id||"").trim()||"default":R||!M?"default":TS(g),[g,M,u,i,R]),U=F(()=>PS({provider:p,accountId:I}),[p,I]),W=F(()=>Array.isArray(E?.accounts)&&E.accounts.some(K=>String(K?.id||"").trim()===String(I||"").trim()),[E,I]);L(()=>{if(!t||!u)return;let K=!1;return(async()=>{z(!0);try{let X=await jm({provider:p,accountId:I});if(K)return;let de=String(X?.token||""),le=String(X?.appToken||"");b(de),v(de),$(le)}catch{}finally{K||z(!1)}})(),()=>{K=!0}},[t,u,p,I]);let G=!!String(p||"").trim()&&!!String(g||"").trim()&&!!String(I||"").trim()&&!!String(S||"").trim()&&(u||!!String(h||"").trim())&&(u||!B||!!String(w||"").trim())&&(u||!W)&&!D;if(!t)return null;let se=async()=>{if(!String(g||"").trim()){k("Name is required");return}if(!String(I||"").trim()){k("Channel id could not be derived from the name");return}if(!u&&!String(h||"").trim()){k("Token is required");return}if(!u&&B&&!String(w||"").trim()){k("App Token is required for Slack");return}if(!String(S||"").trim()){k("Agent is required");return}if(!u&&W){k("That channel id is already configured for this provider");return}k("");let K=String(h||"").trim(),j=K&&K!==String(x||"").trim(),X=String(w||"").trim();await d({provider:p,name:String(g||"").trim(),accountId:I,agentId:S,...j?{token:K}:{},...B&&X?{appToken:X}:{}})};return Js` +`;var Js=P.bind(T),CS={telegram:"TELEGRAM_BOT_TOKEN",discord:"DISCORD_BOT_TOKEN",slack:"SLACK_BOT_TOKEN"},_S={slack:"SLACK_APP_TOKEN"},MS=["app_mentions:read","channels:history","channels:read","chat:write","groups:history","im:history","im:read","im:write","mpim:history","reactions:read","reactions:write","users:read"],AS="https://docs.openclaw.ai/channels/slack",TS=t=>String(t||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,""),PS=({provider:t,accountId:e})=>{let n=CS[String(t||"").trim()]||"",s=String(e||"").trim();return n?!s||s==="default"?n:`${n}_${s.replace(/-/g,"_").toUpperCase()}`:""},RS=t=>/^\*+$/.test(String(t||"").trim()),qr=({visible:t=!1,loading:e=!1,createLoadingLabel:n="Creating...",agents:s=[],existingChannels:o=[],mode:r="create",account:i=null,initialAgentId:a="",initialProvider:l="",onClose:c=()=>{},onSubmit:d=async()=>{}})=>{let u=r==="edit",[p,f]=y("telegram"),[g,m]=y(""),[h,b]=y(""),[x,v]=y(""),[w,$]=y(""),[S,C]=y(""),[_,k]=y(""),[A,O]=y(!1),[D,j]=y(!1);L(()=>{if(!t)return;let K=u?String(i?.provider||"").trim()||"telegram":bs.includes(l)?l:bs[0]||"telegram",z=Rt(K).label||"Channel",X=o.find(xe=>String(xe?.channel||"").trim()===String(K||"").trim())||null,de=Array.isArray(X?.accounts)&&X.accounts.length>0,le=u?String(i?.name||"").trim()||z:de?"":z,ie=u?String(i?.ownerAgentId||"").trim()||String(a||"").trim()||String(s[0]?.id||"").trim():String(a||"").trim()||String(s[0]?.id||"").trim();f(K),m(le);let ne=u?(()=>{let xe=String(i?.token||"").trim();return RS(xe)?"":xe})():"";b(ne),v(ne),$(""),C(ie),k(""),O(u)},[t,a,l,s,o,u,i]);let E=F(()=>o.find(K=>String(K?.channel||"").trim()===String(p||"").trim())||null,[o,p]),M=F(()=>Array.isArray(E?.accounts)&&E.accounts.length>0,[E]);L(()=>{if(A)return;let K=Rt(p).label||"Channel";if(!u&&M){m("");return}m(K)},[p,M,A,u]);let R=String(p||"").trim()==="discord"||String(p||"").trim()==="slack",B=String(p||"").trim()==="slack",I=F(()=>u?String(i?.id||"").trim()||"default":R||!M?"default":TS(g),[g,M,u,i,R]),U=F(()=>PS({provider:p,accountId:I}),[p,I]),W=F(()=>Array.isArray(E?.accounts)&&E.accounts.some(K=>String(K?.id||"").trim()===String(I||"").trim()),[E,I]);L(()=>{if(!t||!u)return;let K=!1;return(async()=>{j(!0);try{let X=await jm({provider:p,accountId:I});if(K)return;let de=String(X?.token||""),le=String(X?.appToken||"");b(de),v(de),$(le)}catch{}finally{K||j(!1)}})(),()=>{K=!0}},[t,u,p,I]);let G=!!String(p||"").trim()&&!!String(g||"").trim()&&!!String(I||"").trim()&&!!String(S||"").trim()&&(u||!!String(h||"").trim())&&(u||!B||!!String(w||"").trim())&&(u||!W)&&!D;if(!t)return null;let se=async()=>{if(!String(g||"").trim()){k("Name is required");return}if(!String(I||"").trim()){k("Channel id could not be derived from the name");return}if(!u&&!String(h||"").trim()){k("Token is required");return}if(!u&&B&&!String(w||"").trim()){k("App Token is required for Slack");return}if(!String(S||"").trim()){k("Agent is required");return}if(!u&&W){k("That channel id is already configured for this provider");return}k("");let K=String(h||"").trim(),z=K&&K!==String(x||"").trim(),X=String(w||"").trim();await d({provider:p,name:String(g||"").trim(),accountId:I,agentId:S,...z?{token:K}:{},...B&&X?{appToken:X}:{}})};return Js` <${De} visible=${t} onClose=${c} @@ -1416,13 +1416,13 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa />
            - `};var Cb=P.bind(T),LS=0,Da=null,ES={success:"success",error:"error",warning:"warning",info:"info",green:"success",red:"error",yellow:"warning",blue:"info"},IS={success:"bg-status-success-bg border border-status-success-border text-status-success",error:"bg-status-error-bg border border-status-error-border text-status-error",warning:"bg-status-warning-bg border border-status-warning-border text-status-warning",info:"bg-status-info-bg border border-status-info-border text-status-info"},_b=t=>{let e=String(t||"").trim().toLowerCase();return ES[e]||"info"};function N(t,e="info"){Da&&Da({id:++LS,text:t,type:_b(e)})}function Na({className:t="fixed bottom-4 right-4 z-50 space-y-2"}){let[e,n]=y([]);return L(()=>(Da=s=>{n(o=>[...o,s]),setTimeout(()=>n(o=>o.filter(r=>r.id!==s.id)),4e3)},()=>{Da=null}),[]),e.length===0?null:Do(Cb`
            - ${e.map(s=>Cb` -
            + `};var _b=P.bind(T),LS=0,Da=null,ES={success:"success",error:"error",warning:"warning",info:"info",green:"success",red:"error",yellow:"warning",blue:"info"},IS={success:"bg-status-success-bg border border-status-success-border text-status-success",error:"bg-status-error-bg border border-status-error-border text-status-error",warning:"bg-status-warning-bg border border-status-warning-border text-status-warning",info:"bg-status-info-bg border border-status-info-border text-status-info"},Mb=t=>{let e=String(t||"").trim().toLowerCase();return ES[e]||"info"};function N(t,e="info"){Da&&Da({id:++LS,text:t,type:Mb(e)})}function Na({className:t="fixed bottom-4 right-4 z-50 space-y-2"}){let[e,n]=y([]);return L(()=>(Da=s=>{n(o=>[...o,s]),setTimeout(()=>n(o=>o.filter(r=>r.id!==s.id)),4e3)},()=>{Da=null}),[]),e.length===0?null:Do(_b`
            + ${e.map(s=>_b` +
            ${s.text}
            `)} -
            `,document.body)}var en=P.bind(T),bs=["telegram","discord","slack"],DS={telegram:{label:"Telegram",iconSrc:"/assets/icons/telegram.svg"},discord:{label:"Discord",iconSrc:"/assets/icons/discord.svg"},slack:{label:"Slack",iconSrc:"/assets/icons/slack.svg"}},Rt=(t="")=>{let e=String(t||"").trim();return DS[e]||{label:e?e.charAt(0).toUpperCase()+e.slice(1):"Channel",iconSrc:""}},Mb=()=>window.dispatchEvent(new CustomEvent("alphaclaw:restart-required")),Oa=({title:t="Channels",items:e=[],loadingLabel:n="Loading...",actions:s=null,renderItem:o=null})=>en` +
            `,document.body)}var en=P.bind(T),bs=["telegram","discord","slack"],DS={telegram:{label:"Telegram",iconSrc:"/assets/icons/telegram.svg"},discord:{label:"Discord",iconSrc:"/assets/icons/discord.svg"},slack:{label:"Slack",iconSrc:"/assets/icons/slack.svg"}},Rt=(t="")=>{let e=String(t||"").trim();return DS[e]||{label:e?e.charAt(0).toUpperCase()+e.slice(1):"Channel",iconSrc:""}},Ab=()=>window.dispatchEvent(new CustomEvent("alphaclaw:restart-required")),Oa=({title:t="Channels",items:e=[],loadingLabel:n="Loading...",actions:s=null,renderItem:o=null})=>en`

            ${t}

            @@ -1482,7 +1482,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa
            `}
            -`,Ab=({channels:t=null,agents:e=[],onNavigate:n=()=>{},onRefreshStatuses:s=()=>{}})=>{let[o,r]=y(!1),[i,a]=y("Creating..."),[l,c]=y(""),[d,u]=y(null),[p,f]=y(null),{data:g,loading:m,refresh:h}=ct("/api/channels/accounts",To,{maxAgeMs:3e4}),b=Array.isArray(g?.channels)?g.channels:[],x=q(async()=>{try{await h({force:!0})}catch{}},[h]),v=F(()=>new Map(b.map(D=>[String(D?.channel||"").trim(),D])),[b]),w=F(()=>new Map(e.map(D=>[String(D?.id||"").trim(),String(D?.name||"").trim()||String(D?.id||"").trim()])),[e]),$=F(()=>String(e.find(D=>D?.default)?.id||"").trim(),[e]),S=e.length>0,C=async D=>{r(!0);try{let z=await Fr(D);u(null),N("Channel updated","success"),z?.restartRequired&&Mb(),await Promise.all([x(),Promise.resolve(s?.())])}catch(z){N(z.message||"Could not update channel","error")}finally{r(!1)}},_=async D=>{r(!0),a("Creating...");try{let z=await Ea({payload:D,onPhase:E=>{a(String(E||"").trim()||"Creating...")}});u(null),N("Channel configured","success"),z?.restartRequired&&Mb(),await Promise.all([x(),Promise.resolve(s?.())])}catch(z){N(z.message||"Could not configure channel","error")}finally{r(!1),a("Creating...")}},k=async()=>{if(p){r(!0);try{await $a({provider:p.provider,accountId:p.id}),f(null),N("Channel deleted","success"),await Promise.all([x(),Promise.resolve(s?.())])}catch(D){N(D.message||"Could not delete channel","error")}finally{r(!1)}}},A=D=>{c(""),u({id:"default",provider:D,name:Rt(D).label,ownerAgentId:$,mode:"create"})},O=F(()=>{if(m||!t)return[];let D=new Map(b.map((E,M)=>[String(E?.channel||"").trim(),M])),z=new Map(b.flatMap(E=>(Array.isArray(E?.accounts)?E.accounts:[]).map((M,R)=>[`${String(E?.channel||"").trim()}:${String(M?.id||"").trim()||"default"}`,R])));return Array.from(new Set([...b.map(E=>String(E?.channel||"").trim())])).filter(Boolean).flatMap(E=>{let M=t[E],R=v.get(E),B=Array.isArray(R?.accounts)?R.accounts:[];return R?B.map(I=>{let U=String(I?.id||"").trim()||"default",W=M?.accounts?.[U]||M||null,G=String(W?.status||I?.status||"configured").trim(),se=Number(W?.paired??I?.paired??M?.paired??0),K=E==="telegram"&&G==="paired"&&n,j=String(I?.boundAgentId||"").trim(),X=j||(gs({accountId:U,boundAgentId:j})?$:""),de=w.get(X)||X||"",le={id:U,provider:E,name:qs({channelId:E,account:I,providerLabel:Rt(E).label||"Channel"}),ownerAgentId:X,envKey:String(I?.envKey||"").trim(),token:String(I?.token||"").trim()},ie=en` +`,Tb=({channels:t=null,agents:e=[],onNavigate:n=()=>{},onRefreshStatuses:s=()=>{}})=>{let[o,r]=y(!1),[i,a]=y("Creating..."),[l,c]=y(""),[d,u]=y(null),[p,f]=y(null),{data:g,loading:m,refresh:h}=ct("/api/channels/accounts",To,{maxAgeMs:3e4}),b=Array.isArray(g?.channels)?g.channels:[],x=q(async()=>{try{await h({force:!0})}catch{}},[h]),v=F(()=>new Map(b.map(D=>[String(D?.channel||"").trim(),D])),[b]),w=F(()=>new Map(e.map(D=>[String(D?.id||"").trim(),String(D?.name||"").trim()||String(D?.id||"").trim()])),[e]),$=F(()=>String(e.find(D=>D?.default)?.id||"").trim(),[e]),S=e.length>0,C=async D=>{r(!0);try{let j=await Fr(D);u(null),N("Channel updated","success"),j?.restartRequired&&Ab(),await Promise.all([x(),Promise.resolve(s?.())])}catch(j){N(j.message||"Could not update channel","error")}finally{r(!1)}},_=async D=>{r(!0),a("Creating...");try{let j=await Ea({payload:D,onPhase:E=>{a(String(E||"").trim()||"Creating...")}});u(null),N("Channel configured","success"),j?.restartRequired&&Ab(),await Promise.all([x(),Promise.resolve(s?.())])}catch(j){N(j.message||"Could not configure channel","error")}finally{r(!1),a("Creating...")}},k=async()=>{if(p){r(!0);try{await $a({provider:p.provider,accountId:p.id}),f(null),N("Channel deleted","success"),await Promise.all([x(),Promise.resolve(s?.())])}catch(D){N(D.message||"Could not delete channel","error")}finally{r(!1)}}},A=D=>{c(""),u({id:"default",provider:D,name:Rt(D).label,ownerAgentId:$,mode:"create"})},O=F(()=>{if(m||!t)return[];let D=new Map(b.map((E,M)=>[String(E?.channel||"").trim(),M])),j=new Map(b.flatMap(E=>(Array.isArray(E?.accounts)?E.accounts:[]).map((M,R)=>[`${String(E?.channel||"").trim()}:${String(M?.id||"").trim()||"default"}`,R])));return Array.from(new Set([...b.map(E=>String(E?.channel||"").trim())])).filter(Boolean).flatMap(E=>{let M=t[E],R=v.get(E),B=Array.isArray(R?.accounts)?R.accounts:[];return R?B.map(I=>{let U=String(I?.id||"").trim()||"default",W=M?.accounts?.[U]||M||null,G=String(W?.status||I?.status||"configured").trim(),se=Number(W?.paired??I?.paired??M?.paired??0),K=E==="telegram"&&G==="paired"&&n,z=String(I?.boundAgentId||"").trim(),X=z||(gs({accountId:U,boundAgentId:z})?$:""),de=w.get(X)||X||"",le={id:U,provider:E,name:qs({channelId:E,account:I,providerLabel:Rt(E).label||"Channel"}),ownerAgentId:X,envKey:String(I?.envKey||"").trim(),token:String(I?.token||"").trim()},ie=en`
            ${S&&de&&G==="paired"?en`<${Ks} status=${G} @@ -1524,7 +1524,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa
            - `;return{id:`${E}:${U}`,channel:E,channelOrder:Number(D.get(E)??9999),accountOrder:Number(z.get(`${E}:${U}`)??9999),label:qs({channelId:E,account:I,providerLabel:Rt(E).label||"Channel"}),isAwaitingPairing:G!=="paired",detailText:K?"Workspace":"",detailChevron:K,clickable:K,onClick:K?()=>n(`telegram/${encodeURIComponent(U)}`):void 0,trailing:ie}}):[]}).sort((E,M)=>{let R=+!!E?.isAwaitingPairing-+!!M?.isAwaitingPairing;if(R!==0)return R;let B=Number(E?.channelOrder??9999)-Number(M?.channelOrder??9999);if(B!==0)return B;let I=Number(E?.accountOrder??9999)-Number(M?.accountOrder??9999);return I!==0?I:String(E?.label||"").localeCompare(String(M?.label||""))})},[w,e.length,b,t,v,$,m,l,n,S]);return en` + `;return{id:`${E}:${U}`,channel:E,channelOrder:Number(D.get(E)??9999),accountOrder:Number(j.get(`${E}:${U}`)??9999),label:qs({channelId:E,account:I,providerLabel:Rt(E).label||"Channel"}),isAwaitingPairing:G!=="paired",detailText:K?"Workspace":"",detailChevron:K,clickable:K,onClick:K?()=>n(`telegram/${encodeURIComponent(U)}`):void 0,trailing:ie}}):[]}).sort((E,M)=>{let R=+!!E?.isAwaitingPairing-+!!M?.isAwaitingPairing;if(R!==0)return R;let B=Number(E?.channelOrder??9999)-Number(M?.channelOrder??9999);if(B!==0)return B;let I=Number(E?.accountOrder??9999)-Number(M?.accountOrder??9999);return I!==0?I:String(E?.label||"").localeCompare(String(M?.label||""))})},[w,e.length,b,t,v,$,m,l,n,S]);return en`
            <${Oa} title="Channels" @@ -1568,14 +1568,14 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa onCancel=${()=>{o||f(null)}} />
            - `};var Pe=P.bind(T),NS=[{id:"telegram",title:"Telegram",fieldKeys:["TELEGRAM_BOT_TOKEN"]},{id:"discord",title:"Discord",fieldKeys:["DISCORD_BOT_TOKEN"]},{id:"slack",title:"Slack",fieldKeys:["SLACK_BOT_TOKEN","SLACK_APP_TOKEN"]}],Tb=({activeGroup:t,vals:e,hasAi:n,setValue:s,modelOptions:o,modelsLoading:r,modelsError:i,canToggleFullCatalog:a,showAllModels:l,setShowAllModels:c,selectedProvider:d,codexLoading:u,codexStatus:p,startCodexAuth:f,handleCodexDisconnect:g,codexAuthStarted:m,codexAuthWaiting:h,codexManualInput:b,setCodexManualInput:x,completeCodexAuth:v,codexExchanging:w,visibleAiFieldKeys:$,error:S,step:C,totalGroups:_,currentGroupValid:k,goBack:A,goNext:O,loading:D,githubStepLoading:z,allValid:E,handleSubmit:M})=>{let[R,B]=y(!1),[I,U]=y(!1),[W,G]=y(()=>new Set(["telegram"])),se=e._GITHUB_FLOW||An,K=se===Ot?Tn:e._GITHUB_TARGET_REPO_MODE||Tn,j=se===Ot||K===qn?"ghp_... or github_pat_...":"ghp_...";L(()=>{t.id},[t.id]),L(()=>{C===_-1&&(B(!e.OPENAI_API_KEY),U(!e.GEMINI_API_KEY))},[C===_-1]),L(()=>{t.id==="channels"&&G(ne=>ne.size>0?ne:new Set(["telegram"]))},[t.id]);let X=ne=>Pe` + `};var Pe=P.bind(T),NS=[{id:"telegram",title:"Telegram",fieldKeys:["TELEGRAM_BOT_TOKEN"]},{id:"discord",title:"Discord",fieldKeys:["DISCORD_BOT_TOKEN"]},{id:"slack",title:"Slack",fieldKeys:["SLACK_BOT_TOKEN","SLACK_APP_TOKEN"]}],Pb=({activeGroup:t,vals:e,hasAi:n,setValue:s,modelOptions:o,modelsLoading:r,modelsError:i,canToggleFullCatalog:a,showAllModels:l,setShowAllModels:c,selectedProvider:d,codexLoading:u,codexStatus:p,startCodexAuth:f,handleCodexDisconnect:g,codexAuthStarted:m,codexAuthWaiting:h,codexManualInput:b,setCodexManualInput:x,completeCodexAuth:v,codexExchanging:w,visibleAiFieldKeys:$,error:S,step:C,totalGroups:_,currentGroupValid:k,goBack:A,goNext:O,loading:D,githubStepLoading:j,allValid:E,handleSubmit:M})=>{let[R,B]=y(!1),[I,U]=y(!1),[W,G]=y(()=>new Set(["telegram"])),se=e._GITHUB_FLOW||An,K=se===Ot?Tn:e._GITHUB_TARGET_REPO_MODE||Tn,z=se===Ot||K===qn?"ghp_... or github_pat_...":"ghp_...";L(()=>{t.id},[t.id]),L(()=>{C===_-1&&(B(!e.OPENAI_API_KEY),U(!e.GEMINI_API_KEY))},[C===_-1]),L(()=>{t.id==="channels"&&G(ne=>ne.size>0?ne:new Set(["telegram"]))},[t.id]);let X=ne=>Pe`
            <${Pt} key=${ne.key} value=${e[ne.key]||""} onInput=${xe=>s(ne.key,xe.target.value)} - placeholder=${t.id==="github"&&ne.key==="GITHUB_TOKEN"?j:ne.placeholder||""} + placeholder=${t.id==="github"&&ne.key==="GITHUB_TOKEN"?z:ne.placeholder||""} isSecret=${!ne.isText} inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" /> @@ -1805,7 +1805,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa <${Z} onClick=${O} disabled=${!k} - loading=${t.id==="github"&&z} + loading=${t.id==="github"&&j} tone="primary" size="md" idleLabel=${t.id==="github"&&se===Ot?"Check compatibility":"Next"} @@ -1864,7 +1864,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa
            - `},Pb=({channel:t,pairings:e,channels:n,loading:s,error:o,onApprove:r,onReject:i,canFinish:a,onContinue:l,onSkip:c})=>{let d=OS[t]||{label:t?t.charAt(0).toUpperCase()+t.slice(1):"Channel",iconSrc:""},u=n?.[t];return t?a?Rn` + `},Rb=({channel:t,pairings:e,channels:n,loading:s,error:o,onApprove:r,onReject:i,canFinish:a,onContinue:l,onSkip:c})=>{let d=OS[t]||{label:t?t.charAt(0).toUpperCase()+t.slice(1):"Channel",iconSrc:""},u=n?.[t];return t?a?Rn`
            @@ -1945,9 +1945,9 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa Missing channel configuration. Go back and add a Telegram or Discord bot token.
            - `};var kd=(t={})=>t.TELEGRAM_BOT_TOKEN?"telegram":t.DISCORD_BOT_TOKEN?"discord":t.SLACK_BOT_TOKEN&&t.SLACK_APP_TOKEN?"slack":"",Rb=(t={},e="")=>{if(!e)return!1;let n=t?.[e];return n?n.status==="paired"&&Number(n.paired||0)>0:!1};var Sd="alphaclaw.ui.settings",Cd="alphaclaw.ui.theme",Jr="alphaclaw.browse.viewerMode",Ba="alphaclaw.browse.editorSelection",_d="alphaclaw.browse.expandedFolders",Fa="alphaclaw.browse.draft.",Md="alphaclaw.browse.draftIndex",Zr="alphaclaw.onboarding.state",Ha="alphaclaw.telegram.workspaceState",Wa="alphaclaw.telegram.workspaceCache",Ad="alphaclaw.agent.sessionsCache",Td="alphaclaw.agent.lastSessionKey";var Lb="_step",Pd="_pairingChannel",Eb="_lastSetupError",FS=()=>{try{return JSON.parse(localStorage.getItem(Zr)||"{}")}catch{return{}}},Ib=({kSetupStepIndex:t,kPairingStepIndex:e}={})=>{let[n]=y(FS),[s,o]=y(()=>({...n})),[r,i]=y(null),l=!!String(n?.[Eb]||"").trim(),[c,d]=y(()=>{let p=Number.parseInt(String(n?.[Lb]||""),10);if(!Number.isFinite(p))return-1;let f=Math.max(-1,Math.min(e,p));return f===t&&l?0:f});return L(()=>{localStorage.setItem(Zr,JSON.stringify({...s,[Lb]:c,...r?{[Eb]:r}:{}}))},[s,c,r]),{vals:s,setVals:o,setValue:(p,f)=>o(g=>({...g,[p]:f})),step:c,setStep:d,setupError:r,setSetupError:i}};var Db=({setFormError:t}={})=>{let[e,n]=y({connected:!1}),[s,o]=y(!0),[r,i]=y(""),[a,l]=y(!1),[c,d]=y(!1),[u,p]=y(!1),f=ee(null),g=async()=>{try{let x=await Po();n(x),x?.connected&&(d(!1),p(!1))}catch{n({connected:!1})}finally{o(!1)}};return L(()=>{g()},[]),L(()=>{let x=async v=>{v.data?.codex==="success"&&await g(),v.data?.codex==="error"&&t(`Codex auth failed: ${v.data.message||"unknown error"}`)};return window.addEventListener("message",x),()=>window.removeEventListener("message",x)},[t]),L(()=>()=>{f.current&&(clearInterval(f.current),f.current=null)},[]),{codexStatus:e,codexLoading:s,codexManualInput:r,setCodexManualInput:i,codexExchanging:a,codexAuthStarted:c,codexAuthWaiting:u,startCodexAuth:()=>{if(e.connected)return;d(!0),p(!0);let x="/auth/codex/start",v=window.open(x,"codex-auth","popup=yes,width=640,height=780");if(!v||v.closed){p(!1),window.location.href=x;return}f.current&&clearInterval(f.current),f.current=setInterval(()=>{v.closed&&(clearInterval(f.current),f.current=null,p(!1))},500)},completeCodexAuth:async()=>{if(!(!r.trim()||a)){l(!0),t(null);try{let x=await Wr(r.trim());if(!x.ok)throw new Error(x.error||"Codex OAuth exchange failed");i(""),d(!1),p(!1),await g()}catch(x){t(x.message||"Codex OAuth exchange failed")}finally{l(!1)}}},handleCodexDisconnect:async()=>{let x=await Hr();if(!x.ok){t(x.error||"Failed to disconnect Codex");return}d(!1),p(!1),i(""),await g()}}};var Re=(t,e,{enabled:n=!0,pauseWhenHidden:s=!0,cacheKey:o=""}={})=>{let r=String(o||""),[i,a]=y(()=>r?ms(r):null),[l,c]=y(null),d=ee(t);d.current=t;let u=q(async()=>{try{let p=await d.current();return r&&La(r,p),a(p),c(null),p}catch(p){return c(p),null}},[r]);return L(()=>{if(!r)return;let p=ms(r);p!==null&&a(p)},[r]),L(()=>{if(!n||s&&typeof document<"u"&&document.hidden)return;u();let p=setInterval(u,e);return()=>clearInterval(p)},[n,e,s,u]),L(()=>{if(!n||!s||typeof document>"u")return;let p=()=>{document.hidden||u()};return document.addEventListener("visibilitychange",p),()=>document.removeEventListener("visibilitychange",p)},[n,s,u]),{data:i,error:l,refresh:u}};var Nb=({isPairingStep:t=!1,selectedPairingChannel:e=""}={})=>{let[n,s]=y(null),[o,r]=y(!1),i=Re(So,3e3,{enabled:t}),a=Re(async()=>((await Co()).pending||[]).filter(m=>m.channel===e),1e3,{enabled:t&&!!e}),l=i.data?.channels||{},c=Rb(l,e);return L(()=>{t&&c&&r(!0)},[t,c]),{pairingStatusPoll:i,pairingRequestsPoll:a,pairingChannels:l,canFinishPairing:c,pairingError:n,pairingComplete:o,handlePairingApprove:async(f,g,m="")=>{try{s(null);let h=await _o(f,g,m);if(!h.ok)throw new Error(h.error||"Could not approve pairing");r(!0),a.refresh(),i.refresh()}catch(h){s(h.message||"Could not approve pairing")}},handlePairingReject:async(f,g,m="")=>{try{s(null);let h=await Mo(f,g,m);if(!h.ok)throw new Error(h.error||"Could not reject pairing");a.refresh()}catch(h){s(h.message||"Could not reject pairing")}},resetPairingState:()=>{s(null),r(!1)}}};var Ob=64,HS=128,Bb=4096,Rd="import",Fb="secret-review",Va="placeholder-review",Hb="_IMPORT_SUBSTEP",Ld="_IMPORT_PLACEHOLDER_REVIEW",Wb="_IMPORT_PLACEHOLDER_SKIP_CONFIRMED",Vb=(t={})=>{let e=!1,n=Object.entries(t).map(([s,o])=>{let r=typeof o=="string"?o.trim():o;return r!==o&&(e=!0),[s,r]});return{normalizedVals:e?Object.fromEntries(n):t,didChange:e}},jb=t=>!t||!Array.isArray(t.vars)||t.vars.length===0?{found:!1,count:0,vars:[]}:{found:!0,count:typeof t.count=="number"?t.count:t.vars.length,vars:t.vars.map(e=>({key:String(e?.key||"").trim(),status:String(e?.status||"missing").trim()||"missing"})).filter(e=>e.key)},zb=({onComplete:t})=>{let e=Ht.length,n=e+1,{vals:s,setVals:o,setValue:r,step:i,setStep:a,setupError:l,setSetupError:c}=Ib({kSetupStepIndex:e,kPairingStepIndex:n}),[d,u]=y([]),[p,f]=y(!0),[g,m]=y(null),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(!1),[S,C]=y(null),{codexStatus:_,codexLoading:k,codexManualInput:A,setCodexManualInput:O,codexExchanging:D,codexAuthStarted:z,codexAuthWaiting:E,startCodexAuth:M,completeCodexAuth:R,handleCodexDisconnect:B}=Db({setFormError:C}),[I,U]=y(()=>{let ge=String(s[Hb]||"").trim();return ge===Va?ge:null}),[W,G]=y(null),[se,K]=y(null),[j,X]=y(!1),[de,le]=y(null),ie=ge=>{U(ge),o(bt=>({...bt,[Hb]:ge===Va?ge:""}))},ne=()=>{o(ge=>({...ge,[Ld]:null,[Wb]:!1}))};L(()=>{Br().then(ge=>{let bt=Array.isArray(ge.models)?ge.models:[],Tt=Lo(bt);if(u(bt),!s.MODEL_KEY&&bt.length>0){let xt=Tt[0]||bt[0];o(fn=>({...fn,MODEL_KEY:xt.key}))}}).catch(()=>m("Failed to load models")).finally(()=>f(!1))},[]);let xe=js(s.MODEL_KEY),he=jb(s[Ld]),ue=Lo(d),me=h?d:ue.length>0?ue:d,V=d.find(ge=>ge.key===s.MODEL_KEY),J=V&&!me.some(ge=>ge.key===V.key)?[...me,V]:me,oe=ue.length>0&&d.length>ue.length,ce=xg(xe),be=Ro(xe),Ve=Mn[be]||[],et=xe==="openai-codex"?!!_.connected:Ve.some(ge=>!!String(s[ge.key]||"").trim()),wt=Ht.every(ge=>ge.validate(s,{hasAi:et})),Nt=i===-1,Q=i===e,pe=i===n,te=i>=0&&i{let{normalizedVals:ge,didChange:bt}=Vb(s);if(bt&&o(ge),!Ht.every(Ke=>Ke.validate(ge,{hasAi:et}))||x)return;let Tt=Object.entries(ge).filter(([Ke])=>Ke!=="MODEL_KEY"&&!String(Ke||"").startsWith("_")).filter(([,Ke])=>Ke).map(([Ke,kt])=>({key:Ke,value:kt})),xt=(()=>{if(!ge.MODEL_KEY||!String(ge.MODEL_KEY).includes("/"))return"A model selection is required";if(Tt.length>Ob)return`Too many environment variables (max ${Ob})`;for(let Ke of Tt){let kt=String(Ke?.key||""),Gt=String(Ke?.value||"");if(!kt)return"Each variable must include a key";if(kt.length>HS)return`Variable key is too long: ${kt.slice(0,32)}...`;if(Gt.length>Bb)return`Value too long for ${kt} (max ${Bb} chars)`}return!ge.GITHUB_TOKEN||!zr(ge.GITHUB_WORKSPACE_REPO)?'Target repo must be in "owner/repo" format.':(ge._GITHUB_FLOW||An)===Ot&&!zr(ge._GITHUB_SOURCE_REPO)?'Source repo must be in "owner/repo" format.':""})();if(xt){C(xt),c(null),a(Math.max(0,Ht.findIndex(Ke=>Ke.id==="github")));return}a(e),v(!0),C(null),c(null),ea();let fn=(ge._GITHUB_FLOW||An)===Ot;try{let Ke=await Bm(Tt,ge.MODEL_KEY,{importMode:fn});if(!Ke.ok)throw new Error(Ke.error||"Onboarding failed");let kt=kd(ge);if(!kt)throw new Error("No Telegram or Discord bot token configured for pairing.");o(Gt=>({...Gt,[Pd]:kt})),v(!1),a(n),ea(),c(null)}catch(Ke){console.error("Onboard error:",Ke),c(Ke.message||"Onboarding failed"),v(!1)}},Cr=()=>{localStorage.removeItem(Zr),t()},Vc=()=>{Q||(C(null),a(ge=>Math.max(-1,ge-1)))},ta=()=>{v(!1),c(null),a(Ht.length-1)},Kt=async()=>{let{normalizedVals:ge,didChange:bt}=Vb(s);if(bt&&o(ge),!(!te||!te.validate(ge,{hasAi:et}))){if(C(null),te.id==="github"){let Tt=ge._GITHUB_FLOW||An,xt=Tt===Ot?Tn:ge._GITHUB_TARGET_REPO_MODE||Tn,fn=xt===qn?fd:pd,Ke=Tt===Ot?ge._GITHUB_SOURCE_REPO:ge.GITHUB_WORKSPACE_REPO;$(!0),ne();try{if(Tt===Ot){let Gt=await va(Ke,ge.GITHUB_TOKEN,fd);if(!Gt?.ok){C(Gt?.error||"GitHub source verification failed");return}if(Gt.repoIsEmpty){C("That source repository is empty. Use Start fresh if you want AlphaClaw to bootstrap a new setup there.");return}let vo=await va(ge.GITHUB_WORKSPACE_REPO,ge.GITHUB_TOKEN,pd);if(!vo?.ok){C(vo?.error||"GitHub target verification failed");return}if(xt===Tn&&vo.repoExists){C("That target repository already exists. Choose Use existing empty repo or pick a new target repo name.");return}if(xt===qn&&!vo.repoExists){C("That target repository does not exist yet. Choose Create new repo or enter an existing empty target repo.");return}if(Gt.tempDir&&!Gt.repoIsEmpty){G(Gt.tempDir),ie(Rd),X(!0),le(null);try{let Hs=await Fm(Gt.tempDir);if(!Hs?.ok){le(Hs?.error||"Import scan failed"),X(!1);return}K(Hs)}catch(Hs){le(Hs?.message||"Import scan failed")}finally{X(!1)}return}}let kt=await va(ge.GITHUB_WORKSPACE_REPO,ge.GITHUB_TOKEN,fn);if(!kt?.ok){C(kt?.error||"GitHub verification failed");return}if(xt===Tn&&kt.repoExists){C("That target repository already exists. Choose Use existing empty repo or pick a new target repo name.");return}if(xt===qn&&!kt.repoExists){C("That target repository does not exist yet. Choose Create new repo or enter an existing empty target repo.");return}}catch(kt){C(kt?.message||"GitHub verification failed");return}finally{$(!1)}}a(Tt=>Math.min(Ht.length-1,Tt+1))}},jf=async(ge=[])=>{X(!0),le(null);try{let bt=ge.length===0,Tt=Sg(ge),xt=await Hm({tempDir:W,approvedSecrets:ge,skipSecretExtraction:bt,githubRepo:s.GITHUB_WORKSPACE_REPO,githubToken:s.GITHUB_TOKEN});if(!xt?.ok){le(xt?.error||"Import failed"),X(!1);return}let fn=jb(xt.placeholderReview);if(o(Ke=>({...Ke,...Tt,...xt.preFill||{},[Ld]:fn,[Wb]:!1})),fn.found){ie(Va);return}ne(),ie(null),a(Ke=>Math.min(Ht.length-1,Ke+1))}catch(bt){le(bt?.message||"Import failed")}finally{X(!1)}},go=()=>{ie(Fb)},bo=()=>{ie(Rd)},_r=()=>{ie(null),G(null),K(null),le(null),ne()},na=()=>{ne(),ie(null),a(ge=>Math.min(Ht.length-1,ge+1))},ps=ge=>{r("_GITHUB_FLOW",ge),a(0)},xo=I===Rd,Fs=I===Fb,fs=I===Va,Gn=Nt?"Getting Started":xo?"Import":Fs?"Review Secrets":fs?"Review Env Vars":Q?"Initializing":pe?"Pairing":te?.title||"Setup",yo=Nt?0:xo||Fs||fs?i+1:Q?Ht.length+1:pe?Ht.length+2:i+1;return{state:{vals:s,step:i,setupError:l,modelsLoading:p,modelsError:g,showAllModels:h,loading:x,githubStepLoading:w,formError:S,importScanResult:se,importScanning:j,importError:de,selectedProvider:xe,modelOptions:J,canToggleFullCatalog:oe,visibleAiFieldKeys:ce,hasAi:et,allValid:wt,isPreStep:Nt,isSetupStep:Q,isPairingStep:pe,activeGroup:te,currentGroupValid:Ee,selectedPairingChannel:ke,placeholderReview:he,isImportStep:xo,isSecretReviewStep:Fs,isPlaceholderReviewStep:fs,activeStepLabel:Gn,stepNumber:yo,codexStatus:_,codexLoading:k,codexManualInput:A,codexExchanging:D,codexAuthStarted:z,codexAuthWaiting:E,pairingStatusPoll:H,pairingRequestsPoll:re,pairingChannels:ye,canFinishPairing:ve,pairingError:Ie,pairingComplete:gt},actions:{setVals:o,setValue:r,setShowAllModels:b,setCodexManualInput:O,startCodexAuth:M,completeCodexAuth:R,handleCodexDisconnect:B,handleSubmit:Wc,finishOnboarding:Cr,goBack:Vc,goBackFromSetupError:ta,goNext:Kt,handleSelectFlow:ps,handleImportApprove:jf,handleShowSecretReview:go,handleSecretReviewBack:bo,handleImportBack:_r,handlePlaceholderReviewContinue:na,handlePairingApprove:Fc,handlePairingReject:Hc}}};var Jn=P.bind(T),Ub=({onComplete:t,acVersion:e})=>{let{state:n,actions:s}=zb({onComplete:t});return Jn` + `};var Sd=(t={})=>t.TELEGRAM_BOT_TOKEN?"telegram":t.DISCORD_BOT_TOKEN?"discord":t.SLACK_BOT_TOKEN&&t.SLACK_APP_TOKEN?"slack":"",Lb=(t={},e="")=>{if(!e)return!1;let n=t?.[e];return n?n.status==="paired"&&Number(n.paired||0)>0:!1};var Cd="alphaclaw.ui.settings",_d="alphaclaw.ui.theme",Jr="alphaclaw.browse.viewerMode",Ba="alphaclaw.browse.editorSelection",Md="alphaclaw.browse.expandedFolders",Fa="alphaclaw.browse.draft.",Ad="alphaclaw.browse.draftIndex",Zr="alphaclaw.onboarding.state",Ha="alphaclaw.telegram.workspaceState",Wa="alphaclaw.telegram.workspaceCache",Td="alphaclaw.agent.sessionsCache",Pd="alphaclaw.agent.lastSessionKey";var Eb="_step",Rd="_pairingChannel",Ib="_lastSetupError",FS=()=>{try{return JSON.parse(localStorage.getItem(Zr)||"{}")}catch{return{}}},Db=({kSetupStepIndex:t,kPairingStepIndex:e}={})=>{let[n]=y(FS),[s,o]=y(()=>({...n})),[r,i]=y(null),l=!!String(n?.[Ib]||"").trim(),[c,d]=y(()=>{let p=Number.parseInt(String(n?.[Eb]||""),10);if(!Number.isFinite(p))return-1;let f=Math.max(-1,Math.min(e,p));return f===t&&l?0:f});return L(()=>{localStorage.setItem(Zr,JSON.stringify({...s,[Eb]:c,...r?{[Ib]:r}:{}}))},[s,c,r]),{vals:s,setVals:o,setValue:(p,f)=>o(g=>({...g,[p]:f})),step:c,setStep:d,setupError:r,setSetupError:i}};var Nb=({setFormError:t}={})=>{let[e,n]=y({connected:!1}),[s,o]=y(!0),[r,i]=y(""),[a,l]=y(!1),[c,d]=y(!1),[u,p]=y(!1),f=ee(null),g=async()=>{try{let x=await Po();n(x),x?.connected&&(d(!1),p(!1))}catch{n({connected:!1})}finally{o(!1)}};return L(()=>{g()},[]),L(()=>{let x=async v=>{v.data?.codex==="success"&&await g(),v.data?.codex==="error"&&t(`Codex auth failed: ${v.data.message||"unknown error"}`)};return window.addEventListener("message",x),()=>window.removeEventListener("message",x)},[t]),L(()=>()=>{f.current&&(clearInterval(f.current),f.current=null)},[]),{codexStatus:e,codexLoading:s,codexManualInput:r,setCodexManualInput:i,codexExchanging:a,codexAuthStarted:c,codexAuthWaiting:u,startCodexAuth:()=>{if(e.connected)return;d(!0),p(!0);let x="/auth/codex/start",v=window.open(x,"codex-auth","popup=yes,width=640,height=780");if(!v||v.closed){p(!1),window.location.href=x;return}f.current&&clearInterval(f.current),f.current=setInterval(()=>{v.closed&&(clearInterval(f.current),f.current=null,p(!1))},500)},completeCodexAuth:async()=>{if(!(!r.trim()||a)){l(!0),t(null);try{let x=await Wr(r.trim());if(!x.ok)throw new Error(x.error||"Codex OAuth exchange failed");i(""),d(!1),p(!1),await g()}catch(x){t(x.message||"Codex OAuth exchange failed")}finally{l(!1)}}},handleCodexDisconnect:async()=>{let x=await Hr();if(!x.ok){t(x.error||"Failed to disconnect Codex");return}d(!1),p(!1),i(""),await g()}}};var Re=(t,e,{enabled:n=!0,pauseWhenHidden:s=!0,cacheKey:o=""}={})=>{let r=String(o||""),[i,a]=y(()=>r?ms(r):null),[l,c]=y(null),d=ee(t);d.current=t;let u=q(async()=>{try{let p=await d.current();return r&&La(r,p),a(p),c(null),p}catch(p){return c(p),null}},[r]);return L(()=>{if(!r)return;let p=ms(r);p!==null&&a(p)},[r]),L(()=>{if(!n||s&&typeof document<"u"&&document.hidden)return;u();let p=setInterval(u,e);return()=>clearInterval(p)},[n,e,s,u]),L(()=>{if(!n||!s||typeof document>"u")return;let p=()=>{document.hidden||u()};return document.addEventListener("visibilitychange",p),()=>document.removeEventListener("visibilitychange",p)},[n,s,u]),{data:i,error:l,refresh:u}};var Ob=({isPairingStep:t=!1,selectedPairingChannel:e=""}={})=>{let[n,s]=y(null),[o,r]=y(!1),i=Re(So,3e3,{enabled:t}),a=Re(async()=>((await Co()).pending||[]).filter(m=>m.channel===e),1e3,{enabled:t&&!!e}),l=i.data?.channels||{},c=Lb(l,e);return L(()=>{t&&c&&r(!0)},[t,c]),{pairingStatusPoll:i,pairingRequestsPoll:a,pairingChannels:l,canFinishPairing:c,pairingError:n,pairingComplete:o,handlePairingApprove:async(f,g,m="")=>{try{s(null);let h=await _o(f,g,m);if(!h.ok)throw new Error(h.error||"Could not approve pairing");r(!0),a.refresh(),i.refresh()}catch(h){s(h.message||"Could not approve pairing")}},handlePairingReject:async(f,g,m="")=>{try{s(null);let h=await Mo(f,g,m);if(!h.ok)throw new Error(h.error||"Could not reject pairing");a.refresh()}catch(h){s(h.message||"Could not reject pairing")}},resetPairingState:()=>{s(null),r(!1)}}};var Bb=64,HS=128,Fb=4096,Ld="import",Hb="secret-review",Va="placeholder-review",Wb="_IMPORT_SUBSTEP",Ed="_IMPORT_PLACEHOLDER_REVIEW",Vb="_IMPORT_PLACEHOLDER_SKIP_CONFIRMED",zb=(t={})=>{let e=!1,n=Object.entries(t).map(([s,o])=>{let r=typeof o=="string"?o.trim():o;return r!==o&&(e=!0),[s,r]});return{normalizedVals:e?Object.fromEntries(n):t,didChange:e}},jb=t=>!t||!Array.isArray(t.vars)||t.vars.length===0?{found:!1,count:0,vars:[]}:{found:!0,count:typeof t.count=="number"?t.count:t.vars.length,vars:t.vars.map(e=>({key:String(e?.key||"").trim(),status:String(e?.status||"missing").trim()||"missing"})).filter(e=>e.key)},Ub=({onComplete:t})=>{let e=Ht.length,n=e+1,{vals:s,setVals:o,setValue:r,step:i,setStep:a,setupError:l,setSetupError:c}=Db({kSetupStepIndex:e,kPairingStepIndex:n}),[d,u]=y([]),[p,f]=y(!0),[g,m]=y(null),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(!1),[S,C]=y(null),{codexStatus:_,codexLoading:k,codexManualInput:A,setCodexManualInput:O,codexExchanging:D,codexAuthStarted:j,codexAuthWaiting:E,startCodexAuth:M,completeCodexAuth:R,handleCodexDisconnect:B}=Nb({setFormError:C}),[I,U]=y(()=>{let ge=String(s[Wb]||"").trim();return ge===Va?ge:null}),[W,G]=y(null),[se,K]=y(null),[z,X]=y(!1),[de,le]=y(null),ie=ge=>{U(ge),o(bt=>({...bt,[Wb]:ge===Va?ge:""}))},ne=()=>{o(ge=>({...ge,[Ed]:null,[Vb]:!1}))};L(()=>{Br().then(ge=>{let bt=Array.isArray(ge.models)?ge.models:[],Tt=Lo(bt);if(u(bt),!s.MODEL_KEY&&bt.length>0){let xt=Tt[0]||bt[0];o(fn=>({...fn,MODEL_KEY:xt.key}))}}).catch(()=>m("Failed to load models")).finally(()=>f(!1))},[]);let xe=zs(s.MODEL_KEY),he=jb(s[Ed]),ue=Lo(d),me=h?d:ue.length>0?ue:d,V=d.find(ge=>ge.key===s.MODEL_KEY),J=V&&!me.some(ge=>ge.key===V.key)?[...me,V]:me,oe=ue.length>0&&d.length>ue.length,ce=yg(xe),be=Ro(xe),Ve=Mn[be]||[],et=xe==="openai-codex"?!!_.connected:Ve.some(ge=>!!String(s[ge.key]||"").trim()),wt=Ht.every(ge=>ge.validate(s,{hasAi:et})),Nt=i===-1,Q=i===e,pe=i===n,te=i>=0&&i{let{normalizedVals:ge,didChange:bt}=zb(s);if(bt&&o(ge),!Ht.every(Ke=>Ke.validate(ge,{hasAi:et}))||x)return;let Tt=Object.entries(ge).filter(([Ke])=>Ke!=="MODEL_KEY"&&!String(Ke||"").startsWith("_")).filter(([,Ke])=>Ke).map(([Ke,kt])=>({key:Ke,value:kt})),xt=(()=>{if(!ge.MODEL_KEY||!String(ge.MODEL_KEY).includes("/"))return"A model selection is required";if(Tt.length>Bb)return`Too many environment variables (max ${Bb})`;for(let Ke of Tt){let kt=String(Ke?.key||""),Gt=String(Ke?.value||"");if(!kt)return"Each variable must include a key";if(kt.length>HS)return`Variable key is too long: ${kt.slice(0,32)}...`;if(Gt.length>Fb)return`Value too long for ${kt} (max ${Fb} chars)`}return!ge.GITHUB_TOKEN||!jr(ge.GITHUB_WORKSPACE_REPO)?'Target repo must be in "owner/repo" format.':(ge._GITHUB_FLOW||An)===Ot&&!jr(ge._GITHUB_SOURCE_REPO)?'Source repo must be in "owner/repo" format.':""})();if(xt){C(xt),c(null),a(Math.max(0,Ht.findIndex(Ke=>Ke.id==="github")));return}a(e),v(!0),C(null),c(null),ea();let fn=(ge._GITHUB_FLOW||An)===Ot;try{let Ke=await Fm(Tt,ge.MODEL_KEY,{importMode:fn});if(!Ke.ok)throw new Error(Ke.error||"Onboarding failed");let kt=Sd(ge);if(!kt)throw new Error("No Telegram or Discord bot token configured for pairing.");o(Gt=>({...Gt,[Rd]:kt})),v(!1),a(n),ea(),c(null)}catch(Ke){console.error("Onboard error:",Ke),c(Ke.message||"Onboarding failed"),v(!1)}},Cr=()=>{localStorage.removeItem(Zr),t()},zc=()=>{Q||(C(null),a(ge=>Math.max(-1,ge-1)))},ta=()=>{v(!1),c(null),a(Ht.length-1)},Kt=async()=>{let{normalizedVals:ge,didChange:bt}=zb(s);if(bt&&o(ge),!(!te||!te.validate(ge,{hasAi:et}))){if(C(null),te.id==="github"){let Tt=ge._GITHUB_FLOW||An,xt=Tt===Ot?Tn:ge._GITHUB_TARGET_REPO_MODE||Tn,fn=xt===qn?hd:fd,Ke=Tt===Ot?ge._GITHUB_SOURCE_REPO:ge.GITHUB_WORKSPACE_REPO;$(!0),ne();try{if(Tt===Ot){let Gt=await va(Ke,ge.GITHUB_TOKEN,hd);if(!Gt?.ok){C(Gt?.error||"GitHub source verification failed");return}if(Gt.repoIsEmpty){C("That source repository is empty. Use Start fresh if you want AlphaClaw to bootstrap a new setup there.");return}let vo=await va(ge.GITHUB_WORKSPACE_REPO,ge.GITHUB_TOKEN,fd);if(!vo?.ok){C(vo?.error||"GitHub target verification failed");return}if(xt===Tn&&vo.repoExists){C("That target repository already exists. Choose Use existing empty repo or pick a new target repo name.");return}if(xt===qn&&!vo.repoExists){C("That target repository does not exist yet. Choose Create new repo or enter an existing empty target repo.");return}if(Gt.tempDir&&!Gt.repoIsEmpty){G(Gt.tempDir),ie(Ld),X(!0),le(null);try{let Hs=await Hm(Gt.tempDir);if(!Hs?.ok){le(Hs?.error||"Import scan failed"),X(!1);return}K(Hs)}catch(Hs){le(Hs?.message||"Import scan failed")}finally{X(!1)}return}}let kt=await va(ge.GITHUB_WORKSPACE_REPO,ge.GITHUB_TOKEN,fn);if(!kt?.ok){C(kt?.error||"GitHub verification failed");return}if(xt===Tn&&kt.repoExists){C("That target repository already exists. Choose Use existing empty repo or pick a new target repo name.");return}if(xt===qn&&!kt.repoExists){C("That target repository does not exist yet. Choose Create new repo or enter an existing empty target repo.");return}}catch(kt){C(kt?.message||"GitHub verification failed");return}finally{$(!1)}}a(Tt=>Math.min(Ht.length-1,Tt+1))}},jf=async(ge=[])=>{X(!0),le(null);try{let bt=ge.length===0,Tt=Cg(ge),xt=await Wm({tempDir:W,approvedSecrets:ge,skipSecretExtraction:bt,githubRepo:s.GITHUB_WORKSPACE_REPO,githubToken:s.GITHUB_TOKEN});if(!xt?.ok){le(xt?.error||"Import failed"),X(!1);return}let fn=jb(xt.placeholderReview);if(o(Ke=>({...Ke,...Tt,...xt.preFill||{},[Ed]:fn,[Vb]:!1})),fn.found){ie(Va);return}ne(),ie(null),a(Ke=>Math.min(Ht.length-1,Ke+1))}catch(bt){le(bt?.message||"Import failed")}finally{X(!1)}},go=()=>{ie(Hb)},bo=()=>{ie(Ld)},_r=()=>{ie(null),G(null),K(null),le(null),ne()},na=()=>{ne(),ie(null),a(ge=>Math.min(Ht.length-1,ge+1))},ps=ge=>{r("_GITHUB_FLOW",ge),a(0)},xo=I===Ld,Fs=I===Hb,fs=I===Va,Gn=Nt?"Getting Started":xo?"Import":Fs?"Review Secrets":fs?"Review Env Vars":Q?"Initializing":pe?"Pairing":te?.title||"Setup",yo=Nt?0:xo||Fs||fs?i+1:Q?Ht.length+1:pe?Ht.length+2:i+1;return{state:{vals:s,step:i,setupError:l,modelsLoading:p,modelsError:g,showAllModels:h,loading:x,githubStepLoading:w,formError:S,importScanResult:se,importScanning:z,importError:de,selectedProvider:xe,modelOptions:J,canToggleFullCatalog:oe,visibleAiFieldKeys:ce,hasAi:et,allValid:wt,isPreStep:Nt,isSetupStep:Q,isPairingStep:pe,activeGroup:te,currentGroupValid:Ee,selectedPairingChannel:ke,placeholderReview:he,isImportStep:xo,isSecretReviewStep:Fs,isPlaceholderReviewStep:fs,activeStepLabel:Gn,stepNumber:yo,codexStatus:_,codexLoading:k,codexManualInput:A,codexExchanging:D,codexAuthStarted:j,codexAuthWaiting:E,pairingStatusPoll:H,pairingRequestsPoll:re,pairingChannels:ye,canFinishPairing:ve,pairingError:Ie,pairingComplete:gt},actions:{setVals:o,setValue:r,setShowAllModels:b,setCodexManualInput:O,startCodexAuth:M,completeCodexAuth:R,handleCodexDisconnect:B,handleSubmit:Vc,finishOnboarding:Cr,goBack:zc,goBackFromSetupError:ta,goNext:Kt,handleSelectFlow:ps,handleImportApprove:jf,handleShowSecretReview:go,handleSecretReviewBack:bo,handleImportBack:_r,handlePlaceholderReviewContinue:na,handlePairingApprove:Hc,handlePairingReject:Wc}}};var Jn=P.bind(T),Kb=({onComplete:t,acVersion:e})=>{let{state:n,actions:s}=Ub({onComplete:t});return Jn`
            - <${Tg} + <${Pg} groups=${Ht} step=${n.step} isPreStep=${n.isPreStep} @@ -1958,30 +1958,30 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa />
            - ${n.isPreStep?Jn`<${vg} onSelectFlow=${s.handleSelectFlow} />`:n.isImportStep?Jn`<${Cg} + ${n.isPreStep?Jn`<${$g} onSelectFlow=${s.handleSelectFlow} />`:n.isImportStep?Jn`<${_g} scanResult=${n.importScanResult} scanning=${n.importScanning} error=${n.importError} onApprove=${s.handleImportApprove} onShowSecretReview=${s.handleShowSecretReview} onBack=${s.handleImportBack} - />`:n.isSecretReviewStep?Jn`<${Mg} + />`:n.isSecretReviewStep?Jn`<${Ag} secrets=${n.importScanResult?.secrets||[]} onApprove=${s.handleImportApprove} onBack=${s.handleSecretReviewBack} loading=${n.importScanning} error=${n.importError} - />`:n.isPlaceholderReviewStep?Jn`<${_g} + />`:n.isPlaceholderReviewStep?Jn`<${Mg} placeholderReview=${n.placeholderReview} vals=${n.vals} setValue=${s.setValue} onContinue=${s.handlePlaceholderReviewContinue} - />`:n.isSetupStep?Jn`<${Lg} + />`:n.isSetupStep?Jn`<${Eg} error=${n.setupError} loading=${n.loading} onRetry=${s.handleSubmit} onBack=${s.goBackFromSetupError} - />`:n.isPairingStep?Jn`<${Pb} + />`:n.isPairingStep?Jn`<${Rb} channel=${n.selectedPairingChannel} pairings=${n.pairingRequestsPoll.data||[]} channels=${n.pairingChannels} @@ -1993,7 +1993,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa onContinue=${s.finishOnboarding} onSkip=${s.finishOnboarding} />`:Jn` - <${Tb} + <${Pb} activeGroup=${n.activeGroup} vals=${n.vals} hasAi=${n.hasAi} @@ -2035,7 +2035,20 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa
            `:null}
            - `};var WS=P.bind(T),tn=({onClick:t,disabled:e=!1,loading:n=!1,warning:s=!1,idleLabel:o="Check updates",loadingLabel:r="Checking...",className:i=""})=>WS` + `};var Id=P.bind(T),WS=()=>{try{let t=localStorage.getItem(_d);if(t==="dark"||t==="light")return t}catch{}return window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"},VS=t=>{document.documentElement.dataset.theme=t;try{localStorage.setItem(_d,t)}catch{}},za=()=>{let[t,e]=y(WS);return Id` + + `};var zS=P.bind(T),tn=({onClick:t,disabled:e=!1,loading:n=!1,warning:s=!1,idleLabel:o="Check updates",loadingLabel:r="Checking...",className:i=""})=>zS` <${Z} onClick=${t} disabled=${e} @@ -2046,7 +2059,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa loadingLabel=${r} className=${i} /> -`;var VS=P.bind(T),Kb=({visible:t=!1,restarting:e=!1,onRestart:n,onDismiss:s=()=>{}})=>t?VS` +`;var jS=P.bind(T),Gb=({visible:t=!1,restarting:e=!1,onRestart:n,onDismiss:s=()=>{}})=>t?jS`

            @@ -2074,7 +2087,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa

            - `:null;var ja="alphaclaw:browse-draft-index-changed",Fo=t=>t||window.localStorage,Ed=t=>`${Fa}${String(t||"").trim()}`,Gb=(t,e)=>{try{if(!t)return"";let s=Fo(e).getItem(Ed(t));return typeof s=="string"?s:""}catch{return""}},za=(t,e,n)=>{try{if(!t)return;Fo(n).setItem(Ed(t),String(e||""))}catch{}},xs=(t,e)=>{try{if(!t)return;Fo(e).removeItem(Ed(t))}catch{}},qb=t=>{try{let n=Fo(t).getItem(Md);if(!n)return new Set;let s=JSON.parse(n);return Array.isArray(s)?new Set(s.map(o=>String(o||"").trim()).filter(Boolean)):new Set}catch{return new Set}},Jb=(t,e={})=>{let{storage:n,dispatchEvent:s}=e;try{let o=Fo(n),r=Array.from(t).sort((i,a)=>i.localeCompare(a));o.setItem(Md,JSON.stringify(r)),s&&s(new CustomEvent(ja,{detail:{paths:r}}))}catch{}},mn=(t,e,n={})=>{let{storage:s,dispatchEvent:o}=n;if(!t)return;let r=String(t||"").trim();if(!r)return;let i=qb(s);e?i.add(r):i.delete(r),Jb(i,{storage:s,dispatchEvent:o})},Id=t=>{try{let e=Fo(t),n=qb(e);if(n.size>0)return n;let s=new Set;for(let o=0;o0&&Jb(s,{storage:e}),s}catch{return new Set}};var jS=new URL("../../shared/browse-file-policies.json",import.meta.url),Yr={protectedPaths:[],lockedPaths:[]};try{let t=await fetch(jS);if(t.ok){let e=await t.json();e&&typeof e=="object"&&(Yr=e)}}catch{}var Ua=new Set(Array.isArray(Yr?.protectedPaths)?Yr.protectedPaths:[]),Zs=new Set(Array.isArray(Yr?.lockedPaths)?Yr.lockedPaths:[]),Ys=t=>String(t||"").replaceAll("\\","/").replace(/^\.\/+/,"").replace(/^\/+/,"").trim().toLowerCase(),Zn=(t,e)=>{let n=String(e||"").trim();if(!n)return!1;for(let s of t)if(n===s||n.endsWith(`/${s}`)||n.startsWith(`${s}/`)||n.includes(`/${s}/`))return!0;return!1};var Zb=t=>{let e=String(t||"").split("/").map(s=>s.trim()).filter(Boolean);if(e.length<=1)return[];let n=[];for(let s=0;s{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}let n=null,s=!1;try{return!document?.createElement||!document?.body?.appendChild||!document?.body?.removeChild||typeof document.execCommand!="function"?!1:(n=document.createElement("textarea"),n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),s=!0,n.select(),document.execCommand("copy"))}catch{return!1}finally{n&&s&&document.body.removeChild(n)}};var Ge=P.bind(T),Dd=9,zS=10,Yb=14,US=5e3,KS=()=>{try{let t=window.localStorage.getItem(_d);if(!t)return null;let e=JSON.parse(t);return Array.isArray(e)?new Set(e.map(n=>String(n))):null}catch{return null}},Xb=(t,e)=>{!t||t.type!=="folder"||(t.path&&e.add(t.path),(t.children||[]).forEach(n=>Xb(n,e)))},Nd=(t,e)=>{if(t){if(t.type==="file"){t.path&&e.push(t.path);return}(t.children||[]).forEach(n=>Nd(n,e))}},Od=(t,e)=>{if(!t)return null;let n=String(e||"").trim();if(!n)return t;if(String(t.path||"").trim()===n)return null;if(t.type!=="folder")return t;let o=(t.children||[]).map(r=>Od(r,n)).filter(Boolean);return o.length===(t.children||[]).length?t:{...t,children:o}},Qb=(t,e)=>{if(!t)return null;let n=String(e||"").trim().toLowerCase();if(!n)return t;let s=String(t.name||"").toLowerCase(),o=String(t.path||"").toLowerCase(),r=s.includes(n)||o.includes(n);if(t.type==="file")return r?t:null;let i=(t.children||[]).map(a=>Qb(a,n)).filter(Boolean);return!r&&i.length===0?null:{...t,children:i}},GS=t=>{let n=String(t||"").toLowerCase().replace(/(\.bak)+$/i,"");return n.endsWith(".md")?{icon:Qg,className:"file-icon file-icon-md"}:n.endsWith(".js")||n.endsWith(".mjs")?{icon:tb,className:"file-icon file-icon-js"}:n.endsWith(".json")||n.endsWith(".jsonl")?{icon:Ma,className:"file-icon file-icon-json"}:n.endsWith(".css")||n.endsWith(".scss")?{icon:db,className:"file-icon file-icon-css"}:/\.(html?)$/i.test(n)?{icon:lb,className:"file-icon file-icon-html"}:/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)?{icon:nb,className:"file-icon file-icon-image"}:/\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test(n)?{icon:ib,className:"file-icon file-icon-audio"}:/\.(sh|bash|zsh|command)$/i.test(n)||[".bashrc",".zshrc",".profile",".bash_profile",".zprofile",".zshenv"].includes(n)?{icon:ab,className:"file-icon file-icon-shell"}:/\.(db|sqlite|sqlite3|db3|sdb|sqlitedb|duckdb|mdb|accdb)$/i.test(n)?{icon:cb,className:"file-icon file-icon-db"}:{icon:eb,className:"file-icon file-icon-generic"}},qS=({x:t,y:e,targetPath:n,targetType:s,isLocked:o,onNewFile:r,onNewFolder:i,onCopyPath:a,onDownload:l,onDelete:c,onClose:d})=>{let u=ee(null);L(()=>{let w=C=>{u.current&&!u.current.contains(C.target)&&d()},$=C=>{C.key==="Escape"&&d()},S=()=>d();return window.addEventListener("mousedown",w),window.addEventListener("keydown",$),window.addEventListener("scroll",S,!0),()=>{window.removeEventListener("mousedown",w),window.removeEventListener("keydown",$),window.removeEventListener("scroll",S,!0)}},[d]);let p=s==="folder",f=s==="file",g=s==="root",m=p?n:"",h=!o&&(p||g),b=!!((p||f)&&n),x=f&&n,v=!o&&(p||f)&&n;return Ge` + `:null;var ja="alphaclaw:browse-draft-index-changed",Fo=t=>t||window.localStorage,Dd=t=>`${Fa}${String(t||"").trim()}`,qb=(t,e)=>{try{if(!t)return"";let s=Fo(e).getItem(Dd(t));return typeof s=="string"?s:""}catch{return""}},Ua=(t,e,n)=>{try{if(!t)return;Fo(n).setItem(Dd(t),String(e||""))}catch{}},xs=(t,e)=>{try{if(!t)return;Fo(e).removeItem(Dd(t))}catch{}},Jb=t=>{try{let n=Fo(t).getItem(Ad);if(!n)return new Set;let s=JSON.parse(n);return Array.isArray(s)?new Set(s.map(o=>String(o||"").trim()).filter(Boolean)):new Set}catch{return new Set}},Zb=(t,e={})=>{let{storage:n,dispatchEvent:s}=e;try{let o=Fo(n),r=Array.from(t).sort((i,a)=>i.localeCompare(a));o.setItem(Ad,JSON.stringify(r)),s&&s(new CustomEvent(ja,{detail:{paths:r}}))}catch{}},mn=(t,e,n={})=>{let{storage:s,dispatchEvent:o}=n;if(!t)return;let r=String(t||"").trim();if(!r)return;let i=Jb(s);e?i.add(r):i.delete(r),Zb(i,{storage:s,dispatchEvent:o})},Nd=t=>{try{let e=Fo(t),n=Jb(e);if(n.size>0)return n;let s=new Set;for(let o=0;o0&&Zb(s,{storage:e}),s}catch{return new Set}};var US=new URL("../../shared/browse-file-policies.json",import.meta.url),Yr={protectedPaths:[],lockedPaths:[]};try{let t=await fetch(US);if(t.ok){let e=await t.json();e&&typeof e=="object"&&(Yr=e)}}catch{}var Ka=new Set(Array.isArray(Yr?.protectedPaths)?Yr.protectedPaths:[]),Zs=new Set(Array.isArray(Yr?.lockedPaths)?Yr.lockedPaths:[]),Ys=t=>String(t||"").replaceAll("\\","/").replace(/^\.\/+/,"").replace(/^\/+/,"").trim().toLowerCase(),Zn=(t,e)=>{let n=String(e||"").trim();if(!n)return!1;for(let s of t)if(n===s||n.endsWith(`/${s}`)||n.startsWith(`${s}/`)||n.includes(`/${s}/`))return!0;return!1};var Yb=t=>{let e=String(t||"").split("/").map(s=>s.trim()).filter(Boolean);if(e.length<=1)return[];let n=[];for(let s=0;s{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}let n=null,s=!1;try{return!document?.createElement||!document?.body?.appendChild||!document?.body?.removeChild||typeof document.execCommand!="function"?!1:(n=document.createElement("textarea"),n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),s=!0,n.select(),document.execCommand("copy"))}catch{return!1}finally{n&&s&&document.body.removeChild(n)}};var Ge=P.bind(T),Od=9,KS=10,Xb=14,GS=5e3,qS=()=>{try{let t=window.localStorage.getItem(Md);if(!t)return null;let e=JSON.parse(t);return Array.isArray(e)?new Set(e.map(n=>String(n))):null}catch{return null}},Qb=(t,e)=>{!t||t.type!=="folder"||(t.path&&e.add(t.path),(t.children||[]).forEach(n=>Qb(n,e)))},Bd=(t,e)=>{if(t){if(t.type==="file"){t.path&&e.push(t.path);return}(t.children||[]).forEach(n=>Bd(n,e))}},Fd=(t,e)=>{if(!t)return null;let n=String(e||"").trim();if(!n)return t;if(String(t.path||"").trim()===n)return null;if(t.type!=="folder")return t;let o=(t.children||[]).map(r=>Fd(r,n)).filter(Boolean);return o.length===(t.children||[]).length?t:{...t,children:o}},ex=(t,e)=>{if(!t)return null;let n=String(e||"").trim().toLowerCase();if(!n)return t;let s=String(t.name||"").toLowerCase(),o=String(t.path||"").toLowerCase(),r=s.includes(n)||o.includes(n);if(t.type==="file")return r?t:null;let i=(t.children||[]).map(a=>ex(a,n)).filter(Boolean);return!r&&i.length===0?null:{...t,children:i}},JS=t=>{let n=String(t||"").toLowerCase().replace(/(\.bak)+$/i,"");return n.endsWith(".md")?{icon:eb,className:"file-icon file-icon-md"}:n.endsWith(".js")||n.endsWith(".mjs")?{icon:nb,className:"file-icon file-icon-js"}:n.endsWith(".json")||n.endsWith(".jsonl")?{icon:Ma,className:"file-icon file-icon-json"}:n.endsWith(".css")||n.endsWith(".scss")?{icon:ub,className:"file-icon file-icon-css"}:/\.(html?)$/i.test(n)?{icon:cb,className:"file-icon file-icon-html"}:/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)?{icon:sb,className:"file-icon file-icon-image"}:/\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test(n)?{icon:ab,className:"file-icon file-icon-audio"}:/\.(sh|bash|zsh|command)$/i.test(n)||[".bashrc",".zshrc",".profile",".bash_profile",".zprofile",".zshenv"].includes(n)?{icon:lb,className:"file-icon file-icon-shell"}:/\.(db|sqlite|sqlite3|db3|sdb|sqlitedb|duckdb|mdb|accdb)$/i.test(n)?{icon:db,className:"file-icon file-icon-db"}:{icon:tb,className:"file-icon file-icon-generic"}},ZS=({x:t,y:e,targetPath:n,targetType:s,isLocked:o,onNewFile:r,onNewFolder:i,onCopyPath:a,onDownload:l,onDelete:c,onClose:d})=>{let u=ee(null);L(()=>{let w=C=>{u.current&&!u.current.contains(C.target)&&d()},$=C=>{C.key==="Escape"&&d()},S=()=>d();return window.addEventListener("mousedown",w),window.addEventListener("keydown",$),window.addEventListener("scroll",S,!0),()=>{window.removeEventListener("mousedown",w),window.removeEventListener("keydown",$),window.removeEventListener("scroll",S,!0)}},[d]);let p=s==="folder",f=s==="file",g=s==="root",m=p?n:"",h=!o&&(p||g),b=!!((p||f)&&n),x=f&&n,v=!o&&(p||f)&&n;return Ge`
            {l(n),d()}} > - <${mb} className="tree-context-menu-icon" /> + <${gb} className="tree-context-menu-icon" /> Download `:null} @@ -2133,11 +2146,11 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa
            `:null}
            - `},Ka=({type:t,depth:e,onConfirm:n,onCancel:s})=>{let o=ee(null),[r,i]=y(""),a=ee(!1);L(()=>{o.current?.focus()},[]);let l=()=>{let d=r.trim();if(!d||a.current){s();return}a.current=!0,n(d)},c=t==="folder"?Gr:Kr;return Ge` + `},Ga=({type:t,depth:e,onConfirm:n,onCancel:s})=>{let o=ee(null),[r,i]=y(""),a=ee(!1);L(()=>{o.current?.focus()},[]);let l=()=>{let d=r.trim();if(!d||a.current){s();return}a.current=!0,n(d)},c=t==="folder"?Gr:Kr;return Ge`
          1. <${c} className="tree-create-icon" />
          2. - `},Ga=({node:t,depth:e=0,expandedPaths:n,onSetFolderExpanded:s,onSelectFolder:o,onRequestDelete:r,onSelectFile:i,onContextMenu:a,onDragDrop:l,selectedPath:c="",draftPaths:d,isSearchActive:u=!1,searchActivePath:p="",creatingInFolder:f="",creatingType:g="",onCreationConfirm:m,onCreationCancel:h,dragSourcePath:b=""})=>{if(!t)return null;if(t.type==="file"){let k=c===t.path,A=p===t.path,O=d.has(t.path||""),D=Zn(Zs,Ys(t.path||"")),z=GS(t.name),E=z.icon;return Ge` + `},qa=({node:t,depth:e=0,expandedPaths:n,onSetFolderExpanded:s,onSelectFolder:o,onRequestDelete:r,onSelectFile:i,onContextMenu:a,onDragDrop:l,selectedPath:c="",draftPaths:d,isSearchActive:u=!1,searchActivePath:p="",creatingInFolder:f="",creatingType:g="",onCreationConfirm:m,onCreationCancel:h,dragSourcePath:b=""})=>{if(!t)return null;if(t.type==="file"){let k=c===t.path,A=p===t.path,O=d.has(t.path||""),D=Zn(Zs,Ys(t.path||"")),j=JS(t.name),E=j.icon;return Ge`
          3. {!(M.key==="Delete"||M.key==="Backspace")||!k||(M.preventDefault(),r(t.path))}} tabindex="0" role="button" - style=${{paddingLeft:`${Yb+e*Dd}px`}} + style=${{paddingLeft:`${Xb+e*Od}px`}} title=${t.path||t.name} > - <${E} className=${z.className} /> + <${E} className=${j.className} /> ${t.name} ${D?Ge`<${Oo} className="tree-lock-icon" @@ -2190,7 +2203,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa onDragEnter=${k=>{$||(k.preventDefault(),_.current+=1,_.current===1&&C(!0))}} onDragLeave=${()=>{_.current-=1,_.current<=0&&(_.current=0,C(!1))}} onDrop=${k=>{if($)return;k.preventDefault(),k.stopPropagation(),C(!1),_.current=0;let A=k.dataTransfer.getData("text/plain");A&&A!==x&&l("drop",A,x)}} - style=${{paddingLeft:`${zS+e*Dd}px`}} + style=${{paddingLeft:`${KS+e*Od}px`}} title=${x||t.name} >
          4. - `},ex=({onSelectFile:t=()=>{},selectedPath:e="",onPreviewFile:n=()=>{},isActive:s=!0})=>{let[o,r]=y(null),[i,a]=y(!0),[l,c]=y(""),[d,u]=y(KS),[p,f]=y(Id),[g,m]=y(""),[h,b]=y(""),[x,v]=y(""),[w,$]=y(!1),[S,C]=y(""),[_,k]=y(""),[A,O]=y(null),[D,z]=y(""),[E,M]=y(""),R=E||e,B=ee(null),I=ee(""),U=q(async({showLoading:H=!1}={})=>{H&&a(!0),H&&c("");try{let ye=(await ag()).root||null,ve=JSON.stringify(ye||{});I.current!==ve&&(I.current=ve,r(ye)),u(Ie=>Ie instanceof Set?Ie:new Set),H&&c("")}catch(re){H&&c(re.message||"Could not load file tree")}finally{H&&a(!1)}},[]);L(()=>{U({showLoading:!0})},[U]),L(()=>{if(!s)return()=>{};let H=()=>{U({showLoading:!1})},re=ve=>{let Ie=String(ve?.detail?.path||"").trim();Ie&&r(gt=>Od(gt,Ie))};H();let ye=window.setInterval(H,US);return window.addEventListener("alphaclaw:browse-file-saved",H),window.addEventListener("alphaclaw:browse-tree-refresh",H),window.addEventListener("alphaclaw:browse-file-deleted",re),()=>{window.clearInterval(ye),window.removeEventListener("alphaclaw:browse-file-saved",H),window.removeEventListener("alphaclaw:browse-tree-refresh",H),window.removeEventListener("alphaclaw:browse-file-deleted",re)}},[s,U]);let W=String(g||"").trim().toLowerCase(),G=F(()=>{let H=o?.children||[];return W?H.map(re=>Qb(re,W)).filter(Boolean):H},[o,W]),se=d instanceof Set?d:new Set,K=W.length>0,j=F(()=>{let H=[];return G.forEach(re=>Nd(re,H)),H},[G]),X=F(()=>{let H=[];return(o?.children||[]).forEach(re=>Nd(re,H)),new Set(H)},[o]),de=F(()=>{let H=new Set;return G.forEach(re=>Xb(re,H)),H},[G]);L(()=>{if(d instanceof Set)try{window.localStorage.setItem(_d,JSON.stringify(Array.from(d)))}catch{}},[d]),L(()=>{e&&M("")},[e]),L(()=>{if(!e)return;let H=Zb(e);H.length&&u(re=>{if(!(re instanceof Set))return re;let ye=!1,ve=new Set(re);return H.forEach(Ie=>{ve.has(Ie)||(ve.add(Ie),ye=!0)}),ye?ve:re})},[e]),L(()=>{let H=re=>{let ye=re?.detail?.paths;if(Array.isArray(ye)){f(new Set(ye.map(ve=>String(ve||"").trim()).filter(Boolean)));return}f(Id())};return window.addEventListener(ja,H),window.addEventListener("storage",H),()=>{window.removeEventListener(ja,H),window.removeEventListener("storage",H)}},[]),L(()=>{if(!s)return()=>{};let H=re=>{if(re.key!=="/"||re.metaKey||re.ctrlKey||re.altKey)return;let ye=re.target,ve=String(ye?.tagName||"").toLowerCase();(ve==="input"||ve==="textarea"||ve==="select"||ye?.isContentEditable)&&ye!==B.current||(re.preventDefault(),B.current?.focus(),B.current?.select())};return window.addEventListener("keydown",H),()=>{window.removeEventListener("keydown",H)}},[s]),L(()=>{if(!K){b(""),n("");return}h&&j.includes(h)||(b(""),n(""))},[K,j,h,n]);let le=(H,re)=>{u(ye=>{let ve=ye instanceof Set?new Set(ye):new Set;return re===!0?(ve.add(H),ve):re===!1?(ve.delete(H),ve):(ve.has(H)?ve.delete(H):ve.add(H),ve)})},ie=q((H,re)=>{M(""),t(H,re)},[t]),ne=H=>{M(H)},xe=H=>{let re=Ys(H);if(re){if(Zn(Zs,re)||Zn(Ua,re)){N("Protected or locked paths cannot be deleted","warning");return}v(H)}},he=de.has(x),ue=async()=>{if(!(!x||w)){$(!0);try{await wa(x),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:x}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-deleted",{detail:{path:x}})),r(H=>Od(H,x)),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),ie(""),N(he?"Folder deleted":"File deleted","success"),v("")}catch(H){N(H.message||"Could not delete","error")}finally{$(!1)}}},me=H=>{if(H!==void 0)return H;if(!R)return"";if(de.has(R))return R;let re=R.lastIndexOf("/");return re>0?R.slice(0,re):""},V=(H,re)=>{let ye=me(H);if(ye&&Zn(Zs,Ys(ye))){N("Cannot create inside a locked folder","warning");return}C(ye),k(re),ye&&u(ve=>{let Ie=ve instanceof Set?new Set(ve):new Set;return Ie.add(ye),Ie})},J=H=>{V(me(),H)},oe=()=>{C(""),k("")},ce=async H=>{let re=S,ye=_;oe();let ve=re?`${re}/${H}`:H;try{ye==="folder"?(await dg(ve),N("Folder created","success")):(await cg(ve),N("File created","success")),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),re&&u(Ie=>{let gt=Ie instanceof Set?new Set(Ie):new Set;return gt.add(re),gt}),ye==="file"&&ie(ve)}catch(Ie){N(Ie.message||`Could not create ${ye}`,"error")}},be=H=>{O(H)},Ve=()=>{O(null)},et=async H=>{try{await pg(H),N("Download started","success")}catch(re){N(re.message||"Could not download file","error")}},wt=async H=>{if(await Ho(H)){N("Path copied","success");return}N("Could not copy path","error")},Nt=async(H,re,ye)=>{if(H==="start"){z(re);return}if(H==="end"){z("");return}if(H==="drop"){z("");let ve=re.split("/").pop();if(!ve)return;let Ie=ye?`${ye}/${ve}`:ve;if(re===Ie)return;try{await ug(re,Ie),N(`Moved to ${ye||"root"}`,"success"),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),e===re&&ie(Ie)}catch(gt){N(gt.message||"Could not move","error")}}},Q=H=>{m(H)},pe=()=>{m(""),b(""),n("")},te=H=>{if(!j.length)return;let re=j.indexOf(h),Ie=((re===-1?H==="up"?0:-1:re)+(H==="up"?-1:1)+j.length)%j.length,gt=j[Ie];b(gt),n(gt)},Ee=()=>{let[H=""]=j,re=h||(j.length===1?H:"");re&&(ie(re),pe())},ke=H=>{if(H.key==="ArrowDown"){H.preventDefault(),te("down");return}if(H.key==="ArrowUp"){H.preventDefault(),te("up");return}if(H.key==="Enter"){H.preventDefault(),Ee();return}H.key==="Escape"&&(H.preventDefault(),pe())};return i?Ge` + `},tx=({onSelectFile:t=()=>{},selectedPath:e="",onPreviewFile:n=()=>{},isActive:s=!0})=>{let[o,r]=y(null),[i,a]=y(!0),[l,c]=y(""),[d,u]=y(qS),[p,f]=y(Nd),[g,m]=y(""),[h,b]=y(""),[x,v]=y(""),[w,$]=y(!1),[S,C]=y(""),[_,k]=y(""),[A,O]=y(null),[D,j]=y(""),[E,M]=y(""),R=E||e,B=ee(null),I=ee(""),U=q(async({showLoading:H=!1}={})=>{H&&a(!0),H&&c("");try{let ye=(await lg()).root||null,ve=JSON.stringify(ye||{});I.current!==ve&&(I.current=ve,r(ye)),u(Ie=>Ie instanceof Set?Ie:new Set),H&&c("")}catch(re){H&&c(re.message||"Could not load file tree")}finally{H&&a(!1)}},[]);L(()=>{U({showLoading:!0})},[U]),L(()=>{if(!s)return()=>{};let H=()=>{U({showLoading:!1})},re=ve=>{let Ie=String(ve?.detail?.path||"").trim();Ie&&r(gt=>Fd(gt,Ie))};H();let ye=window.setInterval(H,GS);return window.addEventListener("alphaclaw:browse-file-saved",H),window.addEventListener("alphaclaw:browse-tree-refresh",H),window.addEventListener("alphaclaw:browse-file-deleted",re),()=>{window.clearInterval(ye),window.removeEventListener("alphaclaw:browse-file-saved",H),window.removeEventListener("alphaclaw:browse-tree-refresh",H),window.removeEventListener("alphaclaw:browse-file-deleted",re)}},[s,U]);let W=String(g||"").trim().toLowerCase(),G=F(()=>{let H=o?.children||[];return W?H.map(re=>ex(re,W)).filter(Boolean):H},[o,W]),se=d instanceof Set?d:new Set,K=W.length>0,z=F(()=>{let H=[];return G.forEach(re=>Bd(re,H)),H},[G]),X=F(()=>{let H=[];return(o?.children||[]).forEach(re=>Bd(re,H)),new Set(H)},[o]),de=F(()=>{let H=new Set;return G.forEach(re=>Qb(re,H)),H},[G]);L(()=>{if(d instanceof Set)try{window.localStorage.setItem(Md,JSON.stringify(Array.from(d)))}catch{}},[d]),L(()=>{e&&M("")},[e]),L(()=>{if(!e)return;let H=Yb(e);H.length&&u(re=>{if(!(re instanceof Set))return re;let ye=!1,ve=new Set(re);return H.forEach(Ie=>{ve.has(Ie)||(ve.add(Ie),ye=!0)}),ye?ve:re})},[e]),L(()=>{let H=re=>{let ye=re?.detail?.paths;if(Array.isArray(ye)){f(new Set(ye.map(ve=>String(ve||"").trim()).filter(Boolean)));return}f(Nd())};return window.addEventListener(ja,H),window.addEventListener("storage",H),()=>{window.removeEventListener(ja,H),window.removeEventListener("storage",H)}},[]),L(()=>{if(!s)return()=>{};let H=re=>{if(re.key!=="/"||re.metaKey||re.ctrlKey||re.altKey)return;let ye=re.target,ve=String(ye?.tagName||"").toLowerCase();(ve==="input"||ve==="textarea"||ve==="select"||ye?.isContentEditable)&&ye!==B.current||(re.preventDefault(),B.current?.focus(),B.current?.select())};return window.addEventListener("keydown",H),()=>{window.removeEventListener("keydown",H)}},[s]),L(()=>{if(!K){b(""),n("");return}h&&z.includes(h)||(b(""),n(""))},[K,z,h,n]);let le=(H,re)=>{u(ye=>{let ve=ye instanceof Set?new Set(ye):new Set;return re===!0?(ve.add(H),ve):re===!1?(ve.delete(H),ve):(ve.has(H)?ve.delete(H):ve.add(H),ve)})},ie=q((H,re)=>{M(""),t(H,re)},[t]),ne=H=>{M(H)},xe=H=>{let re=Ys(H);if(re){if(Zn(Zs,re)||Zn(Ka,re)){N("Protected or locked paths cannot be deleted","warning");return}v(H)}},he=de.has(x),ue=async()=>{if(!(!x||w)){$(!0);try{await wa(x),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-saved",{detail:{path:x}})),window.dispatchEvent(new CustomEvent("alphaclaw:browse-file-deleted",{detail:{path:x}})),r(H=>Fd(H,x)),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),ie(""),N(he?"Folder deleted":"File deleted","success"),v("")}catch(H){N(H.message||"Could not delete","error")}finally{$(!1)}}},me=H=>{if(H!==void 0)return H;if(!R)return"";if(de.has(R))return R;let re=R.lastIndexOf("/");return re>0?R.slice(0,re):""},V=(H,re)=>{let ye=me(H);if(ye&&Zn(Zs,Ys(ye))){N("Cannot create inside a locked folder","warning");return}C(ye),k(re),ye&&u(ve=>{let Ie=ve instanceof Set?new Set(ve):new Set;return Ie.add(ye),Ie})},J=H=>{V(me(),H)},oe=()=>{C(""),k("")},ce=async H=>{let re=S,ye=_;oe();let ve=re?`${re}/${H}`:H;try{ye==="folder"?(await ug(ve),N("Folder created","success")):(await dg(ve),N("File created","success")),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),re&&u(Ie=>{let gt=Ie instanceof Set?new Set(Ie):new Set;return gt.add(re),gt}),ye==="file"&&ie(ve)}catch(Ie){N(Ie.message||`Could not create ${ye}`,"error")}},be=H=>{O(H)},Ve=()=>{O(null)},et=async H=>{try{await fg(H),N("Download started","success")}catch(re){N(re.message||"Could not download file","error")}},wt=async H=>{if(await Ho(H)){N("Path copied","success");return}N("Could not copy path","error")},Nt=async(H,re,ye)=>{if(H==="start"){j(re);return}if(H==="end"){j("");return}if(H==="drop"){j("");let ve=re.split("/").pop();if(!ve)return;let Ie=ye?`${ye}/${ve}`:ve;if(re===Ie)return;try{await pg(re,Ie),N(`Moved to ${ye||"root"}`,"success"),window.dispatchEvent(new CustomEvent("alphaclaw:browse-tree-refresh")),e===re&&ie(Ie)}catch(gt){N(gt.message||"Could not move","error")}}},Q=H=>{m(H)},pe=()=>{m(""),b(""),n("")},te=H=>{if(!z.length)return;let re=z.indexOf(h),Ie=((re===-1?H==="up"?0:-1:re)+(H==="up"?-1:1)+z.length)%z.length,gt=z[Ie];b(gt),n(gt)},Ee=()=>{let[H=""]=z,re=h||(z.length===1?H:"");re&&(ie(re),pe())},ke=H=>{if(H.key==="ArrowDown"){H.preventDefault(),te("down");return}if(H.key==="ArrowUp"){H.preventDefault(),te("up");return}if(H.key==="Enter"){H.preventDefault(),Ee();return}H.key==="Escape"&&(H.preventDefault(),pe())};return i?Ge`
            <${Ce} className="h-5 w-5 text-fg-muted" /> @@ -2361,7 +2374,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa onDrop=${H=>{H.preventDefault();let re=H.dataTransfer.getData("text/plain");re&&Nt("drop",re,"")}} > ${S===""&&_==="folder"?Ge` - <${Ka} + <${Ga} key="__root-creation__" type="folder" depth=${0} @@ -2370,7 +2383,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa /> `:null} ${G.filter(H=>H.type==="folder").map(H=>Ge` - <${Ga} + <${qa} key=${H.path||H.name} node=${H} expandedPaths=${se} @@ -2392,7 +2405,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa /> `)} ${S===""&&_==="file"?Ge` - <${Ka} + <${Ga} key="__root-creation__" type="file" depth=${0} @@ -2401,7 +2414,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa /> `:null} ${G.filter(H=>H.type!=="folder").map(H=>Ge` - <${Ga} + <${qa} key=${H.path||H.name} node=${H} expandedPaths=${se} @@ -2425,7 +2438,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa
      ${A?Ge` - <${qS} + <${ZS} x=${A.x} y=${A.y} targetPath=${A.targetPath} @@ -2453,7 +2466,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa onConfirm=${ue} />
      - `};var JS=new Intl.NumberFormat("en-US"),ZS=new Intl.NumberFormat("en-US",{notation:"compact",minimumFractionDigits:1,maximumFractionDigits:1}),YS=new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:2,maximumFractionDigits:3}),Bd=(t,{valueIsUnixSeconds:e=!1,valueIsEpochMs:n=!1}={})=>t==null||t===""?null:t instanceof Date?t:e?new Date(Number(t)*1e3):n?new Date(Number(t)):new Date(t),XS=(t,e)=>t.getFullYear()===e.getFullYear()&&t.getMonth()===e.getMonth()&&t.getDate()===e.getDate(),Jt=t=>JS.format(Number(t||0)),qa=t=>{let e=Number(t||0);return Number.isFinite(e)?Math.abs(e)<1e3?Jt(e):ZS.format(e):"0"},tx=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0 B";let n=["B","KB","MB","GB","TB"],s=0,o=e;for(;o>=1024&&s=100||s===0?0:o>=10?1:2;return`${o.toFixed(r)} ${n[s]}`},gn=t=>YS.format(Number(t||0)),Wo=(t,{fallback:e="\u2014",valueIsUnixSeconds:n=!1,valueIsEpochMs:s=!1}={})=>{try{let o=Bd(t,{valueIsUnixSeconds:n,valueIsEpochMs:s});return!o||Number.isNaN(o.getTime())?e:o.toLocaleString()}catch{return e}},Yn=(t,{fallback:e="\u2014",valueIsUnixSeconds:n=!1,valueIsEpochMs:s=!1}={})=>{try{let o=Bd(t,{valueIsUnixSeconds:n,valueIsEpochMs:s});return!o||Number.isNaN(o.getTime())?e:XS(o,new Date)?o.toLocaleTimeString():o.toLocaleString()}catch{return e}},bn=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";if(e<1e3)return`${Math.round(e)}ms`;let n=Math.round(e/1e3),s=Math.floor(n/60),o=n%60;return s<=0?`${o}s`:`${s}m ${o}s`},QS=(t="")=>{let n=String(t||"").trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);if(!n)return null;let s=Number.parseInt(n[1],10),o=Number.parseInt(n[2],10)-1,r=Number.parseInt(n[3],10);return!Number.isFinite(s)||!Number.isFinite(o)||!Number.isFinite(r)?null:new Date(s,o,r)},Xn=(t,{range:e="7d",valueType:n="epoch-ms"}={})=>{let s=null;if(n==="day-key")s=QS(t);else if(n==="epoch-ms"){let r=Number(t);s=Number.isFinite(r)?new Date(r):null}else s=Bd(t);if(!s||Number.isNaN(s.getTime()))return String(t??"");let o=String(e||"").trim().toLowerCase();return o==="24h"?s.toLocaleTimeString([],{hour:"numeric"}):o==="7d"?s.toLocaleDateString([],{weekday:"short",month:"numeric",day:"numeric"}):s.toLocaleDateString([],{month:"numeric",day:"numeric"})};var Wt=P.bind(T),eC=1e4,tC=4,nC=12,sC=t=>Wo(t,{fallback:"",valueIsUnixSeconds:!0}),nx=t=>{let e=String(t?.repoSlug||"").trim();return e||String(t?.repoPath||"").split("/").filter(Boolean).pop()||"repo"},oC=t=>{let e=String(t?.statusKind||"M").toUpperCase();return e==="U"?{statusLabel:"U",statusClass:"is-untracked",rowClass:"is-clickable",canOpen:!0}:e==="D"?{statusLabel:"D",statusClass:"is-deleted",rowClass:"is-clickable",canOpen:!0}:{statusLabel:"M",statusClass:"is-modified",rowClass:"is-clickable",canOpen:!0}},sx=(t,e)=>{if(t==null||t==="")return"";let n=Number(t);return!Number.isFinite(n)||n<=0?"":`${e}${n}`},rC=(t,e)=>{let n=String(t||"").trim(),s=String(e||"").toUpperCase();return n?n.endsWith("/")?!0:s==="U"&&n.endsWith("\\"):!1},iC=t=>{let e=String(t?.syncState||"").trim(),n=Number(t?.aheadCount)||0,s=Number(t?.behindCount)||0;return e==="ahead"?{label:"\u2191",title:`Ahead by ${n}`,className:"is-ahead"}:e==="behind"?{label:"\u2193",title:`Behind by ${s}`,className:"is-behind"}:e==="diverged"?{label:"\u2195",title:`Diverged (${n} ahead, ${s} behind)`,className:"is-diverged"}:e==="upstream-gone"?{label:"!",title:"Upstream missing",className:"is-upstream-gone"}:e==="no-upstream"||!t?.hasUpstream?{label:"!",title:"Not linked",className:"is-no-upstream"}:{label:"",title:"Up to date",className:"is-up-to-date"}},aC=t=>{let e=Array.isArray(t?.changedFiles)?t.changedFiles:[],n=Number(t?.changedFilesCount)||0,s=e.map(u=>String(u?.path||"").trim()).filter(Boolean),o=n||s.length;if(o<=0)return"sync changes";let r=s.map(u=>u.split("/").filter(Boolean).pop()||u),i=Array.from(new Set(r));if(i.length<=0)return`Edited ${o} ${o===1?"file":"files"}`;let a=i.slice(0,tC),l=Math.max(0,o-a.length),c=o===1?"file":"files",d=l>0?` +${l} more`:"";return`Edited ${o} ${c} - ${a.join(", ")}${d}`},ox=({onSelectFile:t=()=>{},isActive:e=!0})=>{let[n,s]=y(!0),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(null);if(L(()=>{if(!e)return()=>{};let m=!0,h=null,b=async()=>{if(m)try{let v=await ud();if(!m)return;c(v),a("")}catch(v){if(!m)return;a(v.message||"Could not load git summary")}finally{m&&s(!1)}},x=()=>{b()};return b(),h=window.setInterval(b,eC),window.addEventListener("alphaclaw:browse-file-saved",x),()=>{m=!1,h&&window.clearInterval(h),window.removeEventListener("alphaclaw:browse-file-saved",x)}},[e]),n)return Wt` + `};var YS=new Intl.NumberFormat("en-US"),XS=new Intl.NumberFormat("en-US",{notation:"compact",minimumFractionDigits:1,maximumFractionDigits:1}),QS=new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:2,maximumFractionDigits:3}),Hd=(t,{valueIsUnixSeconds:e=!1,valueIsEpochMs:n=!1}={})=>t==null||t===""?null:t instanceof Date?t:e?new Date(Number(t)*1e3):n?new Date(Number(t)):new Date(t),eC=(t,e)=>t.getFullYear()===e.getFullYear()&&t.getMonth()===e.getMonth()&&t.getDate()===e.getDate(),Jt=t=>YS.format(Number(t||0)),Ja=t=>{let e=Number(t||0);return Number.isFinite(e)?Math.abs(e)<1e3?Jt(e):XS.format(e):"0"},nx=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0 B";let n=["B","KB","MB","GB","TB"],s=0,o=e;for(;o>=1024&&s=100||s===0?0:o>=10?1:2;return`${o.toFixed(r)} ${n[s]}`},gn=t=>QS.format(Number(t||0)),Wo=(t,{fallback:e="\u2014",valueIsUnixSeconds:n=!1,valueIsEpochMs:s=!1}={})=>{try{let o=Hd(t,{valueIsUnixSeconds:n,valueIsEpochMs:s});return!o||Number.isNaN(o.getTime())?e:o.toLocaleString()}catch{return e}},Yn=(t,{fallback:e="\u2014",valueIsUnixSeconds:n=!1,valueIsEpochMs:s=!1}={})=>{try{let o=Hd(t,{valueIsUnixSeconds:n,valueIsEpochMs:s});return!o||Number.isNaN(o.getTime())?e:eC(o,new Date)?o.toLocaleTimeString():o.toLocaleString()}catch{return e}},bn=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";if(e<1e3)return`${Math.round(e)}ms`;let n=Math.round(e/1e3),s=Math.floor(n/60),o=n%60;return s<=0?`${o}s`:`${s}m ${o}s`},tC=(t="")=>{let n=String(t||"").trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);if(!n)return null;let s=Number.parseInt(n[1],10),o=Number.parseInt(n[2],10)-1,r=Number.parseInt(n[3],10);return!Number.isFinite(s)||!Number.isFinite(o)||!Number.isFinite(r)?null:new Date(s,o,r)},Xn=(t,{range:e="7d",valueType:n="epoch-ms"}={})=>{let s=null;if(n==="day-key")s=tC(t);else if(n==="epoch-ms"){let r=Number(t);s=Number.isFinite(r)?new Date(r):null}else s=Hd(t);if(!s||Number.isNaN(s.getTime()))return String(t??"");let o=String(e||"").trim().toLowerCase();return o==="24h"?s.toLocaleTimeString([],{hour:"numeric"}):o==="7d"?s.toLocaleDateString([],{weekday:"short",month:"numeric",day:"numeric"}):s.toLocaleDateString([],{month:"numeric",day:"numeric"})};var Wt=P.bind(T),nC=1e4,sC=4,oC=12,rC=t=>Wo(t,{fallback:"",valueIsUnixSeconds:!0}),sx=t=>{let e=String(t?.repoSlug||"").trim();return e||String(t?.repoPath||"").split("/").filter(Boolean).pop()||"repo"},iC=t=>{let e=String(t?.statusKind||"M").toUpperCase();return e==="U"?{statusLabel:"U",statusClass:"is-untracked",rowClass:"is-clickable",canOpen:!0}:e==="D"?{statusLabel:"D",statusClass:"is-deleted",rowClass:"is-clickable",canOpen:!0}:{statusLabel:"M",statusClass:"is-modified",rowClass:"is-clickable",canOpen:!0}},ox=(t,e)=>{if(t==null||t==="")return"";let n=Number(t);return!Number.isFinite(n)||n<=0?"":`${e}${n}`},aC=(t,e)=>{let n=String(t||"").trim(),s=String(e||"").toUpperCase();return n?n.endsWith("/")?!0:s==="U"&&n.endsWith("\\"):!1},lC=t=>{let e=String(t?.syncState||"").trim(),n=Number(t?.aheadCount)||0,s=Number(t?.behindCount)||0;return e==="ahead"?{label:"\u2191",title:`Ahead by ${n}`,className:"is-ahead"}:e==="behind"?{label:"\u2193",title:`Behind by ${s}`,className:"is-behind"}:e==="diverged"?{label:"\u2195",title:`Diverged (${n} ahead, ${s} behind)`,className:"is-diverged"}:e==="upstream-gone"?{label:"!",title:"Upstream missing",className:"is-upstream-gone"}:e==="no-upstream"||!t?.hasUpstream?{label:"!",title:"Not linked",className:"is-no-upstream"}:{label:"",title:"Up to date",className:"is-up-to-date"}},cC=t=>{let e=Array.isArray(t?.changedFiles)?t.changedFiles:[],n=Number(t?.changedFilesCount)||0,s=e.map(u=>String(u?.path||"").trim()).filter(Boolean),o=n||s.length;if(o<=0)return"sync changes";let r=s.map(u=>u.split("/").filter(Boolean).pop()||u),i=Array.from(new Set(r));if(i.length<=0)return`Edited ${o} ${o===1?"file":"files"}`;let a=i.slice(0,sC),l=Math.max(0,o-a.length),c=o===1?"file":"files",d=l>0?` +${l} more`:"";return`Edited ${o} ${c} - ${a.join(", ")}${d}`},rx=({onSelectFile:t=()=>{},isActive:e=!0})=>{let[n,s]=y(!0),[o,r]=y(!1),[i,a]=y(""),[l,c]=y(null);if(L(()=>{if(!e)return()=>{};let m=!0,h=null,b=async()=>{if(m)try{let v=await pd();if(!m)return;c(v),a("")}catch(v){if(!m)return;a(v.message||"Could not load git summary")}finally{m&&s(!1)}},x=()=>{b()};return b(),h=window.setInterval(b,nC),window.addEventListener("alphaclaw:browse-file-saved",x),()=>{m=!1,h&&window.clearInterval(h),window.removeEventListener("alphaclaw:browse-file-saved",x)}},[e]),n)return Wt` @@ -2461,7 +2474,7 @@ import"./chunks/chunk-72ZECFVW.js";var Tr,Se,Jf,Pr,hs,Kf,Zf,Yf,Xf,Gc,jc,zc,Qf,oa - `;let d=(l.changedFiles||[]).length>0,u=Number(l?.aheadCount)||0,p=d||u>0,f=iC(l),g=async()=>{if(!(!p||o))try{r(!0);let m=aC(l),h=await gg(m);h?.committed||h?.pushed?(window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced")),N(h.message||"Changes synced","success")):N(h?.message||"No changes to sync","info");let b=await ud();c(b),a("")}catch(m){N(m.message||"Could not sync changes","error")}finally{r(!1)}};return Wt` + `;let d=(l.changedFiles||[]).length>0,u=Number(l?.aheadCount)||0,p=d||u>0,f=lC(l),g=async()=>{if(!(!p||o))try{r(!0);let m=cC(l),h=await bg(m);h?.committed||h?.pushed?(window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced")),N(h.message||"Changes synced","success")):N(h?.message||"No changes to sync","info");let b=await pd();c(b),a("")}catch(m){N(m.message||"Could not sync changes","error")}finally{r(!1)}};return Wt` - `};function Vd(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var Qs=Vd();function ux(t){Qs=t}var ti={exec:()=>null};function Be(t,e=""){let n=typeof t=="string"?t:t.source,s={replace:(o,r)=>{let i=typeof r=="string"?r:r.source;return i=i.replace(Vt.caret,"$1"),n=n.replace(o,i),s},getRegex:()=>new RegExp(n,e)};return s}var lC=(()=>{try{return!!new RegExp("(?<=1)(?/,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:t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i")},cC=/^(?:[ \t]*(?:\n|$))+/,dC=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,uC=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,ni=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,pC=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,jd=/(?:[*+-]|\d{1,9}[.)])/,px=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,fx=Be(px).replace(/bull/g,jd).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(),fC=Be(px).replace(/bull/g,jd).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(),zd=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,hC=/^[^\n]+/,Ud=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,mC=Be(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Ud).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),gC=Be(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,jd).getRegex(),Qa="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",Kd=/|$))/,bC=Be("^ {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",Kd).replace("tag",Qa).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),hx=Be(zd).replace("hr",ni).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",Qa).getRegex(),xC=Be(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",hx).getRegex(),Gd={blockquote:xC,code:dC,def:mC,fences:uC,heading:pC,hr:ni,html:bC,lheading:fx,list:gC,newline:cC,paragraph:hx,table:ti,text:hC},rx=Be("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",ni).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",Qa).getRegex(),yC={...Gd,lheading:fC,table:rx,paragraph:Be(zd).replace("hr",ni).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",rx).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",Qa).getRegex()},vC={...Gd,html:Be(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Kd).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:ti,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:Be(zd).replace("hr",ni).replace("heading",` *#{1,6} *[^ -]`).replace("lheading",fx).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},$C=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,wC=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,mx=/^( {2,}|\\)\n(?!\s*$)/,kC=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",lC?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),xx=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,AC=Be(xx,"u").replace(/punct/g,el).getRegex(),TC=Be(xx,"u").replace(/punct/g,bx).getRegex(),yx="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",PC=Be(yx,"gu").replace(/notPunctSpace/g,gx).replace(/punctSpace/g,qd).replace(/punct/g,el).getRegex(),RC=Be(yx,"gu").replace(/notPunctSpace/g,_C).replace(/punctSpace/g,CC).replace(/punct/g,bx).getRegex(),LC=Be("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,gx).replace(/punctSpace/g,qd).replace(/punct/g,el).getRegex(),EC=Be(/\\(punct)/,"gu").replace(/punct/g,el).getRegex(),IC=Be(/^<(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(),DC=Be(Kd).replace("(?:-->|$)","-->").getRegex(),NC=Be("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",DC).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Za=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,OC=Be(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Za).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),vx=Be(/^!?\[(label)\]\[(ref)\]/).replace("label",Za).replace("ref",Ud).getRegex(),$x=Be(/^!?\[(ref)\](?:\[\])?/).replace("ref",Ud).getRegex(),BC=Be("reflink|nolink(?!\\()","g").replace("reflink",vx).replace("nolink",$x).getRegex(),ix=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,Jd={_backpedal:ti,anyPunctuation:EC,autolink:IC,blockSkip:MC,br:mx,code:wC,del:ti,emStrongLDelim:AC,emStrongRDelimAst:PC,emStrongRDelimUnd:LC,escape:$C,link:OC,nolink:$x,punctuation:SC,reflink:vx,reflinkSearch:BC,tag:NC,text:kC,url:ti},FC={...Jd,link:Be(/^!?\[(label)\]\((.*?)\)/).replace("label",Za).getRegex(),reflink:Be(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Za).getRegex()},Fd={...Jd,emStrongRDelimAst:RC,emStrongLDelim:TC,url:Be(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",ix).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\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:Be(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},ax=t=>WC[t];function Ln(t,e){if(e){if(Vt.escapeTest.test(t))return t.replace(Vt.escapeReplace,ax)}else if(Vt.escapeTestNoEncode.test(t))return t.replace(Vt.escapeReplaceNoEncode,ax);return t}function lx(t){try{t=encodeURI(t).replace(Vt.percentDecode,"%")}catch{return null}return t}function cx(t,e){let n=t.replace(Vt.findPipe,(r,i,a)=>{let l=!1,c=i;for(;--c>=0&&a[c]==="\\";)l=!l;return l?"|":" |"}),s=n.split(Vt.splitPipe),o=0;if(s[0].trim()||s.shift(),s.length>0&&!s.at(-1)?.trim()&&s.pop(),e)if(s.length>e)s.splice(e);else for(;s.length0?-2:-1}function dx(t,e,n,s,o){let r=e.href,i=e.title||null,a=t[1].replace(o.other.outputLinkReplace,"$1");s.state.inLink=!0;let l={type:t[0].charAt(0)==="!"?"image":"link",raw:n,href:r,title:i,text:a,tokens:s.inlineTokens(a)};return s.state.inLink=!1,l}function jC(t,e,n){let s=t.match(n.other.indentCodeCompensation);if(s===null)return e;let o=s[1];return e.split(` + `};function jd(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var Qs=jd();function px(t){Qs=t}var ti={exec:()=>null};function Be(t,e=""){let n=typeof t=="string"?t:t.source,s={replace:(o,r)=>{let i=typeof r=="string"?r:r.source;return i=i.replace(Vt.caret,"$1"),n=n.replace(o,i),s},getRegex:()=>new RegExp(n,e)};return s}var dC=(()=>{try{return!!new RegExp("(?<=1)(?/,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:t=>new RegExp(`^( {0,3}${t})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}#`),htmlBeginRegex:t=>new RegExp(`^ {0,${Math.min(3,t-1)}}<(?:[a-z].*>|!--)`,"i")},uC=/^(?:[ \t]*(?:\n|$))+/,pC=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,fC=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,ni=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,hC=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Ud=/(?:[*+-]|\d{1,9}[.)])/,fx=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,hx=Be(fx).replace(/bull/g,Ud).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(),mC=Be(fx).replace(/bull/g,Ud).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(),Kd=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,gC=/^[^\n]+/,Gd=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,bC=Be(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Gd).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),xC=Be(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Ud).getRegex(),el="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",qd=/|$))/,yC=Be("^ {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",qd).replace("tag",el).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),mx=Be(Kd).replace("hr",ni).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",el).getRegex(),vC=Be(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",mx).getRegex(),Jd={blockquote:vC,code:pC,def:bC,fences:fC,heading:hC,hr:ni,html:yC,lheading:hx,list:xC,newline:uC,paragraph:mx,table:ti,text:gC},ix=Be("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",ni).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",el).getRegex(),$C={...Jd,lheading:mC,table:ix,paragraph:Be(Kd).replace("hr",ni).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ix).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",el).getRegex()},wC={...Jd,html:Be(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",qd).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:ti,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:Be(Kd).replace("hr",ni).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",hx).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},kC=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,SC=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,gx=/^( {2,}|\\)\n(?!\s*$)/,CC=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",dC?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),yx=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,PC=Be(yx,"u").replace(/punct/g,tl).getRegex(),RC=Be(yx,"u").replace(/punct/g,xx).getRegex(),vx="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",LC=Be(vx,"gu").replace(/notPunctSpace/g,bx).replace(/punctSpace/g,Zd).replace(/punct/g,tl).getRegex(),EC=Be(vx,"gu").replace(/notPunctSpace/g,AC).replace(/punctSpace/g,MC).replace(/punct/g,xx).getRegex(),IC=Be("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,bx).replace(/punctSpace/g,Zd).replace(/punct/g,tl).getRegex(),DC=Be(/\\(punct)/,"gu").replace(/punct/g,tl).getRegex(),NC=Be(/^<(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(),OC=Be(qd).replace("(?:-->|$)","-->").getRegex(),BC=Be("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",OC).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Ya=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,FC=Be(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",Ya).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),$x=Be(/^!?\[(label)\]\[(ref)\]/).replace("label",Ya).replace("ref",Gd).getRegex(),wx=Be(/^!?\[(ref)\](?:\[\])?/).replace("ref",Gd).getRegex(),HC=Be("reflink|nolink(?!\\()","g").replace("reflink",$x).replace("nolink",wx).getRegex(),ax=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,Yd={_backpedal:ti,anyPunctuation:DC,autolink:NC,blockSkip:TC,br:gx,code:SC,del:ti,emStrongLDelim:PC,emStrongRDelimAst:LC,emStrongRDelimUnd:IC,escape:kC,link:FC,nolink:wx,punctuation:_C,reflink:$x,reflinkSearch:HC,tag:BC,text:CC,url:ti},WC={...Yd,link:Be(/^!?\[(label)\]\((.*?)\)/).replace("label",Ya).getRegex(),reflink:Be(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Ya).getRegex()},Wd={...Yd,emStrongRDelimAst:EC,emStrongLDelim:RC,url:Be(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",ax).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\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:Be(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},lx=t=>zC[t];function Ln(t,e){if(e){if(Vt.escapeTest.test(t))return t.replace(Vt.escapeReplace,lx)}else if(Vt.escapeTestNoEncode.test(t))return t.replace(Vt.escapeReplaceNoEncode,lx);return t}function cx(t){try{t=encodeURI(t).replace(Vt.percentDecode,"%")}catch{return null}return t}function dx(t,e){let n=t.replace(Vt.findPipe,(r,i,a)=>{let l=!1,c=i;for(;--c>=0&&a[c]==="\\";)l=!l;return l?"|":" |"}),s=n.split(Vt.splitPipe),o=0;if(s[0].trim()||s.shift(),s.length>0&&!s.at(-1)?.trim()&&s.pop(),e)if(s.length>e)s.splice(e);else for(;s.length0?-2:-1}function ux(t,e,n,s,o){let r=e.href,i=e.title||null,a=t[1].replace(o.other.outputLinkReplace,"$1");s.state.inLink=!0;let l={type:t[0].charAt(0)==="!"?"image":"link",raw:n,href:r,title:i,text:a,tokens:s.inlineTokens(a)};return s.state.inLink=!1,l}function UC(t,e,n){let s=t.match(n.other.indentCodeCompensation);if(s===null)return e;let o=s[1];return e.split(` `).map(r=>{let i=r.match(n.other.beginningSpace);if(i===null)return r;let[a]=i;return a.length>=o.length?r.slice(o.length):r}).join(` -`)}var Ya=class{options;rules;lexer;constructor(t){this.options=t||Qs}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let n=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?n:Qr(n,` -`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let n=e[0],s=jC(n,e[3]||"",this.rules);return{type:"code",raw:n,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:s}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let n=e[2].trim();if(this.rules.other.endingHash.test(n)){let s=Qr(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:Qr(e[0],` +`)}var Xa=class{options;rules;lexer;constructor(t){this.options=t||Qs}space(t){let e=this.rules.block.newline.exec(t);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(t){let e=this.rules.block.code.exec(t);if(e){let n=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?n:Qr(n,` +`)}}}fences(t){let e=this.rules.block.fences.exec(t);if(e){let n=e[0],s=UC(n,e[3]||"",this.rules);return{type:"code",raw:n,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:s}}}heading(t){let e=this.rules.block.heading.exec(t);if(e){let n=e[2].trim();if(this.rules.other.endingHash.test(n)){let s=Qr(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(t){let e=this.rules.block.hr.exec(t);if(e)return{type:"hr",raw:Qr(e[0],` `)}}blockquote(t){let e=this.rules.block.blockquote.exec(t);if(e){let n=Qr(e[0],` `).split(` `),s="",o="",r=[];for(;n.length>0;){let i=!1,a=[],l;for(l=0;l=g||!p.trim())d+=` `+C.slice(g);else{if(f||u.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||v.test(u)||w.test(u)||x.test(u))break;d+=` `+p}!f&&!p.trim()&&(f=!0),c+=S+` -`,t=t.substring(S.length+1),u=C.slice(g)}}o.loose||(i?o.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(i=!0));let m=null,h;this.options.gfm&&(m=this.rules.other.listIsTask.exec(d),m&&(h=m[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),o.items.push({type:"list_item",raw:c,task:!!m,checked:h,loose:!1,text:d,tokens:[]}),o.raw+=c}let a=o.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;o.raw=o.raw.trimEnd();for(let l=0;lu.type==="space"),d=c.length>0&&c.some(u=>this.rules.other.anyLine.test(u.raw));o.loose=d}if(o.loose)for(let l=0;l({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[l]})));return r}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let n=e[1].charAt(e[1].length-1)===` -`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:n,tokens:this.lexer.inline(n)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let n=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=Qr(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=VC(e[2],"()");if(r===-2)return;if(r>-1){let i=(e[0].indexOf("!")===0?5:4)+e[1].length+r;e[2]=e[2].substring(0,r),e[0]=e[0].substring(0,i).trim(),e[3]=""}}let s=e[2],o="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],o=r[3])}else o=e[3]?e[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),dx(e,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:o&&o.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let n;if((n=this.rules.inline.reflink.exec(t))||(n=this.rules.inline.nolink.exec(t))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),o=e[s.toLowerCase()];if(!o){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return dx(n,o,n[0],this.lexer,this.rules)}}emStrong(t,e,n=""){let s=this.rules.inline.emStrongLDelim.exec(t);if(!(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!(s[1]||s[2])||!n||this.rules.inline.punctuation.exec(n))){let o=[...s[0]].length-1,r,i,a=o,l=0,c=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(c.lastIndex=0,e=e.slice(-1*t.length+o);(s=c.exec(e))!=null;){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){a+=i;continue}else if((s[5]||s[6])&&o%3&&!((o+i)%3)){l+=i;continue}if(a-=i,a>0)continue;i=Math.min(i,i+a+l);let d=[...s[0]][0].length,u=t.slice(0,o+s.index+d+i);if(Math.min(o,i)%2){let f=u.slice(1,-1);return{type:"em",raw:u,text:f,tokens:this.lexer.inlineTokens(f)}}let p=u.slice(2,-2);return{type:"strong",raw:u,text:p,tokens:this.lexer.inlineTokens(p)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let n=e[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),o=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&o&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:e[0],text:n}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){let e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let n,s;return e[2]==="@"?(n=e[1],s="mailto:"+n):(n=e[1],s=n),{type:"link",raw:e[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(t){let e;if(e=this.rules.inline.url.exec(t)){let n,s;if(e[2]==="@")n=e[0],s="mailto:"+n;else{let o;do o=e[0],e[0]=this.rules.inline._backpedal.exec(e[0])?.[0]??"";while(o!==e[0]);n=e[0],e[1]==="www."?s="http://"+e[0]:s=e[0]}return{type:"link",raw:e[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let n=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:n}}}},xn=class Hd{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||Qs,this.options.tokenizer=this.options.tokenizer||new Ya,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let n={other:Vt,block:Ja.normal,inline:Xr.normal};this.options.pedantic?(n.block=Ja.pedantic,n.inline=Xr.pedantic):this.options.gfm&&(n.block=Ja.gfm,this.options.breaks?n.inline=Xr.breaks:n.inline=Xr.gfm),this.tokenizer.rules=n}static get rules(){return{block:Ja,inline:Xr}}static lex(e,n){return new Hd(n).lex(e)}static lexInline(e,n){return new Hd(n).inlineTokens(e)}lex(e){e=e.replace(Vt.carriageReturn,` +`,t=t.substring(S.length+1),u=C.slice(g)}}o.loose||(i?o.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(i=!0));let m=null,h;this.options.gfm&&(m=this.rules.other.listIsTask.exec(d),m&&(h=m[0]!=="[ ] ",d=d.replace(this.rules.other.listReplaceTask,""))),o.items.push({type:"list_item",raw:c,task:!!m,checked:h,loose:!1,text:d,tokens:[]}),o.raw+=c}let a=o.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;o.raw=o.raw.trimEnd();for(let l=0;lu.type==="space"),d=c.length>0&&c.some(u=>this.rules.other.anyLine.test(u.raw));o.loose=d}if(o.loose)for(let l=0;l({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[l]})));return r}}lheading(t){let e=this.rules.block.lheading.exec(t);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(t){let e=this.rules.block.paragraph.exec(t);if(e){let n=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:n,tokens:this.lexer.inline(n)}}}text(t){let e=this.rules.block.text.exec(t);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(t){let e=this.rules.inline.escape.exec(t);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(t){let e=this.rules.inline.tag.exec(t);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(t){let e=this.rules.inline.link.exec(t);if(e){let n=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=Qr(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=jC(e[2],"()");if(r===-2)return;if(r>-1){let i=(e[0].indexOf("!")===0?5:4)+e[1].length+r;e[2]=e[2].substring(0,r),e[0]=e[0].substring(0,i).trim(),e[3]=""}}let s=e[2],o="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],o=r[3])}else o=e[3]?e[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),ux(e,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:o&&o.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(t,e){let n;if((n=this.rules.inline.reflink.exec(t))||(n=this.rules.inline.nolink.exec(t))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),o=e[s.toLowerCase()];if(!o){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return ux(n,o,n[0],this.lexer,this.rules)}}emStrong(t,e,n=""){let s=this.rules.inline.emStrongLDelim.exec(t);if(!(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!(s[1]||s[2])||!n||this.rules.inline.punctuation.exec(n))){let o=[...s[0]].length-1,r,i,a=o,l=0,c=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(c.lastIndex=0,e=e.slice(-1*t.length+o);(s=c.exec(e))!=null;){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){a+=i;continue}else if((s[5]||s[6])&&o%3&&!((o+i)%3)){l+=i;continue}if(a-=i,a>0)continue;i=Math.min(i,i+a+l);let d=[...s[0]][0].length,u=t.slice(0,o+s.index+d+i);if(Math.min(o,i)%2){let f=u.slice(1,-1);return{type:"em",raw:u,text:f,tokens:this.lexer.inlineTokens(f)}}let p=u.slice(2,-2);return{type:"strong",raw:u,text:p,tokens:this.lexer.inlineTokens(p)}}}}codespan(t){let e=this.rules.inline.code.exec(t);if(e){let n=e[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),o=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&o&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:e[0],text:n}}}br(t){let e=this.rules.inline.br.exec(t);if(e)return{type:"br",raw:e[0]}}del(t){let e=this.rules.inline.del.exec(t);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(t){let e=this.rules.inline.autolink.exec(t);if(e){let n,s;return e[2]==="@"?(n=e[1],s="mailto:"+n):(n=e[1],s=n),{type:"link",raw:e[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(t){let e;if(e=this.rules.inline.url.exec(t)){let n,s;if(e[2]==="@")n=e[0],s="mailto:"+n;else{let o;do o=e[0],e[0]=this.rules.inline._backpedal.exec(e[0])?.[0]??"";while(o!==e[0]);n=e[0],e[1]==="www."?s="http://"+e[0]:s=e[0]}return{type:"link",raw:e[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(t){let e=this.rules.inline.text.exec(t);if(e){let n=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:n}}}},xn=class Vd{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||Qs,this.options.tokenizer=this.options.tokenizer||new Xa,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let n={other:Vt,block:Za.normal,inline:Xr.normal};this.options.pedantic?(n.block=Za.pedantic,n.inline=Xr.pedantic):this.options.gfm&&(n.block=Za.gfm,this.options.breaks?n.inline=Xr.breaks:n.inline=Xr.gfm),this.tokenizer.rules=n}static get rules(){return{block:Za,inline:Xr}}static lex(e,n){return new Vd(n).lex(e)}static lexInline(e,n){return new Vd(n).inlineTokens(e)}lex(e){e=e.replace(Vt.carriageReturn,` `),this.blockTokens(e,this.tokens);for(let n=0;n(o=i.call({lexer:this},e,n))?(e=e.substring(o.raw.length),n.push(o),!0):!1))continue;if(o=this.tokenizer.space(e)){e=e.substring(o.raw.length);let i=n.at(-1);o.raw.length===1&&i!==void 0?i.raw+=` `:n.push(o);continue}if(o=this.tokenizer.code(e)){e=e.substring(o.raw.length);let i=n.at(-1);i?.type==="paragraph"||i?.type==="text"?(i.raw+=(i.raw.endsWith(` `)?"":` @@ -2596,7 +2609,7 @@ ${d}`:d;let u=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTo `+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):n.push(o),s=r.length!==e.length,e=e.substring(o.raw.length);continue}if(o=this.tokenizer.text(e)){e=e.substring(o.raw.length);let i=n.at(-1);i?.type==="text"?(i.raw+=(i.raw.endsWith(` `)?"":` `)+o.raw,i.text+=` -`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):n.push(o);continue}if(e){let i="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(i);break}else throw new Error(i)}}return this.state.top=!0,n}inline(e,n=[]){return this.inlineQueue.push({src:e,tokens:n}),n}inlineTokens(e,n=[]){let s=e,o=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(o=this.tokenizer.rules.inline.reflinkSearch.exec(s))!=null;)l.includes(o[0].slice(o[0].lastIndexOf("[")+1,-1))&&(s=s.slice(0,o.index)+"["+"a".repeat(o[0].length-2)+"]"+s.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(o=this.tokenizer.rules.inline.anyPunctuation.exec(s))!=null;)s=s.slice(0,o.index)+"++"+s.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r;for(;(o=this.tokenizer.rules.inline.blockSkip.exec(s))!=null;)r=o[2]?o[2].length:0,s=s.slice(0,o.index+r)+"["+"a".repeat(o[0].length-r-2)+"]"+s.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);s=this.options.hooks?.emStrongMask?.call({lexer:this},s)??s;let i=!1,a="";for(;e;){i||(a=""),i=!1;let l;if(this.options.extensions?.inline?.some(d=>(l=d.call({lexer:this},e,n))?(e=e.substring(l.raw.length),n.push(l),!0):!1))continue;if(l=this.tokenizer.escape(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.tag(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.link(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(l.raw.length);let d=n.at(-1);l.type==="text"&&d?.type==="text"?(d.raw+=l.raw,d.text+=l.text):n.push(l);continue}if(l=this.tokenizer.emStrong(e,s,a)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.codespan(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.br(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.del(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.autolink(e)){e=e.substring(l.raw.length),n.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(e))){e=e.substring(l.raw.length),n.push(l);continue}let c=e;if(this.options.extensions?.startInline){let d=1/0,u=e.slice(1),p;this.options.extensions.startInline.forEach(f=>{p=f.call({lexer:this},u),typeof p=="number"&&p>=0&&(d=Math.min(d,p))}),d<1/0&&d>=0&&(c=e.substring(0,d+1))}if(l=this.tokenizer.inlineText(c)){e=e.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(a=l.raw.slice(-1)),i=!0;let d=n.at(-1);d?.type==="text"?(d.raw+=l.raw,d.text+=l.text):n.push(l);continue}if(e){let d="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(d);break}else throw new Error(d)}}return n}},Xa=class{options;parser;constructor(t){this.options=t||Qs}space(t){return""}code({text:t,lang:e,escaped:n}){let s=(e||"").match(Vt.notSpaceStart)?.[0],o=t.replace(Vt.endingNewline,"")+` +`+o.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):n.push(o);continue}if(e){let i="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(i);break}else throw new Error(i)}}return this.state.top=!0,n}inline(e,n=[]){return this.inlineQueue.push({src:e,tokens:n}),n}inlineTokens(e,n=[]){let s=e,o=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(o=this.tokenizer.rules.inline.reflinkSearch.exec(s))!=null;)l.includes(o[0].slice(o[0].lastIndexOf("[")+1,-1))&&(s=s.slice(0,o.index)+"["+"a".repeat(o[0].length-2)+"]"+s.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(o=this.tokenizer.rules.inline.anyPunctuation.exec(s))!=null;)s=s.slice(0,o.index)+"++"+s.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r;for(;(o=this.tokenizer.rules.inline.blockSkip.exec(s))!=null;)r=o[2]?o[2].length:0,s=s.slice(0,o.index+r)+"["+"a".repeat(o[0].length-r-2)+"]"+s.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);s=this.options.hooks?.emStrongMask?.call({lexer:this},s)??s;let i=!1,a="";for(;e;){i||(a=""),i=!1;let l;if(this.options.extensions?.inline?.some(d=>(l=d.call({lexer:this},e,n))?(e=e.substring(l.raw.length),n.push(l),!0):!1))continue;if(l=this.tokenizer.escape(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.tag(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.link(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(l.raw.length);let d=n.at(-1);l.type==="text"&&d?.type==="text"?(d.raw+=l.raw,d.text+=l.text):n.push(l);continue}if(l=this.tokenizer.emStrong(e,s,a)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.codespan(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.br(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.del(e)){e=e.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.autolink(e)){e=e.substring(l.raw.length),n.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(e))){e=e.substring(l.raw.length),n.push(l);continue}let c=e;if(this.options.extensions?.startInline){let d=1/0,u=e.slice(1),p;this.options.extensions.startInline.forEach(f=>{p=f.call({lexer:this},u),typeof p=="number"&&p>=0&&(d=Math.min(d,p))}),d<1/0&&d>=0&&(c=e.substring(0,d+1))}if(l=this.tokenizer.inlineText(c)){e=e.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(a=l.raw.slice(-1)),i=!0;let d=n.at(-1);d?.type==="text"?(d.raw+=l.raw,d.text+=l.text):n.push(l);continue}if(e){let d="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(d);break}else throw new Error(d)}}return n}},Qa=class{options;parser;constructor(t){this.options=t||Qs}space(t){return""}code({text:t,lang:e,escaped:n}){let s=(e||"").match(Vt.notSpaceStart)?.[0],o=t.replace(Vt.endingNewline,"")+` `;return s?'
      '+(n?o:Ln(o,!0))+`
      `:"
      "+(n?o:Ln(o,!0))+`
      `}blockquote({tokens:t}){return`
      @@ -2614,9 +2627,9 @@ ${this.parser.parse(t)}
      `}tablerow({text:t}){return` ${t} `}tablecell(t){let e=this.parser.parseInline(t.tokens),n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+` -`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${Ln(t,!0)}`}br(t){return"
      "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:n}){let s=this.parser.parseInline(n),o=lx(t);if(o===null)return s;t=o;let r='
      ",r}image({href:t,title:e,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let o=lx(t);if(o===null)return Ln(n);t=o;let r=`${n}{let i=o[r].flat(1/0);n=n.concat(this.walkTokens(i,e))}):o.tokens&&(n=n.concat(this.walkTokens(o.tokens,e)))}}return n}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(o=>{if(!o.name)throw new Error("extension name required");if("renderer"in o){let r=e.renderers[o.name];r?e.renderers[o.name]=function(...i){let a=o.renderer.apply(this,i);return a===!1&&(a=r.apply(this,i)),a}:e.renderers[o.name]=o.renderer}if("tokenizer"in o){if(!o.level||o.level!=="block"&&o.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=e[o.level];r?r.unshift(o.tokenizer):e[o.level]=[o.tokenizer],o.start&&(o.level==="block"?e.startBlock?e.startBlock.push(o.start):e.startBlock=[o.start]:o.level==="inline"&&(e.startInline?e.startInline.push(o.start):e.startInline=[o.start]))}"childTokens"in o&&o.childTokens&&(e.childTokens[o.name]=o.childTokens)}),s.extensions=e),n.renderer){let o=this.defaults.renderer||new Xa(this.defaults);for(let r in n.renderer){if(!(r in o))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let i=r,a=n.renderer[i],l=o[i];o[i]=(...c)=>{let d=a.apply(o,c);return d===!1&&(d=l.apply(o,c)),d||""}}s.renderer=o}if(n.tokenizer){let o=this.defaults.tokenizer||new Ya(this.defaults);for(let r in n.tokenizer){if(!(r in o))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let i=r,a=n.tokenizer[i],l=o[i];o[i]=(...c)=>{let d=a.apply(o,c);return d===!1&&(d=l.apply(o,c)),d}}s.tokenizer=o}if(n.hooks){let o=this.defaults.hooks||new ei;for(let r in n.hooks){if(!(r in o))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let i=r,a=n.hooks[i],l=o[i];ei.passThroughHooks.has(r)?o[i]=c=>{if(this.defaults.async&&ei.passThroughHooksRespectAsync.has(r))return(async()=>{let u=await a.call(o,c);return l.call(o,u)})();let d=a.call(o,c);return l.call(o,d)}:o[i]=(...c)=>{if(this.defaults.async)return(async()=>{let u=await a.apply(o,c);return u===!1&&(u=await l.apply(o,c)),u})();let d=a.apply(o,c);return d===!1&&(d=l.apply(o,c)),d}}s.hooks=o}if(n.walkTokens){let o=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(i){let a=[];return a.push(r.call(this,i)),o&&(a=a.concat(o.call(this,i))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return xn.lex(t,e??this.defaults)}parser(t,e){return yn.parse(t,e??this.defaults)}parseMarkdown(t){return(e,n)=>{let s={...n},o={...this.defaults,...s},r=this.onError(!!o.silent,!!o.async);if(this.defaults.async===!0&&s.async===!1)return r(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 e>"u"||e===null)return r(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return r(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(o.hooks&&(o.hooks.options=o,o.hooks.block=t),o.async)return(async()=>{let i=o.hooks?await o.hooks.preprocess(e):e,a=await(o.hooks?await o.hooks.provideLexer():t?xn.lex:xn.lexInline)(i,o),l=o.hooks?await o.hooks.processAllTokens(a):a;o.walkTokens&&await Promise.all(this.walkTokens(l,o.walkTokens));let c=await(o.hooks?await o.hooks.provideParser():t?yn.parse:yn.parseInline)(l,o);return o.hooks?await o.hooks.postprocess(c):c})().catch(r);try{o.hooks&&(e=o.hooks.preprocess(e));let i=(o.hooks?o.hooks.provideLexer():t?xn.lex:xn.lexInline)(e,o);o.hooks&&(i=o.hooks.processAllTokens(i)),o.walkTokens&&this.walkTokens(i,o.walkTokens);let a=(o.hooks?o.hooks.provideParser():t?yn.parse:yn.parseInline)(i,o);return o.hooks&&(a=o.hooks.postprocess(a)),a}catch(i){return r(i)}}}onError(t,e){return n=>{if(n.message+=` -Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error occurred:

      "+Ln(n.message+"",!0)+"
      ";return e?Promise.resolve(s):s}if(e)return Promise.reject(n);throw n}}},Xs=new zC;function Le(t,e){return Xs.parse(t,e)}Le.options=Le.setOptions=function(t){return Xs.setOptions(t),Le.defaults=Xs.defaults,ux(Le.defaults),Le};Le.getDefaults=Vd;Le.defaults=Qs;Le.use=function(...t){return Xs.use(...t),Le.defaults=Xs.defaults,ux(Le.defaults),Le};Le.walkTokens=function(t,e){return Xs.walkTokens(t,e)};Le.parseInline=Xs.parseInline;Le.Parser=yn;Le.parser=yn.parse;Le.Renderer=Xa;Le.TextRenderer=Zd;Le.Lexer=xn;Le.lexer=xn.lex;Le.Tokenizer=Ya;Le.Hooks=ei;Le.parse=Le;var FD=Le.options,HD=Le.setOptions,WD=Le.use,VD=Le.walkTokens,jD=Le.parseInline;var zD=yn.parse,UD=xn.lex;var Vo=P.bind(T),UC=t=>{let e=String(t||"").trim();return e?e.startsWith("v")?e:`v${e}`:""},KC=t=>{let e=Date.parse(String(t||""));if(!Number.isFinite(e))return"";try{return new Intl.DateTimeFormat(void 0,{dateStyle:"medium",timeStyle:"short"}).format(new Date(e))}catch{return""}},GC=t=>t?`https://github.com/chrysb/alphaclaw/releases/tag/${encodeURIComponent(t)}`:"https://github.com/chrysb/alphaclaw/releases",wx=({visible:t=!1,onClose:e=()=>{},version:n="",onUpdate:s=()=>{},updating:o=!1})=>{let r=F(()=>UC(n),[n]),[i,a]=y(!1),[l,c]=y(""),[d,u]=y(null);L(()=>{if(!t)return;let x=!0;return(async()=>{a(!0),c("");try{let w=await xm(r);if(!x)return;if(!w?.ok){c(w?.error||"Could not load release notes"),u(null);return}u(w)}catch(w){if(!x)return;c(w?.message||"Could not load release notes"),u(null)}finally{if(!x)return;a(!1)}})(),()=>{x=!1}},[t,r]);let p=String(d?.tag||r||"").trim(),f=String(d?.htmlUrl||"").trim()||GC(p),g=p?`Update to ${p}`:"Update now",m=KC(d?.publishedAt),h=String(d?.body||"").trim(),b=F(()=>Le.parse(h,{gfm:!0,breaks:!0}),[h]);return Vo` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${Ln(t,!0)}`}br(t){return"
      "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:e,tokens:n}){let s=this.parser.parseInline(n),o=cx(t);if(o===null)return s;t=o;let r='
      ",r}image({href:t,title:e,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let o=cx(t);if(o===null)return Ln(n);t=o;let r=`${n}{let i=o[r].flat(1/0);n=n.concat(this.walkTokens(i,e))}):o.tokens&&(n=n.concat(this.walkTokens(o.tokens,e)))}}return n}use(...t){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(o=>{if(!o.name)throw new Error("extension name required");if("renderer"in o){let r=e.renderers[o.name];r?e.renderers[o.name]=function(...i){let a=o.renderer.apply(this,i);return a===!1&&(a=r.apply(this,i)),a}:e.renderers[o.name]=o.renderer}if("tokenizer"in o){if(!o.level||o.level!=="block"&&o.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=e[o.level];r?r.unshift(o.tokenizer):e[o.level]=[o.tokenizer],o.start&&(o.level==="block"?e.startBlock?e.startBlock.push(o.start):e.startBlock=[o.start]:o.level==="inline"&&(e.startInline?e.startInline.push(o.start):e.startInline=[o.start]))}"childTokens"in o&&o.childTokens&&(e.childTokens[o.name]=o.childTokens)}),s.extensions=e),n.renderer){let o=this.defaults.renderer||new Qa(this.defaults);for(let r in n.renderer){if(!(r in o))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let i=r,a=n.renderer[i],l=o[i];o[i]=(...c)=>{let d=a.apply(o,c);return d===!1&&(d=l.apply(o,c)),d||""}}s.renderer=o}if(n.tokenizer){let o=this.defaults.tokenizer||new Xa(this.defaults);for(let r in n.tokenizer){if(!(r in o))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let i=r,a=n.tokenizer[i],l=o[i];o[i]=(...c)=>{let d=a.apply(o,c);return d===!1&&(d=l.apply(o,c)),d}}s.tokenizer=o}if(n.hooks){let o=this.defaults.hooks||new ei;for(let r in n.hooks){if(!(r in o))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let i=r,a=n.hooks[i],l=o[i];ei.passThroughHooks.has(r)?o[i]=c=>{if(this.defaults.async&&ei.passThroughHooksRespectAsync.has(r))return(async()=>{let u=await a.call(o,c);return l.call(o,u)})();let d=a.call(o,c);return l.call(o,d)}:o[i]=(...c)=>{if(this.defaults.async)return(async()=>{let u=await a.apply(o,c);return u===!1&&(u=await l.apply(o,c)),u})();let d=a.apply(o,c);return d===!1&&(d=l.apply(o,c)),d}}s.hooks=o}if(n.walkTokens){let o=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(i){let a=[];return a.push(r.call(this,i)),o&&(a=a.concat(o.call(this,i))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,e){return xn.lex(t,e??this.defaults)}parser(t,e){return yn.parse(t,e??this.defaults)}parseMarkdown(t){return(e,n)=>{let s={...n},o={...this.defaults,...s},r=this.onError(!!o.silent,!!o.async);if(this.defaults.async===!0&&s.async===!1)return r(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 e>"u"||e===null)return r(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return r(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(o.hooks&&(o.hooks.options=o,o.hooks.block=t),o.async)return(async()=>{let i=o.hooks?await o.hooks.preprocess(e):e,a=await(o.hooks?await o.hooks.provideLexer():t?xn.lex:xn.lexInline)(i,o),l=o.hooks?await o.hooks.processAllTokens(a):a;o.walkTokens&&await Promise.all(this.walkTokens(l,o.walkTokens));let c=await(o.hooks?await o.hooks.provideParser():t?yn.parse:yn.parseInline)(l,o);return o.hooks?await o.hooks.postprocess(c):c})().catch(r);try{o.hooks&&(e=o.hooks.preprocess(e));let i=(o.hooks?o.hooks.provideLexer():t?xn.lex:xn.lexInline)(e,o);o.hooks&&(i=o.hooks.processAllTokens(i)),o.walkTokens&&this.walkTokens(i,o.walkTokens);let a=(o.hooks?o.hooks.provideParser():t?yn.parse:yn.parseInline)(i,o);return o.hooks&&(a=o.hooks.postprocess(a)),a}catch(i){return r(i)}}}onError(t,e){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error occurred:

      "+Ln(n.message+"",!0)+"
      ";return e?Promise.resolve(s):s}if(e)return Promise.reject(n);throw n}}},Xs=new KC;function Le(t,e){return Xs.parse(t,e)}Le.options=Le.setOptions=function(t){return Xs.setOptions(t),Le.defaults=Xs.defaults,px(Le.defaults),Le};Le.getDefaults=jd;Le.defaults=Qs;Le.use=function(...t){return Xs.use(...t),Le.defaults=Xs.defaults,px(Le.defaults),Le};Le.walkTokens=function(t,e){return Xs.walkTokens(t,e)};Le.parseInline=Xs.parseInline;Le.Parser=yn;Le.parser=yn.parse;Le.Renderer=Qa;Le.TextRenderer=Xd;Le.Lexer=xn;Le.lexer=xn.lex;Le.Tokenizer=Xa;Le.Hooks=ei;Le.parse=Le;var UD=Le.options,KD=Le.setOptions,GD=Le.use,qD=Le.walkTokens,JD=Le.parseInline;var ZD=yn.parse,YD=xn.lex;var Vo=P.bind(T),GC=t=>{let e=String(t||"").trim();return e?e.startsWith("v")?e:`v${e}`:""},qC=t=>{let e=Date.parse(String(t||""));if(!Number.isFinite(e))return"";try{return new Intl.DateTimeFormat(void 0,{dateStyle:"medium",timeStyle:"short"}).format(new Date(e))}catch{return""}},JC=t=>t?`https://github.com/chrysb/alphaclaw/releases/tag/${encodeURIComponent(t)}`:"https://github.com/chrysb/alphaclaw/releases",kx=({visible:t=!1,onClose:e=()=>{},version:n="",onUpdate:s=()=>{},updating:o=!1})=>{let r=F(()=>GC(n),[n]),[i,a]=y(!1),[l,c]=y(""),[d,u]=y(null);L(()=>{if(!t)return;let x=!0;return(async()=>{a(!0),c("");try{let w=await ym(r);if(!x)return;if(!w?.ok){c(w?.error||"Could not load release notes"),u(null);return}u(w)}catch(w){if(!x)return;c(w?.message||"Could not load release notes"),u(null)}finally{if(!x)return;a(!1)}})(),()=>{x=!1}},[t,r]);let p=String(d?.tag||r||"").trim(),f=String(d?.htmlUrl||"").trim()||JC(p),g=p?`Update to ${p}`:"Update now",m=qC(d?.publishedAt),h=String(d?.body||"").trim(),b=F(()=>Le.parse(h,{gfm:!0,breaks:!0}),[h]);return Vo` <${De} visible=${t} onClose=${e} @@ -2686,20 +2699,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error />

      - `};var qC=t=>{if(!t)return{};try{let e=JSON.parse(t);return e&&typeof e=="object"?e:{}}catch{return{}}},Fe=()=>{try{let t=window.localStorage.getItem(Sd);return qC(t)}catch{return{}}},St=t=>{try{window.localStorage.setItem(Sd,JSON.stringify(t&&typeof t=="object"?t:{}))}catch{}},kx=t=>{let e=Fe(),n=t(e);return St(n),n};var Yd=P.bind(T),JC=()=>{try{let t=localStorage.getItem(Cd);if(t)return t}catch{}return window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"},ZC=t=>{document.documentElement.dataset.theme=t;try{localStorage.setItem(Cd,t)}catch{}},Sx=()=>{let[t,e]=y(JC);return Yd` - - `};var En=P.bind(T),Ax="browseBottomPanelHeightPx",YC=120,Cx=120,XC=6,_x=260,QC={cron:Ta,usage:ub,doctor:xb,watchdog:yb,models:_a,envars:Ma,webhooks:pb,nodes:Pa},e2=()=>{try{let t=Fe(),e=Number.parseInt(String(t?.[Ax]||""),10);return Number.isFinite(e)&&e>0?e:_x}catch{return _x}},Mx=({item:t,selectedNavId:e,onSelectNavItem:n})=>{let s=QC[t.id]||null;return En` + `};var ZC=t=>{if(!t)return{};try{let e=JSON.parse(t);return e&&typeof e=="object"?e:{}}catch{return{}}},Fe=()=>{try{let t=window.localStorage.getItem(Cd);return ZC(t)}catch{return{}}},St=t=>{try{window.localStorage.setItem(Cd,JSON.stringify(t&&typeof t=="object"?t:{}))}catch{}},Sx=t=>{let e=Fe(),n=t(e);return St(n),n};var En=P.bind(T),Ax="browseBottomPanelHeightPx",YC=120,Cx=120,XC=6,_x=260,QC={cron:Ta,usage:pb,doctor:yb,watchdog:vb,models:_a,envars:Ma,webhooks:fb,nodes:Pa},e2=()=>{try{let t=Fe(),e=Number.parseInt(String(t?.[Ax]||""),10);return Number.isFinite(e)&&e>0?e:_x}catch{return _x}},Mx=({item:t,selectedNavId:e,onSelectNavItem:n})=>{let s=QC[t.id]||null;return En` n(t.id)} @@ -2707,13 +2707,13 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error ${s?En`<${s} className="sidebar-nav-icon" />`:null} ${t.label} - `},t2=t=>String(t?.identity?.emoji||"").trim(),Tx=({mobileSidebarOpen:t=!1,authEnabled:e=!1,menuRef:n=null,menuOpen:s=!1,onToggleMenu:o=()=>{},onLogout:r=()=>{},sidebarTab:i="menu",onSelectSidebarTab:a=()=>{},navSections:l=[],selectedNavId:c="",onSelectNavItem:d=()=>{},selectedBrowsePath:u="",onSelectBrowseFile:p=()=>{},onPreviewBrowseFile:f=()=>{},acHasUpdate:g=!1,acLatest:m="",acUpdating:h=!1,onAcUpdate:b=()=>{},agents:x=[],selectedAgentId:v="",onSelectAgent:w=()=>{},onAddAgent:$=()=>{}})=>{let S=ee(null),C=ee(null),_=ee({startY:0,startHeight:0}),[k,A]=y(e2),[O,D]=y(!1),[z,E]=y(!1);L(()=>{let W=Fe();W[Ax]=k,St(W)},[k]);let M=W=>{let G=S.current;if(!G)return W;let se=G.getBoundingClientRect(),K=Math.max(Cx,se.height-YC-XC);return Math.max(Cx,Math.min(K,W))},R=W=>{let{startY:G,startHeight:se}=_.current,K=se+(G-W);A(M(K))};L(()=>{let W=S.current;if(!W||typeof ResizeObserver>"u")return()=>{};let G=new ResizeObserver(()=>{W.getBoundingClientRect().height<=0||A(K=>M(K))});return G.observe(W),()=>G.disconnect()},[]),L(()=>{if(!O)return()=>{};let W=se=>R(se.clientY),G=()=>D(!1);return window.addEventListener("pointermove",W),window.addEventListener("pointerup",G),()=>{window.removeEventListener("pointermove",W),window.removeEventListener("pointerup",G)}},[O]);let B=W=>{W.preventDefault();let G=C.current?.getBoundingClientRect().height||k;_.current={startY:W.clientY,startHeight:G},A(M(G)),D(!0)},I=l.find(W=>W.label==="Setup")||null,U=l.filter(W=>W.label!=="Setup");return En` + `},t2=t=>String(t?.identity?.emoji||"").trim(),Tx=({mobileSidebarOpen:t=!1,authEnabled:e=!1,menuRef:n=null,menuOpen:s=!1,onToggleMenu:o=()=>{},onLogout:r=()=>{},sidebarTab:i="menu",onSelectSidebarTab:a=()=>{},navSections:l=[],selectedNavId:c="",onSelectNavItem:d=()=>{},selectedBrowsePath:u="",onSelectBrowseFile:p=()=>{},onPreviewBrowseFile:f=()=>{},acHasUpdate:g=!1,acLatest:m="",acUpdating:h=!1,onAcUpdate:b=()=>{},agents:x=[],selectedAgentId:v="",onSelectAgent:w=()=>{},onAddAgent:$=()=>{}})=>{let S=ee(null),C=ee(null),_=ee({startY:0,startHeight:0}),[k,A]=y(e2),[O,D]=y(!1),[j,E]=y(!1);L(()=>{let W=Fe();W[Ax]=k,St(W)},[k]);let M=W=>{let G=S.current;if(!G)return W;let se=G.getBoundingClientRect(),K=Math.max(Cx,se.height-YC-XC);return Math.max(Cx,Math.min(K,W))},R=W=>{let{startY:G,startHeight:se}=_.current,K=se+(G-W);A(M(K))};L(()=>{let W=S.current;if(!W||typeof ResizeObserver>"u")return()=>{};let G=new ResizeObserver(()=>{W.getBoundingClientRect().height<=0||A(K=>M(K))});return G.observe(W),()=>G.disconnect()},[]),L(()=>{if(!O)return()=>{};let W=se=>R(se.clientY),G=()=>D(!1);return window.addEventListener("pointermove",W),window.addEventListener("pointerup",G),()=>{window.removeEventListener("pointermove",W),window.removeEventListener("pointerup",G)}},[O]);let B=W=>{W.preventDefault();let G=C.current?.getBoundingClientRect().height||k;_.current={startY:W.clientY,startHeight:G},A(M(G)),D(!0)},I=l.find(W=>W.label==="Setup")||null,U=l.filter(W=>W.label!=="Setup");return En`

      w(W.id)} > - ${G?En``:En`<${Xg} className="sidebar-agent-icon" />`} + ${G?En``:En`<${Qg} className="sidebar-agent-icon" />`} ${W.name||W.id} `})} @@ -2807,7 +2807,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error

      - <${wx} - visible=${z} + <${kx} + visible=${j} onClose=${()=>{h||E(!1)}} version=${m} onUpdate=${b} @@ -2975,7 +2975,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error />

      - `};var si=t=>{window.dispatchEvent(new CustomEvent("alphaclaw:agent-bindings-changed",{detail:{agentId:String(t||"").trim()}}))},Ex=()=>{window.dispatchEvent(new CustomEvent("alphaclaw:restart-required"))};var Xd=(t={})=>t.isAwaitingPairing?99:t.isOwned?0:t.isUnconfigured?3:t.isAvailable?1:2,c2=({statusInfo:t,accountId:e})=>{let n=String(e||"").trim()||"default",s=t?.accounts&&typeof t.accounts=="object"?t.accounts:null;return s?.[n]?s[n]:n==="default"&&t?t:null},Ix=({account:t,statusInfo:e,accountId:n})=>{let s=String(t?.status||"").trim();return s?{status:s,paired:Number(t?.paired||0)}:c2({statusInfo:e,accountId:n})};var Dx=({accountId:t,boundAgentId:e,agentId:n,isDefaultAgent:s})=>{let o=String(e||"").trim();return o?o===String(n||"").trim():gs({accountId:t,boundAgentId:e})?!!s:!0};var Nx=({agent:t={},agents:e=[]})=>{let[n,s]=y([]),[o,r]=y({}),[i,a]=y(!0),[l,c]=y(!1),[d,u]=y("Creating..."),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(""),[x,v]=y(null),[w,$]=y(null),[S,C]=y(null),_=String(t?.id||"").trim(),k=!!t?.default,A=F(()=>String(e.find(K=>K?.default)?.id||"").trim(),[e]),O=F(()=>new Map(e.map(K=>[String(K?.id||"").trim(),String(K?.name||"").trim()||String(K?.id||"").trim()])),[e]),D=q(async({includeStatus:K=!0}={})=>{a(!0);try{let j=[To(),K?So():Promise.resolve(null)],[X,de]=await Promise.all(j);s(Array.isArray(X?.channels)?X.channels:[]),K&&de&&r(de?.channels||{})}finally{a(!1)}},[]);L(()=>{_&&D().catch(()=>{})},[_,D]),L(()=>{let K=j=>{let X=String(j?.detail?.agentId||"").trim();X&&X!==_||D({includeStatus:!0}).catch(()=>{})};return window.addEventListener("alphaclaw:pairings-changed",K),()=>{window.removeEventListener("alphaclaw:pairings-changed",K)}},[_,D]);let z=F(()=>n.filter(K=>String(K?.channel||"").trim()&&Array.isArray(K?.accounts)&&K.accounts.length>0),[n]),E=F(()=>new Map(z.map(K=>[String(K.channel||"").trim(),K])),[z]),M=(K="")=>{b(""),m(String(K||"").trim()),f(!0)},R=K=>{b(""),v(K)},B=K=>{b(""),$(K)},I=async K=>{c(!0),u("Creating...");try{let j=await Ea({payload:K,onPhase:X=>{u(String(X||"").trim()||"Creating...")}});si(String(j?.binding?.agentId||K.agentId||"").trim()),N("Channel added","success"),await D({includeStatus:!1}),f(!1),m("")}catch(j){N(j.message||"Could not add channel","error")}finally{c(!1),u("Creating...")}},U=async K=>{c(!0);try{let j=await Fr(K);v(null),si(String(K.agentId||"").trim()),N("Channel updated","success"),j?.restartRequired&&Ex(),await D()}catch(j){N(j.message||"Could not update channel","error")}finally{c(!1)}},W=async()=>{if(w){c(!0);try{await $a({provider:w.provider,accountId:w.id}),$(null),si(_),N("Channel deleted","success"),await D({includeStatus:!1})}catch(K){N(K.message||"Could not delete channel","error")}finally{c(!1)}}},G=async K=>{if(K){c(!0);try{await Fr({provider:K.provider,accountId:K.id,name:K.name,agentId:_}),b(""),C(null),si(_),N("Channel bound","success"),await D()}catch(j){N(j.message||"Could not bind channel","error")}finally{c(!1)}}};return{agentId:_,agentNameMap:O,channelStatus:o,channels:n,configuredChannelMap:E,configuredChannels:z,createLoadingLabel:d,createProvider:g,defaultAgentId:A,deletingAccount:w,editingAccount:x,handleCreateChannel:I,handleDeleteChannel:W,handleQuickBind:G,handleUpdateChannel:U,isDefaultAgent:k,loading:i,menuOpenId:h,openCreateChannelModal:M,openDeleteChannelDialog:B,openEditChannelModal:R,pendingBindAccount:S,requestBindAccount:K=>{if(!K)return;let j=String(K?.ownerAgentId||"").trim(),X=String(K?.ownerAgentName||"").trim();if(j&&j!==_&&X){b(""),C(K);return}G(K)},saving:l,setCreateProvider:m,setDeletingAccount:$,setEditingAccount:v,setMenuOpenId:b,setPendingBindAccount:C,setShowCreateModal:f,showCreateModal:p}};var Ox=P.bind(T),Bx=({agentId:t="",agentNameMap:e=new Map,channelStatus:n={},configuredChannelMap:s=new Map,configuredChannels:o=[],defaultAgentId:r="",isDefaultAgent:i=!1})=>{let[a,l]=y(!1),c=F(()=>{let f=new Map(o.map((h,b)=>[String(h?.channel||"").trim(),b])),g=new Map(o.flatMap(h=>(Array.isArray(h?.accounts)?h.accounts:[]).map((b,x)=>[`${String(h?.channel||"").trim()}:${String(b?.id||"").trim()||"default"}`,x])));return Array.from(new Set([...o.map(h=>String(h.channel||"").trim())])).filter(Boolean).flatMap(h=>{let b=s.get(h),x=n?.[h]||null,v=Array.isArray(b?.accounts)?b.accounts:[];return!b&&!x?[]:v.map(w=>{let $=String(w?.id||"").trim()||"default",S=String(w?.boundAgentId||"").trim(),C=Ix({account:w,statusInfo:x,accountId:$}),_=i&&gs({accountId:$,boundAgentId:S}),k=S===t||_,A=!i&&gs({accountId:$,boundAgentId:S}),O=Dx({accountId:$,boundAgentId:S,agentId:t,isDefaultAgent:i}),D=S||(gs({accountId:$,boundAgentId:S})?r:""),z=String(e.get(D)||D||"").trim(),E=!!D&&D!==t&&!!z,M=h==="telegram"&&k&&C?.status==="paired",R={id:$,provider:h,name:qs({channelId:h,account:w}),rawName:String(w?.name||"").trim(),ownerAgentId:D,ownerAgentName:z,boundAgentId:S,isOwned:k,envKey:String(w?.envKey||"").trim(),token:String(w?.token||"").trim(),isAvailable:O,isBoundElsewhere:!k&&(!O||A||!!D)};return{id:`${h}:${$}`,channel:h,accountId:$,channelOrder:Number(f.get(h)??9999),accountOrder:Number(g.get(`${h}:${$}`)??9999),label:qs({channelId:h,account:w}),isAwaitingPairing:C?.status!=="paired",canOpenWorkspace:M,canNavigateToOwnerAgent:E,ownerAgentId:D,ownerAgentName:z,accountStatusInfo:C,accountData:R,isOwned:k,isAvailable:O,dimmedLabel:R.isBoundElsewhere,isBoundElsewhere:R.isBoundElsewhere}})}).filter(Boolean).sort((h,b)=>{let x=Xd(h)-Xd(b);if(x!==0)return x;let v=Number(h?.channelOrder??9999)-Number(b?.channelOrder??9999);if(v!==0)return v;let w=Number(h?.accountOrder??9999)-Number(b?.accountOrder??9999);return w!==0?w:String(h?.label||"").localeCompare(String(b?.label||""))})},[t,e,n,s,o,r,i]),d=c.filter(f=>!f?.isBoundElsewhere),u=c.filter(f=>!!f?.isBoundElsewhere);return L(()=>{if(u.length===0){l(!1);return}d.length===0&&l(!0)},[t,u.length,d.length]),{mergedChannelItems:F(()=>{let f=[...d];return u.length===0||(f.push({id:"__assigned_elsewhere_toggle",label:Ox` + `};var si=t=>{window.dispatchEvent(new CustomEvent("alphaclaw:agent-bindings-changed",{detail:{agentId:String(t||"").trim()}}))},Ex=()=>{window.dispatchEvent(new CustomEvent("alphaclaw:restart-required"))};var Qd=(t={})=>t.isAwaitingPairing?99:t.isOwned?0:t.isUnconfigured?3:t.isAvailable?1:2,c2=({statusInfo:t,accountId:e})=>{let n=String(e||"").trim()||"default",s=t?.accounts&&typeof t.accounts=="object"?t.accounts:null;return s?.[n]?s[n]:n==="default"&&t?t:null},Ix=({account:t,statusInfo:e,accountId:n})=>{let s=String(t?.status||"").trim();return s?{status:s,paired:Number(t?.paired||0)}:c2({statusInfo:e,accountId:n})};var Dx=({accountId:t,boundAgentId:e,agentId:n,isDefaultAgent:s})=>{let o=String(e||"").trim();return o?o===String(n||"").trim():gs({accountId:t,boundAgentId:e})?!!s:!0};var Nx=({agent:t={},agents:e=[]})=>{let[n,s]=y([]),[o,r]=y({}),[i,a]=y(!0),[l,c]=y(!1),[d,u]=y("Creating..."),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(""),[x,v]=y(null),[w,$]=y(null),[S,C]=y(null),_=String(t?.id||"").trim(),k=!!t?.default,A=F(()=>String(e.find(K=>K?.default)?.id||"").trim(),[e]),O=F(()=>new Map(e.map(K=>[String(K?.id||"").trim(),String(K?.name||"").trim()||String(K?.id||"").trim()])),[e]),D=q(async({includeStatus:K=!0}={})=>{a(!0);try{let z=[To(),K?So():Promise.resolve(null)],[X,de]=await Promise.all(z);s(Array.isArray(X?.channels)?X.channels:[]),K&&de&&r(de?.channels||{})}finally{a(!1)}},[]);L(()=>{_&&D().catch(()=>{})},[_,D]),L(()=>{let K=z=>{let X=String(z?.detail?.agentId||"").trim();X&&X!==_||D({includeStatus:!0}).catch(()=>{})};return window.addEventListener("alphaclaw:pairings-changed",K),()=>{window.removeEventListener("alphaclaw:pairings-changed",K)}},[_,D]);let j=F(()=>n.filter(K=>String(K?.channel||"").trim()&&Array.isArray(K?.accounts)&&K.accounts.length>0),[n]),E=F(()=>new Map(j.map(K=>[String(K.channel||"").trim(),K])),[j]),M=(K="")=>{b(""),m(String(K||"").trim()),f(!0)},R=K=>{b(""),v(K)},B=K=>{b(""),$(K)},I=async K=>{c(!0),u("Creating...");try{let z=await Ea({payload:K,onPhase:X=>{u(String(X||"").trim()||"Creating...")}});si(String(z?.binding?.agentId||K.agentId||"").trim()),N("Channel added","success"),await D({includeStatus:!1}),f(!1),m("")}catch(z){N(z.message||"Could not add channel","error")}finally{c(!1),u("Creating...")}},U=async K=>{c(!0);try{let z=await Fr(K);v(null),si(String(K.agentId||"").trim()),N("Channel updated","success"),z?.restartRequired&&Ex(),await D()}catch(z){N(z.message||"Could not update channel","error")}finally{c(!1)}},W=async()=>{if(w){c(!0);try{await $a({provider:w.provider,accountId:w.id}),$(null),si(_),N("Channel deleted","success"),await D({includeStatus:!1})}catch(K){N(K.message||"Could not delete channel","error")}finally{c(!1)}}},G=async K=>{if(K){c(!0);try{await Fr({provider:K.provider,accountId:K.id,name:K.name,agentId:_}),b(""),C(null),si(_),N("Channel bound","success"),await D()}catch(z){N(z.message||"Could not bind channel","error")}finally{c(!1)}}};return{agentId:_,agentNameMap:O,channelStatus:o,channels:n,configuredChannelMap:E,configuredChannels:j,createLoadingLabel:d,createProvider:g,defaultAgentId:A,deletingAccount:w,editingAccount:x,handleCreateChannel:I,handleDeleteChannel:W,handleQuickBind:G,handleUpdateChannel:U,isDefaultAgent:k,loading:i,menuOpenId:h,openCreateChannelModal:M,openDeleteChannelDialog:B,openEditChannelModal:R,pendingBindAccount:S,requestBindAccount:K=>{if(!K)return;let z=String(K?.ownerAgentId||"").trim(),X=String(K?.ownerAgentName||"").trim();if(z&&z!==_&&X){b(""),C(K);return}G(K)},saving:l,setCreateProvider:m,setDeletingAccount:$,setEditingAccount:v,setMenuOpenId:b,setPendingBindAccount:C,setShowCreateModal:f,showCreateModal:p}};var Ox=P.bind(T),Bx=({agentId:t="",agentNameMap:e=new Map,channelStatus:n={},configuredChannelMap:s=new Map,configuredChannels:o=[],defaultAgentId:r="",isDefaultAgent:i=!1})=>{let[a,l]=y(!1),c=F(()=>{let f=new Map(o.map((h,b)=>[String(h?.channel||"").trim(),b])),g=new Map(o.flatMap(h=>(Array.isArray(h?.accounts)?h.accounts:[]).map((b,x)=>[`${String(h?.channel||"").trim()}:${String(b?.id||"").trim()||"default"}`,x])));return Array.from(new Set([...o.map(h=>String(h.channel||"").trim())])).filter(Boolean).flatMap(h=>{let b=s.get(h),x=n?.[h]||null,v=Array.isArray(b?.accounts)?b.accounts:[];return!b&&!x?[]:v.map(w=>{let $=String(w?.id||"").trim()||"default",S=String(w?.boundAgentId||"").trim(),C=Ix({account:w,statusInfo:x,accountId:$}),_=i&&gs({accountId:$,boundAgentId:S}),k=S===t||_,A=!i&&gs({accountId:$,boundAgentId:S}),O=Dx({accountId:$,boundAgentId:S,agentId:t,isDefaultAgent:i}),D=S||(gs({accountId:$,boundAgentId:S})?r:""),j=String(e.get(D)||D||"").trim(),E=!!D&&D!==t&&!!j,M=h==="telegram"&&k&&C?.status==="paired",R={id:$,provider:h,name:qs({channelId:h,account:w}),rawName:String(w?.name||"").trim(),ownerAgentId:D,ownerAgentName:j,boundAgentId:S,isOwned:k,envKey:String(w?.envKey||"").trim(),token:String(w?.token||"").trim(),isAvailable:O,isBoundElsewhere:!k&&(!O||A||!!D)};return{id:`${h}:${$}`,channel:h,accountId:$,channelOrder:Number(f.get(h)??9999),accountOrder:Number(g.get(`${h}:${$}`)??9999),label:qs({channelId:h,account:w}),isAwaitingPairing:C?.status!=="paired",canOpenWorkspace:M,canNavigateToOwnerAgent:E,ownerAgentId:D,ownerAgentName:j,accountStatusInfo:C,accountData:R,isOwned:k,isAvailable:O,dimmedLabel:R.isBoundElsewhere,isBoundElsewhere:R.isBoundElsewhere}})}).filter(Boolean).sort((h,b)=>{let x=Qd(h)-Qd(b);if(x!==0)return x;let v=Number(h?.channelOrder??9999)-Number(b?.channelOrder??9999);if(v!==0)return v;let w=Number(h?.accountOrder??9999)-Number(b?.accountOrder??9999);return w!==0?w:String(h?.label||"").localeCompare(String(b?.label||""))})},[t,e,n,s,o,r,i]),d=c.filter(f=>!f?.isBoundElsewhere),u=c.filter(f=>!!f?.isBoundElsewhere);return L(()=>{if(u.length===0){l(!1);return}d.length===0&&l(!0)},[t,u.length,d.length]),{mergedChannelItems:F(()=>{let f=[...d];return u.length===0||(f.push({id:"__assigned_elsewhere_toggle",label:Ox` Assigned elsewhere @@ -2986,7 +2986,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error ${u.length} - `}),a&&f.push(...u)),f},[u,a,d])}};var eo=P.bind(T),Fx=({agent:t={},agents:e=[],onSetLocation:n=()=>{}})=>{let{agentId:s,agentNameMap:o,channelStatus:r,channels:i,configuredChannelMap:a,configuredChannels:l,createLoadingLabel:c,createProvider:d,defaultAgentId:u,deletingAccount:p,editingAccount:f,handleCreateChannel:g,handleDeleteChannel:m,handleQuickBind:h,handleUpdateChannel:b,isDefaultAgent:x,loading:v,menuOpenId:w,openCreateChannelModal:$,openDeleteChannelDialog:S,openEditChannelModal:C,pendingBindAccount:_,requestBindAccount:k,saving:A,setCreateProvider:O,setDeletingAccount:D,setEditingAccount:z,setMenuOpenId:E,setPendingBindAccount:M,setShowCreateModal:R,showCreateModal:B}=Nx({agent:t,agents:e}),{mergedChannelItems:I}=Bx({agentId:s,agentNameMap:o,channelStatus:r,configuredChannelMap:a,configuredChannels:l,defaultAgentId:u,isDefaultAgent:x});return eo` + `}),a&&f.push(...u)),f},[u,a,d])}};var eo=P.bind(T),Fx=({agent:t={},agents:e=[],onSetLocation:n=()=>{}})=>{let{agentId:s,agentNameMap:o,channelStatus:r,channels:i,configuredChannelMap:a,configuredChannels:l,createLoadingLabel:c,createProvider:d,defaultAgentId:u,deletingAccount:p,editingAccount:f,handleCreateChannel:g,handleDeleteChannel:m,handleQuickBind:h,handleUpdateChannel:b,isDefaultAgent:x,loading:v,menuOpenId:w,openCreateChannelModal:$,openDeleteChannelDialog:S,openEditChannelModal:C,pendingBindAccount:_,requestBindAccount:k,saving:A,setCreateProvider:O,setDeletingAccount:D,setEditingAccount:j,setMenuOpenId:E,setPendingBindAccount:M,setShowCreateModal:R,showCreateModal:B}=Nx({agent:t,agents:e}),{mergedChannelItems:I}=Bx({agentId:s,agentNameMap:o,channelStatus:r,configuredChannelMap:a,configuredChannels:l,defaultAgentId:u,isDefaultAgent:x});return eo`

      ${v?eo` <${Oa} @@ -3061,7 +3061,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error account=${f} initialAgentId=${String(f?.ownerAgentId||s||"").trim()} initialProvider=${String(f?.provider||"").trim()} - onClose=${()=>z(null)} + onClose=${()=>j(null)} onSubmit=${b} /> <${st} @@ -3119,7 +3119,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error className="font-medium px-3 py-1.5" />

      -
      `},u2=["telegram","discord","slack"],p2=t=>t.charAt(0).toUpperCase()+t.slice(1);function tl({pending:t,channels:e,visible:n,onApprove:s,onReject:o,statusRefreshing:r=!1}){if(!n)return null;let i=u2.filter(l=>{let c=e?.[l];if(!c)return!1;let d=c.accounts&&typeof c.accounts=="object"?c.accounts:{};return Object.keys(d).length>0?Object.values(d).some(u=>u&&u.status!=="paired"):c.status!=="paired"}).map(p2),a=i.length<=2?i.join(" or "):i.slice(0,-1).join(", ")+", or "+i[i.length-1];return ys` +
      `},u2=["telegram","discord","slack"],p2=t=>t.charAt(0).toUpperCase()+t.slice(1);function nl({pending:t,channels:e,visible:n,onApprove:s,onReject:o,statusRefreshing:r=!1}){if(!n)return null;let i=u2.filter(l=>{let c=e?.[l];if(!c)return!1;let d=c.accounts&&typeof c.accounts=="object"?c.accounts:{};return Object.keys(d).length>0?Object.values(d).some(u=>u&&u.status!=="paired"):c.status!=="paired"}).map(p2),a=i.length<=2?i.join(" or "):i.slice(0,-1).join(", ")+", or "+i[i.length-1];return ys`

      Pending Pairings

      ${t.length>0?ys`
      @@ -3131,21 +3131,21 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error

      Send a message to your bot on ${a}

      The pairing request will appear here — it may take a few moments

      `} -
      `}var Hx=P.bind(T),to=(t,e)=>{let n=String(t||"").trim(),s=String(e||"").trim()||"default";return n?`${n}:${s}`:""},f2=t=>{window.dispatchEvent(new CustomEvent("alphaclaw:pairings-changed",{detail:{agentId:String(t||"").trim()}}))},Wx=({agent:t={}})=>{let[e,n]=y([]),[s,o]=y([]),[r,i]=y(!0),[a,l]=y(!1),c=ee(null),d=ee([]),u=String(t?.id||"").trim(),p=!!t?.default,{data:f,loading:g,refresh:m}=ct(`/api/agents/${encodeURIComponent(String(u||""))}/bindings`,()=>qm(t.id),{enabled:!!u,maxAgeMs:3e4}),{data:h,loading:b,refresh:x}=ct("/api/channels/accounts",To,{maxAgeMs:3e4}),v=q(async()=>{i(!0);try{let[M,R]=await Promise.all([m({force:!0}),x({force:!0})]);n(Array.isArray(M?.bindings)?M.bindings:[]),o(Array.isArray(R?.channels)?R.channels:[])}catch{n([]),o([])}finally{i(!1)}},[m,x]);L(()=>{n(Array.isArray(f?.bindings)?f.bindings:[]),o(Array.isArray(h?.channels)?h.channels:[]),i(!!(g||b))},[g,f,b,h]),L(()=>{let M=R=>{String(R?.detail?.agentId||"").trim()===u&&v()};return window.addEventListener("alphaclaw:agent-bindings-changed",M),()=>{window.removeEventListener("alphaclaw:agent-bindings-changed",M)}},[u,v]),L(()=>()=>{c.current&&clearTimeout(c.current);for(let M of d.current)clearTimeout(M);d.current=[]},[]);let w=F(()=>{let M=new Map;for(let R of e){let B=String(R?.match?.channel||"").trim();if(!B)continue;let I=String(R?.match?.accountId||"").trim()||"default",U=to(B,I);U&&M.set(U,{channel:B,accountId:I})}for(let R of s){let B=String(R?.channel||"").trim(),U=(Array.isArray(R?.accounts)?R.accounts:[]).find(W=>String(W?.id||"").trim()==="default");if(p&&B&&U&&!String(U?.boundAgentId||"").trim()){let W=to(B,"default");M.set(W,{channel:B,accountId:"default"})}}return Array.from(M.values())},[e,s,p]),$=F(()=>Array.from(new Set(w.map(M=>M.channel))).filter(Boolean),[w]),S=F(()=>new Set(w.map(M=>to(M.channel,M.accountId)).filter(Boolean)),[w]),C=F(()=>{let M=new Map;for(let R of s){let B=String(R?.channel||"").trim(),I=Array.isArray(R?.accounts)?R.accounts:[];for(let U of I){let W=String(U?.id||"").trim()||"default",G=to(B,W);if(!G)continue;let se=String(U?.name||"").trim();M.set(G,se||W)}}return M},[s]),_=F(()=>{let M={};for(let R of w){let B=String(R?.channel||"").trim();if(!B)continue;let I=to(B,R?.accountId),U=s.find(G=>String(G?.channel||"").trim()===B)?.accounts?.find(G=>(String(G?.id||"").trim()||"default")===(String(R?.accountId||"").trim()||"default")),W=String(U?.status||"").trim()||"configured";(!M[B]||W!=="paired")&&(M[B]={status:W==="paired"?"paired":"configured",accountName:C.get(I)||""})}return M},[C,s,w]),k=F(()=>Object.values(_).some(M=>String(M?.status||"").trim()!=="paired"),[_]),A=Re(async()=>{let M=await Co();return(Array.isArray(M?.pending)?M.pending:[]).filter(B=>S.has(to(String(B?.channel||"").trim(),String(B?.accountId||"").trim()||"default"))).map(B=>{let I=to(B?.channel,B?.accountId);return{...B,accountName:C.get(I)||""}})},3e3,{enabled:k&&w.length>0,cacheKey:`/api/pairings?agent=${encodeURIComponent(u)}`}),O=A.data||[],D=q(()=>{l(!0),c.current&&clearTimeout(c.current),c.current=setTimeout(()=>{l(!1),c.current=null},2800);for(let R of d.current)clearTimeout(R);d.current=[];let M=()=>{A.refresh(),v(),f2(u)};M(),d.current.push(setTimeout(M,500)),d.current.push(setTimeout(M,2e3))},[u,v,A]),z=async(M,R,B="")=>{try{await _o(M,R,B),D()}catch(I){N(I.message||"Could not approve pairing","error")}},E=async(M,R,B="")=>{try{await Mo(M,R,B),D()}catch(I){N(I.message||"Could not reject pairing","error")}};return r?Hx` +
      `}var Hx=P.bind(T),to=(t,e)=>{let n=String(t||"").trim(),s=String(e||"").trim()||"default";return n?`${n}:${s}`:""},f2=t=>{window.dispatchEvent(new CustomEvent("alphaclaw:pairings-changed",{detail:{agentId:String(t||"").trim()}}))},Wx=({agent:t={}})=>{let[e,n]=y([]),[s,o]=y([]),[r,i]=y(!0),[a,l]=y(!1),c=ee(null),d=ee([]),u=String(t?.id||"").trim(),p=!!t?.default,{data:f,loading:g,refresh:m}=ct(`/api/agents/${encodeURIComponent(String(u||""))}/bindings`,()=>Jm(t.id),{enabled:!!u,maxAgeMs:3e4}),{data:h,loading:b,refresh:x}=ct("/api/channels/accounts",To,{maxAgeMs:3e4}),v=q(async()=>{i(!0);try{let[M,R]=await Promise.all([m({force:!0}),x({force:!0})]);n(Array.isArray(M?.bindings)?M.bindings:[]),o(Array.isArray(R?.channels)?R.channels:[])}catch{n([]),o([])}finally{i(!1)}},[m,x]);L(()=>{n(Array.isArray(f?.bindings)?f.bindings:[]),o(Array.isArray(h?.channels)?h.channels:[]),i(!!(g||b))},[g,f,b,h]),L(()=>{let M=R=>{String(R?.detail?.agentId||"").trim()===u&&v()};return window.addEventListener("alphaclaw:agent-bindings-changed",M),()=>{window.removeEventListener("alphaclaw:agent-bindings-changed",M)}},[u,v]),L(()=>()=>{c.current&&clearTimeout(c.current);for(let M of d.current)clearTimeout(M);d.current=[]},[]);let w=F(()=>{let M=new Map;for(let R of e){let B=String(R?.match?.channel||"").trim();if(!B)continue;let I=String(R?.match?.accountId||"").trim()||"default",U=to(B,I);U&&M.set(U,{channel:B,accountId:I})}for(let R of s){let B=String(R?.channel||"").trim(),U=(Array.isArray(R?.accounts)?R.accounts:[]).find(W=>String(W?.id||"").trim()==="default");if(p&&B&&U&&!String(U?.boundAgentId||"").trim()){let W=to(B,"default");M.set(W,{channel:B,accountId:"default"})}}return Array.from(M.values())},[e,s,p]),$=F(()=>Array.from(new Set(w.map(M=>M.channel))).filter(Boolean),[w]),S=F(()=>new Set(w.map(M=>to(M.channel,M.accountId)).filter(Boolean)),[w]),C=F(()=>{let M=new Map;for(let R of s){let B=String(R?.channel||"").trim(),I=Array.isArray(R?.accounts)?R.accounts:[];for(let U of I){let W=String(U?.id||"").trim()||"default",G=to(B,W);if(!G)continue;let se=String(U?.name||"").trim();M.set(G,se||W)}}return M},[s]),_=F(()=>{let M={};for(let R of w){let B=String(R?.channel||"").trim();if(!B)continue;let I=to(B,R?.accountId),U=s.find(G=>String(G?.channel||"").trim()===B)?.accounts?.find(G=>(String(G?.id||"").trim()||"default")===(String(R?.accountId||"").trim()||"default")),W=String(U?.status||"").trim()||"configured";(!M[B]||W!=="paired")&&(M[B]={status:W==="paired"?"paired":"configured",accountName:C.get(I)||""})}return M},[C,s,w]),k=F(()=>Object.values(_).some(M=>String(M?.status||"").trim()!=="paired"),[_]),A=Re(async()=>{let M=await Co();return(Array.isArray(M?.pending)?M.pending:[]).filter(B=>S.has(to(String(B?.channel||"").trim(),String(B?.accountId||"").trim()||"default"))).map(B=>{let I=to(B?.channel,B?.accountId);return{...B,accountName:C.get(I)||""}})},3e3,{enabled:k&&w.length>0,cacheKey:`/api/pairings?agent=${encodeURIComponent(u)}`}),O=A.data||[],D=q(()=>{l(!0),c.current&&clearTimeout(c.current),c.current=setTimeout(()=>{l(!1),c.current=null},2800);for(let R of d.current)clearTimeout(R);d.current=[];let M=()=>{A.refresh(),v(),f2(u)};M(),d.current.push(setTimeout(M,500)),d.current.push(setTimeout(M,2e3))},[u,v,A]),j=async(M,R,B="")=>{try{await _o(M,R,B),D()}catch(I){N(I.message||"Could not approve pairing","error")}},E=async(M,R,B="")=>{try{await Mo(M,R,B),D()}catch(I){N(I.message||"Could not reject pairing","error")}};return r?Hx`

      Pairing

      Loading pairing status...

      `:k?Hx` - <${tl} + <${nl} pending=${O} channels=${_} visible=${k} statusRefreshing=${a} - onApprove=${z} + onApprove=${j} onReject=${E} /> - `:null};var Vx=P.bind(T),nl=({agent:t=null,agents:e=[],onSetLocation:n=()=>{},channelsSection:s=null,pairingsSection:o=null})=>t?Vx` + `:null};var Vx=P.bind(T),sl=({agent:t=null,agents:e=[],onSetLocation:n=()=>{},channelsSection:s=null,pairingsSection:o=null})=>t?Vx`
      <${Fx} agent=${t} @@ -3159,11 +3159,11 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error ${s} ${o}

      - `;var Qd=P.bind(T),jx=({agent:t={},saving:e=!1,onSetDefault:n=()=>{},onDelete:s=()=>{}})=>{let o=String(t.id||"")==="main";return Qd` + `;var eu=P.bind(T),zx=({agent:t={},saving:e=!1,onSetDefault:n=()=>{},onDelete:s=()=>{}})=>{let o=String(t.id||"")==="main";return eu`

      Manage

      - ${t.default?null:Qd` + ${t.default?null:eu` <${Z} onClick=${()=>n(t.id)} disabled=${e} @@ -3172,7 +3172,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error idleLabel="Set as default" /> `} - ${o?null:Qd` + ${o?null:eu` <${Z} onClick=${()=>s(t)} disabled=${e} @@ -3183,7 +3183,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error `}

      - `};var oi=P.bind(T),h2=["anthropic","openai","openai-codex",...jr.filter(t=>!["anthropic","openai"].includes(t))],ri=t=>{let e=js(t);return e==="openai-codex"?"openai-codex":Ro(e)},vs=t=>String(t?.provider||js(t?.key)).trim(),jo=t=>{let e=h2.indexOf(t);return e>=0?e:Number.MAX_SAFE_INTEGER},m2=t=>String(zs[t]||t).toUpperCase(),g2=t=>String(t||"").trim().toLowerCase(),eu=t=>t?.featuredLabel||t?.label||t?.key,b2=t=>[t?.featuredLabel||"",t?.label||"",t?.key||"",t?.provider||js(t?.key)].join(" ").toLowerCase(),sl=({authProfiles:t=[],codexStatus:e={connected:!1}}={})=>{let n={};for(let s of t)(s?.key||s?.token||s?.access)&&(n[s.provider]=!0);return e?.connected&&(n["openai-codex"]=!0),n},ol=({options:t=[],popularModels:e=[],placeholder:n="Add model...",onSelect:s=()=>{},disabled:o=!1})=>{let[r,i]=y(""),[a,l]=y(!1),c=ee(null),d=g2(r),u=F(()=>d?t.filter(m=>b2(m).includes(d)):t,[t,d]),p=F(()=>{let m=[],h=!d,b=new Set(u.map(v=>v.key)),x=e.filter(v=>b.has(v.key));h&&x.length>0&&m.push({provider:"popular",label:"POPULAR",options:x});for(let v of u){let w=vs(v),$=m2(w),S=m[m.length-1];if(!S||S.provider!==w){m.push({provider:w,label:$,options:[v]});continue}S.options.push(v)}return m},[u,e,d]);L(()=>{let m=h=>{c.current?.contains(h.target)||l(!1)};return document.addEventListener("mousedown",m),()=>document.removeEventListener("mousedown",m)},[]);let f=m=>{!m||o||(s(m),i(""),l(!1))};return oi` + `};var oi=P.bind(T),h2=["anthropic","openai","openai-codex",...zr.filter(t=>!["anthropic","openai"].includes(t))],ri=t=>{let e=zs(t);return e==="openai-codex"?"openai-codex":Ro(e)},vs=t=>String(t?.provider||zs(t?.key)).trim(),zo=t=>{let e=h2.indexOf(t);return e>=0?e:Number.MAX_SAFE_INTEGER},m2=t=>String(js[t]||t).toUpperCase(),g2=t=>String(t||"").trim().toLowerCase(),tu=t=>t?.featuredLabel||t?.label||t?.key,b2=t=>[t?.featuredLabel||"",t?.label||"",t?.key||"",t?.provider||zs(t?.key)].join(" ").toLowerCase(),ol=({authProfiles:t=[],codexStatus:e={connected:!1}}={})=>{let n={};for(let s of t)(s?.key||s?.token||s?.access)&&(n[s.provider]=!0);return e?.connected&&(n["openai-codex"]=!0),n},rl=({options:t=[],popularModels:e=[],placeholder:n="Add model...",onSelect:s=()=>{},disabled:o=!1})=>{let[r,i]=y(""),[a,l]=y(!1),c=ee(null),d=g2(r),u=F(()=>d?t.filter(m=>b2(m).includes(d)):t,[t,d]),p=F(()=>{let m=[],h=!d,b=new Set(u.map(v=>v.key)),x=e.filter(v=>b.has(v.key));h&&x.length>0&&m.push({provider:"popular",label:"POPULAR",options:x});for(let v of u){let w=vs(v),$=m2(w),S=m[m.length-1];if(!S||S.provider!==w){m.push({provider:w,label:$,options:[v]});continue}S.options.push(v)}return m},[u,e,d]);L(()=>{let m=h=>{c.current?.contains(h.target)||l(!1)};return document.addEventListener("mousedown",m),()=>document.removeEventListener("mousedown",m)},[]);let f=m=>{!m||o||(s(m),i(""),l(!1))};return oi`
      - ${eu(b)} + ${tu(b)}
      ${b.key} @@ -3235,7 +3235,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error

      `:null}
      - `};var cn=null,zo=t=>String(t?.key||t?.token||t?.access||"").trim(),rl=t=>{let e=!!t,n=String(t||"").trim(),s=!e,[o,r]=y(()=>s&&cn?.catalog||[]),[i,a]=y(()=>s&&cn?.primary||""),[l,c]=y(()=>s&&cn?.configuredModels||{}),[d,u]=y(()=>s&&cn?.authProfiles||[]),[p,f]=y(()=>s&&cn?.authOrder||{}),[g,m]=y(()=>s&&cn?.codexStatus||{connected:!1}),[h,b]=y(()=>!(s&&cn)),[x,v]=y(!1),[w,$]=y(()=>!!(s&&cn)),[S,C]=y(""),[_,k]=y({}),[A,O]=y({}),D=ee(cn?.primary||""),z=ee(cn?.configuredModels||{}),E=q(V=>{e||(cn={...cn||{},...V})},[e]),M=n?`/api/models/config?agentId=${encodeURIComponent(n)}`:"/api/models/config",R=ct("/api/models",Br,{maxAgeMs:3e4}),B=ct(M,()=>Wm(e?{agentId:t}:void 0),{maxAgeMs:3e4}),I=ct("/api/codex/status",Po,{maxAgeMs:15e3}),U=q(async()=>{w||b(!0),C("");try{let[V,J,oe]=await Promise.all([R.refresh({force:!0}),B.refresh({force:!0}),I.refresh({force:!0})]),ce=Array.isArray(V.models)?V.models:[];r(ce);let be=J.primary||"",Ve=J.configuredModels||{},et=J.authProfiles||[],wt=J.authOrder||{};a(be),c(Ve),u(et),f(wt),m(oe||{connected:!1}),k({}),O({}),D.current=be,z.current=Ve,E({catalog:ce,primary:be,configuredModels:Ve,authProfiles:et,authOrder:wt,codexStatus:oe||{connected:!1}}),ce.length||C("No models found")}catch(V){C("Failed to load model settings"),N(`Failed to load model settings: ${V.message}`,"error")}finally{$(!0),b(!1)}},[R,I,B,w,E,t,e]);L(()=>{U()},[t]);let W=V=>JSON.stringify(Object.keys(V).sort().reduce((J,oe)=>(J[oe]=V[oe],J),{})),G=i!==D.current||W(l)!==W(z.current),se=(()=>{let V=Object.entries(_).some(([oe,ce])=>{let be=d.find(Ve=>Ve.id===oe);return zo(ce)!==zo(be)}),J=Object.entries(A).some(([oe,ce])=>{let be=p[oe];return JSON.stringify(ce)!==JSON.stringify(be)});return V||J})(),K=G||se,j=q(V=>{V&&c(J=>{let oe={...J,[V]:{}};return E({configuredModels:oe}),oe})},[E]),X=q(V=>{if(c(J=>{let oe={...J};return delete oe[V],E({configuredModels:oe}),oe}),i===V){let oe=Object.keys(l).filter(ce=>ce!==V)[0]||"";a(oe),E({primary:oe})}},[i,l,E]),de=q(V=>{a(V),E({primary:V})},[E]),le=q((V,J)=>{let oe=d.find(ce=>ce.id===V);if(zo(J)===zo(oe)){k(ce=>{let be={...ce};return delete be[V],be});return}k(ce=>({...ce,[V]:J}))},[d]),ie=q((V,J)=>{let oe=p[V]||null;if(JSON.stringify(J)===JSON.stringify(oe)){O(ce=>{let be={...ce};return delete be[V],be});return}O(ce=>({...ce,[V]:J}))},[p]),ne=q(V=>_[V]!==void 0?_[V]:d.find(oe=>oe.id===V)||null,[_,d]),xe=q(V=>A[V]!==void 0?A[V]:p[V]||null,[A,p]),he=q(()=>{let V=D.current||"",J=z.current||{};a(V),c(J),k({}),O({}),E({primary:V,configuredModels:J})},[E]),ue=q(async()=>{if(!x){v(!0);try{let V=Object.entries(_).filter(([oe,ce])=>{let be=d.find(Ve=>Ve.id===oe);return zo(ce)!==zo(be)}).map(([oe,ce])=>({id:oe,...ce})),J=await Vm({primary:i,configuredModels:l,profiles:V.length>0?V:void 0,authOrder:Object.keys(A).length>0?A:void 0,...e?{agentId:t}:{}});if(!J.ok)throw new Error(J.error||"Failed to save config");N("Changes saved","success"),J.syncWarning&&N(`Saved, but git-sync failed: ${J.syncWarning}`,"warning"),await U()}catch(V){N(V.message||"Failed to save changes","error")}finally{v(!1)}}},[x,i,l,_,A,d,U]),me=q(async()=>{try{let V=await Po();m(V||{connected:!1}),E({codexStatus:V||{connected:!1}})}catch{m({connected:!1}),E({codexStatus:{connected:!1}})}},[E]);return{catalog:o,primary:i,configuredModels:l,authProfiles:d,authOrder:p,codexStatus:g,loading:h,saving:x,ready:w,error:S,isDirty:K,refresh:U,addModel:j,removeModel:X,setPrimaryModel:de,editProfile:le,editAuthOrder:ie,getProfileValue:ne,getEffectiveOrder:xe,cancelChanges:he,saveAll:ue,refreshCodexStatus:me}};var x2=t=>t?typeof t=="string"?t:t.primary||null:null,zx=(t=[],e="")=>t.find(n=>String(n?.key||"").trim()===String(e||"").trim())||null,Ux=({agent:t={},onUpdateAgent:e=async()=>{}})=>{let[n,s]=y(!1),[o,r]=y(!1),{catalog:i,primary:a,configuredModels:l,authProfiles:c,codexStatus:d,loading:u,ready:p}=rl(),f=x2(t.model),g=f||a||"",m=!!f&&String(f).trim()!==String(a||"").trim(),h=F(()=>sl({authProfiles:c,codexStatus:d}),[c,d]),b=F(()=>Object.keys(l||{}).map(k=>zx(i,k)||{key:k,label:k}).filter(k=>{let A=ri(k.key);return!!h[A]}).sort((k,A)=>{let O=jo(vs(k))-jo(vs(A));return O!==0?O:String(k?.label||k?.key).localeCompare(String(A?.label||A?.key))}),[i,l,h]),x=F(()=>zx(i,g)||(g?{key:g,label:g}:null),[i,g]),v=F(()=>b.filter(k=>{let A=vs(k);return A==="anthropic"||A==="openai"}),[b]),w=F(()=>{if(!x)return[];let k=String(x?.key||"").trim(),A=b.filter(O=>String(O?.key||"").trim()!==k);return[x,...A]},[b,x]),$=F(()=>new Set(w.map(k=>String(k?.key||"").trim()).filter(Boolean)),[w]),S=F(()=>b.filter(k=>!$.has(String(k?.key||"").trim())),[b,$]);return{authorizedModelOptions:b,canEditModel:p&&!u,effectiveModel:g,effectiveModelEntry:x,handleClearModelOverride:async()=>{if(m){s(!0);try{await e(String(t.id||"").trim(),{model:null},"Agent model reset to default")}finally{s(!1)}}},handleSelectModel:async k=>{let A=String(k||"").trim();if(!(!A||A===g)){s(!0);try{await e(String(t.id||"").trim(),{model:{primary:A}},"Agent model updated")}finally{s(!1)}}},hasDistinctModelOverride:m,loading:!p||u,menuOpen:o,modelEntries:w,popularModels:v,remainingModelOptions:S,setMenuOpen:r,updatingModel:n}};var sn=P.bind(T),Kx=({agent:t={},saving:e=!1,onUpdateAgent:n=async()=>{},onSwitchToModels:s=()=>{}})=>{let{authorizedModelOptions:o,canEditModel:r,effectiveModel:i,effectiveModelEntry:a,handleClearModelOverride:l,handleSelectModel:c,hasDistinctModelOverride:d,loading:u,menuOpen:p,modelEntries:f,popularModels:g,remainingModelOptions:m,setMenuOpen:h,updatingModel:b}=Ux({agent:t,onUpdateAgent:n});return sn` + `};var cn=null,jo=t=>String(t?.key||t?.token||t?.access||"").trim(),il=t=>{let e=!!t,n=String(t||"").trim(),s=!e,[o,r]=y(()=>s&&cn?.catalog||[]),[i,a]=y(()=>s&&cn?.primary||""),[l,c]=y(()=>s&&cn?.configuredModels||{}),[d,u]=y(()=>s&&cn?.authProfiles||[]),[p,f]=y(()=>s&&cn?.authOrder||{}),[g,m]=y(()=>s&&cn?.codexStatus||{connected:!1}),[h,b]=y(()=>!(s&&cn)),[x,v]=y(!1),[w,$]=y(()=>!!(s&&cn)),[S,C]=y(""),[_,k]=y({}),[A,O]=y({}),D=ee(cn?.primary||""),j=ee(cn?.configuredModels||{}),E=q(V=>{e||(cn={...cn||{},...V})},[e]),M=n?`/api/models/config?agentId=${encodeURIComponent(n)}`:"/api/models/config",R=ct("/api/models",Br,{maxAgeMs:3e4}),B=ct(M,()=>Vm(e?{agentId:t}:void 0),{maxAgeMs:3e4}),I=ct("/api/codex/status",Po,{maxAgeMs:15e3}),U=q(async()=>{w||b(!0),C("");try{let[V,J,oe]=await Promise.all([R.refresh({force:!0}),B.refresh({force:!0}),I.refresh({force:!0})]),ce=Array.isArray(V.models)?V.models:[];r(ce);let be=J.primary||"",Ve=J.configuredModels||{},et=J.authProfiles||[],wt=J.authOrder||{};a(be),c(Ve),u(et),f(wt),m(oe||{connected:!1}),k({}),O({}),D.current=be,j.current=Ve,E({catalog:ce,primary:be,configuredModels:Ve,authProfiles:et,authOrder:wt,codexStatus:oe||{connected:!1}}),ce.length||C("No models found")}catch(V){C("Failed to load model settings"),N(`Failed to load model settings: ${V.message}`,"error")}finally{$(!0),b(!1)}},[R,I,B,w,E,t,e]);L(()=>{U()},[t]);let W=V=>JSON.stringify(Object.keys(V).sort().reduce((J,oe)=>(J[oe]=V[oe],J),{})),G=i!==D.current||W(l)!==W(j.current),se=(()=>{let V=Object.entries(_).some(([oe,ce])=>{let be=d.find(Ve=>Ve.id===oe);return jo(ce)!==jo(be)}),J=Object.entries(A).some(([oe,ce])=>{let be=p[oe];return JSON.stringify(ce)!==JSON.stringify(be)});return V||J})(),K=G||se,z=q(V=>{V&&c(J=>{let oe={...J,[V]:{}};return E({configuredModels:oe}),oe})},[E]),X=q(V=>{if(c(J=>{let oe={...J};return delete oe[V],E({configuredModels:oe}),oe}),i===V){let oe=Object.keys(l).filter(ce=>ce!==V)[0]||"";a(oe),E({primary:oe})}},[i,l,E]),de=q(V=>{a(V),E({primary:V})},[E]),le=q((V,J)=>{let oe=d.find(ce=>ce.id===V);if(jo(J)===jo(oe)){k(ce=>{let be={...ce};return delete be[V],be});return}k(ce=>({...ce,[V]:J}))},[d]),ie=q((V,J)=>{let oe=p[V]||null;if(JSON.stringify(J)===JSON.stringify(oe)){O(ce=>{let be={...ce};return delete be[V],be});return}O(ce=>({...ce,[V]:J}))},[p]),ne=q(V=>_[V]!==void 0?_[V]:d.find(oe=>oe.id===V)||null,[_,d]),xe=q(V=>A[V]!==void 0?A[V]:p[V]||null,[A,p]),he=q(()=>{let V=D.current||"",J=j.current||{};a(V),c(J),k({}),O({}),E({primary:V,configuredModels:J})},[E]),ue=q(async()=>{if(!x){v(!0);try{let V=Object.entries(_).filter(([oe,ce])=>{let be=d.find(Ve=>Ve.id===oe);return jo(ce)!==jo(be)}).map(([oe,ce])=>({id:oe,...ce})),J=await zm({primary:i,configuredModels:l,profiles:V.length>0?V:void 0,authOrder:Object.keys(A).length>0?A:void 0,...e?{agentId:t}:{}});if(!J.ok)throw new Error(J.error||"Failed to save config");N("Changes saved","success"),J.syncWarning&&N(`Saved, but git-sync failed: ${J.syncWarning}`,"warning"),await U()}catch(V){N(V.message||"Failed to save changes","error")}finally{v(!1)}}},[x,i,l,_,A,d,U]),me=q(async()=>{try{let V=await Po();m(V||{connected:!1}),E({codexStatus:V||{connected:!1}})}catch{m({connected:!1}),E({codexStatus:{connected:!1}})}},[E]);return{catalog:o,primary:i,configuredModels:l,authProfiles:d,authOrder:p,codexStatus:g,loading:h,saving:x,ready:w,error:S,isDirty:K,refresh:U,addModel:z,removeModel:X,setPrimaryModel:de,editProfile:le,editAuthOrder:ie,getProfileValue:ne,getEffectiveOrder:xe,cancelChanges:he,saveAll:ue,refreshCodexStatus:me}};var x2=t=>t?typeof t=="string"?t:t.primary||null:null,jx=(t=[],e="")=>t.find(n=>String(n?.key||"").trim()===String(e||"").trim())||null,Ux=({agent:t={},onUpdateAgent:e=async()=>{}})=>{let[n,s]=y(!1),[o,r]=y(!1),{catalog:i,primary:a,configuredModels:l,authProfiles:c,codexStatus:d,loading:u,ready:p}=il(),f=x2(t.model),g=f||a||"",m=!!f&&String(f).trim()!==String(a||"").trim(),h=F(()=>ol({authProfiles:c,codexStatus:d}),[c,d]),b=F(()=>Object.keys(l||{}).map(k=>jx(i,k)||{key:k,label:k}).filter(k=>{let A=ri(k.key);return!!h[A]}).sort((k,A)=>{let O=zo(vs(k))-zo(vs(A));return O!==0?O:String(k?.label||k?.key).localeCompare(String(A?.label||A?.key))}),[i,l,h]),x=F(()=>jx(i,g)||(g?{key:g,label:g}:null),[i,g]),v=F(()=>b.filter(k=>{let A=vs(k);return A==="anthropic"||A==="openai"}),[b]),w=F(()=>{if(!x)return[];let k=String(x?.key||"").trim(),A=b.filter(O=>String(O?.key||"").trim()!==k);return[x,...A]},[b,x]),$=F(()=>new Set(w.map(k=>String(k?.key||"").trim()).filter(Boolean)),[w]),S=F(()=>b.filter(k=>!$.has(String(k?.key||"").trim())),[b,$]);return{authorizedModelOptions:b,canEditModel:p&&!u,effectiveModel:g,effectiveModelEntry:x,handleClearModelOverride:async()=>{if(m){s(!0);try{await e(String(t.id||"").trim(),{model:null},"Agent model reset to default")}finally{s(!1)}}},handleSelectModel:async k=>{let A=String(k||"").trim();if(!(!A||A===g)){s(!0);try{await e(String(t.id||"").trim(),{model:{primary:A}},"Agent model updated")}finally{s(!1)}}},hasDistinctModelOverride:m,loading:!p||u,menuOpen:o,modelEntries:w,popularModels:v,remainingModelOptions:S,setMenuOpen:r,updatingModel:n}};var sn=P.bind(T),Kx=({agent:t={},saving:e=!1,onUpdateAgent:n=async()=>{},onSwitchToModels:s=()=>{}})=>{let{authorizedModelOptions:o,canEditModel:r,effectiveModel:i,effectiveModelEntry:a,handleClearModelOverride:l,handleSelectModel:c,hasDistinctModelOverride:d,loading:u,menuOpen:p,modelEntries:f,popularModels:g,remainingModelOptions:m,setMenuOpen:h,updatingModel:b}=Ux({agent:t,onUpdateAgent:n});return sn`

      Model

      @@ -3282,7 +3282,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error >

      - ${eu(x)} + ${tu(x)} ${x.key===i?sn`<${ae} tone="cyan">Primary`:sn`
      `:null}
      - `};var Gx=["minimal","messaging","coding","full"],il={minimal:"Minimal",messaging:"Messaging",coding:"Coding",full:"Full"},tu=[{id:"read",label:"Read files",profiles:["coding"],section:"filesystem"},{id:"edit",label:"Edit files",profiles:["coding"],section:"filesystem"},{id:"write",label:"Write files",profiles:["coding"],section:"filesystem"},{id:"apply_patch",label:"Apply patches",help:"Make targeted patch edits, mainly for OpenAI-compatible patch workflows.",profiles:["coding"],section:"filesystem"},{id:"exec",label:"Run commands",help:"Execute shell commands inside the agent environment.",profiles:["coding"],section:"execution"},{id:"process",label:"Manage processes",help:"Inspect and control long-running background processes.",profiles:["coding"],section:"execution"},{id:"message",label:"Send messages",help:"Send outbound messages through configured messaging channels.",profiles:["messaging"],section:"communication"},{id:"tts",label:"Text-to-speech",help:"Convert text responses into generated speech audio.",profiles:[],section:"communication"},{id:"browser",label:"Control browser",help:"Drive a browser for page navigation and interactive web tasks.",profiles:[],section:"web"},{id:"web_search",label:"Search the web",help:"Run web searches to discover external information.",profiles:[],section:"web"},{id:"web_fetch",label:"Fetch URLs",help:"Fetch and read webpage content from a specific URL.",profiles:[],section:"web"},{id:"memory_search",label:"Semantic search",help:"Search memory semantically to find related notes and prior context.",profiles:["coding"],section:"memory"},{id:"memory_get",label:"Read memories",help:"Read stored memory files and saved context entries.",profiles:["coding"],section:"memory"},{id:"agents_list",label:"List agents",help:"List known agent IDs that can be targeted in multi-agent flows.",profiles:[],section:"multiagent"},{id:"sessions_spawn",label:"Spawn sessions",help:"Start a new background session/run; this is the base primitive used by sub-agent workflows.",profiles:["coding"],section:"multiagent"},{id:"sessions_send",label:"Send to session",help:"Send messages or tasks into an existing running session.",profiles:["coding","messaging"],section:"multiagent"},{id:"sessions_list",label:"List sessions",help:"List active or recent sessions available to the agent.",profiles:["coding","messaging"],section:"multiagent"},{id:"sessions_history",label:"Session history",help:"Read the transcript and prior exchanges from a session.",profiles:["coding","messaging"],section:"multiagent"},{id:"session_status",label:"Session status",help:"Check whether a session is running and inspect runtime health/state.",profiles:["minimal","coding","messaging"],section:"multiagent"},{id:"subagents",label:"Sub-agents",help:"Launch specialized delegated agents (higher-level orchestration built on session spawning).",profiles:["coding"],section:"multiagent"},{id:"cron",label:"Scheduled jobs",help:"Create and manage scheduled automation jobs.",profiles:["coding"],section:"scheduling"},{id:"gateway",label:"Gateway control",help:"Inspect and control the running Gateway service (status, health, and control actions like restart).",profiles:[],section:"scheduling"},{id:"image",label:"Generate images",help:"Generate or analyze images with image-capable model tools.",profiles:["coding"],section:"creative"},{id:"canvas",label:"Visual canvas",help:"Control the Canvas panel (present, navigate, eval, snapshot). Primarily a macOS app capability when a canvas-capable node is connected.",profiles:[],section:"creative"},{id:"nodes",label:"Node workflows",help:"Use paired device/node capabilities (for example canvas, camera, notifications, and system actions).",profiles:[],section:"creative"}],qx=[{id:"filesystem",label:"Filesystem",description:"Read, edit, and write files"},{id:"execution",label:"Execution",description:"Run shell commands and scripts"},{id:"communication",label:"Communication",description:"Send messages across Telegram, Slack, Discord"},{id:"web",label:"Web & Browser",description:"Browse pages, search the web, fetch URLs"},{id:"memory",label:"Memory",description:"Semantic search and retrieval across the agent's stored knowledge"},{id:"multiagent",label:"Multi-Agent",description:"List agents, spawn sessions, send messages between agents. Orchestrate sub-agents."},{id:"scheduling",label:"Scheduling",description:"Create and manage scheduled jobs"},{id:"creative",label:"Creative",description:"Generate images, visual canvas, node-based workflows"}];var ii=t=>t==="full"?tu.map(e=>e.id):tu.filter(e=>e.profiles.includes(t)).map(e=>e.id),Jx=({profile:t="full",alsoAllow:e=[],deny:n=[]})=>{let s=new Set(ii(t)),o=new Set(e),r=new Set(n);return tu.map(i=>{let a=s.has(i.id),l=r.has(i.id),c=o.has(i.id);return{...i,enabled:l?!1:a||c,inProfile:a,isDenied:l,isAlsoAllowed:c}})},Zx=({profile:t,toolStates:e})=>{let n=new Set(ii(t)),s=[],o=[];for(let i of e){let a=n.has(i.id);i.enabled&&!a?s.push(i.id):!i.enabled&&a&&o.push(i.id)}let r={profile:t};return s.length&&(r.alsoAllow=s),o.length&&(r.deny=o),r};var y2=P.bind(T),Yx=({profile:t="full",enabledCount:e=0,totalCount:n=0,onSwitchToTools:s=()=>{}})=>{let o=il[t]||t;return y2` + `};var Gx=["minimal","messaging","coding","full"],al={minimal:"Minimal",messaging:"Messaging",coding:"Coding",full:"Full"},nu=[{id:"read",label:"Read files",profiles:["coding"],section:"filesystem"},{id:"edit",label:"Edit files",profiles:["coding"],section:"filesystem"},{id:"write",label:"Write files",profiles:["coding"],section:"filesystem"},{id:"apply_patch",label:"Apply patches",help:"Make targeted patch edits, mainly for OpenAI-compatible patch workflows.",profiles:["coding"],section:"filesystem"},{id:"exec",label:"Run commands",help:"Execute shell commands inside the agent environment.",profiles:["coding"],section:"execution"},{id:"process",label:"Manage processes",help:"Inspect and control long-running background processes.",profiles:["coding"],section:"execution"},{id:"message",label:"Send messages",help:"Send outbound messages through configured messaging channels.",profiles:["messaging"],section:"communication"},{id:"tts",label:"Text-to-speech",help:"Convert text responses into generated speech audio.",profiles:[],section:"communication"},{id:"browser",label:"Control browser",help:"Drive a browser for page navigation and interactive web tasks.",profiles:[],section:"web"},{id:"web_search",label:"Search the web",help:"Run web searches to discover external information.",profiles:[],section:"web"},{id:"web_fetch",label:"Fetch URLs",help:"Fetch and read webpage content from a specific URL.",profiles:[],section:"web"},{id:"memory_search",label:"Semantic search",help:"Search memory semantically to find related notes and prior context.",profiles:["coding"],section:"memory"},{id:"memory_get",label:"Read memories",help:"Read stored memory files and saved context entries.",profiles:["coding"],section:"memory"},{id:"agents_list",label:"List agents",help:"List known agent IDs that can be targeted in multi-agent flows.",profiles:[],section:"multiagent"},{id:"sessions_spawn",label:"Spawn sessions",help:"Start a new background session/run; this is the base primitive used by sub-agent workflows.",profiles:["coding"],section:"multiagent"},{id:"sessions_send",label:"Send to session",help:"Send messages or tasks into an existing running session.",profiles:["coding","messaging"],section:"multiagent"},{id:"sessions_list",label:"List sessions",help:"List active or recent sessions available to the agent.",profiles:["coding","messaging"],section:"multiagent"},{id:"sessions_history",label:"Session history",help:"Read the transcript and prior exchanges from a session.",profiles:["coding","messaging"],section:"multiagent"},{id:"session_status",label:"Session status",help:"Check whether a session is running and inspect runtime health/state.",profiles:["minimal","coding","messaging"],section:"multiagent"},{id:"subagents",label:"Sub-agents",help:"Launch specialized delegated agents (higher-level orchestration built on session spawning).",profiles:["coding"],section:"multiagent"},{id:"cron",label:"Scheduled jobs",help:"Create and manage scheduled automation jobs.",profiles:["coding"],section:"scheduling"},{id:"gateway",label:"Gateway control",help:"Inspect and control the running Gateway service (status, health, and control actions like restart).",profiles:[],section:"scheduling"},{id:"image",label:"Generate images",help:"Generate or analyze images with image-capable model tools.",profiles:["coding"],section:"creative"},{id:"canvas",label:"Visual canvas",help:"Control the Canvas panel (present, navigate, eval, snapshot). Primarily a macOS app capability when a canvas-capable node is connected.",profiles:[],section:"creative"},{id:"nodes",label:"Node workflows",help:"Use paired device/node capabilities (for example canvas, camera, notifications, and system actions).",profiles:[],section:"creative"}],qx=[{id:"filesystem",label:"Filesystem",description:"Read, edit, and write files"},{id:"execution",label:"Execution",description:"Run shell commands and scripts"},{id:"communication",label:"Communication",description:"Send messages across Telegram, Slack, Discord"},{id:"web",label:"Web & Browser",description:"Browse pages, search the web, fetch URLs"},{id:"memory",label:"Memory",description:"Semantic search and retrieval across the agent's stored knowledge"},{id:"multiagent",label:"Multi-Agent",description:"List agents, spawn sessions, send messages between agents. Orchestrate sub-agents."},{id:"scheduling",label:"Scheduling",description:"Create and manage scheduled jobs"},{id:"creative",label:"Creative",description:"Generate images, visual canvas, node-based workflows"}];var ii=t=>t==="full"?nu.map(e=>e.id):nu.filter(e=>e.profiles.includes(t)).map(e=>e.id),Jx=({profile:t="full",alsoAllow:e=[],deny:n=[]})=>{let s=new Set(ii(t)),o=new Set(e),r=new Set(n);return nu.map(i=>{let a=s.has(i.id),l=r.has(i.id),c=o.has(i.id);return{...i,enabled:l?!1:a||c,inProfile:a,isDenied:l,isAlsoAllowed:c}})},Zx=({profile:t,toolStates:e})=>{let n=new Set(ii(t)),s=[],o=[];for(let i of e){let a=n.has(i.id);i.enabled&&!a?s.push(i.id):!i.enabled&&a&&o.push(i.id)}let r={profile:t};return s.length&&(r.alsoAllow=s),o.length&&(r.deny=o),r};var y2=P.bind(T),Yx=({profile:t="full",enabledCount:e=0,totalCount:n=0,onSwitchToTools:s=()=>{}})=>{let o=al[t]||t;return y2`

      Tools

      {a||(n(Number(d?.sizeBytes||0)),o(d?.exists!==!1))}).catch(()=>{a||(n(null),o(!1))}).finally(()=>{a||i(!1)}),()=>{a=!0}},[t?.id,t?.workspace]),{loadingWorkspaceSize:r,workspaceSizeBytes:e,workspaceSizeExists:s}};var nu=P.bind(T),Qx=({agent:t={},onOpenWorkspace:e=()=>{}})=>{let{loadingWorkspaceSize:n,workspaceSizeBytes:s,workspaceSizeExists:o}=Xx({agent:t});return nu` + `};var Xx=({agent:t={}})=>{let[e,n]=y(null),[s,o]=y(!0),[r,i]=y(!1);return L(()=>{let a=!1,l=String(t?.id||"").trim(),c=String(t?.workspace||"").trim();if(!l||!c){n(null),o(!0),i(!1);return}return i(!0),qm(l).then(d=>{a||(n(Number(d?.sizeBytes||0)),o(d?.exists!==!1))}).catch(()=>{a||(n(null),o(!1))}).finally(()=>{a||i(!1)}),()=>{a=!0}},[t?.id,t?.workspace]),{loadingWorkspaceSize:r,workspaceSizeBytes:e,workspaceSizeExists:s}};var su=P.bind(T),Qx=({agent:t={},onOpenWorkspace:e=()=>{}})=>{let{loadingWorkspaceSize:n,workspaceSizeBytes:s,workspaceSizeExists:o}=Xx({agent:t});return su`

      Workspace

      - ${t.workspace?nu` + ${t.workspace?su`
      @@ -3369,10 +3369,10 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error ${t.workspace}

      - ${n?"Calculating size...":s!=null?tx(s):o?"Size unavailable":"Workspace directory not found"} + ${n?"Calculating size...":s!=null?nx(s):o?"Size unavailable":"Workspace directory not found"}
      - `:nu`

      No workspace configured

      `} + `:su`

      No workspace configured

      `}
      `};var ey=P.bind(T),ty=({agent:t={},agents:e=[],saving:n=!1,toolsSummary:s={},onUpdateAgent:o=async()=>{},onSetLocation:r=()=>{},onOpenWorkspace:i=()=>{},onSwitchToModels:a=()=>{},onSwitchToTools:l=()=>{},onSetDefault:c=()=>{},onDelete:d=()=>{}})=>{let u=String(t.id||"")==="main",p=!t.default||!u;return ey`
      @@ -3392,13 +3392,13 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error totalCount=${s.totalCount||0} onSwitchToTools=${l} /> - <${nl} + <${sl} agent=${t} agents=${e} onSetLocation=${r} /> ${p?ey` - <${jx} + <${zx} agent=${t} saving=${n} onSetDefault=${c} @@ -3428,7 +3428,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error >? -`;var Uo=P.bind(T),$2={minimal:"Only session status \u2014 grant specific tools with alsoAllow",messaging:"Session access and messaging \u2014 ideal for notification agents",coding:"File I/O, shell, memory, sessions, cron, and image generation",full:"All tools enabled, no restrictions"},w2=Gx.map(t=>({label:il[t],value:t,title:$2[t]})),k2=({tool:t,onToggle:e})=>Uo` +`;var Uo=P.bind(T),$2={minimal:"Only session status \u2014 grant specific tools with alsoAllow",messaging:"Session access and messaging \u2014 ideal for notification agents",coding:"File I/O, shell, memory, sessions, cron, and image generation",full:"All tools enabled, no restrictions"},w2=Gx.map(t=>({label:al[t],value:t,title:$2[t]})),k2=({tool:t,onToggle:e})=>Uo`

      @@ -3487,7 +3487,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error `)}

      - `};var su=(t=[],e=[])=>{let n={};for(let s of t)n[s]=!0;for(let s of e)n[s]=!1;return n},ou=({profile:t="full",alsoAllow:e=[],deny:n=[]}={})=>({profile:String(t||"full"),alsoAllow:[...Array.isArray(e)?e:[]].map(String).filter(Boolean).sort(),deny:[...Array.isArray(n)?n:[]].map(String).filter(Boolean).sort()}),oy=({agent:t={}}={})=>{let e=t.tools||{},n=ou(e),s=n.profile,o=n.alsoAllow,r=n.deny,[i,a]=y(s),[l,c]=y(()=>su(o,r)),[d,u]=y(n),p=JSON.stringify([t.id,e]),f=ee(p);L(()=>{f.current!==p&&(f.current=p,a(s),c(su(o,r)),u(n))},[p,s,o,r,n]);let g=F(()=>{let $=new Set(ii(i)),S=[],C=[];for(let[_,k]of Object.entries(l))k&&!$.has(_)?S.push(_):!k&&$.has(_)&&C.push(_);return Jx({profile:i,alsoAllow:S,deny:C})},[i,l]),m=F(()=>Zx({profile:i,toolStates:g}),[i,g]),h=F(()=>{let $=ou(m);return JSON.stringify(d)!==JSON.stringify($)},[d,m]),b=q($=>{a($),c({})},[]),x=q(($,S)=>{c(C=>{let _={...C};return new Set(ii(i)).has($)===S?delete _[$]:_[$]=S,_})},[i]),v=q(()=>{a(d.profile);let $={};for(let S of d.alsoAllow)$[S]=!0;for(let S of d.deny)$[S]=!1;c($)},[d]),w=q(($={})=>{let S=ou($);u(S),a(S.profile),c(su(S.alsoAllow,S.deny))},[]);return{profile:i,toolStates:g,toolsConfig:m,dirty:h,setProfile:b,toggleTool:x,reset:v,markSaved:w}};var Ko=P.bind(T),C2=[{label:"Overview",value:"overview"},{label:"Tools",value:"tools"}],_2=({className:t="w-3.5 h-3.5"})=>Ko` + `};var ou=(t=[],e=[])=>{let n={};for(let s of t)n[s]=!0;for(let s of e)n[s]=!1;return n},ru=({profile:t="full",alsoAllow:e=[],deny:n=[]}={})=>({profile:String(t||"full"),alsoAllow:[...Array.isArray(e)?e:[]].map(String).filter(Boolean).sort(),deny:[...Array.isArray(n)?n:[]].map(String).filter(Boolean).sort()}),oy=({agent:t={}}={})=>{let e=t.tools||{},n=ru(e),s=n.profile,o=n.alsoAllow,r=n.deny,[i,a]=y(s),[l,c]=y(()=>ou(o,r)),[d,u]=y(n),p=JSON.stringify([t.id,e]),f=ee(p);L(()=>{f.current!==p&&(f.current=p,a(s),c(ou(o,r)),u(n))},[p,s,o,r,n]);let g=F(()=>{let $=new Set(ii(i)),S=[],C=[];for(let[_,k]of Object.entries(l))k&&!$.has(_)?S.push(_):!k&&$.has(_)&&C.push(_);return Jx({profile:i,alsoAllow:S,deny:C})},[i,l]),m=F(()=>Zx({profile:i,toolStates:g}),[i,g]),h=F(()=>{let $=ru(m);return JSON.stringify(d)!==JSON.stringify($)},[d,m]),b=q($=>{a($),c({})},[]),x=q(($,S)=>{c(C=>{let _={...C};return new Set(ii(i)).has($)===S?delete _[$]:_[$]=S,_})},[i]),v=q(()=>{a(d.profile);let $={};for(let S of d.alsoAllow)$[S]=!0;for(let S of d.deny)$[S]=!1;c($)},[d]),w=q(($={})=>{let S=ru($);u(S),a(S.profile),c(ou(S.alsoAllow,S.deny))},[]);return{profile:i,toolStates:g,toolsConfig:m,dirty:h,setProfile:b,toggleTool:x,reset:v,markSaved:w}};var Ko=P.bind(T),C2=[{label:"Overview",value:"overview"},{label:"Tools",value:"tools"}],_2=({className:t="w-3.5 h-3.5"})=>Ko` Select an agent to view details
      - `};var ru=P.bind(T),iy=/^[a-z0-9]+(?:-[a-z0-9]+)*$/,ay=/^[a-z0-9]+(?:-[a-z0-9]+)*$/,iu=t=>String(t||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,""),ly=({visible:t=!1,loading:e=!1,onClose:n=()=>{},onSubmit:s=()=>{}})=>{let[o,r]=y(""),[i,a]=y(""),[l,c]=y(""),[d,u]=y(""),[p,f]=y(!1),[g,m]=y(!1);L(()=>{t&&(r(""),a(""),c(""),u(""),f(!1),m(!1))},[t]),L(()=>{if(p)return;let v=iu(o);a(v)},[o,p]),L(()=>{if(g)return;let v=String(i||"").trim();if(!v){c("");return}c(v)},[i,g]);let h=F(()=>`workspace-${String(l||"").trim()}`,[l]),b=String(o||"").trim().length>0&&iy.test(String(i||"").trim())&&ay.test(String(l||"").trim());if(!t)return null;let x=async()=>{let v=String(o||"").trim(),w=String(i||"").trim(),$=String(l||"").trim(),S=`workspace-${$}`;if(!v){u("Display name is required");return}if(!iy.test(w)){u("Agent ID must be lowercase letters, numbers, and hyphens");return}if(!ay.test($)){u("Workspace folder must be lowercase letters, numbers, and hyphens");return}u(""),await s({name:v,id:w,workspaceFolder:S})};return ru` + `};var iu=P.bind(T),iy=/^[a-z0-9]+(?:-[a-z0-9]+)*$/,ay=/^[a-z0-9]+(?:-[a-z0-9]+)*$/,au=t=>String(t||"").toLowerCase().trim().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,""),ly=({visible:t=!1,loading:e=!1,onClose:n=()=>{},onSubmit:s=()=>{}})=>{let[o,r]=y(""),[i,a]=y(""),[l,c]=y(""),[d,u]=y(""),[p,f]=y(!1),[g,m]=y(!1);L(()=>{t&&(r(""),a(""),c(""),u(""),f(!1),m(!1))},[t]),L(()=>{if(p)return;let v=au(o);a(v)},[o,p]),L(()=>{if(g)return;let v=String(i||"").trim();if(!v){c("");return}c(v)},[i,g]);let h=F(()=>`workspace-${String(l||"").trim()}`,[l]),b=String(o||"").trim().length>0&&iy.test(String(i||"").trim())&&ay.test(String(l||"").trim());if(!t)return null;let x=async()=>{let v=String(o||"").trim(),w=String(i||"").trim(),$=String(l||"").trim(),S=`workspace-${$}`;if(!v){u("Display name is required");return}if(!iy.test(w)){u("Agent ID must be lowercase letters, numbers, and hyphens");return}if(!ay.test($)){u("Workspace folder must be lowercase letters, numbers, and hyphens");return}u(""),await s({name:v,id:w,workspaceFolder:S})};return iu` <${De} visible=${t} onClose=${n} @@ -3591,7 +3591,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error > <${Ne} title="Add Agent" - actions=${ru` + actions=${iu`

      - ${d?ru`

      ${d}

      `:null} + ${d?iu`

      ${d}

      `:null}
      @@ -3687,7 +3687,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error onCancel=${s} onConfirm=${()=>o({id:String(n?.id||"").trim(),keepWorkspace:r})} /> - `};var au=P.bind(T),uy=({visible:t=!1,loading:e=!1,agent:n=null,onClose:s=()=>{},onSubmit:o=()=>{}})=>{let[r,i]=y(""),[a,l]=y("");if(L(()=>{t&&(i(String(n?.name||"")),l(""))},[t,n]),!t)return null;let c=async()=>{let d=String(r||"").trim();if(!d){l("Display name is required");return}l(""),await o({id:String(n?.id||"").trim(),patch:{name:d}})};return au` + `};var lu=P.bind(T),uy=({visible:t=!1,loading:e=!1,agent:n=null,onClose:s=()=>{},onSubmit:o=()=>{}})=>{let[r,i]=y(""),[a,l]=y("");if(L(()=>{t&&(i(String(n?.name||"")),l(""))},[t,n]),!t)return null;let c=async()=>{let d=String(r||"").trim();if(!d){l("Display name is required");return}l(""),await o({id:String(n?.id||"").trim(),patch:{name:d}})};return lu` <${De} visible=${t} onClose=${s} @@ -3695,7 +3695,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error > <${Ne} title="Edit Agent" - actions=${au` + actions=${lu`

      @@ -3751,7 +3751,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error />

      - `};var py=P.bind(T),M2=t=>{let e=String(t||"").trim();if(!e)return"";let n=e.match(/[\\/]\.openclaw[\\/](.+)$/);if(n?.[1])return String(n[1]).replace(/\\/g,"/");let s=e.split(/[\\/]/).filter(Boolean);return s[s.length-1]||""},fy=({agents:t=[],loading:e=!1,saving:n=!1,agentsActions:s={},selectedAgentId:o="",activeTab:r="overview",onSelectAgent:i=()=>{},onSelectTab:a=()=>{},onNavigateToBrowseFile:l=()=>{},onSetLocation:c=()=>{}})=>{let{create:d,remove:u,setDefault:p,update:f}=s,[g,m]=y(!1),[h,b]=y(null),[x,v]=y(null);L(()=>{let O=()=>m(!0);return window.addEventListener("alphaclaw:create-agent",O),()=>window.removeEventListener("alphaclaw:create-agent",O)},[]);let w=t.find(O=>O.id===o)||null,$=async({id:O,name:D,workspaceFolder:z})=>{try{let E=await d({id:O,name:D,workspaceFolder:z});m(!1),i(E.id),N("Agent created","success")}catch(E){N(E.message||"Could not create agent","error")}},S=async O=>{try{await p(O),N("Default agent updated","success")}catch(D){N(D.message||"Could not set default agent","error")}},C=async(O,D,z="Agent updated")=>{try{let E=await f(O,D);return N(z,"success"),E}catch(E){throw N(E.message||"Could not update agent","error"),E}},_=async({id:O,patch:D})=>{try{await C(O,D),b(null)}catch{return}},k=async({id:O,keepWorkspace:D})=>{try{await u(O,{keepWorkspace:D}),v(null),N("Agent deleted","success")}catch(z){N(z.message||"Could not delete agent","error")}},A=O=>{let D=M2(O);D&&l(D,{view:"edit",directory:!0})};return e?py` + `};var py=P.bind(T),M2=t=>{let e=String(t||"").trim();if(!e)return"";let n=e.match(/[\\/]\.openclaw[\\/](.+)$/);if(n?.[1])return String(n[1]).replace(/\\/g,"/");let s=e.split(/[\\/]/).filter(Boolean);return s[s.length-1]||""},fy=({agents:t=[],loading:e=!1,saving:n=!1,agentsActions:s={},selectedAgentId:o="",activeTab:r="overview",onSelectAgent:i=()=>{},onSelectTab:a=()=>{},onNavigateToBrowseFile:l=()=>{},onSetLocation:c=()=>{}})=>{let{create:d,remove:u,setDefault:p,update:f}=s,[g,m]=y(!1),[h,b]=y(null),[x,v]=y(null);L(()=>{let O=()=>m(!0);return window.addEventListener("alphaclaw:create-agent",O),()=>window.removeEventListener("alphaclaw:create-agent",O)},[]);let w=t.find(O=>O.id===o)||null,$=async({id:O,name:D,workspaceFolder:j})=>{try{let E=await d({id:O,name:D,workspaceFolder:j});m(!1),i(E.id),N("Agent created","success")}catch(E){N(E.message||"Could not create agent","error")}},S=async O=>{try{await p(O),N("Default agent updated","success")}catch(D){N(D.message||"Could not set default agent","error")}},C=async(O,D,j="Agent updated")=>{try{let E=await f(O,D);return N(j,"success"),E}catch(E){throw N(E.message||"Could not update agent","error"),E}},_=async({id:O,patch:D})=>{try{await C(O,D),b(null)}catch{return}},k=async({id:O,keepWorkspace:D})=>{try{await u(O,{keepWorkspace:D}),v(null),N("Agent deleted","success")}catch(j){N(j.message||"Could not delete agent","error")}},A=O=>{let D=M2(O);D&&l(D,{view:"edit",directory:!0})};return e?py`
      <${Ce} className="h-5 w-5" /> @@ -3792,7 +3792,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error onCancel=${()=>v(null)} onConfirm=${k} /> - `};var A2=P.bind(T),lu=({agents:t=[],loading:e=!1,saving:n=!1,agentsActions:s={},selectedAgentId:o="",activeTab:r="overview",onSelectAgent:i=()=>{},onSelectTab:a=()=>{},onNavigateToBrowseFile:l=()=>{},onSetLocation:c=()=>{}})=>A2` + `};var A2=P.bind(T),cu=({agents:t=[],loading:e=!1,saving:n=!1,agentsActions:s={},selectedAgentId:o="",activeTab:r="overview",onSelectAgent:i=()=>{},onSelectTab:a=()=>{},onNavigateToBrowseFile:l=()=>{},onSetLocation:c=()=>{}})=>A2` <${fy} agents=${t} loading=${e} @@ -3965,7 +3965,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error size="sm" idleLabel="Save" loadingLabel="Saving..." - idleIcon=${hb} + idleIcon=${mb} idleIconClassName="file-viewer-save-icon" className="file-viewer-save-action" /> @@ -3979,7 +3979,7 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error size="sm" idleLabel="Restore" loadingLabel="Restoring..." - idleIcon=${gb} + idleIcon=${bb} idleIconClassName="file-viewer-save-icon" className="file-viewer-save-action" /> @@ -4022,14 +4022,14 @@ Please report this to https://github.com/markedjs/marked.`,t){let s="

      An error `:null}

      `:null} -`;var cu=t=>{let e=String(t||"");if(!(e.startsWith(`--- +`;var du=t=>{let e=String(t||"");if(!(e.startsWith(`--- `)||e==="---"))return{entries:[],body:e};let n=e.split(` `);if(n[0]!=="---")return{entries:[],body:e};let s=n.findIndex((a,l)=>l>0&&a==="---");if(s===-1)return{entries:[],body:e};let o=n.slice(1,s),r=n.slice(s+1);return{entries:o.map(a=>{let l=a.indexOf(":");if(l<=0)return null;let c=a.slice(0,l).trim(),d=a.slice(l+1).trim();return c?{key:c,rawValue:d}:null}).filter(a=>a!==null),body:r.join(` -`).replace(/^\n+/,"")}},du=t=>{let e=String(t||"").trim();if(!e)return e;if(e.startsWith("{")&&e.endsWith("}")||e.startsWith("[")&&e.endsWith("]"))try{let n=JSON.parse(e);return JSON.stringify(n,null,2)}catch{return e}return e};var He=t=>String(t||"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">"),qo=(t,e)=>String(t||"").split(` -`).map((n,s)=>({lineNumber:s+1,html:e(n)}));var T2=/(^|[^\w.#-])(-?\d+(?:\.\d+)?(?:px|em|rem|vh|vw|%|deg|s|ms)?)(?=$|[^\w-])/g,P2=t=>{let e=He(t);return e=e.replace(/@[a-zA-Z-]+/g,'$&'),e=e.replace(/#[0-9a-fA-F]{3,8}\b/g,'$&'),e=e.replace(T2,'$1$2'),e=e.replace(/(^|[;{\s])([a-zA-Z-]+)(\s*:)/g,'$1$2$3'),e},R2=(t,e,n)=>{let s=e+1;for(;s{let n=String(t||""),s=[],o=0,r=e;for(;of!==-1);if(c.length===0){s.push({kind:"text",value:n.slice(o)});break}let d=Math.min(...c);if(d>o&&(s.push({kind:"text",value:n.slice(o,d)}),o=d),i===d){let f=n.indexOf("*/",d+2);if(f===-1){s.push({kind:"comment",value:n.slice(d)}),r=!0;break}s.push({kind:"comment",value:n.slice(d,f+2)}),o=f+2;continue}let u=n[d],p=R2(n,d,u);if(p===-1){s.push({kind:"string",value:n.slice(d)});break}s.push({kind:"string",value:n.slice(d,p+1)}),o=p+1}return{parts:s,inBlockComment:r}},al=(t,e={inBlockComment:!1})=>{let n=L2(t,!!e?.inBlockComment);return{html:n.parts.map(o=>o.kind==="comment"?`${He(o.value)}`:o.kind==="string"?`${He(o.value)}`:P2(o.value)).join(""),state:{inBlockComment:n.inBlockComment}}},by=t=>{let e=String(t||"").split(` -`),n={inBlockComment:!1};return e.map((s,o)=>{let r=al(s,n);return n=r.state,{lineNumber:o+1,html:r.html}})};var E2=/\b(await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/g,I2=/(^|[^\w.])(-?(?:0x[a-fA-F0-9]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?))(?=$|[^\w.])/g,D2=t=>{let e=He(t);return e=e.replace(E2,'$1'),e=e.replace(/\b(true|false)\b/g,'$1'),e=e.replace(/\b(null|undefined)\b/g,'$1'),e=e.replace(I2,'$1$2'),e},N2=(t,e,n)=>{let s=e+1;for(;s{let n=String(t||""),s=[],o=0,r=e;for(;om!==-1);if(u.length===0){s.push({kind:"text",value:n.slice(o)});break}let p=Math.min(...u);if(p>o&&(s.push({kind:"text",value:n.slice(o,p)}),o=p),i===p){s.push({kind:"comment",value:n.slice(p)});break}if(a===p){let m=n.indexOf("*/",p+2);if(m===-1){s.push({kind:"comment",value:n.slice(p)}),r=!0;break}s.push({kind:"comment",value:n.slice(p,m+2)}),o=m+2;continue}let f=n[p],g=N2(n,p,f);if(g===-1){s.push({kind:"string",value:n.slice(p)});break}s.push({kind:"string",value:n.slice(p,g+1)}),o=g+1}return{parts:s,inBlockComment:r}},ll=(t,e={inBlockComment:!1})=>{let n=O2(t,!!e?.inBlockComment);return{html:n.parts.map(o=>o.kind==="comment"?`${He(o.value)}`:o.kind==="string"?`${He(o.value)}`:D2(o.value)).join(""),state:{inBlockComment:n.inBlockComment}}},xy=t=>{let e=String(t||"").split(` -`),n={inBlockComment:!1};return e.map((s,o)=>{let r=ll(s,n);return n=r.state,{lineNumber:o+1,html:r.html}})};var yy=t=>He(t).replace(/(&[a-zA-Z][a-zA-Z0-9]+;|&#\d+;|&#x[0-9a-fA-F]+;)/g,'$1'),B2=t=>{let e=t.match(/^\s*/)?.[0]||"",n=t.slice(e.length);return`${He(e)}${He(n)}`},F2=t=>{let e=/([:@A-Za-z_][\w:.-]*)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g,n="",s=0,o=e.exec(t);for(;o;){let r=o[0],i=o[1],a=o[2]||"",l=o.index,c=l+r.length;if(l>s&&(n+=He(t.slice(s,l))),n+=`${He(i)}`,a){let d=a.indexOf("=");if(d!==-1){let u=a.slice(0,d),p=a.slice(d+1);n+=`${He(u)}=${B2(p)}`}else n+=He(a)}s=c,o=e.exec(t)}return s{if(/^$/.test(t)||/^${He(t)}`;let e=t.match(/^<\s*(\/?)\s*([A-Za-z][\w:-]*)([\s\S]*?)(\/?)\s*>$/);if(!e)return`${He(t)}`;let n=e[1]==="/",s=e[2],o=e[3]||"",r=e[4]==="/",i=n?"</":"<",a=n?"":F2(o),l=r?"/>":">";return`${i}${He(s)}${a}${l}`},ai=t=>{let e=/|]*>|<\/?[A-Za-z][^>]*>/gi,n=String(t||""),s="",o=0,r=e.exec(n);for(;r;){let i=r[0],a=r.index,l=a+i.length;a>o&&(s+=yy(n.slice(o,a))),s+=H2(i),o=l,r=e.exec(n)}return o{let s=new RegExp(`<\\/?\\s*${e}\\b[^>]*>`,"ig").exec(t);return s?{text:s[0],start:s.index,end:s.index+s[0].length,isClosing:/^<\s*\//.test(s[0])}:null},W2=(t,e)=>{let n="",s=0,o=e.mode,r=e.languageState;for(;sf&&!f.isClosing).sort((f,g)=>f.start-g.start);if(c.length===0){n+=ai(i),s=t.length;break}let d=c[0],u=s+d.start,p=s+d.end;n+=ai(t.slice(s,u)),n+=ai(t.slice(u,p)),o=/<\s*script\b/i.test(d.text)?"script":"style",r={inBlockComment:!1},s=p}return{html:n,state:{mode:o,languageState:r}}},vy=t=>{let e=String(t||"").split(` -`),n={mode:"html",languageState:{inBlockComment:!1}};return e.map((s,o)=>{let r=W2(s,n);return n=r.state,{lineNumber:o+1,html:r.html}})};var V2=t=>{let e=[],n=String(t||""),s=/"([^"\\]|\\.)*"/g,o=0,r=s.exec(n);for(;r;){let i=r.index,a=s.lastIndex,l=r[0],c=n.slice(a),d=/^\s*:/.test(c);i>o&&e.push({kind:"text",value:n.slice(o,i)}),e.push({kind:d?"key":"string",value:l}),o=a,r=s.exec(n)}return o{let e=He(t);return e=e.replace(/(^|[^\w.])(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=$|[^\w.])/g,'$1$2'),e=e.replace(/\b(true|false)\b/g,'$1'),e=e.replace(/\bnull\b/g,'null'),e=e.replace(/([{}\[\],:])/g,'$1'),e},z2=t=>V2(t).map(e=>e.kind==="key"?`${He(e.value)}`:e.kind==="string"?`${He(e.value)}`:j2(e.value)).join(""),$y=t=>qo(t,z2);var wy=t=>{let e=He(t);return e=e.replace(/`([^`]+)`/g,'`$1`'),e=e.replace(/\*\*([^*]+)\*\*/g,'**$1**'),e=e.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'[$1]($2)'),e},U2=t=>/^#{1,6}\s/.test(t)?`${He(t)}`:/^>\s/.test(t)?`${He(t)}`:/^```/.test(t)?`${He(t)}`:/^\|[-\s|]+\|$/.test(t)?`${He(t)}`:/^\s*[-*]\s/.test(t)?wy(t).replace(/^(\s*)([-*])/,'$1$2'):wy(t),ky=t=>qo(t,U2);var Sy=t=>{let n=String(t||"").toLowerCase().replace(/(\.bak)+$/i,"");return/\.(md|markdown|mdx)$/i.test(n)?"markdown":/\.(json|jsonl)$/i.test(n)?"json":/\.(html|htm)$/i.test(n)?"html":/\.(js|mjs|cjs)$/i.test(n)?"javascript":/\.(css|scss)$/i.test(n)?"css":"plain"},dl=(t,e)=>e==="markdown"?ky(t):e==="json"?$y(t):e==="html"?vy(t):e==="javascript"?xy(t):e==="css"?by(t):qo(t,n=>He(n));var li=P.bind(T),Cy=({isMarkdownFile:t,parsedFrontmatter:e,frontmatterCollapsed:n,setFrontmatterCollapsed:s})=>!t||e.entries.length<=0?null:li` +`).replace(/^\n+/,"")}},uu=t=>{let e=String(t||"").trim();if(!e)return e;if(e.startsWith("{")&&e.endsWith("}")||e.startsWith("[")&&e.endsWith("]"))try{let n=JSON.parse(e);return JSON.stringify(n,null,2)}catch{return e}return e};var He=t=>String(t||"").replaceAll("&","&").replaceAll("<","<").replaceAll(">",">"),qo=(t,e)=>String(t||"").split(` +`).map((n,s)=>({lineNumber:s+1,html:e(n)}));var T2=/(^|[^\w.#-])(-?\d+(?:\.\d+)?(?:px|em|rem|vh|vw|%|deg|s|ms)?)(?=$|[^\w-])/g,P2=t=>{let e=He(t);return e=e.replace(/@[a-zA-Z-]+/g,'$&'),e=e.replace(/#[0-9a-fA-F]{3,8}\b/g,'$&'),e=e.replace(T2,'$1$2'),e=e.replace(/(^|[;{\s])([a-zA-Z-]+)(\s*:)/g,'$1$2$3'),e},R2=(t,e,n)=>{let s=e+1;for(;s{let n=String(t||""),s=[],o=0,r=e;for(;of!==-1);if(c.length===0){s.push({kind:"text",value:n.slice(o)});break}let d=Math.min(...c);if(d>o&&(s.push({kind:"text",value:n.slice(o,d)}),o=d),i===d){let f=n.indexOf("*/",d+2);if(f===-1){s.push({kind:"comment",value:n.slice(d)}),r=!0;break}s.push({kind:"comment",value:n.slice(d,f+2)}),o=f+2;continue}let u=n[d],p=R2(n,d,u);if(p===-1){s.push({kind:"string",value:n.slice(d)});break}s.push({kind:"string",value:n.slice(d,p+1)}),o=p+1}return{parts:s,inBlockComment:r}},ll=(t,e={inBlockComment:!1})=>{let n=L2(t,!!e?.inBlockComment);return{html:n.parts.map(o=>o.kind==="comment"?`${He(o.value)}`:o.kind==="string"?`${He(o.value)}`:P2(o.value)).join(""),state:{inBlockComment:n.inBlockComment}}},by=t=>{let e=String(t||"").split(` +`),n={inBlockComment:!1};return e.map((s,o)=>{let r=ll(s,n);return n=r.state,{lineNumber:o+1,html:r.html}})};var E2=/\b(await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/g,I2=/(^|[^\w.])(-?(?:0x[a-fA-F0-9]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?))(?=$|[^\w.])/g,D2=t=>{let e=He(t);return e=e.replace(E2,'$1'),e=e.replace(/\b(true|false)\b/g,'$1'),e=e.replace(/\b(null|undefined)\b/g,'$1'),e=e.replace(I2,'$1$2'),e},N2=(t,e,n)=>{let s=e+1;for(;s{let n=String(t||""),s=[],o=0,r=e;for(;om!==-1);if(u.length===0){s.push({kind:"text",value:n.slice(o)});break}let p=Math.min(...u);if(p>o&&(s.push({kind:"text",value:n.slice(o,p)}),o=p),i===p){s.push({kind:"comment",value:n.slice(p)});break}if(a===p){let m=n.indexOf("*/",p+2);if(m===-1){s.push({kind:"comment",value:n.slice(p)}),r=!0;break}s.push({kind:"comment",value:n.slice(p,m+2)}),o=m+2;continue}let f=n[p],g=N2(n,p,f);if(g===-1){s.push({kind:"string",value:n.slice(p)});break}s.push({kind:"string",value:n.slice(p,g+1)}),o=g+1}return{parts:s,inBlockComment:r}},cl=(t,e={inBlockComment:!1})=>{let n=O2(t,!!e?.inBlockComment);return{html:n.parts.map(o=>o.kind==="comment"?`${He(o.value)}`:o.kind==="string"?`${He(o.value)}`:D2(o.value)).join(""),state:{inBlockComment:n.inBlockComment}}},xy=t=>{let e=String(t||"").split(` +`),n={inBlockComment:!1};return e.map((s,o)=>{let r=cl(s,n);return n=r.state,{lineNumber:o+1,html:r.html}})};var yy=t=>He(t).replace(/(&[a-zA-Z][a-zA-Z0-9]+;|&#\d+;|&#x[0-9a-fA-F]+;)/g,'$1'),B2=t=>{let e=t.match(/^\s*/)?.[0]||"",n=t.slice(e.length);return`${He(e)}${He(n)}`},F2=t=>{let e=/([:@A-Za-z_][\w:.-]*)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g,n="",s=0,o=e.exec(t);for(;o;){let r=o[0],i=o[1],a=o[2]||"",l=o.index,c=l+r.length;if(l>s&&(n+=He(t.slice(s,l))),n+=`${He(i)}`,a){let d=a.indexOf("=");if(d!==-1){let u=a.slice(0,d),p=a.slice(d+1);n+=`${He(u)}=${B2(p)}`}else n+=He(a)}s=c,o=e.exec(t)}return s{if(/^$/.test(t)||/^${He(t)}`;let e=t.match(/^<\s*(\/?)\s*([A-Za-z][\w:-]*)([\s\S]*?)(\/?)\s*>$/);if(!e)return`${He(t)}`;let n=e[1]==="/",s=e[2],o=e[3]||"",r=e[4]==="/",i=n?"</":"<",a=n?"":F2(o),l=r?"/>":">";return`${i}${He(s)}${a}${l}`},ai=t=>{let e=/|]*>|<\/?[A-Za-z][^>]*>/gi,n=String(t||""),s="",o=0,r=e.exec(n);for(;r;){let i=r[0],a=r.index,l=a+i.length;a>o&&(s+=yy(n.slice(o,a))),s+=H2(i),o=l,r=e.exec(n)}return o{let s=new RegExp(`<\\/?\\s*${e}\\b[^>]*>`,"ig").exec(t);return s?{text:s[0],start:s.index,end:s.index+s[0].length,isClosing:/^<\s*\//.test(s[0])}:null},W2=(t,e)=>{let n="",s=0,o=e.mode,r=e.languageState;for(;sf&&!f.isClosing).sort((f,g)=>f.start-g.start);if(c.length===0){n+=ai(i),s=t.length;break}let d=c[0],u=s+d.start,p=s+d.end;n+=ai(t.slice(s,u)),n+=ai(t.slice(u,p)),o=/<\s*script\b/i.test(d.text)?"script":"style",r={inBlockComment:!1},s=p}return{html:n,state:{mode:o,languageState:r}}},vy=t=>{let e=String(t||"").split(` +`),n={mode:"html",languageState:{inBlockComment:!1}};return e.map((s,o)=>{let r=W2(s,n);return n=r.state,{lineNumber:o+1,html:r.html}})};var V2=t=>{let e=[],n=String(t||""),s=/"([^"\\]|\\.)*"/g,o=0,r=s.exec(n);for(;r;){let i=r.index,a=s.lastIndex,l=r[0],c=n.slice(a),d=/^\s*:/.test(c);i>o&&e.push({kind:"text",value:n.slice(o,i)}),e.push({kind:d?"key":"string",value:l}),o=a,r=s.exec(n)}return o{let e=He(t);return e=e.replace(/(^|[^\w.])(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=$|[^\w.])/g,'$1$2'),e=e.replace(/\b(true|false)\b/g,'$1'),e=e.replace(/\bnull\b/g,'null'),e=e.replace(/([{}\[\],:])/g,'$1'),e},j2=t=>V2(t).map(e=>e.kind==="key"?`${He(e.value)}`:e.kind==="string"?`${He(e.value)}`:z2(e.value)).join(""),$y=t=>qo(t,j2);var wy=t=>{let e=He(t);return e=e.replace(/`([^`]+)`/g,'`$1`'),e=e.replace(/\*\*([^*]+)\*\*/g,'**$1**'),e=e.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'[$1]($2)'),e},U2=t=>/^#{1,6}\s/.test(t)?`${He(t)}`:/^>\s/.test(t)?`${He(t)}`:/^```/.test(t)?`${He(t)}`:/^\|[-\s|]+\|$/.test(t)?`${He(t)}`:/^\s*[-*]\s/.test(t)?wy(t).replace(/^(\s*)([-*])/,'$1$2'):wy(t),ky=t=>qo(t,U2);var Sy=t=>{let n=String(t||"").toLowerCase().replace(/(\.bak)+$/i,"");return/\.(md|markdown|mdx)$/i.test(n)?"markdown":/\.(json|jsonl)$/i.test(n)?"json":/\.(html|htm)$/i.test(n)?"html":/\.(js|mjs|cjs)$/i.test(n)?"javascript":/\.(css|scss)$/i.test(n)?"css":"plain"},ul=(t,e)=>e==="markdown"?ky(t):e==="json"?$y(t):e==="html"?vy(t):e==="javascript"?xy(t):e==="css"?by(t):qo(t,n=>He(n));var li=P.bind(T),Cy=({isMarkdownFile:t,parsedFrontmatter:e,frontmatterCollapsed:n,setFrontmatterCollapsed:s})=>!t||e.entries.length<=0?null:li`
      - ${l.length===0?xl` + ${l.length===0?yl`
      No cron jobs match your search.
      `:null}
      @@ -4461,11 +4461,11 @@ ${(n||"").split(` ${m_(t)}
      - `};function pi(t){return t+.5|0}var ks=(t,e,n)=>Math.max(Math.min(t,n),e);function di(t){return ks(pi(t*2.55),0,255)}function Ss(t){return ks(pi(t*255),0,255)}function ts(t){return ks(pi(t/2.55)/100,0,1)}function iv(t){return ks(pi(t*100),0,100)}var un={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},hu=[..."0123456789ABCDEF"],g_=t=>hu[t&15],b_=t=>hu[(t&240)>>4]+hu[t&15],$l=t=>(t&240)>>4===(t&15),x_=t=>$l(t.r)&&$l(t.g)&&$l(t.b)&&$l(t.a);function y_(t){var e=t.length,n;return t[0]==="#"&&(e===4||e===5?n={r:255&un[t[1]]*17,g:255&un[t[2]]*17,b:255&un[t[3]]*17,a:e===5?un[t[4]]*17:255}:(e===7||e===9)&&(n={r:un[t[1]]<<4|un[t[2]],g:un[t[3]]<<4|un[t[4]],b:un[t[5]]<<4|un[t[6]],a:e===9?un[t[7]]<<4|un[t[8]]:255})),n}var v_=(t,e)=>t<255?e(t):"";function $_(t){var e=x_(t)?g_:b_;return t?"#"+e(t.r)+e(t.g)+e(t.b)+v_(t.a,e):void 0}var w_=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function dv(t,e,n){let s=e*Math.min(n,1-n),o=(r,i=(r+t/30)%12)=>n-s*Math.max(Math.min(i-3,9-i,1),-1);return[o(0),o(8),o(4)]}function k_(t,e,n){let s=(o,r=(o+t/60)%6)=>n-n*e*Math.max(Math.min(r,4-r,1),0);return[s(5),s(3),s(1)]}function S_(t,e,n){let s=dv(t,1,.5),o;for(e+n>1&&(o=1/(e+n),e*=o,n*=o),o=0;o<3;o++)s[o]*=1-e-n,s[o]+=e;return s}function C_(t,e,n,s,o){return t===o?(e-n)/s+(e.5?d/(2-r-i):d/(r+i),l=C_(n,s,o,d,r),l=l*60+.5),[l|0,c||0,a]}function gu(t,e,n,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,n,s)).map(Ss)}function bu(t,e,n){return gu(dv,t,e,n)}function __(t,e,n){return gu(S_,t,e,n)}function M_(t,e,n){return gu(k_,t,e,n)}function uv(t){return(t%360+360)%360}function A_(t){let e=w_.exec(t),n=255,s;if(!e)return;e[5]!==s&&(n=e[6]?di(+e[5]):Ss(+e[5]));let o=uv(+e[2]),r=+e[3]/100,i=+e[4]/100;return e[1]==="hwb"?s=__(o,r,i):e[1]==="hsv"?s=M_(o,r,i):s=bu(o,r,i),{r:s[0],g:s[1],b:s[2],a:n}}function T_(t,e){var n=mu(t);n[0]=uv(n[0]+e),n=bu(n),t.r=n[0],t.g=n[1],t.b=n[2]}function P_(t){if(!t)return;let e=mu(t),n=e[0],s=iv(e[1]),o=iv(e[2]);return t.a<255?`hsla(${n}, ${s}%, ${o}%, ${ts(t.a)})`:`hsl(${n}, ${s}%, ${o}%)`}var av={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},lv={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};function R_(){let t={},e=Object.keys(lv),n=Object.keys(av),s,o,r,i,a;for(s=0;s>16&255,r>>8&255,r&255]}return t}var wl;function L_(t){wl||(wl=R_(),wl.transparent=[0,0,0,0]);let e=wl[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:e.length===4?e[3]:255}}var E_=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function I_(t){let e=E_.exec(t),n=255,s,o,r;if(e){if(e[7]!==s){let i=+e[7];n=e[8]?di(i):ks(i*255,0,255)}return s=+e[1],o=+e[3],r=+e[5],s=255&(e[2]?di(s):ks(s,0,255)),o=255&(e[4]?di(o):ks(o,0,255)),r=255&(e[6]?di(r):ks(r,0,255)),{r:s,g:o,b:r,a:n}}}function D_(t){return t&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${ts(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`)}var fu=t=>t<=.0031308?t*12.92:Math.pow(t,1/2.4)*1.055-.055,tr=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function N_(t,e,n){let s=tr(ts(t.r)),o=tr(ts(t.g)),r=tr(ts(t.b));return{r:Ss(fu(s+n*(tr(ts(e.r))-s))),g:Ss(fu(o+n*(tr(ts(e.g))-o))),b:Ss(fu(r+n*(tr(ts(e.b))-r))),a:t.a+n*(e.a-t.a)}}function kl(t,e,n){if(t){let s=mu(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*n,e===0?360:1)),s=bu(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function pv(t,e){return t&&Object.assign(e||{},t)}function cv(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Ss(t[3]))):(e=pv(t,{r:0,g:0,b:0,a:1}),e.a=Ss(e.a)),e}function O_(t){return t.charAt(0)==="r"?I_(t):A_(t)}var ui=class t{constructor(e){if(e instanceof t)return e;let n=typeof e,s;n==="object"?s=cv(e):n==="string"&&(s=y_(e)||L_(e)||O_(e)),this._rgb=s,this._valid=!!s}get valid(){return this._valid}get rgb(){var e=pv(this._rgb);return e&&(e.a=ts(e.a)),e}set rgb(e){this._rgb=cv(e)}rgbString(){return this._valid?D_(this._rgb):void 0}hexString(){return this._valid?$_(this._rgb):void 0}hslString(){return this._valid?P_(this._rgb):void 0}mix(e,n){if(e){let s=this.rgb,o=e.rgb,r,i=n===r?.5:n,a=2*i-1,l=s.a-o.a,c=((a*l===-1?a:(a+l)/(1+a*l))+1)/2;r=1-c,s.r=255&c*s.r+r*o.r+.5,s.g=255&c*s.g+r*o.g+.5,s.b=255&c*s.b+r*o.b+.5,s.a=i*s.a+(1-i)*o.a,this.rgb=s}return this}interpolate(e,n){return e&&(this._rgb=N_(this._rgb,e._rgb,n)),this}clone(){return new t(this.rgb)}alpha(e){return this._rgb.a=Ss(e),this}clearer(e){let n=this._rgb;return n.a*=1-e,this}greyscale(){let e=this._rgb,n=pi(e.r*.3+e.g*.59+e.b*.11);return e.r=e.g=e.b=n,this}opaquer(e){let n=this._rgb;return n.a*=1+e,this}negate(){let e=this._rgb;return e.r=255-e.r,e.g=255-e.g,e.b=255-e.b,this}lighten(e){return kl(this._rgb,2,e),this}darken(e){return kl(this._rgb,2,-e),this}saturate(e){return kl(this._rgb,1,e),this}desaturate(e){return kl(this._rgb,1,-e),this}rotate(e){return T_(this._rgb,e),this}};function On(){}var kv=(()=>{let t=0;return()=>t++})();function Me(t){return t==null}function Ze(t){if(Array.isArray&&Array.isArray(t))return!0;let e=Object.prototype.toString.call(t);return e.slice(0,7)==="[object"&&e.slice(-6)==="Array]"}function Ae(t){return t!==null&&Object.prototype.toString.call(t)==="[object Object]"}function at(t){return(typeof t=="number"||t instanceof Number)&&isFinite(+t)}function Zt(t,e){return at(t)?t:e}function $e(t,e){return typeof t>"u"?e:t}var Sv=(t,e)=>typeof t=="string"&&t.endsWith("%")?parseFloat(t)/100:+t/e,$u=(t,e)=>typeof t=="string"&&t.endsWith("%")?parseFloat(t)/100*e:+t;function qe(t,e,n){if(t&&typeof t.call=="function")return t.apply(n,e)}function ze(t,e,n,s){let o,r,i;if(Ze(t))if(r=t.length,s)for(o=r-1;o>=0;o--)e.call(n,t[o],o);else for(o=0;ot,x:t=>t.x,y:t=>t.y};function H_(t){let e=t.split("."),n=[],s="";for(let o of e)s+=o,s.endsWith("\\")?s=s.slice(0,-1)+".":(n.push(s),s="");return n}function W_(t){let e=H_(t);return n=>{for(let s of e){if(s==="")break;n=n&&n[s]}return n}}function os(t,e){return(fv[e]||(fv[e]=W_(e)))(t)}function Rl(t){return t.charAt(0).toUpperCase()+t.slice(1)}var ir=t=>typeof t<"u",ns=t=>typeof t=="function",wu=(t,e)=>{if(t.size!==e.size)return!1;for(let n of t)if(!e.has(n))return!1;return!0};function _v(t){return t.type==="mouseup"||t.type==="click"||t.type==="contextmenu"}var Oe=Math.PI,Ye=2*Oe,V_=Ye+Oe,Al=Number.POSITIVE_INFINITY,j_=Oe/180,dt=Oe/2,so=Oe/4,hv=Oe*2/3,ss=Math.log10,wn=Math.sign;function ar(t,e,n){return Math.abs(t-e)o-r).pop(),e}function z_(t){return typeof t=="symbol"||typeof t=="object"&&t!==null&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}function io(t){return!z_(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function Av(t,e){let n=Math.round(t);return n-e<=t&&n+e>=t}function Su(t,e,n){let s,o,r;for(s=0,o=t.length;sl&&c=Math.min(e,n)-s&&t<=Math.max(e,n)+s}function El(t,e,n){n=n||(i=>t[i]1;)r=o+s>>1,n(r)?o=r:s=r;return{lo:o,hi:s}}var Dn=(t,e,n,s)=>El(t,n,s?o=>{let r=t[o][e];return rt[o][e]El(t,n,s=>t[s][e]>=n);function Rv(t,e,n){let s=0,o=t.length;for(;ss&&t[o-1]>n;)o--;return s>0||o{let s="_onData"+Rl(n),o=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:!1,value(...r){let i=o.apply(this,r);return t._chartjs.listeners.forEach(a=>{typeof a[s]=="function"&&a[s](...r)}),i}})})}function Mu(t,e){let n=t._chartjs;if(!n)return;let s=n.listeners,o=s.indexOf(e);o!==-1&&s.splice(o,1),!(s.length>0)&&(Lv.forEach(r=>{delete t[r]}),delete t._chartjs)}function Au(t){let e=new Set(t);return e.size===t.length?t:Array.from(e)}var Tu=(function(){return typeof window>"u"?function(t){return t()}:window.requestAnimationFrame})();function Pu(t,e){let n=[],s=!1;return function(...o){n=o,s||(s=!0,Tu.call(window,()=>{s=!1,t.apply(e,n)}))}}function Iv(t,e){let n;return function(...s){return e?(clearTimeout(n),n=setTimeout(t,e,s)):t.apply(this,s),e}}var Il=t=>t==="start"?"left":t==="end"?"right":"center",Et=(t,e,n)=>t==="start"?e:t==="end"?n:(e+n)/2,Dv=(t,e,n,s)=>t===(s?"left":"right")?n:t==="center"?(e+n)/2:e;function Ru(t,e,n){let s=e.length,o=0,r=s;if(t._sorted){let{iScale:i,vScale:a,_parsed:l}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=i.axis,{min:u,max:p,minDefined:f,maxDefined:g}=i.getUserBounds();if(f){if(o=Math.min(Dn(l,d,u).lo,n?s:Dn(e,d,i.getPixelForValue(u)).lo),c){let m=l.slice(0,o+1).reverse().findIndex(h=>!Me(h[a.axis]));o-=Math.max(0,m)}o=yt(o,0,s-1)}if(g){let m=Math.max(Dn(l,i.axis,p,!0).hi+1,n?0:Dn(e,d,i.getPixelForValue(p),!0).hi+1);if(c){let h=l.slice(m-1).findIndex(b=>!Me(b[a.axis]));m+=Math.max(0,h)}r=yt(m,o,s)-o}else r=s-o}return{start:o,count:r}}function Lu(t){let{xScale:e,yScale:n,_scaleRanges:s}=t,o={xmin:e.min,xmax:e.max,ymin:n.min,ymax:n.max};if(!s)return t._scaleRanges=o,!0;let r=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==n.min||s.ymax!==n.max;return Object.assign(s,o),r}var Sl=t=>t===0||t===1,mv=(t,e,n)=>-(Math.pow(2,10*(t-=1))*Math.sin((t-e)*Ye/n)),gv=(t,e,n)=>Math.pow(2,-10*t)*Math.sin((t-e)*Ye/n)+1,nr={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>-Math.cos(t*dt)+1,easeOutSine:t=>Math.sin(t*dt),easeInOutSine:t=>-.5*(Math.cos(Oe*t)-1),easeInExpo:t=>t===0?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>t===1?1:-Math.pow(2,-10*t)+1,easeInOutExpo:t=>Sl(t)?t:t<.5?.5*Math.pow(2,10*(t*2-1)):.5*(-Math.pow(2,-10*(t*2-1))+2),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>Sl(t)?t:mv(t,.075,.3),easeOutElastic:t=>Sl(t)?t:gv(t,.075,.3),easeInOutElastic(t){return Sl(t)?t:t<.5?.5*mv(t*2,.1125,.45):.5+.5*gv(t*2-1,.1125,.45)},easeInBack(t){return t*t*((1.70158+1)*t-1.70158)},easeOutBack(t){return(t-=1)*t*((1.70158+1)*t+1.70158)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?.5*(t*t*(((e*=1.525)+1)*t-e)):.5*((t-=2)*t*(((e*=1.525)+1)*t+e)+2)},easeInBounce:t=>1-nr.easeOutBounce(1-t),easeOutBounce(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:t=>t<.5?nr.easeInBounce(t*2)*.5:nr.easeOutBounce(t*2-1)*.5+.5};function Eu(t){if(t&&typeof t=="object"){let e=t.toString();return e==="[object CanvasPattern]"||e==="[object CanvasGradient]"}return!1}function Iu(t){return Eu(t)?t:new ui(t)}function xu(t){return Eu(t)?t:new ui(t).saturate(.5).darken(.1).hexString()}var K_=["x","y","borderWidth","radius","tension"],G_=["color","borderColor","backgroundColor"];function q_(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:e=>e!=="onProgress"&&e!=="onComplete"&&e!=="fn"}),t.set("animations",{colors:{type:"color",properties:G_},numbers:{type:"number",properties:K_}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:e=>e|0}}}})}function J_(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}var bv=new Map;function Z_(t,e){e=e||{};let n=t+JSON.stringify(e),s=bv.get(n);return s||(s=new Intl.NumberFormat(t,e),bv.set(n,s)),s}function cr(t,e,n){return Z_(e,n).format(t)}var Nv={values(t){return Ze(t)?t:""+t},numeric(t,e,n){if(t===0)return"0";let s=this.chart.options.locale,o,r=t;if(n.length>1){let c=Math.max(Math.abs(n[0].value),Math.abs(n[n.length-1].value));(c<1e-4||c>1e15)&&(o="scientific"),r=Y_(t,n)}let i=ss(Math.abs(r)),a=isNaN(i)?1:Math.max(Math.min(-1*Math.floor(i),20),0),l={notation:o,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(l,this.options.ticks.format),cr(t,s,l)},logarithmic(t,e,n){if(t===0)return"0";let s=n[e].significand||t/Math.pow(10,Math.floor(ss(t)));return[1,2,3,5,10,15].includes(s)||e>.8*n.length?Nv.numeric.call(this,t,e,n):""}};function Y_(t,e){let n=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;return Math.abs(n)>=1&&t!==Math.floor(t)&&(n=t-Math.floor(t)),n}var gi={formatters:Nv};function X_(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(e,n)=>n.lineWidth,tickColor:(e,n)=>n.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:gi.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:e=>!e.startsWith("before")&&!e.startsWith("after")&&e!=="callback"&&e!=="parser",_indexable:e=>e!=="borderDash"&&e!=="tickBorderDash"&&e!=="dash"}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:e=>e!=="backdropPadding"&&e!=="callback",_indexable:e=>e!=="backdropPadding"})}var Ms=Object.create(null),Dl=Object.create(null);function fi(t,e){if(!e)return t;let n=e.split(".");for(let s=0,o=n.length;ss.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(s,o)=>xu(o.backgroundColor),this.hoverBorderColor=(s,o)=>xu(o.borderColor),this.hoverColor=(s,o)=>xu(o.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e),this.apply(n)}set(e,n){return yu(this,e,n)}get(e){return fi(this,e)}describe(e,n){return yu(Dl,e,n)}override(e,n){return yu(Ms,e,n)}route(e,n,s,o){let r=fi(this,e),i=fi(this,s),a="_"+n;Object.defineProperties(r,{[a]:{value:r[n],writable:!0},[n]:{enumerable:!0,get(){let l=this[a],c=i[o];return Ae(l)?Object.assign({},c,l):$e(l,c)},set(l){this[a]=l}}})}apply(e){e.forEach(n=>n(this))}},Qe=new vu({_scriptable:t=>!t.startsWith("on"),_indexable:t=>t!=="events",hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[q_,J_,X_]);function Q_(t){return!t||Me(t.size)||Me(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function hi(t,e,n,s,o){let r=e[o];return r||(r=e[o]=t.measureText(o).width,n.push(o)),r>s&&(s=r),s}function Ov(t,e,n,s){s=s||{};let o=s.data=s.data||{},r=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},r=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let i=0,a=n.length,l,c,d,u,p;for(l=0;ln.length){for(l=0;l0&&t.stroke()}}function Nn(t,e,n){return n=n||.5,!e||t&&t.x>e.left-n&&t.xe.top-n&&t.y0&&r.strokeColor!=="",l,c;for(t.save(),t.font=o.string,e5(t,r),l=0;l+t||0;function Ol(t,e){let n={},s=Ae(e),o=s?Object.keys(e):e,r=Ae(t)?s?i=>$e(t[i],t[e[i]]):i=>t[i]:()=>t;for(let i of o)n[i]=i5(r(i));return n}function Ou(t){return Ol(t,{top:"y",right:"x",bottom:"y",left:"x"})}function Ps(t){return Ol(t,["topLeft","topRight","bottomLeft","bottomRight"])}function It(t){let e=Ou(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function ht(t,e){t=t||{},e=e||Qe.font;let n=$e(t.size,e.size);typeof n=="string"&&(n=parseInt(n,10));let s=$e(t.style,e.style);s&&!(""+s).match(o5)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);let o={family:$e(t.family,e.family),lineHeight:r5($e(t.lineHeight,e.lineHeight),n),size:n,style:s,weight:$e(t.weight,e.weight),string:""};return o.string=Q_(o),o}function ur(t,e,n,s){let o=!0,r,i,a;for(r=0,i=t.length;rn&&a===0?0:a+l;return{min:i(s,-Math.abs(r)),max:i(o,r)}}function rs(t,e){return Object.assign(Object.create(t),e)}function Bl(t,e=[""],n,s,o=()=>t[0]){let r=n||t;typeof s>"u"&&(s=jv("_fallback",t));let i={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:r,_fallback:s,_getTarget:o,override:a=>Bl([a,...t],e,r,s)};return new Proxy(i,{deleteProperty(a,l){return delete a[l],delete a._keys,delete t[0][l],!0},get(a,l){return Wv(a,l,()=>h5(l,e,t,a))},getOwnPropertyDescriptor(a,l){return Reflect.getOwnPropertyDescriptor(a._scopes[0],l)},getPrototypeOf(){return Reflect.getPrototypeOf(t[0])},has(a,l){return yv(a).includes(l)},ownKeys(a){return yv(a)},set(a,l,c){let d=a._storage||(a._storage=o());return a[l]=d[l]=c,delete a._keys,!0}})}function ro(t,e,n,s){let o={_cacheable:!1,_proxy:t,_context:e,_subProxy:n,_stack:new Set,_descriptors:Bu(t,s),setContext:r=>ro(t,r,n,s),override:r=>ro(t.override(r),e,n,s)};return new Proxy(o,{deleteProperty(r,i){return delete r[i],delete t[i],!0},get(r,i,a){return Wv(r,i,()=>l5(r,i,a))},getOwnPropertyDescriptor(r,i){return r._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i)},getPrototypeOf(){return Reflect.getPrototypeOf(t)},has(r,i){return Reflect.has(t,i)},ownKeys(){return Reflect.ownKeys(t)},set(r,i,a){return t[i]=a,delete r[i],!0}})}function Bu(t,e={scriptable:!0,indexable:!0}){let{_scriptable:n=e.scriptable,_indexable:s=e.indexable,_allKeys:o=e.allKeys}=t;return{allKeys:o,scriptable:n,indexable:s,isScriptable:ns(n)?n:()=>n,isIndexable:ns(s)?s:()=>s}}var a5=(t,e)=>t?t+Rl(e):e,Fu=(t,e)=>Ae(e)&&t!=="adapters"&&(Object.getPrototypeOf(e)===null||e.constructor===Object);function Wv(t,e,n){if(Object.prototype.hasOwnProperty.call(t,e)||e==="constructor")return t[e];let s=n();return t[e]=s,s}function l5(t,e,n){let{_proxy:s,_context:o,_subProxy:r,_descriptors:i}=t,a=s[e];return ns(a)&&i.isScriptable(e)&&(a=c5(e,a,t,n)),Ze(a)&&a.length&&(a=d5(e,a,t,i.isIndexable)),Fu(e,a)&&(a=ro(a,o,r&&r[e],i)),a}function c5(t,e,n,s){let{_proxy:o,_context:r,_subProxy:i,_stack:a}=n;if(a.has(t))throw new Error("Recursion detected: "+Array.from(a).join("->")+"->"+t);a.add(t);let l=e(r,i||s);return a.delete(t),Fu(t,l)&&(l=Hu(o._scopes,o,t,l)),l}function d5(t,e,n,s){let{_proxy:o,_context:r,_subProxy:i,_descriptors:a}=n;if(typeof r.index<"u"&&s(t))return e[r.index%e.length];if(Ae(e[0])){let l=e,c=o._scopes.filter(d=>d!==l);e=[];for(let d of l){let u=Hu(c,o,t,d);e.push(ro(u,r,i&&i[t],a))}}return e}function Vv(t,e,n){return ns(t)?t(e,n):t}var u5=(t,e)=>t===!0?e:typeof t=="string"?os(e,t):void 0;function p5(t,e,n,s,o){for(let r of e){let i=u5(n,r);if(i){t.add(i);let a=Vv(i._fallback,n,o);if(typeof a<"u"&&a!==n&&a!==s)return a}else if(i===!1&&typeof s<"u"&&n!==s)return null}return!1}function Hu(t,e,n,s){let o=e._rootScopes,r=Vv(e._fallback,n,s),i=[...t,...o],a=new Set;a.add(s);let l=xv(a,i,n,r||n,s);return l===null||typeof r<"u"&&r!==n&&(l=xv(a,i,r,l,s),l===null)?!1:Bl(Array.from(a),[""],o,r,()=>f5(e,n,s))}function xv(t,e,n,s,o){for(;n;)n=p5(t,e,n,s,o);return n}function f5(t,e,n){let s=t._getTarget();e in s||(s[e]={});let o=s[e];return Ze(o)&&Ae(n)?n:o||{}}function h5(t,e,n,s){let o;for(let r of e)if(o=jv(a5(r,t),n),typeof o<"u")return Fu(t,o)?Hu(n,s,t,o):o}function jv(t,e){for(let n of e){if(!n)continue;let s=n[t];if(typeof s<"u")return s}}function yv(t){let e=t._keys;return e||(e=t._keys=m5(t._scopes)),e}function m5(t){let e=new Set;for(let n of t)for(let s of Object.keys(n).filter(o=>!o.startsWith("_")))e.add(s);return Array.from(e)}function Wu(t,e,n,s){let{iScale:o}=t,{key:r="r"}=this._parsing,i=new Array(s),a,l,c,d;for(a=0,l=s;aet==="x"?"y":"x";function b5(t,e,n,s){let o=t.skip?e:t,r=e,i=n.skip?e:n,a=Tl(r,o),l=Tl(i,r),c=a/(a+l),d=l/(a+l);c=isNaN(c)?0:c,d=isNaN(d)?0:d;let u=s*c,p=s*d;return{previous:{x:r.x-u*(i.x-o.x),y:r.y-u*(i.y-o.y)},next:{x:r.x+p*(i.x-o.x),y:r.y+p*(i.y-o.y)}}}function x5(t,e,n){let s=t.length,o,r,i,a,l,c=or(t,0);for(let d=0;d!c.skip)),e.cubicInterpolationMode==="monotone")v5(t,o);else{let c=s?t[t.length-1]:t[0];for(r=0,i=t.length;rt.ownerDocument.defaultView.getComputedStyle(t,null);function w5(t,e){return Wl(t).getPropertyValue(e)}var k5=["top","right","bottom","left"];function oo(t,e,n){let s={};n=n?"-"+n:"";for(let o=0;o<4;o++){let r=k5[o];s[r]=parseFloat(t[e+"-"+r+n])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}var S5=(t,e,n)=>(t>0||e>0)&&(!n||!n.shadowRoot);function C5(t,e){let n=t.touches,s=n&&n.length?n[0]:t,{offsetX:o,offsetY:r}=s,i=!1,a,l;if(S5(o,r,t.target))a=o,l=r;else{let c=e.getBoundingClientRect();a=s.clientX-c.left,l=s.clientY-c.top,i=!0}return{x:a,y:l,box:i}}function Rs(t,e){if("native"in t)return t;let{canvas:n,currentDevicePixelRatio:s}=e,o=Wl(n),r=o.boxSizing==="border-box",i=oo(o,"padding"),a=oo(o,"border","width"),{x:l,y:c,box:d}=C5(t,n),u=i.left+(d&&a.left),p=i.top+(d&&a.top),{width:f,height:g}=e;return r&&(f-=i.width+a.width,g-=i.height+a.height),{x:Math.round((l-u)/f*n.width/s),y:Math.round((c-p)/g*n.height/s)}}function _5(t,e,n){let s,o;if(e===void 0||n===void 0){let r=t&&Hl(t);if(!r)e=t.clientWidth,n=t.clientHeight;else{let i=r.getBoundingClientRect(),a=Wl(r),l=oo(a,"border","width"),c=oo(a,"padding");e=i.width-c.width-l.width,n=i.height-c.height-l.height,s=Pl(a.maxWidth,r,"clientWidth"),o=Pl(a.maxHeight,r,"clientHeight")}}return{width:e,height:n,maxWidth:s||Al,maxHeight:o||Al}}var _s=t=>Math.round(t*10)/10;function Kv(t,e,n,s){let o=Wl(t),r=oo(o,"margin"),i=Pl(o.maxWidth,t,"clientWidth")||Al,a=Pl(o.maxHeight,t,"clientHeight")||Al,l=_5(t,e,n),{width:c,height:d}=l;if(o.boxSizing==="content-box"){let p=oo(o,"border","width"),f=oo(o,"padding");c-=f.width+p.width,d-=f.height+p.height}return c=Math.max(0,c-r.width),d=Math.max(0,s?c/s:d-r.height),c=_s(Math.min(c,i,l.maxWidth)),d=_s(Math.min(d,a,l.maxHeight)),c&&!d&&(d=_s(c/2)),(e!==void 0||n!==void 0)&&s&&l.height&&d>l.height&&(d=l.height,c=_s(Math.floor(d*s))),{width:c,height:d}}function Vu(t,e,n){let s=e||1,o=_s(t.height*s),r=_s(t.width*s);t.height=_s(t.height),t.width=_s(t.width);let i=t.canvas;return i.style&&(n||!i.style.height&&!i.style.width)&&(i.style.height=`${t.height}px`,i.style.width=`${t.width}px`),t.currentDevicePixelRatio!==s||i.height!==o||i.width!==r?(t.currentDevicePixelRatio=s,i.height=o,i.width=r,t.ctx.setTransform(s,0,0,s,0,0),!0):!1}var Gv=(function(){let t=!1;try{let e={get passive(){return t=!0,!1}};Fl()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch{}return t})();function ju(t,e){let n=w5(t,e),s=n&&n.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function Cs(t,e,n,s){return{x:t.x+n*(e.x-t.x),y:t.y+n*(e.y-t.y)}}function qv(t,e,n,s){return{x:t.x+n*(e.x-t.x),y:s==="middle"?n<.5?t.y:e.y:s==="after"?n<1?t.y:e.y:n>0?e.y:t.y}}function Jv(t,e,n,s){let o={x:t.cp2x,y:t.cp2y},r={x:e.cp1x,y:e.cp1y},i=Cs(t,o,n),a=Cs(o,r,n),l=Cs(r,e,n),c=Cs(i,a,n),d=Cs(a,l,n);return Cs(c,d,n)}var M5=function(t,e){return{x(n){return t+t+e-n},setWidth(n){e=n},textAlign(n){return n==="center"?n:n==="right"?"left":"right"},xPlus(n,s){return n-s},leftForLtr(n,s){return n-s}}},A5=function(){return{x(t){return t},setWidth(t){},textAlign(t){return t},xPlus(t,e){return t+e},leftForLtr(t,e){return t}}};function ao(t,e,n){return t?M5(e,n):A5()}function zu(t,e){let n,s;(e==="ltr"||e==="rtl")&&(n=t.canvas.style,s=[n.getPropertyValue("direction"),n.getPropertyPriority("direction")],n.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Uu(t,e){e!==void 0&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Zv(t){return t==="angle"?{between:lr,compare:U_,normalize:Lt}:{between:Bn,compare:(e,n)=>e-n,normalize:e=>e}}function vv({start:t,end:e,count:n,loop:s,style:o}){return{start:t%n,end:e%n,loop:s&&(e-t+1)%n===0,style:o}}function T5(t,e,n){let{property:s,start:o,end:r}=n,{between:i,normalize:a}=Zv(s),l=e.length,{start:c,end:d,loop:u}=t,p,f;if(u){for(c+=l,d+=l,p=0,f=l;pl(o,v,b)&&a(o,v)!==0,$=()=>a(r,b)===0||l(r,v,b),S=()=>m||w(),C=()=>!m||$();for(let _=d,k=d;_<=u;++_)x=e[_%i],!x.skip&&(b=c(x[s]),b!==v&&(m=l(b,o,r),h===null&&S()&&(h=a(b,o)===0?_:k),h!==null&&C()&&(g.push(vv({start:h,end:_,loop:p,count:i,style:f})),h=null),k=_,v=b));return h!==null&&g.push(vv({start:h,end:u,loop:p,count:i,style:f})),g}function Gu(t,e){let n=[],s=t.segments;for(let o=0;oo&&t[r%e].skip;)r--;return r%=e,{start:o,end:r}}function R5(t,e,n,s){let o=t.length,r=[],i=e,a=t[e],l;for(l=e+1;l<=n;++l){let c=t[l%o];c.skip||c.stop?a.skip||(s=!1,r.push({start:e%o,end:(l-1)%o,loop:s}),e=i=c.stop?l:null):(i=l,a.skip&&(e=l)),a=c}return i!==null&&r.push({start:e%o,end:i%o,loop:s}),r}function Yv(t,e){let n=t.points,s=t.options.spanGaps,o=n.length;if(!o)return[];let r=!!t._loop,{start:i,end:a}=P5(n,o,r,s);if(s===!0)return $v(t,[{start:i,end:a,loop:r}],n,e);let l=aa({chart:e,initial:n.initial,numSteps:i,currentStep:Math.min(s-n.start,i)}))}_refresh(){this._request||(this._running=!0,this._request=Tu.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(e=Date.now()){let n=0;this._charts.forEach((s,o)=>{if(!s.running||!s.items.length)return;let r=s.items,i=r.length-1,a=!1,l;for(;i>=0;--i)l=r[i],l._active?(l._total>s.duration&&(s.duration=l._total),l.tick(e),a=!0):(r[i]=r[r.length-1],r.pop());a&&(o.draw(),this._notify(o,s,e,"progress")),r.length||(s.running=!1,this._notify(o,s,e,"complete"),s.initial=!1),n+=r.length}),this._lastDate=e,n===0&&(this._running=!1)}_getAnims(e){let n=this._charts,s=n.get(e);return s||(s={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},n.set(e,s)),s}listen(e,n,s){this._getAnims(e).listeners[n].push(s)}add(e,n){!n||!n.length||this._getAnims(e).items.push(...n)}has(e){return this._getAnims(e).items.length>0}start(e){let n=this._charts.get(e);n&&(n.running=!0,n.start=Date.now(),n.duration=n.items.reduce((s,o)=>Math.max(s,o._duration),0),this._refresh())}running(e){if(!this._running)return!1;let n=this._charts.get(e);return!(!n||!n.running||!n.items.length)}stop(e){let n=this._charts.get(e);if(!n||!n.items.length)return;let s=n.items,o=s.length-1;for(;o>=0;--o)s[o].cancel();n.items=[],this._notify(e,n,Date.now(),"complete")}remove(e){return this._charts.delete(e)}},is=new ip,Xv="transparent",D5={boolean(t,e,n){return n>.5?e:t},color(t,e,n){let s=Iu(t||Xv),o=s.valid&&Iu(e||Xv);return o&&o.valid?o.mix(s,n).hexString():e},number(t,e,n){return t+(e-t)*n}},ap=class{constructor(e,n,s,o){let r=n[s];o=ur([e.to,o,r,e.from]);let i=ur([e.from,r,o]);this._active=!0,this._fn=e.fn||D5[e.type||typeof i],this._easing=nr[e.easing]||nr.linear,this._start=Math.floor(Date.now()+(e.delay||0)),this._duration=this._total=Math.floor(e.duration),this._loop=!!e.loop,this._target=n,this._prop=s,this._from=i,this._to=o,this._promises=void 0}active(){return this._active}update(e,n,s){if(this._active){this._notify(!1);let o=this._target[this._prop],r=s-this._start,i=this._duration-r;this._start=s,this._duration=Math.floor(Math.max(i,e.duration)),this._total+=r,this._loop=!!e.loop,this._to=ur([e.to,n,o,e.from]),this._from=ur([e.from,o,n])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(e){let n=e-this._start,s=this._duration,o=this._prop,r=this._from,i=this._loop,a=this._to,l;if(this._active=r!==a&&(i||n1?2-l:l,l=this._easing(Math.min(1,Math.max(0,l))),this._target[o]=this._fn(r,a,l)}wait(){let e=this._promises||(this._promises=[]);return new Promise((n,s)=>{e.push({res:n,rej:s})})}_notify(e){let n=e?"res":"rej",s=this._promises||[];for(let o=0;o{let r=e[o];if(!Ae(r))return;let i={};for(let a of n)i[a]=r[a];(Ze(r.properties)&&r.properties||[o]).forEach(a=>{(a===o||!s.has(a))&&s.set(a,i)})})}_animateOptions(e,n){let s=n.options,o=O5(e,s);if(!o)return[];let r=this._createAnimations(o,s);return s.$shared&&N5(e.options.$animations,s).then(()=>{e.options=s},()=>{}),r}_createAnimations(e,n){let s=this._properties,o=[],r=e.$animations||(e.$animations={}),i=Object.keys(n),a=Date.now(),l;for(l=i.length-1;l>=0;--l){let c=i[l];if(c.charAt(0)==="$")continue;if(c==="options"){o.push(...this._animateOptions(e,n));continue}let d=n[c],u=r[c],p=s.get(c);if(u)if(p&&u.active()){u.update(p,d,a);continue}else u.cancel();if(!p||!p.duration){e[c]=d;continue}r[c]=u=new ap(p,e,c,d),o.push(u)}return o}update(e,n){if(this._properties.size===0){Object.assign(e,n);return}let s=this._createAnimations(e,n);if(s.length)return is.add(this._chart,s),!0}};function N5(t,e){let n=[],s=Object.keys(e);for(let o=0;o0||!n&&r<0)return o.index}return null}function n$(t,e){let{chart:n,_cachedMeta:s}=t,o=n._stacks||(n._stacks={}),{iScale:r,vScale:i,index:a}=s,l=r.axis,c=i.axis,d=W5(r,i,s),u=e.length,p;for(let f=0;fn[s].axis===e).shift()}function z5(t,e){return rs(t,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}function U5(t,e,n){return rs(t,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:n,index:e,mode:"default",type:"data"})}function yi(t,e){let n=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(let o of e){let r=o._stacks;if(!r||r[s]===void 0||r[s][n]===void 0)return;delete r[s][n],r[s]._visualValues!==void 0&&r[s]._visualValues[n]!==void 0&&delete r[s]._visualValues[n]}}}var Yu=t=>t==="reset"||t==="none",s$=(t,e)=>e?t:Object.assign({},t),K5=(t,e,n)=>t&&!e.hidden&&e._stacked&&{keys:Y$(n,!0),values:null},Wn=class{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(e,n){this.chart=e,this._ctx=e.ctx,this.index=n,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){let e=this._cachedMeta;this.configure(),this.linkScales(),e._stacked=Ju(e.vScale,e),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(e){this.index!==e&&yi(this._cachedMeta),this.index=e}linkScales(){let e=this.chart,n=this._cachedMeta,s=this.getDataset(),o=(u,p,f,g)=>u==="x"?p:u==="r"?g:f,r=n.xAxisID=$e(s.xAxisID,Zu(e,"x")),i=n.yAxisID=$e(s.yAxisID,Zu(e,"y")),a=n.rAxisID=$e(s.rAxisID,Zu(e,"r")),l=n.indexAxis,c=n.iAxisID=o(l,r,i,a),d=n.vAxisID=o(l,i,r,a);n.xScale=this.getScaleForId(r),n.yScale=this.getScaleForId(i),n.rScale=this.getScaleForId(a),n.iScale=this.getScaleForId(c),n.vScale=this.getScaleForId(d)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(e){return this.chart.scales[e]}_getOtherScale(e){let n=this._cachedMeta;return e===n.iScale?n.vScale:n.iScale}reset(){this._update("reset")}_destroy(){let e=this._cachedMeta;this._data&&Mu(this._data,this),e._stacked&&yi(e)}_dataCheck(){let e=this.getDataset(),n=e.data||(e.data=[]),s=this._data;if(Ae(n)){let o=this._cachedMeta;this._data=H5(n,o)}else if(s!==n){if(s){Mu(s,this);let o=this._cachedMeta;yi(o),o._parsed=[]}n&&Object.isExtensible(n)&&Ev(n,this),this._syncList=[],this._data=n}}addElements(){let e=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(e.dataset=new this.datasetElementType)}buildOrUpdateElements(e){let n=this._cachedMeta,s=this.getDataset(),o=!1;this._dataCheck();let r=n._stacked;n._stacked=Ju(n.vScale,n),n.stack!==s.stack&&(o=!0,yi(n),n.stack=s.stack),this._resyncElements(e),(o||r!==n._stacked)&&(n$(this,n._parsed),n._stacked=Ju(n.vScale,n))}configure(){let e=this.chart.config,n=e.datasetScopeKeys(this._type),s=e.getOptionScopes(this.getDataset(),n,!0);this.options=e.createResolver(s,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(e,n){let{_cachedMeta:s,_data:o}=this,{iScale:r,_stacked:i}=s,a=r.axis,l=e===0&&n===o.length?!0:s._sorted,c=e>0&&s._parsed[e-1],d,u,p;if(this._parsing===!1)s._parsed=o,s._sorted=!0,p=o;else{Ze(o[e])?p=this.parseArrayData(s,o,e,n):Ae(o[e])?p=this.parseObjectData(s,o,e,n):p=this.parsePrimitiveData(s,o,e,n);let f=()=>u[a]===null||c&&u[a]m||u=0;--p)if(!g()){this.updateRangeFromParsed(c,e,f,l);break}}return c}getAllParsedValues(e){let n=this._cachedMeta._parsed,s=[],o,r,i;for(o=0,r=n.length;o=0&&ethis.getContext(s,o,n),m=c.resolveNamedOptions(p,f,g,u);return m.$shared&&(m.$shared=l,r[i]=Object.freeze(s$(m,l))),m}_resolveAnimations(e,n,s){let o=this.chart,r=this._cachedDataOpts,i=`animation-${n}`,a=r[i];if(a)return a;let l;if(o.options.animation!==!1){let d=this.chart.config,u=d.datasetAnimationScopeKeys(this._type,n),p=d.getOptionScopes(this.getDataset(),u);l=d.createResolver(p,this.getContext(e,s,n))}let c=new Zl(o,l&&l.animations);return l&&l._cacheable&&(r[i]=Object.freeze(c)),c}getSharedOptions(e){if(e.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},e))}includeOptions(e,n){return!n||Yu(e)||this.chart._animationsDisabled}_getSharedOptions(e,n){let s=this.resolveDataElementOptions(e,n),o=this._sharedOptions,r=this.getSharedOptions(s),i=this.includeOptions(n,r)||r!==o;return this.updateSharedOptions(r,n,s),{sharedOptions:r,includeOptions:i}}updateElement(e,n,s,o){Yu(o)?Object.assign(e,s):this._resolveAnimations(n,o).update(e,s)}updateSharedOptions(e,n,s){e&&!Yu(n)&&this._resolveAnimations(void 0,n).update(e,s)}_setStyle(e,n,s,o){e.active=o;let r=this.getStyle(n,o);this._resolveAnimations(n,s,o).update(e,{options:!o&&this.getSharedOptions(r)||r})}removeHoverStyle(e,n,s){this._setStyle(e,s,"active",!1)}setHoverStyle(e,n,s){this._setStyle(e,s,"active",!0)}_removeDatasetHoverStyle(){let e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!1)}_setDatasetHoverStyle(){let e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!0)}_resyncElements(e){let n=this._data,s=this._cachedMeta.data;for(let[a,l,c]of this._syncList)this[a](l,c);this._syncList=[];let o=s.length,r=n.length,i=Math.min(r,o);i&&this.parse(0,i),r>o?this._insertElements(o,r-o,e):r{for(c.length+=n,a=c.length-1;a>=i;a--)c[a]=c[a-n]};for(l(r),a=e;ao-r))}return t._cache.$bar}function q5(t){let e=t.iScale,n=G5(e,t.type),s=e._length,o,r,i,a,l=()=>{i===32767||i===-32768||(ir(a)&&(s=Math.min(s,Math.abs(i-a)||s)),a=i)};for(o=0,r=n.length;o0?o[t-1]:null,a=tMath.abs(a)&&(l=a,c=i),e[n.axis]=c,e._custom={barStart:l,barEnd:c,start:o,end:r,min:i,max:a}}function X$(t,e,n,s){return Ze(t)?Y5(t,e,n,s):e[n.axis]=n.parse(t,s),e}function o$(t,e,n,s){let o=t.iScale,r=t.vScale,i=o.getLabels(),a=o===r,l=[],c,d,u,p;for(c=n,d=n+s;c=n?1:-1)}function Q5(t){let e,n,s,o,r;return t.horizontal?(e=t.base>t.x,n="left",s="right"):(e=t.based.controller.options.grouped),r=s.options.stacked,i=[],a=this._cachedMeta.controller.getParsed(n),l=a&&a[s.axis],c=d=>{let u=d._parsed.find(f=>f[s.axis]===l),p=u&&u[d.vScale.axis];if(Me(p)||isNaN(p))return!0};for(let d of o)if(!(n!==void 0&&c(d))&&((r===!1||i.indexOf(d.stack)===-1||r===void 0&&d.stack===void 0)&&i.push(d.stack),d.index===e))break;return i.length||i.push(void 0),i}_getStackCount(e){return this._getStacks(void 0,e).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){let e=this.chart.scales,n=this.chart.options.indexAxis;return Object.keys(e).filter(s=>e[s].axis===n).shift()}_getAxis(){let e={},n=this.getFirstScaleIdForIndexAxis();for(let s of this.chart.data.datasets)e[$e(this.chart.options.indexAxis==="x"?s.xAxisID:s.yAxisID,n)]=!0;return Object.keys(e)}_getStackIndex(e,n,s){let o=this._getStacks(e,s),r=n!==void 0?o.indexOf(n):-1;return r===-1?o.length-1:r}_getRuler(){let e=this.options,n=this._cachedMeta,s=n.iScale,o=[],r,i;for(r=0,i=n.data.length;r=0;--s)n=Math.max(n,e[s].size(this.resolveDataElementOptions(s))/2);return n>0&&n}getLabelAndValue(e){let n=this._cachedMeta,s=this.chart.data.labels||[],{xScale:o,yScale:r}=n,i=this.getParsed(e),a=o.getLabelForValue(i.x),l=r.getLabelForValue(i.y),c=i._custom;return{label:s[e]||"",value:"("+a+", "+l+(c?", "+c:"")+")"}}update(e){let n=this._cachedMeta.data;this.updateElements(n,0,n.length,e)}updateElements(e,n,s,o){let r=o==="reset",{iScale:i,vScale:a}=this._cachedMeta,{sharedOptions:l,includeOptions:c}=this._getSharedOptions(n,o),d=i.axis,u=a.axis;for(let p=n;plr(v,a,l,!0)?1:Math.max(w,w*n,$,$*n),g=(v,w,$)=>lr(v,a,l,!0)?-1:Math.min(w,w*n,$,$*n),m=f(0,c,u),h=f(dt,d,p),b=g(Oe,c,u),x=g(Oe+dt,d,p);s=(m-b)/2,o=(h-x)/2,r=-(m+b)/2,i=-(h+x)/2}return{ratioX:s,ratioY:o,offsetX:r,offsetY:i}}var _i=class extends Wn{static id="doughnut";static defaults={datasetElementType:!1,dataElementType:"arc",animation:{animateRotate:!0,animateScale:!1},animations:{numbers:{type:"number",properties:["circumference","endAngle","innerRadius","outerRadius","startAngle","x","y","offset","borderWidth","spacing"]}},cutout:"50%",rotation:0,circumference:360,radius:"100%",spacing:0,indexAxis:"r"};static descriptors={_scriptable:e=>e!=="spacing",_indexable:e=>e!=="spacing"&&!e.startsWith("borderDash")&&!e.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){let n=e.data,{labels:{pointStyle:s,textAlign:o,color:r,useBorderRadius:i,borderRadius:a}}=e.legend.options;return n.labels.length&&n.datasets.length?n.labels.map((l,c)=>{let u=e.getDatasetMeta(0).controller.getStyle(c);return{text:l,fillStyle:u.backgroundColor,fontColor:r,hidden:!e.getDataVisibility(c),lineDash:u.borderDash,lineDashOffset:u.borderDashOffset,lineJoin:u.borderJoinStyle,lineWidth:u.borderWidth,strokeStyle:u.borderColor,textAlign:o,pointStyle:s,borderRadius:i&&(a||u.borderRadius),index:c}}):[]}},onClick(e,n,s){s.chart.toggleDataVisibility(n.index),s.chart.update()}}}};constructor(e,n){super(e,n),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(e,n){let s=this.getDataset().data,o=this._cachedMeta;if(this._parsing===!1)o._parsed=s;else{let r=l=>+s[l];if(Ae(s[e])){let{key:l="value"}=this._parsing;r=c=>+os(s[c],l)}let i,a;for(i=e,a=e+n;i0&&!isNaN(e)?Ye*(Math.abs(e)/n):0}getLabelAndValue(e){let n=this._cachedMeta,s=this.chart,o=s.data.labels||[],r=cr(n._parsed[e],s.options.locale);return{label:o[e]||"",value:r}}getMaxBorderWidth(e){let n=0,s=this.chart,o,r,i,a,l;if(!e){for(o=0,r=s.data.datasets.length;o0&&this.getParsed(n-1);for(let $=0;$=x){C.skip=!0;continue}let _=this.getParsed($),k=Me(_[f]),A=C[p]=i.getPixelForValue(_[p],$),O=C[f]=r||k?a.getBasePixel():a.getPixelForValue(l?this.applyStack(a,_,l):_[f],$);C.skip=isNaN(A)||isNaN(O)||k,C.stop=$>0&&Math.abs(_[p]-w[p])>h,m&&(C.parsed=_,C.raw=c.data[$]),u&&(C.options=d||this.resolveDataElementOptions($,S.active?"active":o)),b||this.updateElement(S,$,C,o),w=_}}getMaxOverflow(){let e=this._cachedMeta,n=e.dataset,s=n.options&&n.options.borderWidth||0,o=e.data||[];if(!o.length)return s;let r=o[0].size(this.resolveDataElementOptions(0)),i=o[o.length-1].size(this.resolveDataElementOptions(o.length-1));return Math.max(s,r,i)/2}draw(){let e=this._cachedMeta;e.dataset.updateControlPoints(this.chart.chartArea,e.iScale.axis),super.draw()}},Yl=class extends Wn{static id="polarArea";static defaults={dataElementType:"arc",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:"number",properties:["x","y","startAngle","endAngle","innerRadius","outerRadius"]}},indexAxis:"r",startAngle:0};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){let n=e.data;if(n.labels.length&&n.datasets.length){let{labels:{pointStyle:s,color:o}}=e.legend.options;return n.labels.map((r,i)=>{let l=e.getDatasetMeta(0).controller.getStyle(i);return{text:r,fillStyle:l.backgroundColor,strokeStyle:l.borderColor,fontColor:o,lineWidth:l.borderWidth,pointStyle:s,hidden:!e.getDataVisibility(i),index:i}})}return[]}},onClick(e,n,s){s.chart.toggleDataVisibility(n.index),s.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(e,n){super(e,n),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(e){let n=this._cachedMeta,s=this.chart,o=s.data.labels||[],r=cr(n._parsed[e].r,s.options.locale);return{label:o[e]||"",value:r}}parseObjectData(e,n,s,o){return Wu.bind(this)(e,n,s,o)}update(e){let n=this._cachedMeta.data;this._updateRadius(),this.updateElements(n,0,n.length,e)}getMinMax(){let e=this._cachedMeta,n={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return e.data.forEach((s,o)=>{let r=this.getParsed(o).r;!isNaN(r)&&this.chart.getDataVisibility(o)&&(rn.max&&(n.max=r))}),n}_updateRadius(){let e=this.chart,n=e.chartArea,s=e.options,o=Math.min(n.right-n.left,n.bottom-n.top),r=Math.max(o/2,0),i=Math.max(s.cutoutPercentage?r/100*s.cutoutPercentage:1,0),a=(r-i)/e.getVisibleDatasetCount();this.outerRadius=r-a*this.index,this.innerRadius=this.outerRadius-a}updateElements(e,n,s,o){let r=o==="reset",i=this.chart,l=i.options.animation,c=this._cachedMeta.rScale,d=c.xCenter,u=c.yCenter,p=c.getIndexAngle(0)-.5*Oe,f=p,g,m=360/this.countVisibleElements();for(g=0;g{!isNaN(this.getParsed(o).r)&&this.chart.getDataVisibility(o)&&n++}),n}_computeAngle(e,n,s){return this.chart.getDataVisibility(e)?pn(this.resolveDataElementOptions(e,n).angle||s):0}},up=class extends _i{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},pp=class extends Wn{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(e){let n=this._cachedMeta.vScale,s=this.getParsed(e);return{label:n.getLabels()[e],value:""+n.getLabelForValue(s[n.axis])}}parseObjectData(e,n,s,o){return Wu.bind(this)(e,n,s,o)}update(e){let n=this._cachedMeta,s=n.dataset,o=n.data||[],r=n.iScale.getLabels();if(s.points=o,e!=="resize"){let i=this.resolveDatasetElementOptions(e);this.options.showLine||(i.borderWidth=0);let a={_loop:!0,_fullLoop:r.length===o.length,options:i};this.updateElement(s,void 0,a,e)}this.updateElements(o,0,o.length,e)}updateElements(e,n,s,o){let r=this._cachedMeta.rScale,i=o==="reset";for(let a=n;a0&&this.getParsed(n-1);for(let w=n;w0&&Math.abs(S[f]-v[f])>b,h&&(C.parsed=S,C.raw=c.data[w]),p&&(C.options=u||this.resolveDataElementOptions(w,$.active?"active":o)),x||this.updateElement($,w,C,o),v=S}this.updateSharedOptions(u,o,d)}getMaxOverflow(){let e=this._cachedMeta,n=e.data||[];if(!this.options.showLine){let a=0;for(let l=n.length-1;l>=0;--l)a=Math.max(a,n[l].size(this.resolveDataElementOptions(l))/2);return a>0&&a}let s=e.dataset,o=s.options&&s.options.borderWidth||0;if(!n.length)return o;let r=n[0].size(this.resolveDataElementOptions(0)),i=n[n.length-1].size(this.resolveDataElementOptions(n.length-1));return Math.max(o,r,i)/2}},oM=Object.freeze({__proto__:null,BarController:lp,BubbleController:cp,DoughnutController:_i,LineController:dp,PieController:up,PolarAreaController:Yl,RadarController:pp,ScatterController:fp});function lo(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}var hp=class t{static override(e){Object.assign(t.prototype,e)}options;constructor(e){this.options=e||{}}init(){}formats(){return lo()}parse(){return lo()}format(){return lo()}add(){return lo()}diff(){return lo()}startOf(){return lo()}endOf(){return lo()}},rM={_date:hp};function iM(t,e,n,s){let{controller:o,data:r,_sorted:i}=t,a=o._cachedMeta.iScale,l=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null;if(a&&e===a.axis&&e!=="r"&&i&&r.length){let c=a._reversePixels?Pv:Dn;if(s){if(o._sharedOptions){let d=r[0],u=typeof d.getRange=="function"&&d.getRange(e);if(u){let p=c(r,e,n-u),f=c(r,e,n+u);return{lo:p.lo,hi:f.hi}}}}else{let d=c(r,e,n);if(l){let{vScale:u}=o._cachedMeta,{_parsed:p}=t,f=p.slice(0,d.lo+1).reverse().findIndex(m=>!Me(m[u.axis]));d.lo-=Math.max(0,f);let g=p.slice(d.hi).findIndex(m=>!Me(m[u.axis]));d.hi+=Math.max(0,g)}return d}}return{lo:0,hi:r.length-1}}function Ri(t,e,n,s,o){let r=t.getSortedVisibleDatasetMetas(),i=n[e];for(let a=0,l=r.length;a{l[i]&&l[i](e[n],o)&&(r.push({element:l,datasetIndex:c,index:d}),a=a||l.inRange(e.x,e.y,o))}),s&&!a?[]:r}var dM={evaluateInteractionItems:Ri,modes:{index(t,e,n,s){let o=Rs(e,t),r=n.axis||"x",i=n.includeInvisible||!1,a=n.intersect?Qu(t,o,r,s,i):ep(t,o,r,!1,s,i),l=[];return a.length?(t.getSortedVisibleDatasetMetas().forEach(c=>{let d=a[0].index,u=c.data[d];u&&!u.skip&&l.push({element:u,datasetIndex:c.index,index:d})}),l):[]},dataset(t,e,n,s){let o=Rs(e,t),r=n.axis||"xy",i=n.includeInvisible||!1,a=n.intersect?Qu(t,o,r,s,i):ep(t,o,r,!1,s,i);if(a.length>0){let l=a[0].datasetIndex,c=t.getDatasetMeta(l).data;a=[];for(let d=0;dn.pos===e)}function l$(t,e){return t.filter(n=>Q$.indexOf(n.pos)===-1&&n.box.axis===e)}function $i(t,e){return t.sort((n,s)=>{let o=e?s:n,r=e?n:s;return o.weight===r.weight?o.index-r.index:o.weight-r.weight})}function uM(t){let e=[],n,s,o,r,i,a;for(n=0,s=(t||[]).length;nc.box.fullSize),!0),s=$i(vi(e,"left"),!0),o=$i(vi(e,"right")),r=$i(vi(e,"top"),!0),i=$i(vi(e,"bottom")),a=l$(e,"x"),l=l$(e,"y");return{fullSize:n,leftAndTop:s.concat(r),rightAndBottom:o.concat(l).concat(i).concat(a),chartArea:vi(e,"chartArea"),vertical:s.concat(o).concat(l),horizontal:r.concat(i).concat(a)}}function c$(t,e,n,s){return Math.max(t[n],e[n])+Math.max(t[s],e[s])}function e0(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function mM(t,e,n,s){let{pos:o,box:r}=n,i=t.maxPadding;if(!Ae(o)){n.size&&(t[o]-=n.size);let u=s[n.stack]||{size:0,count:1};u.size=Math.max(u.size,n.horizontal?r.height:r.width),n.size=u.size/u.count,t[o]+=n.size}r.getPadding&&e0(i,r.getPadding());let a=Math.max(0,e.outerWidth-c$(i,t,"left","right")),l=Math.max(0,e.outerHeight-c$(i,t,"top","bottom")),c=a!==t.w,d=l!==t.h;return t.w=a,t.h=l,n.horizontal?{same:c,other:d}:{same:d,other:c}}function gM(t){let e=t.maxPadding;function n(s){let o=Math.max(e[s]-t[s],0);return t[s]+=o,o}t.y+=n("top"),t.x+=n("left"),n("right"),n("bottom")}function bM(t,e){let n=e.maxPadding;function s(o){let r={left:0,top:0,right:0,bottom:0};return o.forEach(i=>{r[i]=Math.max(e[i],n[i])}),r}return s(t?["left","right"]:["top","bottom"])}function Si(t,e,n,s){let o=[],r,i,a,l,c,d;for(r=0,i=t.length,c=0;r{typeof m.beforeLayout=="function"&&m.beforeLayout()});let d=l.reduce((m,h)=>h.box.options&&h.box.options.display===!1?m:m+1,0)||1,u=Object.freeze({outerWidth:e,outerHeight:n,padding:o,availableWidth:r,availableHeight:i,vBoxMaxWidth:r/2/d,hBoxMaxHeight:i/2}),p=Object.assign({},o);e0(p,It(s));let f=Object.assign({maxPadding:p,w:r,h:i,x:o.left,y:o.top},o),g=fM(l.concat(c),u);Si(a.fullSize,f,u,g),Si(l,f,u,g),Si(c,f,u,g)&&Si(l,f,u,g),gM(f),d$(a.leftAndTop,f,u,g),f.x+=f.w,f.y+=f.h,d$(a.rightAndBottom,f,u,g),t.chartArea={left:f.left,top:f.top,right:f.left+f.w,bottom:f.top+f.h,height:f.h,width:f.w},ze(a.chartArea,m=>{let h=m.box;Object.assign(h,t.chartArea),h.update(f.w,f.h,{left:0,top:0,right:0,bottom:0})})}},Xl=class{acquireContext(e,n){}releaseContext(e){return!1}addEventListener(e,n,s){}removeEventListener(e,n,s){}getDevicePixelRatio(){return 1}getMaximumSize(e,n,s,o){return n=Math.max(0,n||e.width),s=s||e.height,{width:n,height:Math.max(0,o?Math.floor(n/o):s)}}isAttached(e){return!0}updateConfig(e){}},mp=class extends Xl{acquireContext(e){return e&&e.getContext&&e.getContext("2d")||null}updateConfig(e){e.options.animation=!1}},ql="$chartjs",xM={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},u$=t=>t===null||t==="";function yM(t,e){let n=t.style,s=t.getAttribute("height"),o=t.getAttribute("width");if(t[ql]={initial:{height:s,width:o,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||"block",n.boxSizing=n.boxSizing||"border-box",u$(o)){let r=ju(t,"width");r!==void 0&&(t.width=r)}if(u$(s))if(t.style.height==="")t.height=t.width/(e||2);else{let r=ju(t,"height");r!==void 0&&(t.height=r)}return t}var t0=Gv?{passive:!0}:!1;function vM(t,e,n){t&&t.addEventListener(e,n,t0)}function $M(t,e,n){t&&t.canvas&&t.canvas.removeEventListener(e,n,t0)}function wM(t,e){let n=xM[t.type]||t.type,{x:s,y:o}=Rs(t,e);return{type:n,chart:e,native:t,x:s!==void 0?s:null,y:o!==void 0?o:null}}function Ql(t,e){for(let n of t)if(n===e||n.contains(e))return!0}function kM(t,e,n){let s=t.canvas,o=new MutationObserver(r=>{let i=!1;for(let a of r)i=i||Ql(a.addedNodes,s),i=i&&!Ql(a.removedNodes,s);i&&n()});return o.observe(document,{childList:!0,subtree:!0}),o}function SM(t,e,n){let s=t.canvas,o=new MutationObserver(r=>{let i=!1;for(let a of r)i=i||Ql(a.removedNodes,s),i=i&&!Ql(a.addedNodes,s);i&&n()});return o.observe(document,{childList:!0,subtree:!0}),o}var Mi=new Map,p$=0;function n0(){let t=window.devicePixelRatio;t!==p$&&(p$=t,Mi.forEach((e,n)=>{n.currentDevicePixelRatio!==t&&e()}))}function CM(t,e){Mi.size||window.addEventListener("resize",n0),Mi.set(t,e)}function _M(t){Mi.delete(t),Mi.size||window.removeEventListener("resize",n0)}function MM(t,e,n){let s=t.canvas,o=s&&Hl(s);if(!o)return;let r=Pu((a,l)=>{let c=o.clientWidth;n(a,l),c{let l=a[0],c=l.contentRect.width,d=l.contentRect.height;c===0&&d===0||r(c,d)});return i.observe(o),CM(t,r),i}function tp(t,e,n){n&&n.disconnect(),e==="resize"&&_M(t)}function AM(t,e,n){let s=t.canvas,o=Pu(r=>{t.ctx!==null&&n(wM(r,t))},t);return vM(s,e,o),o}var gp=class extends Xl{acquireContext(e,n){let s=e&&e.getContext&&e.getContext("2d");return s&&s.canvas===e?(yM(e,n),s):null}releaseContext(e){let n=e.canvas;if(!n[ql])return!1;let s=n[ql].initial;["height","width"].forEach(r=>{let i=s[r];Me(i)?n.removeAttribute(r):n.setAttribute(r,i)});let o=s.style||{};return Object.keys(o).forEach(r=>{n.style[r]=o[r]}),n.width=n.width,delete n[ql],!0}addEventListener(e,n,s){this.removeEventListener(e,n);let o=e.$proxies||(e.$proxies={}),i={attach:kM,detach:SM,resize:MM}[n]||AM;o[n]=i(e,n,s)}removeEventListener(e,n){let s=e.$proxies||(e.$proxies={}),o=s[n];if(!o)return;({attach:tp,detach:tp,resize:tp}[n]||$M)(e,n,o),s[n]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(e,n,s,o){return Kv(e,n,s,o)}isAttached(e){let n=e&&Hl(e);return!!(n&&n.isConnected)}};function TM(t){return!Fl()||typeof OffscreenCanvas<"u"&&t instanceof OffscreenCanvas?mp:gp}var kn=class{static defaults={};static defaultRoutes=void 0;x;y;active=!1;options;$animations;tooltipPosition(e){let{x:n,y:s}=this.getProps(["x","y"],e);return{x:n,y:s}}hasValue(){return io(this.x)&&io(this.y)}getProps(e,n){let s=this.$animations;if(!n||!s)return this;let o={};return e.forEach(r=>{o[r]=s[r]&&s[r].active()?s[r]._to:this[r]}),o}};function PM(t,e){let n=t.options.ticks,s=RM(t),o=Math.min(n.maxTicksLimit||s,s),r=n.major.enabled?EM(e):[],i=r.length,a=r[0],l=r[i-1],c=[];if(i>o)return IM(e,c,r,i/o),c;let d=LM(r,e,o);if(i>0){let u,p,f=i>1?Math.round((l-a)/(i-1)):null;for(jl(e,c,d,Me(f)?0:a-f,a),u=0,p=i-1;uo)return l}return Math.max(o,1)}function EM(t){let e=[],n,s;for(n=0,s=t.length;nt==="left"?"right":t==="right"?"left":t,f$=(t,e,n)=>e==="top"||e==="left"?t[e]+n:t[e]-n,h$=(t,e)=>Math.min(e||t,t);function m$(t,e){let n=[],s=t.length/e,o=t.length,r=0;for(;ri+a)))return l}function BM(t,e){ze(t,n=>{let s=n.gc,o=s.length/2,r;if(o>e){for(r=0;rs?s:n,s=o&&n>s?n:s,{min:Zt(n,Zt(s,n)),max:Zt(s,Zt(n,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){let e=this.chart.data;return this.options.labels||(this.isHorizontal()?e.xLabels:e.yLabels)||e.labels||[]}getLabelItems(e=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(e))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){qe(this.options.beforeUpdate,[this])}update(e,n,s){let{beginAtZero:o,grace:r,ticks:i}=this.options,a=i.sampleSize;this.beforeUpdate(),this.maxWidth=e,this.maxHeight=n,this._margins=s=Object.assign({left:0,right:0,top:0,bottom:0},s),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+s.left+s.right:this.height+s.top+s.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Hv(this,r,o),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();let l=a=r||s<=1||!this.isHorizontal()){this.labelRotation=o;return}let d=this._getLabelSizes(),u=d.widest.width,p=d.highest.height,f=yt(this.chart.width-u,0,this.maxWidth);a=e.offset?this.maxWidth/s:f/(s-1),u+6>a&&(a=f/(s-(e.offset?.5:1)),l=this.maxHeight-wi(e.grid)-n.padding-g$(e.title,this.chart.options.font),c=Math.sqrt(u*u+p*p),i=Ll(Math.min(Math.asin(yt((d.highest.height+6)/a,-1,1)),Math.asin(yt(l/c,-1,1))-Math.asin(yt(p/c,-1,1)))),i=Math.max(o,Math.min(r,i))),this.labelRotation=i}afterCalculateLabelRotation(){qe(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){qe(this.options.beforeFit,[this])}fit(){let e={width:0,height:0},{chart:n,options:{ticks:s,title:o,grid:r}}=this,i=this._isVisible(),a=this.isHorizontal();if(i){let l=g$(o,n.options.font);if(a?(e.width=this.maxWidth,e.height=wi(r)+l):(e.height=this.maxHeight,e.width=wi(r)+l),s.display&&this.ticks.length){let{first:c,last:d,widest:u,highest:p}=this._getLabelSizes(),f=s.padding*2,g=pn(this.labelRotation),m=Math.cos(g),h=Math.sin(g);if(a){let b=s.mirror?0:h*u.width+m*p.height;e.height=Math.min(this.maxHeight,e.height+b+f)}else{let b=s.mirror?0:m*u.width+h*p.height;e.width=Math.min(this.maxWidth,e.width+b+f)}this._calculatePadding(c,d,h,m)}}this._handleMargins(),a?(this.width=this._length=n.width-this._margins.left-this._margins.right,this.height=e.height):(this.width=e.width,this.height=this._length=n.height-this._margins.top-this._margins.bottom)}_calculatePadding(e,n,s,o){let{ticks:{align:r,padding:i},position:a}=this.options,l=this.labelRotation!==0,c=a!=="top"&&this.axis==="x";if(this.isHorizontal()){let d=this.getPixelForTick(0)-this.left,u=this.right-this.getPixelForTick(this.ticks.length-1),p=0,f=0;l?c?(p=o*e.width,f=s*n.height):(p=s*e.height,f=o*n.width):r==="start"?f=n.width:r==="end"?p=e.width:r!=="inner"&&(p=e.width/2,f=n.width/2),this.paddingLeft=Math.max((p-d+i)*this.width/(this.width-d),0),this.paddingRight=Math.max((f-u+i)*this.width/(this.width-u),0)}else{let d=n.height/2,u=e.height/2;r==="start"?(d=0,u=e.height):r==="end"&&(d=n.height,u=0),this.paddingTop=d+i,this.paddingBottom=u+i}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){qe(this.options.afterFit,[this])}isHorizontal(){let{axis:e,position:n}=this.options;return n==="top"||n==="bottom"||e==="x"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(e){this.beforeTickToLabelConversion(),this.generateTickLabels(e);let n,s;for(n=0,s=e.length;n({width:i[k]||0,height:a[k]||0});return{first:_(0),last:_(n-1),widest:_(S),highest:_(C),widths:i,heights:a}}getLabelForValue(e){return e}getPixelForValue(e,n){return NaN}getValueForPixel(e){}getPixelForTick(e){let n=this.ticks;return e<0||e>n.length-1?null:this.getPixelForValue(n[e].value)}getPixelForDecimal(e){this._reversePixels&&(e=1-e);let n=this._startPixel+e*this._length;return Tv(this._alignToPixels?As(this.chart,n,0):n)}getDecimalForPixel(e){let n=(e-this._startPixel)/this._length;return this._reversePixels?1-n:n}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){let{min:e,max:n}=this;return e<0&&n<0?n:e>0&&n>0?e:0}getContext(e){let n=this.ticks||[];if(e>=0&&ea*o?a/s:l/o:l*o0}_computeGridLineItems(e){let n=this.axis,s=this.chart,o=this.options,{grid:r,position:i,border:a}=o,l=r.offset,c=this.isHorizontal(),u=this.ticks.length+(l?1:0),p=wi(r),f=[],g=a.setContext(this.getContext()),m=g.display?g.width:0,h=m/2,b=function(R){return As(s,R,m)},x,v,w,$,S,C,_,k,A,O,D,z;if(i==="top")x=b(this.bottom),C=this.bottom-p,k=x-h,O=b(e.top)+h,z=e.bottom;else if(i==="bottom")x=b(this.top),O=e.top,z=b(e.bottom)-h,C=x+h,k=this.top+p;else if(i==="left")x=b(this.right),S=this.right-p,_=x-h,A=b(e.left)+h,D=e.right;else if(i==="right")x=b(this.left),A=e.left,D=b(e.right)-h,S=x+h,_=this.left+p;else if(n==="x"){if(i==="center")x=b((e.top+e.bottom)/2+.5);else if(Ae(i)){let R=Object.keys(i)[0],B=i[R];x=b(this.chart.scales[R].getPixelForValue(B))}O=e.top,z=e.bottom,C=x+h,k=C+p}else if(n==="y"){if(i==="center")x=b((e.left+e.right)/2);else if(Ae(i)){let R=Object.keys(i)[0],B=i[R];x=b(this.chart.scales[R].getPixelForValue(B))}S=x-h,_=S-p,A=e.left,D=e.right}let E=$e(o.ticks.maxTicksLimit,u),M=Math.max(1,Math.ceil(u/E));for(v=0;v0&&(de-=j/2);break}G={left:de,top:X,width:j+se.width,height:K+se.height,color:M.backdropColor}}h.push({label:w,font:k,textOffset:D,options:{rotation:m,color:B,strokeColor:I,strokeWidth:U,textAlign:W,textBaseline:z,translation:[$,S],backdrop:G}})}return h}_getXAxisLabelAlignment(){let{position:e,ticks:n}=this.options;if(-pn(this.labelRotation))return e==="top"?"left":"right";let o="center";return n.align==="start"?o="left":n.align==="end"?o="right":n.align==="inner"&&(o="inner"),o}_getYAxisLabelAlignment(e){let{position:n,ticks:{crossAlign:s,mirror:o,padding:r}}=this.options,i=this._getLabelSizes(),a=e+r,l=i.widest.width,c,d;return n==="left"?o?(d=this.right+r,s==="near"?c="left":s==="center"?(c="center",d+=l/2):(c="right",d+=l)):(d=this.right-a,s==="near"?c="right":s==="center"?(c="center",d-=l/2):(c="left",d=this.left)):n==="right"?o?(d=this.left+r,s==="near"?c="right":s==="center"?(c="center",d-=l/2):(c="left",d-=l)):(d=this.left+a,s==="near"?c="left":s==="center"?(c="center",d+=l/2):(c="right",d=this.right)):c="right",{textAlign:c,x:d}}_computeLabelArea(){if(this.options.ticks.mirror)return;let e=this.chart,n=this.options.position;if(n==="left"||n==="right")return{top:0,left:this.left,bottom:e.height,right:this.right};if(n==="top"||n==="bottom")return{top:this.top,left:0,bottom:this.bottom,right:e.width}}drawBackground(){let{ctx:e,options:{backgroundColor:n},left:s,top:o,width:r,height:i}=this;n&&(e.save(),e.fillStyle=n,e.fillRect(s,o,r,i),e.restore())}getLineWidthForValue(e){let n=this.options.grid;if(!this._isVisible()||!n.display)return 0;let o=this.ticks.findIndex(r=>r.value===e);return o>=0?n.setContext(this.getContext(o)).lineWidth:0}drawGrid(e){let n=this.options.grid,s=this.ctx,o=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(e)),r,i,a=(l,c,d)=>{!d.width||!d.color||(s.save(),s.lineWidth=d.width,s.strokeStyle=d.color,s.setLineDash(d.borderDash||[]),s.lineDashOffset=d.borderDashOffset,s.beginPath(),s.moveTo(l.x,l.y),s.lineTo(c.x,c.y),s.stroke(),s.restore())};if(n.display)for(r=0,i=o.length;r{this.draw(r)}}]:[{z:s,draw:r=>{this.drawBackground(),this.drawGrid(r),this.drawTitle()}},{z:o,draw:()=>{this.drawBorder()}},{z:n,draw:r=>{this.drawLabels(r)}}]}getMatchingVisibleMetas(e){let n=this.chart.getSortedVisibleDatasetMetas(),s=this.axis+"AxisID",o=[],r,i;for(r=0,i=n.length;r{let s=n.split("."),o=s.pop(),r=[t].concat(s).join("."),i=e[n].split("."),a=i.pop(),l=i.join(".");Qe.route(r,o,l,a)})}function UM(t){return"id"in t&&"defaults"in t}var bp=class{constructor(){this.controllers=new fr(Wn,"datasets",!0),this.elements=new fr(kn,"elements"),this.plugins=new fr(Object,"plugins"),this.scales=new fr(uo,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...e){this._each("register",e)}remove(...e){this._each("unregister",e)}addControllers(...e){this._each("register",e,this.controllers)}addElements(...e){this._each("register",e,this.elements)}addPlugins(...e){this._each("register",e,this.plugins)}addScales(...e){this._each("register",e,this.scales)}getController(e){return this._get(e,this.controllers,"controller")}getElement(e){return this._get(e,this.elements,"element")}getPlugin(e){return this._get(e,this.plugins,"plugin")}getScale(e){return this._get(e,this.scales,"scale")}removeControllers(...e){this._each("unregister",e,this.controllers)}removeElements(...e){this._each("unregister",e,this.elements)}removePlugins(...e){this._each("unregister",e,this.plugins)}removeScales(...e){this._each("unregister",e,this.scales)}_each(e,n,s){[...n].forEach(o=>{let r=s||this._getRegistryForType(o);s||r.isForType(o)||r===this.plugins&&o.id?this._exec(e,r,o):ze(o,i=>{let a=s||this._getRegistryForType(i);this._exec(e,a,i)})})}_exec(e,n,s){let o=Rl(e);qe(s["before"+o],[],s),n[e](s),qe(s["after"+o],[],s)}_getRegistryForType(e){for(let n=0;nr.filter(a=>!i.some(l=>a.plugin.id===l.plugin.id));this._notify(o(n,s),e,"stop"),this._notify(o(s,n),e,"start")}};function KM(t){let e={},n=[],s=Object.keys(Hn.plugins.items);for(let r=0;r1&&b$(t[0].toLowerCase());if(s)return s}throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function x$(t,e,n){if(n[e+"AxisID"]===t)return{axis:e}}function QM(t,e){if(e.data&&e.data.datasets){let n=e.data.datasets.filter(s=>s.xAxisID===t||s.yAxisID===t);if(n.length)return x$(t,"x",n[0])||x$(t,"y",n[0])}return{}}function eA(t,e){let n=Ms[t.type]||{scales:{}},s=e.scales||{},o=yp(t.type,e),r=Object.create(null);return Object.keys(s).forEach(i=>{let a=s[i];if(!Ae(a))return console.error(`Invalid scale configuration for scale: ${i}`);if(a._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${i}`);let l=vp(i,a,QM(i,t),Qe.scales[a.type]),c=YM(l,o),d=n.scales||{};r[i]=rr(Object.create(null),[{axis:l},a,d[l],d[c]])}),t.data.datasets.forEach(i=>{let a=i.type||t.type,l=i.indexAxis||yp(a,e),d=(Ms[a]||{}).scales||{};Object.keys(d).forEach(u=>{let p=ZM(u,l),f=i[p+"AxisID"]||p;r[f]=r[f]||Object.create(null),rr(r[f],[{axis:p},s[f],d[u]])})}),Object.keys(r).forEach(i=>{let a=r[i];rr(a,[Qe.scales[a.type],Qe.scale])}),r}function s0(t){let e=t.options||(t.options={});e.plugins=$e(e.plugins,{}),e.scales=eA(t,e)}function o0(t){return t=t||{},t.datasets=t.datasets||[],t.labels=t.labels||[],t}function tA(t){return t=t||{},t.data=o0(t.data),s0(t),t}var y$=new Map,r0=new Set;function zl(t,e){let n=y$.get(t);return n||(n=e(),y$.set(t,n),r0.add(n)),n}var ki=(t,e,n)=>{let s=os(e,n);s!==void 0&&t.add(s)},$p=class{constructor(e){this._config=tA(e),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(e){this._config.type=e}get data(){return this._config.data}set data(e){this._config.data=o0(e)}get options(){return this._config.options}set options(e){this._config.options=e}get plugins(){return this._config.plugins}update(){let e=this._config;this.clearCache(),s0(e)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(e){return zl(e,()=>[[`datasets.${e}`,""]])}datasetAnimationScopeKeys(e,n){return zl(`${e}.transition.${n}`,()=>[[`datasets.${e}.transitions.${n}`,`transitions.${n}`],[`datasets.${e}`,""]])}datasetElementScopeKeys(e,n){return zl(`${e}-${n}`,()=>[[`datasets.${e}.elements.${n}`,`datasets.${e}`,`elements.${n}`,""]])}pluginScopeKeys(e){let n=e.id,s=this.type;return zl(`${s}-plugin-${n}`,()=>[[`plugins.${n}`,...e.additionalOptionScopes||[]]])}_cachedScopes(e,n){let s=this._scopeCache,o=s.get(e);return(!o||n)&&(o=new Map,s.set(e,o)),o}getOptionScopes(e,n,s){let{options:o,type:r}=this,i=this._cachedScopes(e,s),a=i.get(n);if(a)return a;let l=new Set;n.forEach(d=>{e&&(l.add(e),d.forEach(u=>ki(l,e,u))),d.forEach(u=>ki(l,o,u)),d.forEach(u=>ki(l,Ms[r]||{},u)),d.forEach(u=>ki(l,Qe,u)),d.forEach(u=>ki(l,Dl,u))});let c=Array.from(l);return c.length===0&&c.push(Object.create(null)),r0.has(n)&&i.set(n,c),c}chartOptionScopes(){let{options:e,type:n}=this;return[e,Ms[n]||{},Qe.datasets[n]||{},{type:n},Qe,Dl]}resolveNamedOptions(e,n,s,o=[""]){let r={$shared:!0},{resolver:i,subPrefixes:a}=v$(this._resolverCache,e,o),l=i;if(sA(i,n)){r.$shared=!1,s=ns(s)?s():s;let c=this.createResolver(e,s,a);l=ro(i,s,c)}for(let c of n)r[c]=l[c];return r}createResolver(e,n,s=[""],o){let{resolver:r}=v$(this._resolverCache,e,s);return Ae(n)?ro(r,n,void 0,o):r}};function v$(t,e,n){let s=t.get(e);s||(s=new Map,t.set(e,s));let o=n.join(),r=s.get(o);return r||(r={resolver:Bl(e,n),subPrefixes:n.filter(a=>!a.toLowerCase().includes("hover"))},s.set(o,r)),r}var nA=t=>Ae(t)&&Object.getOwnPropertyNames(t).some(e=>ns(t[e]));function sA(t,e){let{isScriptable:n,isIndexable:s}=Bu(t);for(let o of e){let r=n(o),i=s(o),a=(i||r)&&t[o];if(r&&(ns(a)||nA(a))||i&&Ze(a))return!0}return!1}var oA="4.5.1",rA=["top","bottom","left","right","chartArea"];function $$(t,e){return t==="top"||t==="bottom"||rA.indexOf(t)===-1&&e==="x"}function w$(t,e){return function(n,s){return n[t]===s[t]?n[e]-s[e]:n[t]-s[t]}}function k$(t){let e=t.chart,n=e.options.animation;e.notifyPlugins("afterRender"),qe(n&&n.onComplete,[t],e)}function iA(t){let e=t.chart,n=e.options.animation;qe(n&&n.onProgress,[t],e)}function i0(t){return Fl()&&typeof t=="string"?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}var Jl={},S$=t=>{let e=i0(t);return Object.values(Jl).filter(n=>n.canvas===e).pop()};function aA(t,e,n){let s=Object.keys(t);for(let o of s){let r=+o;if(r>=e){let i=t[o];delete t[o],(n>0||r>e)&&(t[r+n]=i)}}}function lA(t,e,n,s){return!n||t.type==="mouseout"?null:s?e:t}var hr=class{static defaults=Qe;static instances=Jl;static overrides=Ms;static registry=Hn;static version=oA;static getChart=S$;static register(...e){Hn.add(...e),C$()}static unregister(...e){Hn.remove(...e),C$()}constructor(e,n){let s=this.config=new $p(n),o=i0(e),r=S$(o);if(r)throw new Error("Canvas is already in use. Chart with ID '"+r.id+"' must be destroyed before the canvas with ID '"+r.canvas.id+"' can be reused.");let i=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||TM(o)),this.platform.updateConfig(s);let a=this.platform.acquireContext(o,i.aspectRatio),l=a&&a.canvas,c=l&&l.height,d=l&&l.width;if(this.id=kv(),this.ctx=a,this.canvas=l,this.width=d,this.height=c,this._options=i,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new xp,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=Iv(u=>this.update(u),i.resizeDelay||0),this._dataChanges=[],Jl[this.id]=this,!a||!l){console.error("Failed to create chart: can't acquire context from the given item");return}is.listen(this,"complete",k$),is.listen(this,"progress",iA),this._initialize(),this.attached&&this.update()}get aspectRatio(){let{options:{aspectRatio:e,maintainAspectRatio:n},width:s,height:o,_aspectRatio:r}=this;return Me(e)?n&&r?r:o?s/o:null:e}get data(){return this.config.data}set data(e){this.config.data=e}get options(){return this._options}set options(e){this.config.options=e}get registry(){return Hn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():Vu(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Du(this.canvas,this.ctx),this}stop(){return is.stop(this),this}resize(e,n){is.running(this)?this._resizeBeforeDraw={width:e,height:n}:this._resize(e,n)}_resize(e,n){let s=this.options,o=this.canvas,r=s.maintainAspectRatio&&this.aspectRatio,i=this.platform.getMaximumSize(o,e,n,r),a=s.devicePixelRatio||this.platform.getDevicePixelRatio(),l=this.width?"resize":"attach";this.width=i.width,this.height=i.height,this._aspectRatio=this.aspectRatio,Vu(this,a,!0)&&(this.notifyPlugins("resize",{size:i}),qe(s.onResize,[this,i],this),this.attached&&this._doResize(l)&&this.render())}ensureScalesHaveIDs(){let n=this.options.scales||{};ze(n,(s,o)=>{s.id=o})}buildOrUpdateScales(){let e=this.options,n=e.scales,s=this.scales,o=Object.keys(s).reduce((i,a)=>(i[a]=!1,i),{}),r=[];n&&(r=r.concat(Object.keys(n).map(i=>{let a=n[i],l=vp(i,a),c=l==="r",d=l==="x";return{options:a,dposition:c?"chartArea":d?"bottom":"left",dtype:c?"radialLinear":d?"category":"linear"}}))),ze(r,i=>{let a=i.options,l=a.id,c=vp(l,a),d=$e(a.type,i.dtype);(a.position===void 0||$$(a.position,c)!==$$(i.dposition))&&(a.position=i.dposition),o[l]=!0;let u=null;if(l in s&&s[l].type===d)u=s[l];else{let p=Hn.getScale(d);u=new p({id:l,type:d,ctx:this.ctx,chart:this}),s[u.id]=u}u.init(a,e)}),ze(o,(i,a)=>{i||delete s[a]}),ze(s,i=>{Ft.configure(this,i,i.options),Ft.addBox(this,i)})}_updateMetasets(){let e=this._metasets,n=this.data.datasets.length,s=e.length;if(e.sort((o,r)=>o.index-r.index),s>n){for(let o=n;on.length&&delete this._stacks,e.forEach((s,o)=>{n.filter(r=>r===s._dataset).length===0&&this._destroyDatasetMeta(o)})}buildOrUpdateControllers(){let e=[],n=this.data.datasets,s,o;for(this._removeUnreferencedMetasets(),s=0,o=n.length;s{this.getDatasetMeta(n).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(e){let n=this.config;n.update();let s=this._options=n.createResolver(n.chartOptionScopes(),this.getContext()),o=this._animationsDisabled=!s.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins("beforeUpdate",{mode:e,cancelable:!0})===!1)return;let r=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let i=0;for(let c=0,d=this.data.datasets.length;c{c.reset()}),this._updateDatasets(e),this.notifyPlugins("afterUpdate",{mode:e}),this._layers.sort(w$("z","_idx"));let{_active:a,_lastEvent:l}=this;l?this._eventHandler(l,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){ze(this.scales,e=>{Ft.removeBox(this,e)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){let e=this.options,n=new Set(Object.keys(this._listeners)),s=new Set(e.events);(!wu(n,s)||!!this._responsiveListeners!==e.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){let{_hiddenIndices:e}=this,n=this._getUniformDataChanges()||[];for(let{method:s,start:o,count:r}of n){let i=s==="_removeElements"?-r:r;aA(e,o,i)}}_getUniformDataChanges(){let e=this._dataChanges;if(!e||!e.length)return;this._dataChanges=[];let n=this.data.datasets.length,s=r=>new Set(e.filter(i=>i[0]===r).map((i,a)=>a+","+i.splice(1).join(","))),o=s(0);for(let r=1;rr.split(",")).map(r=>({method:r[1],start:+r[2],count:+r[3]}))}_updateLayout(e){if(this.notifyPlugins("beforeLayout",{cancelable:!0})===!1)return;Ft.update(this,this.width,this.height,e);let n=this.chartArea,s=n.width<=0||n.height<=0;this._layers=[],ze(this.boxes,o=>{s&&o.position==="chartArea"||(o.configure&&o.configure(),this._layers.push(...o._layers()))},this),this._layers.forEach((o,r)=>{o._idx=r}),this.notifyPlugins("afterLayout")}_updateDatasets(e){if(this.notifyPlugins("beforeDatasetsUpdate",{mode:e,cancelable:!0})!==!1){for(let n=0,s=this.data.datasets.length;n=0;--n)this._drawDataset(e[n]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(e){let n=this.ctx,s={meta:e,index:e.index,cancelable:!0},o=qu(this,e);this.notifyPlugins("beforeDatasetDraw",s)!==!1&&(o&&bi(n,o),e.controller.draw(),o&&xi(n),s.cancelable=!1,this.notifyPlugins("afterDatasetDraw",s))}isPointInArea(e){return Nn(e,this.chartArea,this._minPadding)}getElementsAtEventForMode(e,n,s,o){let r=dM.modes[n];return typeof r=="function"?r(this,e,s,o):[]}getDatasetMeta(e){let n=this.data.datasets[e],s=this._metasets,o=s.filter(r=>r&&r._dataset===n).pop();return o||(o={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:n&&n.order||0,index:e,_dataset:n,_parsed:[],_sorted:!1},s.push(o)),o}getContext(){return this.$context||(this.$context=rs(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(e){let n=this.data.datasets[e];if(!n)return!1;let s=this.getDatasetMeta(e);return typeof s.hidden=="boolean"?!s.hidden:!n.hidden}setDatasetVisibility(e,n){let s=this.getDatasetMeta(e);s.hidden=!n}toggleDataVisibility(e){this._hiddenIndices[e]=!this._hiddenIndices[e]}getDataVisibility(e){return!this._hiddenIndices[e]}_updateVisibility(e,n,s){let o=s?"show":"hide",r=this.getDatasetMeta(e),i=r.controller._resolveAnimations(void 0,o);ir(n)?(r.data[n].hidden=!s,this.update()):(this.setDatasetVisibility(e,s),i.update(r,{visible:s}),this.update(a=>a.datasetIndex===e?o:void 0))}hide(e,n){this._updateVisibility(e,n,!1)}show(e,n){this._updateVisibility(e,n,!0)}_destroyDatasetMeta(e){let n=this._metasets[e];n&&n.controller&&n.controller._destroy(),delete this._metasets[e]}_stop(){let e,n;for(this.stop(),is.remove(this),e=0,n=this.data.datasets.length;e{n.addEventListener(this,r,i),e[r]=i},o=(r,i,a)=>{r.offsetX=i,r.offsetY=a,this._eventHandler(r)};ze(this.options.events,r=>s(r,o))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});let e=this._responsiveListeners,n=this.platform,s=(l,c)=>{n.addEventListener(this,l,c),e[l]=c},o=(l,c)=>{e[l]&&(n.removeEventListener(this,l,c),delete e[l])},r=(l,c)=>{this.canvas&&this.resize(l,c)},i,a=()=>{o("attach",a),this.attached=!0,this.resize(),s("resize",r),s("detach",i)};i=()=>{this.attached=!1,o("resize",r),this._stop(),this._resize(0,0),s("attach",a)},n.isAttached(this.canvas)?a():i()}unbindEvents(){ze(this._listeners,(e,n)=>{this.platform.removeEventListener(this,n,e)}),this._listeners={},ze(this._responsiveListeners,(e,n)=>{this.platform.removeEventListener(this,n,e)}),this._responsiveListeners=void 0}updateHoverStyle(e,n,s){let o=s?"set":"remove",r,i,a,l;for(n==="dataset"&&(r=this.getDatasetMeta(e[0].datasetIndex),r.controller["_"+o+"DatasetHoverStyle"]()),a=0,l=e.length;a{let a=this.getDatasetMeta(r);if(!a)throw new Error("No dataset found at index "+r);return{datasetIndex:r,element:a.data[i],index:i}});!mi(s,n)&&(this._active=s,this._lastEvent=null,this._updateHoverStyles(s,n))}notifyPlugins(e,n,s){return this._plugins.notify(this,e,n,s)}isPluginEnabled(e){return this._plugins._cache.filter(n=>n.plugin.id===e).length===1}_updateHoverStyles(e,n,s){let o=this.options.hover,r=(l,c)=>l.filter(d=>!c.some(u=>d.datasetIndex===u.datasetIndex&&d.index===u.index)),i=r(n,e),a=s?e:r(e,n);i.length&&this.updateHoverStyle(i,o.mode,!1),a.length&&o.mode&&this.updateHoverStyle(a,o.mode,!0)}_eventHandler(e,n){let s={event:e,replay:n,cancelable:!0,inChartArea:this.isPointInArea(e)},o=i=>(i.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins("beforeEvent",s,o)===!1)return;let r=this._handleEvent(e,n,s.inChartArea);return s.cancelable=!1,this.notifyPlugins("afterEvent",s,o),(r||s.changed)&&this.render(),this}_handleEvent(e,n,s){let{_active:o=[],options:r}=this,i=n,a=this._getActiveElements(e,o,s,i),l=_v(e),c=lA(e,this._lastEvent,s,l);s&&(this._lastEvent=null,qe(r.onHover,[e,a,this],this),l&&qe(r.onClick,[e,a,this],this));let d=!mi(a,o);return(d||n)&&(this._active=a,this._updateHoverStyles(a,o,n)),this._lastEvent=c,d}_getActiveElements(e,n,s,o){if(e.type==="mouseout")return[];if(!s)return n;let r=this.options.hover;return this.getElementsAtEventForMode(e,r.mode,r,o)}};function C$(){return ze(hr.instances,t=>t._plugins.invalidate())}function cA(t,e,n){let{startAngle:s,x:o,y:r,outerRadius:i,innerRadius:a,options:l}=e,{borderWidth:c,borderJoinStyle:d}=l,u=Math.min(c/i,Lt(s-n));if(t.beginPath(),t.arc(o,r,i-c/2,s+u/2,n-u/2),a>0){let p=Math.min(c/a,Lt(s-n));t.arc(o,r,a+c/2,n-p/2,s+p/2,!0)}else{let p=Math.min(c/2,i*Lt(s-n));if(d==="round")t.arc(o,r,p,n-Oe/2,s+Oe/2,!0);else if(d==="bevel"){let f=2*p*p,g=-f*Math.cos(n+Oe/2)+o,m=-f*Math.sin(n+Oe/2)+r,h=f*Math.cos(s+Oe/2)+o,b=f*Math.sin(s+Oe/2)+r;t.lineTo(g,m),t.lineTo(h,b)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}function dA(t,e,n){let{startAngle:s,pixelMargin:o,x:r,y:i,outerRadius:a,innerRadius:l}=e,c=o/a;t.beginPath(),t.arc(r,i,a,s-c,n+c),l>o?(c=o/l,t.arc(r,i,l,n+c,s-c,!0)):t.arc(r,i,o,n+dt,s-dt),t.closePath(),t.clip()}function uA(t){return Ol(t,["outerStart","outerEnd","innerStart","innerEnd"])}function pA(t,e,n,s){let o=uA(t.options.borderRadius),r=(n-e)/2,i=Math.min(r,s*e/2),a=l=>{let c=(n-Math.min(r,l))*s/2;return yt(l,0,Math.min(r,c))};return{outerStart:a(o.outerStart),outerEnd:a(o.outerEnd),innerStart:yt(o.innerStart,0,i),innerEnd:yt(o.innerEnd,0,i)}}function pr(t,e,n,s){return{x:n+t*Math.cos(e),y:s+t*Math.sin(e)}}function ec(t,e,n,s,o,r){let{x:i,y:a,startAngle:l,pixelMargin:c,innerRadius:d}=e,u=Math.max(e.outerRadius+s+n-c,0),p=d>0?d+s+n+c:0,f=0,g=o-l;if(s){let M=d>0?d-s:0,R=u>0?u-s:0,B=(M+R)/2,I=B!==0?g*B/(B+s):g;f=(g-I)/2}let m=Math.max(.001,g*u-n/Oe)/u,h=(g-m)/2,b=l+h+f,x=o-h-f,{outerStart:v,outerEnd:w,innerStart:$,innerEnd:S}=pA(e,p,u,x-b),C=u-v,_=u-w,k=b+v/C,A=x-w/_,O=p+$,D=p+S,z=b+$/O,E=x-S/D;if(t.beginPath(),r){let M=(k+A)/2;if(t.arc(i,a,u,k,M),t.arc(i,a,u,M,A),w>0){let U=pr(_,A,i,a);t.arc(U.x,U.y,w,A,x+dt)}let R=pr(D,x,i,a);if(t.lineTo(R.x,R.y),S>0){let U=pr(D,E,i,a);t.arc(U.x,U.y,S,x+dt,E+Math.PI)}let B=(x-S/p+(b+$/p))/2;if(t.arc(i,a,p,x-S/p,B,!0),t.arc(i,a,p,B,b+$/p,!0),$>0){let U=pr(O,z,i,a);t.arc(U.x,U.y,$,z+Math.PI,b-dt)}let I=pr(C,b,i,a);if(t.lineTo(I.x,I.y),v>0){let U=pr(C,k,i,a);t.arc(U.x,U.y,v,b-dt,k)}}else{t.moveTo(i,a);let M=Math.cos(k)*u+i,R=Math.sin(k)*u+a;t.lineTo(M,R);let B=Math.cos(A)*u+i,I=Math.sin(A)*u+a;t.lineTo(B,I)}t.closePath()}function fA(t,e,n,s,o){let{fullCircles:r,startAngle:i,circumference:a}=e,l=e.endAngle;if(r){ec(t,e,n,s,l,o);for(let c=0;c=Oe&&f===0&&d!=="miter"&&cA(t,e,m),r||(ec(t,e,n,s,m,o),t.stroke())}var wp=class extends kn{static id="arc";static defaults={borderAlign:"center",borderColor:"#fff",borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1};static defaultRoutes={backgroundColor:"backgroundColor"};static descriptors={_scriptable:!0,_indexable:e=>e!=="borderDash"};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(e){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,e&&Object.assign(this,e)}inRange(e,n,s){let o=this.getProps(["x","y"],s),{angle:r,distance:i}=_u(o,{x:e,y:n}),{startAngle:a,endAngle:l,innerRadius:c,outerRadius:d,circumference:u}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],s),p=(this.options.spacing+this.options.borderWidth)/2,f=$e(u,l-a),g=lr(r,a,l)&&a!==l,m=f>=Ye||g,h=Bn(i,c+p,d+p);return m&&h}getCenterPoint(e){let{x:n,y:s,startAngle:o,endAngle:r,innerRadius:i,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],e),{offset:l,spacing:c}=this.options,d=(o+r)/2,u=(i+a+c+l)/2;return{x:n+Math.cos(d)*u,y:s+Math.sin(d)*u}}tooltipPosition(e){return this.getCenterPoint(e)}draw(e){let{options:n,circumference:s}=this,o=(n.offset||0)/4,r=(n.spacing||0)/2,i=n.circular;if(this.pixelMargin=n.borderAlign==="inner"?.33:0,this.fullCircles=s>Ye?Math.floor(s/Ye):0,s===0||this.innerRadius<0||this.outerRadius<0)return;e.save();let a=(this.startAngle+this.endAngle)/2;e.translate(Math.cos(a)*o,Math.sin(a)*o);let l=1-Math.sin(Math.min(Oe,s||0)),c=o*l;e.fillStyle=n.backgroundColor,e.strokeStyle=n.borderColor,fA(e,this,c,r,i),hA(e,this,c,r,i),e.restore()}};function a0(t,e,n=e){t.lineCap=$e(n.borderCapStyle,e.borderCapStyle),t.setLineDash($e(n.borderDash,e.borderDash)),t.lineDashOffset=$e(n.borderDashOffset,e.borderDashOffset),t.lineJoin=$e(n.borderJoinStyle,e.borderJoinStyle),t.lineWidth=$e(n.borderWidth,e.borderWidth),t.strokeStyle=$e(n.borderColor,e.borderColor)}function mA(t,e,n){t.lineTo(n.x,n.y)}function gA(t){return t.stepped?Bv:t.tension||t.cubicInterpolationMode==="monotone"?Fv:mA}function l0(t,e,n={}){let s=t.length,{start:o=0,end:r=s-1}=n,{start:i,end:a}=e,l=Math.max(o,i),c=Math.min(r,a),d=oa&&r>a;return{count:s,start:l,loop:e.loop,ilen:c(i+(c?a-w:w))%r,v=()=>{m!==h&&(t.lineTo(d,h),t.lineTo(d,m),t.lineTo(d,b))};for(l&&(f=o[x(0)],t.moveTo(f.x,f.y)),p=0;p<=a;++p){if(f=o[x(p)],f.skip)continue;let w=f.x,$=f.y,S=w|0;S===g?($h&&(h=$),d=(u*d+w)/++u):(v(),t.lineTo(w,$),g=S,u=0,m=h=$),b=$}v()}function kp(t){let e=t.options,n=e.borderDash&&e.borderDash.length;return!t._decimated&&!t._loop&&!e.tension&&e.cubicInterpolationMode!=="monotone"&&!e.stepped&&!n?xA:bA}function yA(t){return t.stepped?qv:t.tension||t.cubicInterpolationMode==="monotone"?Jv:Cs}function vA(t,e,n,s){let o=e._path;o||(o=e._path=new Path2D,e.path(o,n,s)&&o.closePath()),a0(t,e.options),t.stroke(o)}function $A(t,e,n,s){let{segments:o,options:r}=e,i=kp(e);for(let a of o)a0(t,r,a.style),t.beginPath(),i(t,e,a,{start:n,end:n+s-1})&&t.closePath(),t.stroke()}var wA=typeof Path2D=="function";function kA(t,e,n,s){wA&&!e.options.segment?vA(t,e,n,s):$A(t,e,n,s)}var mr=class extends kn{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:e=>e!=="borderDash"&&e!=="fill"};constructor(e){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,e&&Object.assign(this,e)}updateControlPoints(e,n){let s=this.options;if((s.tension||s.cubicInterpolationMode==="monotone")&&!s.stepped&&!this._pointsUpdated){let o=s.spanGaps?this._loop:this._fullLoop;Uv(this._points,s,e,o,n),this._pointsUpdated=!0}}set points(e){this._points=e,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Yv(this,this.options.segment))}first(){let e=this.segments,n=this.points;return e.length&&n[e[0].start]}last(){let e=this.segments,n=this.points,s=e.length;return s&&n[e[s-1].end]}interpolate(e,n){let s=this.options,o=e[n],r=this.points,i=Gu(this,{property:n,start:o,end:o});if(!i.length)return;let a=[],l=yA(s),c,d;for(c=0,d=i.length;ct.replace("rgb(","rgba(").replace(")",", 0.5)"));function d0(t){return _p[t%_p.length]}function u0(t){return M$[t%M$.length]}function PA(t,e){return t.borderColor=d0(e),t.backgroundColor=u0(e),++e}function RA(t,e){return t.backgroundColor=t.data.map(()=>d0(e++)),e}function LA(t,e){return t.backgroundColor=t.data.map(()=>u0(e++)),e}function EA(t){let e=0;return(n,s)=>{let o=t.getDatasetMeta(s).controller;o instanceof _i?e=RA(n,e):o instanceof Yl?e=LA(n,e):o&&(e=PA(n,e))}}function A$(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}function IA(t){return t&&(t.borderColor||t.backgroundColor)}function DA(){return Qe.borderColor!=="rgba(0,0,0,0.1)"||Qe.backgroundColor!=="rgba(0,0,0,0.1)"}var NA={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,n){if(!n.enabled)return;let{data:{datasets:s},options:o}=t.config,{elements:r}=o,i=A$(s)||IA(o)||r&&A$(r)||DA();if(!n.forceOverride&&i)return;let a=EA(t);s.forEach(a)}};function OA(t,e,n,s,o){let r=o.samples||s;if(r>=n)return t.slice(e,e+n);let i=[],a=(n-2)/(r-2),l=0,c=e+n-1,d=e,u,p,f,g,m;for(i[l++]=t[d],u=0;uf&&(f=g,p=t[x],m=x);i[l++]=p,d=m}return i[l++]=t[c],i}function BA(t,e,n,s){let o=0,r=0,i,a,l,c,d,u,p,f,g,m,h=[],b=e+n-1,x=t[e].x,w=t[b].x-x;for(i=e;im&&(m=c,p=i),o=(r*o+a.x)/++r;else{let S=i-1;if(!Me(u)&&!Me(p)){let C=Math.min(u,p),_=Math.max(u,p);C!==f&&C!==S&&h.push({...t[C],x:o}),_!==f&&_!==S&&h.push({...t[_],x:o})}i>0&&S!==f&&h.push(t[S]),h.push(a),d=$,r=0,g=m=c,u=p=f=i}}return h}function p0(t){if(t._decimated){let e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function T$(t){t.data.datasets.forEach(e=>{p0(e)})}function FA(t,e){let n=e.length,s=0,o,{iScale:r}=t,{min:i,max:a,minDefined:l,maxDefined:c}=r.getUserBounds();return l&&(s=yt(Dn(e,r.axis,i).lo,0,n-1)),c?o=yt(Dn(e,r.axis,a).hi+1,s,n)-s:o=n-s,{start:s,count:o}}var HA={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,n)=>{if(!n.enabled){T$(t);return}let s=t.width;t.data.datasets.forEach((o,r)=>{let{_data:i,indexAxis:a}=o,l=t.getDatasetMeta(r),c=i||o.data;if(ur([a,t.options.indexAxis])==="y"||!l.controller.supportsDecimation)return;let d=t.scales[l.xAxisID];if(d.type!=="linear"&&d.type!=="time"||t.options.parsing)return;let{start:u,count:p}=FA(l,c),f=n.threshold||4*s;if(p<=f){p0(o);return}Me(i)&&(o._data=c,delete o.data,Object.defineProperty(o,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(m){this._data=m}}));let g;switch(n.algorithm){case"lttb":g=OA(c,u,p,s,n);break;case"min-max":g=BA(c,u,p,s);break;default:throw new Error(`Unsupported decimation algorithm '${n.algorithm}'`)}o._decimated=g})},destroy(t){T$(t)}};function WA(t,e,n){let s=t.segments,o=t.points,r=e.points,i=[];for(let a of s){let{start:l,end:c}=a;c=oc(l,c,o);let d=Mp(n,o[l],o[c],a.loop);if(!e.segments){i.push({source:a,target:d,start:o[l],end:o[c]});continue}let u=Gu(e,d);for(let p of u){let f=Mp(n,r[p.start],r[p.end],p.loop),g=Ku(a,o,f);for(let m of g)i.push({source:m,target:p,start:{[n]:P$(d,f,"start",Math.max)},end:{[n]:P$(d,f,"end",Math.min)}})}}return i}function Mp(t,e,n,s){if(s)return;let o=e[t],r=n[t];return t==="angle"&&(o=Lt(o),r=Lt(r)),{property:t,start:o,end:r}}function VA(t,e){let{x:n=null,y:s=null}=t||{},o=e.points,r=[];return e.segments.forEach(({start:i,end:a})=>{a=oc(i,a,o);let l=o[i],c=o[a];s!==null?(r.push({x:l.x,y:s}),r.push({x:c.x,y:s})):n!==null&&(r.push({x:n,y:l.y}),r.push({x:n,y:c.y}))}),r}function oc(t,e,n){for(;e>t;e--){let s=n[e];if(!isNaN(s.x)&&!isNaN(s.y))break}return e}function P$(t,e,n,s){return t&&e?s(t[n],e[n]):t?t[n]:e?e[n]:0}function f0(t,e){let n=[],s=!1;return Ze(t)?(s=!0,n=t):n=VA(t,e),n.length?new mr({points:n,options:{tension:0},_loop:s,_fullLoop:s}):null}function R$(t){return t&&t.fill!==!1}function jA(t,e,n){let o=t[e].fill,r=[e],i;if(!n)return o;for(;o!==!1&&r.indexOf(o)===-1;){if(!at(o))return o;if(i=t[o],!i)return!1;if(i.visible)return o;r.push(o),o=i.fill}return!1}function zA(t,e,n){let s=qA(t);if(Ae(s))return isNaN(s.value)?!1:s;let o=parseFloat(s);return at(o)&&Math.floor(o)===o?UA(s[0],e,o,n):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function UA(t,e,n,s){return(t==="-"||t==="+")&&(n=e+n),n===e||n<0||n>=s?!1:n}function KA(t,e){let n=null;return t==="start"?n=e.bottom:t==="end"?n=e.top:Ae(t)?n=e.getPixelForValue(t.value):e.getBasePixel&&(n=e.getBasePixel()),n}function GA(t,e,n){let s;return t==="start"?s=n:t==="end"?s=e.options.reverse?e.min:e.max:Ae(t)?s=t.value:s=e.getBaseValue(),s}function qA(t){let e=t.options,n=e.fill,s=$e(n&&n.target,n);return s===void 0&&(s=!!e.backgroundColor),s===!1||s===null?!1:s===!0?"origin":s}function JA(t){let{scale:e,index:n,line:s}=t,o=[],r=s.segments,i=s.points,a=ZA(e,n);a.push(f0({x:null,y:e.bottom},s));for(let l=0;l=0;--i){let a=o[i].$filler;a&&(a.line.updateControlPoints(r,a.axis),s&&a.fill&&op(t.ctx,a,r))}},beforeDatasetsDraw(t,e,n){if(n.drawTime!=="beforeDatasetsDraw")return;let s=t.getSortedVisibleDatasetMetas();for(let o=s.length-1;o>=0;--o){let r=s[o].$filler;R$(r)&&op(t.ctx,r,t.chartArea)}},beforeDatasetDraw(t,e,n){let s=e.meta.$filler;!R$(s)||n.drawTime!=="beforeDatasetDraw"||op(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}},D$=(t,e)=>{let{boxHeight:n=e,boxWidth:s=e}=t;return t.usePointStyle&&(n=Math.min(n,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:n,itemHeight:Math.max(e,n)}},aT=(t,e)=>t!==null&&e!==null&&t.datasetIndex===e.datasetIndex&&t.index===e.index,nc=class extends kn{constructor(e){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,n,s){this.maxWidth=e,this.maxHeight=n,this._margins=s,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){let e=this.options.labels||{},n=qe(e.generateLabels,[this.chart],this)||[];e.filter&&(n=n.filter(s=>e.filter(s,this.chart.data))),e.sort&&(n=n.sort((s,o)=>e.sort(s,o,this.chart.data))),this.options.reverse&&n.reverse(),this.legendItems=n}fit(){let{options:e,ctx:n}=this;if(!e.display){this.width=this.height=0;return}let s=e.labels,o=ht(s.font),r=o.size,i=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=D$(s,r),c,d;n.font=o.string,this.isHorizontal()?(c=this.maxWidth,d=this._fitRows(i,r,a,l)+10):(d=this.maxHeight,c=this._fitCols(i,o,a,l)+10),this.width=Math.min(c,e.maxWidth||this.maxWidth),this.height=Math.min(d,e.maxHeight||this.maxHeight)}_fitRows(e,n,s,o){let{ctx:r,maxWidth:i,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.lineWidths=[0],d=o+a,u=e;r.textAlign="left",r.textBaseline="middle";let p=-1,f=-d;return this.legendItems.forEach((g,m)=>{let h=s+n/2+r.measureText(g.text).width;(m===0||c[c.length-1]+h+2*a>i)&&(u+=d,c[c.length-(m>0?0:1)]=0,f+=d,p++),l[m]={left:0,top:f,row:p,width:h,height:o},c[c.length-1]+=h+a}),u}_fitCols(e,n,s,o){let{ctx:r,maxHeight:i,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.columnSizes=[],d=i-e,u=a,p=0,f=0,g=0,m=0;return this.legendItems.forEach((h,b)=>{let{itemWidth:x,itemHeight:v}=lT(s,n,r,h,o);b>0&&f+v+2*a>d&&(u+=p+a,c.push({width:p,height:f}),g+=p+a,m++,p=f=0),l[b]={left:g,top:f,col:m,width:x,height:v},p=Math.max(p,x),f+=v+a}),u+=p,c.push({width:p,height:f}),u}adjustHitBoxes(){if(!this.options.display)return;let e=this._computeTitleHeight(),{legendHitBoxes:n,options:{align:s,labels:{padding:o},rtl:r}}=this,i=ao(r,this.left,this.width);if(this.isHorizontal()){let a=0,l=Et(s,this.left+o,this.right-this.lineWidths[a]);for(let c of n)a!==c.row&&(a=c.row,l=Et(s,this.left+o,this.right-this.lineWidths[a])),c.top+=this.top+e+o,c.left=i.leftForLtr(i.x(l),c.width),l+=c.width+o}else{let a=0,l=Et(s,this.top+e+o,this.bottom-this.columnSizes[a].height);for(let c of n)c.col!==a&&(a=c.col,l=Et(s,this.top+e+o,this.bottom-this.columnSizes[a].height)),c.top=l,c.left+=this.left+o,c.left=i.leftForLtr(i.x(c.left),c.width),l+=c.height+o}}isHorizontal(){return this.options.position==="top"||this.options.position==="bottom"}draw(){if(this.options.display){let e=this.ctx;bi(e,this),this._draw(),xi(e)}}_draw(){let{options:e,columnSizes:n,lineWidths:s,ctx:o}=this,{align:r,labels:i}=e,a=Qe.color,l=ao(e.rtl,this.left,this.width),c=ht(i.font),{padding:d}=i,u=c.size,p=u/2,f;this.drawTitle(),o.textAlign=l.textAlign("left"),o.textBaseline="middle",o.lineWidth=.5,o.font=c.string;let{boxWidth:g,boxHeight:m,itemHeight:h}=D$(i,u),b=function(S,C,_){if(isNaN(g)||g<=0||isNaN(m)||m<0)return;o.save();let k=$e(_.lineWidth,1);if(o.fillStyle=$e(_.fillStyle,a),o.lineCap=$e(_.lineCap,"butt"),o.lineDashOffset=$e(_.lineDashOffset,0),o.lineJoin=$e(_.lineJoin,"miter"),o.lineWidth=k,o.strokeStyle=$e(_.strokeStyle,a),o.setLineDash($e(_.lineDash,[])),i.usePointStyle){let A={radius:m*Math.SQRT2/2,pointStyle:_.pointStyle,rotation:_.rotation,borderWidth:k},O=l.xPlus(S,g/2),D=C+p;Nu(o,A,O,D,i.pointStyleWidth&&g)}else{let A=C+Math.max((u-m)/2,0),O=l.leftForLtr(S,g),D=Ps(_.borderRadius);o.beginPath(),Object.values(D).some(z=>z!==0)?dr(o,{x:O,y:A,w:g,h:m,radius:D}):o.rect(O,A,g,m),o.fill(),k!==0&&o.stroke()}o.restore()},x=function(S,C,_){Ts(o,_.text,S,C+h/2,c,{strikethrough:_.hidden,textAlign:l.textAlign(_.textAlign)})},v=this.isHorizontal(),w=this._computeTitleHeight();v?f={x:Et(r,this.left+d,this.right-s[0]),y:this.top+d+w,line:0}:f={x:this.left+d,y:Et(r,this.top+w+d,this.bottom-n[0].height),line:0},zu(this.ctx,e.textDirection);let $=h+d;this.legendItems.forEach((S,C)=>{o.strokeStyle=S.fontColor,o.fillStyle=S.fontColor;let _=o.measureText(S.text).width,k=l.textAlign(S.textAlign||(S.textAlign=i.textAlign)),A=g+p+_,O=f.x,D=f.y;l.setWidth(this.width),v?C>0&&O+A+d>this.right&&(D=f.y+=$,f.line++,O=f.x=Et(r,this.left+d,this.right-s[f.line])):C>0&&D+$>this.bottom&&(O=f.x=O+n[f.line].width+d,f.line++,D=f.y=Et(r,this.top+w+d,this.bottom-n[f.line].height));let z=l.x(O);if(b(z,D,S),O=Dv(k,O+g+p,v?O+A:this.right,e.rtl),x(l.x(O),D,S),v)f.x+=A+d;else if(typeof S.text!="string"){let E=c.lineHeight;f.y+=h0(S,E)+d}else f.y+=$}),Uu(this.ctx,e.textDirection)}drawTitle(){let e=this.options,n=e.title,s=ht(n.font),o=It(n.padding);if(!n.display)return;let r=ao(e.rtl,this.left,this.width),i=this.ctx,a=n.position,l=s.size/2,c=o.top+l,d,u=this.left,p=this.width;if(this.isHorizontal())p=Math.max(...this.lineWidths),d=this.top+c,u=Et(e.align,u,this.right-p);else{let g=this.columnSizes.reduce((m,h)=>Math.max(m,h.height),0);d=c+Et(e.align,this.top,this.bottom-g-e.labels.padding-this._computeTitleHeight())}let f=Et(a,u,u+p);i.textAlign=r.textAlign(Il(a)),i.textBaseline="middle",i.strokeStyle=n.color,i.fillStyle=n.color,i.font=s.string,Ts(i,n.text,f,d,s)}_computeTitleHeight(){let e=this.options.title,n=ht(e.font),s=It(e.padding);return e.display?n.lineHeight+s.height:0}_getLegendItemAt(e,n){let s,o,r;if(Bn(e,this.left,this.right)&&Bn(n,this.top,this.bottom)){for(r=this.legendHitBoxes,s=0;sr.length>i.length?r:i)),e+n.size/2+s.measureText(o).width}function dT(t,e,n){let s=t;return typeof e.text!="string"&&(s=h0(e,n)),s}function h0(t,e){let n=t.text?t.text.length:0;return e*n}function uT(t,e){return!!((t==="mousemove"||t==="mouseout")&&(e.onHover||e.onLeave)||e.onClick&&(t==="click"||t==="mouseup"))}var pT={id:"legend",_element:nc,start(t,e,n){let s=t.legend=new nc({ctx:t.ctx,options:n,chart:t});Ft.configure(t,s,n),Ft.addBox(t,s)},stop(t){Ft.removeBox(t,t.legend),delete t.legend},beforeUpdate(t,e,n){let s=t.legend;Ft.configure(t,s,n),s.options=n},afterUpdate(t){let e=t.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(t,e){e.replay||t.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(t,e,n){let s=e.datasetIndex,o=n.chart;o.isDatasetVisible(s)?(o.hide(s),e.hidden=!0):(o.show(s),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:t=>t.chart.options.color,boxWidth:40,padding:10,generateLabels(t){let e=t.data.datasets,{labels:{usePointStyle:n,pointStyle:s,textAlign:o,color:r,useBorderRadius:i,borderRadius:a}}=t.legend.options;return t._getSortedDatasetMetas().map(l=>{let c=l.controller.getStyle(n?0:void 0),d=It(c.borderWidth);return{text:e[l.index].label,fillStyle:c.backgroundColor,fontColor:r,hidden:!l.visible,lineCap:c.borderCapStyle,lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:(d.width+d.height)/4,strokeStyle:c.borderColor,pointStyle:s||c.pointStyle,rotation:c.rotation,textAlign:o||c.textAlign,borderRadius:i&&(a||c.borderRadius),datasetIndex:l.index}},this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}},Ai=class extends kn{constructor(e){super(),this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,n){let s=this.options;if(this.left=0,this.top=0,!s.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=e,this.height=this.bottom=n;let o=Ze(s.text)?s.text.length:1;this._padding=It(s.padding);let r=o*ht(s.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=r:this.width=r}isHorizontal(){let e=this.options.position;return e==="top"||e==="bottom"}_drawArgs(e){let{top:n,left:s,bottom:o,right:r,options:i}=this,a=i.align,l=0,c,d,u;return this.isHorizontal()?(d=Et(a,s,r),u=n+e,c=r-s):(i.position==="left"?(d=s+e,u=Et(a,o,n),l=Oe*-.5):(d=r-e,u=Et(a,n,o),l=Oe*.5),c=o-n),{titleX:d,titleY:u,maxWidth:c,rotation:l}}draw(){let e=this.ctx,n=this.options;if(!n.display)return;let s=ht(n.font),r=s.lineHeight/2+this._padding.top,{titleX:i,titleY:a,maxWidth:l,rotation:c}=this._drawArgs(r);Ts(e,n.text,0,0,s,{color:n.color,maxWidth:l,rotation:c,textAlign:Il(n.align),textBaseline:"middle",translation:[i,a]})}};function fT(t,e){let n=new Ai({ctx:t.ctx,options:e,chart:t});Ft.configure(t,n,e),Ft.addBox(t,n),t.titleBlock=n}var hT={id:"title",_element:Ai,start(t,e,n){fT(t,n)},stop(t){let e=t.titleBlock;Ft.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,n){let s=t.titleBlock;Ft.configure(t,s,n),s.options=n},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}},Ul=new WeakMap,mT={id:"subtitle",start(t,e,n){let s=new Ai({ctx:t.ctx,options:n,chart:t});Ft.configure(t,s,n),Ft.addBox(t,s),Ul.set(t,s)},stop(t){Ft.removeBox(t,Ul.get(t)),Ul.delete(t)},beforeUpdate(t,e,n){let s=Ul.get(t);Ft.configure(t,s,n),s.options=n},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}},Ci={average(t){if(!t.length)return!1;let e,n,s=new Set,o=0,r=0;for(e=0,n=t.length;ea+l)/s.size,y:o/r}},nearest(t,e){if(!t.length)return!1;let n=e.x,s=e.y,o=Number.POSITIVE_INFINITY,r,i,a;for(r=0,i=t.length;rMath.max(Math.min(t,n),e);function di(t){return ks(pi(t*2.55),0,255)}function Ss(t){return ks(pi(t*255),0,255)}function ts(t){return ks(pi(t/2.55)/100,0,1)}function iv(t){return ks(pi(t*100),0,100)}var un={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},mu=[..."0123456789ABCDEF"],g_=t=>mu[t&15],b_=t=>mu[(t&240)>>4]+mu[t&15],wl=t=>(t&240)>>4===(t&15),x_=t=>wl(t.r)&&wl(t.g)&&wl(t.b)&&wl(t.a);function y_(t){var e=t.length,n;return t[0]==="#"&&(e===4||e===5?n={r:255&un[t[1]]*17,g:255&un[t[2]]*17,b:255&un[t[3]]*17,a:e===5?un[t[4]]*17:255}:(e===7||e===9)&&(n={r:un[t[1]]<<4|un[t[2]],g:un[t[3]]<<4|un[t[4]],b:un[t[5]]<<4|un[t[6]],a:e===9?un[t[7]]<<4|un[t[8]]:255})),n}var v_=(t,e)=>t<255?e(t):"";function $_(t){var e=x_(t)?g_:b_;return t?"#"+e(t.r)+e(t.g)+e(t.b)+v_(t.a,e):void 0}var w_=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function dv(t,e,n){let s=e*Math.min(n,1-n),o=(r,i=(r+t/30)%12)=>n-s*Math.max(Math.min(i-3,9-i,1),-1);return[o(0),o(8),o(4)]}function k_(t,e,n){let s=(o,r=(o+t/60)%6)=>n-n*e*Math.max(Math.min(r,4-r,1),0);return[s(5),s(3),s(1)]}function S_(t,e,n){let s=dv(t,1,.5),o;for(e+n>1&&(o=1/(e+n),e*=o,n*=o),o=0;o<3;o++)s[o]*=1-e-n,s[o]+=e;return s}function C_(t,e,n,s,o){return t===o?(e-n)/s+(e.5?d/(2-r-i):d/(r+i),l=C_(n,s,o,d,r),l=l*60+.5),[l|0,c||0,a]}function bu(t,e,n,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,n,s)).map(Ss)}function xu(t,e,n){return bu(dv,t,e,n)}function __(t,e,n){return bu(S_,t,e,n)}function M_(t,e,n){return bu(k_,t,e,n)}function uv(t){return(t%360+360)%360}function A_(t){let e=w_.exec(t),n=255,s;if(!e)return;e[5]!==s&&(n=e[6]?di(+e[5]):Ss(+e[5]));let o=uv(+e[2]),r=+e[3]/100,i=+e[4]/100;return e[1]==="hwb"?s=__(o,r,i):e[1]==="hsv"?s=M_(o,r,i):s=xu(o,r,i),{r:s[0],g:s[1],b:s[2],a:n}}function T_(t,e){var n=gu(t);n[0]=uv(n[0]+e),n=xu(n),t.r=n[0],t.g=n[1],t.b=n[2]}function P_(t){if(!t)return;let e=gu(t),n=e[0],s=iv(e[1]),o=iv(e[2]);return t.a<255?`hsla(${n}, ${s}%, ${o}%, ${ts(t.a)})`:`hsl(${n}, ${s}%, ${o}%)`}var av={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},lv={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};function R_(){let t={},e=Object.keys(lv),n=Object.keys(av),s,o,r,i,a;for(s=0;s>16&255,r>>8&255,r&255]}return t}var kl;function L_(t){kl||(kl=R_(),kl.transparent=[0,0,0,0]);let e=kl[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:e.length===4?e[3]:255}}var E_=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function I_(t){let e=E_.exec(t),n=255,s,o,r;if(e){if(e[7]!==s){let i=+e[7];n=e[8]?di(i):ks(i*255,0,255)}return s=+e[1],o=+e[3],r=+e[5],s=255&(e[2]?di(s):ks(s,0,255)),o=255&(e[4]?di(o):ks(o,0,255)),r=255&(e[6]?di(r):ks(r,0,255)),{r:s,g:o,b:r,a:n}}}function D_(t){return t&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${ts(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`)}var hu=t=>t<=.0031308?t*12.92:Math.pow(t,1/2.4)*1.055-.055,tr=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function N_(t,e,n){let s=tr(ts(t.r)),o=tr(ts(t.g)),r=tr(ts(t.b));return{r:Ss(hu(s+n*(tr(ts(e.r))-s))),g:Ss(hu(o+n*(tr(ts(e.g))-o))),b:Ss(hu(r+n*(tr(ts(e.b))-r))),a:t.a+n*(e.a-t.a)}}function Sl(t,e,n){if(t){let s=gu(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*n,e===0?360:1)),s=xu(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function pv(t,e){return t&&Object.assign(e||{},t)}function cv(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Ss(t[3]))):(e=pv(t,{r:0,g:0,b:0,a:1}),e.a=Ss(e.a)),e}function O_(t){return t.charAt(0)==="r"?I_(t):A_(t)}var ui=class t{constructor(e){if(e instanceof t)return e;let n=typeof e,s;n==="object"?s=cv(e):n==="string"&&(s=y_(e)||L_(e)||O_(e)),this._rgb=s,this._valid=!!s}get valid(){return this._valid}get rgb(){var e=pv(this._rgb);return e&&(e.a=ts(e.a)),e}set rgb(e){this._rgb=cv(e)}rgbString(){return this._valid?D_(this._rgb):void 0}hexString(){return this._valid?$_(this._rgb):void 0}hslString(){return this._valid?P_(this._rgb):void 0}mix(e,n){if(e){let s=this.rgb,o=e.rgb,r,i=n===r?.5:n,a=2*i-1,l=s.a-o.a,c=((a*l===-1?a:(a+l)/(1+a*l))+1)/2;r=1-c,s.r=255&c*s.r+r*o.r+.5,s.g=255&c*s.g+r*o.g+.5,s.b=255&c*s.b+r*o.b+.5,s.a=i*s.a+(1-i)*o.a,this.rgb=s}return this}interpolate(e,n){return e&&(this._rgb=N_(this._rgb,e._rgb,n)),this}clone(){return new t(this.rgb)}alpha(e){return this._rgb.a=Ss(e),this}clearer(e){let n=this._rgb;return n.a*=1-e,this}greyscale(){let e=this._rgb,n=pi(e.r*.3+e.g*.59+e.b*.11);return e.r=e.g=e.b=n,this}opaquer(e){let n=this._rgb;return n.a*=1+e,this}negate(){let e=this._rgb;return e.r=255-e.r,e.g=255-e.g,e.b=255-e.b,this}lighten(e){return Sl(this._rgb,2,e),this}darken(e){return Sl(this._rgb,2,-e),this}saturate(e){return Sl(this._rgb,1,e),this}desaturate(e){return Sl(this._rgb,1,-e),this}rotate(e){return T_(this._rgb,e),this}};function On(){}var kv=(()=>{let t=0;return()=>t++})();function Me(t){return t==null}function Ze(t){if(Array.isArray&&Array.isArray(t))return!0;let e=Object.prototype.toString.call(t);return e.slice(0,7)==="[object"&&e.slice(-6)==="Array]"}function Ae(t){return t!==null&&Object.prototype.toString.call(t)==="[object Object]"}function at(t){return(typeof t=="number"||t instanceof Number)&&isFinite(+t)}function Zt(t,e){return at(t)?t:e}function $e(t,e){return typeof t>"u"?e:t}var Sv=(t,e)=>typeof t=="string"&&t.endsWith("%")?parseFloat(t)/100:+t/e,wu=(t,e)=>typeof t=="string"&&t.endsWith("%")?parseFloat(t)/100*e:+t;function qe(t,e,n){if(t&&typeof t.call=="function")return t.apply(n,e)}function je(t,e,n,s){let o,r,i;if(Ze(t))if(r=t.length,s)for(o=r-1;o>=0;o--)e.call(n,t[o],o);else for(o=0;ot,x:t=>t.x,y:t=>t.y};function H_(t){let e=t.split("."),n=[],s="";for(let o of e)s+=o,s.endsWith("\\")?s=s.slice(0,-1)+".":(n.push(s),s="");return n}function W_(t){let e=H_(t);return n=>{for(let s of e){if(s==="")break;n=n&&n[s]}return n}}function os(t,e){return(fv[e]||(fv[e]=W_(e)))(t)}function Ll(t){return t.charAt(0).toUpperCase()+t.slice(1)}var ir=t=>typeof t<"u",ns=t=>typeof t=="function",ku=(t,e)=>{if(t.size!==e.size)return!1;for(let n of t)if(!e.has(n))return!1;return!0};function _v(t){return t.type==="mouseup"||t.type==="click"||t.type==="contextmenu"}var Oe=Math.PI,Ye=2*Oe,V_=Ye+Oe,Tl=Number.POSITIVE_INFINITY,z_=Oe/180,dt=Oe/2,so=Oe/4,hv=Oe*2/3,ss=Math.log10,wn=Math.sign;function ar(t,e,n){return Math.abs(t-e)o-r).pop(),e}function j_(t){return typeof t=="symbol"||typeof t=="object"&&t!==null&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}function io(t){return!j_(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function Av(t,e){let n=Math.round(t);return n-e<=t&&n+e>=t}function Cu(t,e,n){let s,o,r;for(s=0,o=t.length;sl&&c=Math.min(e,n)-s&&t<=Math.max(e,n)+s}function Il(t,e,n){n=n||(i=>t[i]1;)r=o+s>>1,n(r)?o=r:s=r;return{lo:o,hi:s}}var Dn=(t,e,n,s)=>Il(t,n,s?o=>{let r=t[o][e];return rt[o][e]Il(t,n,s=>t[s][e]>=n);function Rv(t,e,n){let s=0,o=t.length;for(;ss&&t[o-1]>n;)o--;return s>0||o{let s="_onData"+Ll(n),o=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:!1,value(...r){let i=o.apply(this,r);return t._chartjs.listeners.forEach(a=>{typeof a[s]=="function"&&a[s](...r)}),i}})})}function Au(t,e){let n=t._chartjs;if(!n)return;let s=n.listeners,o=s.indexOf(e);o!==-1&&s.splice(o,1),!(s.length>0)&&(Lv.forEach(r=>{delete t[r]}),delete t._chartjs)}function Tu(t){let e=new Set(t);return e.size===t.length?t:Array.from(e)}var Pu=(function(){return typeof window>"u"?function(t){return t()}:window.requestAnimationFrame})();function Ru(t,e){let n=[],s=!1;return function(...o){n=o,s||(s=!0,Pu.call(window,()=>{s=!1,t.apply(e,n)}))}}function Iv(t,e){let n;return function(...s){return e?(clearTimeout(n),n=setTimeout(t,e,s)):t.apply(this,s),e}}var Dl=t=>t==="start"?"left":t==="end"?"right":"center",Et=(t,e,n)=>t==="start"?e:t==="end"?n:(e+n)/2,Dv=(t,e,n,s)=>t===(s?"left":"right")?n:t==="center"?(e+n)/2:e;function Lu(t,e,n){let s=e.length,o=0,r=s;if(t._sorted){let{iScale:i,vScale:a,_parsed:l}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=i.axis,{min:u,max:p,minDefined:f,maxDefined:g}=i.getUserBounds();if(f){if(o=Math.min(Dn(l,d,u).lo,n?s:Dn(e,d,i.getPixelForValue(u)).lo),c){let m=l.slice(0,o+1).reverse().findIndex(h=>!Me(h[a.axis]));o-=Math.max(0,m)}o=yt(o,0,s-1)}if(g){let m=Math.max(Dn(l,i.axis,p,!0).hi+1,n?0:Dn(e,d,i.getPixelForValue(p),!0).hi+1);if(c){let h=l.slice(m-1).findIndex(b=>!Me(b[a.axis]));m+=Math.max(0,h)}r=yt(m,o,s)-o}else r=s-o}return{start:o,count:r}}function Eu(t){let{xScale:e,yScale:n,_scaleRanges:s}=t,o={xmin:e.min,xmax:e.max,ymin:n.min,ymax:n.max};if(!s)return t._scaleRanges=o,!0;let r=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==n.min||s.ymax!==n.max;return Object.assign(s,o),r}var Cl=t=>t===0||t===1,mv=(t,e,n)=>-(Math.pow(2,10*(t-=1))*Math.sin((t-e)*Ye/n)),gv=(t,e,n)=>Math.pow(2,-10*t)*Math.sin((t-e)*Ye/n)+1,nr={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>-Math.cos(t*dt)+1,easeOutSine:t=>Math.sin(t*dt),easeInOutSine:t=>-.5*(Math.cos(Oe*t)-1),easeInExpo:t=>t===0?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>t===1?1:-Math.pow(2,-10*t)+1,easeInOutExpo:t=>Cl(t)?t:t<.5?.5*Math.pow(2,10*(t*2-1)):.5*(-Math.pow(2,-10*(t*2-1))+2),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>Cl(t)?t:mv(t,.075,.3),easeOutElastic:t=>Cl(t)?t:gv(t,.075,.3),easeInOutElastic(t){return Cl(t)?t:t<.5?.5*mv(t*2,.1125,.45):.5+.5*gv(t*2-1,.1125,.45)},easeInBack(t){return t*t*((1.70158+1)*t-1.70158)},easeOutBack(t){return(t-=1)*t*((1.70158+1)*t+1.70158)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?.5*(t*t*(((e*=1.525)+1)*t-e)):.5*((t-=2)*t*(((e*=1.525)+1)*t+e)+2)},easeInBounce:t=>1-nr.easeOutBounce(1-t),easeOutBounce(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:t=>t<.5?nr.easeInBounce(t*2)*.5:nr.easeOutBounce(t*2-1)*.5+.5};function Iu(t){if(t&&typeof t=="object"){let e=t.toString();return e==="[object CanvasPattern]"||e==="[object CanvasGradient]"}return!1}function Du(t){return Iu(t)?t:new ui(t)}function yu(t){return Iu(t)?t:new ui(t).saturate(.5).darken(.1).hexString()}var K_=["x","y","borderWidth","radius","tension"],G_=["color","borderColor","backgroundColor"];function q_(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:e=>e!=="onProgress"&&e!=="onComplete"&&e!=="fn"}),t.set("animations",{colors:{type:"color",properties:G_},numbers:{type:"number",properties:K_}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:e=>e|0}}}})}function J_(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}var bv=new Map;function Z_(t,e){e=e||{};let n=t+JSON.stringify(e),s=bv.get(n);return s||(s=new Intl.NumberFormat(t,e),bv.set(n,s)),s}function cr(t,e,n){return Z_(e,n).format(t)}var Nv={values(t){return Ze(t)?t:""+t},numeric(t,e,n){if(t===0)return"0";let s=this.chart.options.locale,o,r=t;if(n.length>1){let c=Math.max(Math.abs(n[0].value),Math.abs(n[n.length-1].value));(c<1e-4||c>1e15)&&(o="scientific"),r=Y_(t,n)}let i=ss(Math.abs(r)),a=isNaN(i)?1:Math.max(Math.min(-1*Math.floor(i),20),0),l={notation:o,minimumFractionDigits:a,maximumFractionDigits:a};return Object.assign(l,this.options.ticks.format),cr(t,s,l)},logarithmic(t,e,n){if(t===0)return"0";let s=n[e].significand||t/Math.pow(10,Math.floor(ss(t)));return[1,2,3,5,10,15].includes(s)||e>.8*n.length?Nv.numeric.call(this,t,e,n):""}};function Y_(t,e){let n=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;return Math.abs(n)>=1&&t!==Math.floor(t)&&(n=t-Math.floor(t)),n}var gi={formatters:Nv};function X_(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(e,n)=>n.lineWidth,tickColor:(e,n)=>n.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:gi.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:e=>!e.startsWith("before")&&!e.startsWith("after")&&e!=="callback"&&e!=="parser",_indexable:e=>e!=="borderDash"&&e!=="tickBorderDash"&&e!=="dash"}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:e=>e!=="backdropPadding"&&e!=="callback",_indexable:e=>e!=="backdropPadding"})}var Ms=Object.create(null),Nl=Object.create(null);function fi(t,e){if(!e)return t;let n=e.split(".");for(let s=0,o=n.length;ss.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(s,o)=>yu(o.backgroundColor),this.hoverBorderColor=(s,o)=>yu(o.borderColor),this.hoverColor=(s,o)=>yu(o.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e),this.apply(n)}set(e,n){return vu(this,e,n)}get(e){return fi(this,e)}describe(e,n){return vu(Nl,e,n)}override(e,n){return vu(Ms,e,n)}route(e,n,s,o){let r=fi(this,e),i=fi(this,s),a="_"+n;Object.defineProperties(r,{[a]:{value:r[n],writable:!0},[n]:{enumerable:!0,get(){let l=this[a],c=i[o];return Ae(l)?Object.assign({},c,l):$e(l,c)},set(l){this[a]=l}}})}apply(e){e.forEach(n=>n(this))}},Qe=new $u({_scriptable:t=>!t.startsWith("on"),_indexable:t=>t!=="events",hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[q_,J_,X_]);function Q_(t){return!t||Me(t.size)||Me(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function hi(t,e,n,s,o){let r=e[o];return r||(r=e[o]=t.measureText(o).width,n.push(o)),r>s&&(s=r),s}function Ov(t,e,n,s){s=s||{};let o=s.data=s.data||{},r=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},r=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let i=0,a=n.length,l,c,d,u,p;for(l=0;ln.length){for(l=0;l0&&t.stroke()}}function Nn(t,e,n){return n=n||.5,!e||t&&t.x>e.left-n&&t.xe.top-n&&t.y0&&r.strokeColor!=="",l,c;for(t.save(),t.font=o.string,e5(t,r),l=0;l+t||0;function Bl(t,e){let n={},s=Ae(e),o=s?Object.keys(e):e,r=Ae(t)?s?i=>$e(t[i],t[e[i]]):i=>t[i]:()=>t;for(let i of o)n[i]=i5(r(i));return n}function Bu(t){return Bl(t,{top:"y",right:"x",bottom:"y",left:"x"})}function Ps(t){return Bl(t,["topLeft","topRight","bottomLeft","bottomRight"])}function It(t){let e=Bu(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function ht(t,e){t=t||{},e=e||Qe.font;let n=$e(t.size,e.size);typeof n=="string"&&(n=parseInt(n,10));let s=$e(t.style,e.style);s&&!(""+s).match(o5)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);let o={family:$e(t.family,e.family),lineHeight:r5($e(t.lineHeight,e.lineHeight),n),size:n,style:s,weight:$e(t.weight,e.weight),string:""};return o.string=Q_(o),o}function ur(t,e,n,s){let o=!0,r,i,a;for(r=0,i=t.length;rn&&a===0?0:a+l;return{min:i(s,-Math.abs(r)),max:i(o,r)}}function rs(t,e){return Object.assign(Object.create(t),e)}function Fl(t,e=[""],n,s,o=()=>t[0]){let r=n||t;typeof s>"u"&&(s=zv("_fallback",t));let i={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:r,_fallback:s,_getTarget:o,override:a=>Fl([a,...t],e,r,s)};return new Proxy(i,{deleteProperty(a,l){return delete a[l],delete a._keys,delete t[0][l],!0},get(a,l){return Wv(a,l,()=>h5(l,e,t,a))},getOwnPropertyDescriptor(a,l){return Reflect.getOwnPropertyDescriptor(a._scopes[0],l)},getPrototypeOf(){return Reflect.getPrototypeOf(t[0])},has(a,l){return yv(a).includes(l)},ownKeys(a){return yv(a)},set(a,l,c){let d=a._storage||(a._storage=o());return a[l]=d[l]=c,delete a._keys,!0}})}function ro(t,e,n,s){let o={_cacheable:!1,_proxy:t,_context:e,_subProxy:n,_stack:new Set,_descriptors:Fu(t,s),setContext:r=>ro(t,r,n,s),override:r=>ro(t.override(r),e,n,s)};return new Proxy(o,{deleteProperty(r,i){return delete r[i],delete t[i],!0},get(r,i,a){return Wv(r,i,()=>l5(r,i,a))},getOwnPropertyDescriptor(r,i){return r._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i)},getPrototypeOf(){return Reflect.getPrototypeOf(t)},has(r,i){return Reflect.has(t,i)},ownKeys(){return Reflect.ownKeys(t)},set(r,i,a){return t[i]=a,delete r[i],!0}})}function Fu(t,e={scriptable:!0,indexable:!0}){let{_scriptable:n=e.scriptable,_indexable:s=e.indexable,_allKeys:o=e.allKeys}=t;return{allKeys:o,scriptable:n,indexable:s,isScriptable:ns(n)?n:()=>n,isIndexable:ns(s)?s:()=>s}}var a5=(t,e)=>t?t+Ll(e):e,Hu=(t,e)=>Ae(e)&&t!=="adapters"&&(Object.getPrototypeOf(e)===null||e.constructor===Object);function Wv(t,e,n){if(Object.prototype.hasOwnProperty.call(t,e)||e==="constructor")return t[e];let s=n();return t[e]=s,s}function l5(t,e,n){let{_proxy:s,_context:o,_subProxy:r,_descriptors:i}=t,a=s[e];return ns(a)&&i.isScriptable(e)&&(a=c5(e,a,t,n)),Ze(a)&&a.length&&(a=d5(e,a,t,i.isIndexable)),Hu(e,a)&&(a=ro(a,o,r&&r[e],i)),a}function c5(t,e,n,s){let{_proxy:o,_context:r,_subProxy:i,_stack:a}=n;if(a.has(t))throw new Error("Recursion detected: "+Array.from(a).join("->")+"->"+t);a.add(t);let l=e(r,i||s);return a.delete(t),Hu(t,l)&&(l=Wu(o._scopes,o,t,l)),l}function d5(t,e,n,s){let{_proxy:o,_context:r,_subProxy:i,_descriptors:a}=n;if(typeof r.index<"u"&&s(t))return e[r.index%e.length];if(Ae(e[0])){let l=e,c=o._scopes.filter(d=>d!==l);e=[];for(let d of l){let u=Wu(c,o,t,d);e.push(ro(u,r,i&&i[t],a))}}return e}function Vv(t,e,n){return ns(t)?t(e,n):t}var u5=(t,e)=>t===!0?e:typeof t=="string"?os(e,t):void 0;function p5(t,e,n,s,o){for(let r of e){let i=u5(n,r);if(i){t.add(i);let a=Vv(i._fallback,n,o);if(typeof a<"u"&&a!==n&&a!==s)return a}else if(i===!1&&typeof s<"u"&&n!==s)return null}return!1}function Wu(t,e,n,s){let o=e._rootScopes,r=Vv(e._fallback,n,s),i=[...t,...o],a=new Set;a.add(s);let l=xv(a,i,n,r||n,s);return l===null||typeof r<"u"&&r!==n&&(l=xv(a,i,r,l,s),l===null)?!1:Fl(Array.from(a),[""],o,r,()=>f5(e,n,s))}function xv(t,e,n,s,o){for(;n;)n=p5(t,e,n,s,o);return n}function f5(t,e,n){let s=t._getTarget();e in s||(s[e]={});let o=s[e];return Ze(o)&&Ae(n)?n:o||{}}function h5(t,e,n,s){let o;for(let r of e)if(o=zv(a5(r,t),n),typeof o<"u")return Hu(t,o)?Wu(n,s,t,o):o}function zv(t,e){for(let n of e){if(!n)continue;let s=n[t];if(typeof s<"u")return s}}function yv(t){let e=t._keys;return e||(e=t._keys=m5(t._scopes)),e}function m5(t){let e=new Set;for(let n of t)for(let s of Object.keys(n).filter(o=>!o.startsWith("_")))e.add(s);return Array.from(e)}function Vu(t,e,n,s){let{iScale:o}=t,{key:r="r"}=this._parsing,i=new Array(s),a,l,c,d;for(a=0,l=s;aet==="x"?"y":"x";function b5(t,e,n,s){let o=t.skip?e:t,r=e,i=n.skip?e:n,a=Pl(r,o),l=Pl(i,r),c=a/(a+l),d=l/(a+l);c=isNaN(c)?0:c,d=isNaN(d)?0:d;let u=s*c,p=s*d;return{previous:{x:r.x-u*(i.x-o.x),y:r.y-u*(i.y-o.y)},next:{x:r.x+p*(i.x-o.x),y:r.y+p*(i.y-o.y)}}}function x5(t,e,n){let s=t.length,o,r,i,a,l,c=or(t,0);for(let d=0;d!c.skip)),e.cubicInterpolationMode==="monotone")v5(t,o);else{let c=s?t[t.length-1]:t[0];for(r=0,i=t.length;rt.ownerDocument.defaultView.getComputedStyle(t,null);function w5(t,e){return Vl(t).getPropertyValue(e)}var k5=["top","right","bottom","left"];function oo(t,e,n){let s={};n=n?"-"+n:"";for(let o=0;o<4;o++){let r=k5[o];s[r]=parseFloat(t[e+"-"+r+n])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}var S5=(t,e,n)=>(t>0||e>0)&&(!n||!n.shadowRoot);function C5(t,e){let n=t.touches,s=n&&n.length?n[0]:t,{offsetX:o,offsetY:r}=s,i=!1,a,l;if(S5(o,r,t.target))a=o,l=r;else{let c=e.getBoundingClientRect();a=s.clientX-c.left,l=s.clientY-c.top,i=!0}return{x:a,y:l,box:i}}function Rs(t,e){if("native"in t)return t;let{canvas:n,currentDevicePixelRatio:s}=e,o=Vl(n),r=o.boxSizing==="border-box",i=oo(o,"padding"),a=oo(o,"border","width"),{x:l,y:c,box:d}=C5(t,n),u=i.left+(d&&a.left),p=i.top+(d&&a.top),{width:f,height:g}=e;return r&&(f-=i.width+a.width,g-=i.height+a.height),{x:Math.round((l-u)/f*n.width/s),y:Math.round((c-p)/g*n.height/s)}}function _5(t,e,n){let s,o;if(e===void 0||n===void 0){let r=t&&Wl(t);if(!r)e=t.clientWidth,n=t.clientHeight;else{let i=r.getBoundingClientRect(),a=Vl(r),l=oo(a,"border","width"),c=oo(a,"padding");e=i.width-c.width-l.width,n=i.height-c.height-l.height,s=Rl(a.maxWidth,r,"clientWidth"),o=Rl(a.maxHeight,r,"clientHeight")}}return{width:e,height:n,maxWidth:s||Tl,maxHeight:o||Tl}}var _s=t=>Math.round(t*10)/10;function Kv(t,e,n,s){let o=Vl(t),r=oo(o,"margin"),i=Rl(o.maxWidth,t,"clientWidth")||Tl,a=Rl(o.maxHeight,t,"clientHeight")||Tl,l=_5(t,e,n),{width:c,height:d}=l;if(o.boxSizing==="content-box"){let p=oo(o,"border","width"),f=oo(o,"padding");c-=f.width+p.width,d-=f.height+p.height}return c=Math.max(0,c-r.width),d=Math.max(0,s?c/s:d-r.height),c=_s(Math.min(c,i,l.maxWidth)),d=_s(Math.min(d,a,l.maxHeight)),c&&!d&&(d=_s(c/2)),(e!==void 0||n!==void 0)&&s&&l.height&&d>l.height&&(d=l.height,c=_s(Math.floor(d*s))),{width:c,height:d}}function zu(t,e,n){let s=e||1,o=_s(t.height*s),r=_s(t.width*s);t.height=_s(t.height),t.width=_s(t.width);let i=t.canvas;return i.style&&(n||!i.style.height&&!i.style.width)&&(i.style.height=`${t.height}px`,i.style.width=`${t.width}px`),t.currentDevicePixelRatio!==s||i.height!==o||i.width!==r?(t.currentDevicePixelRatio=s,i.height=o,i.width=r,t.ctx.setTransform(s,0,0,s,0,0),!0):!1}var Gv=(function(){let t=!1;try{let e={get passive(){return t=!0,!1}};Hl()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch{}return t})();function ju(t,e){let n=w5(t,e),s=n&&n.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function Cs(t,e,n,s){return{x:t.x+n*(e.x-t.x),y:t.y+n*(e.y-t.y)}}function qv(t,e,n,s){return{x:t.x+n*(e.x-t.x),y:s==="middle"?n<.5?t.y:e.y:s==="after"?n<1?t.y:e.y:n>0?e.y:t.y}}function Jv(t,e,n,s){let o={x:t.cp2x,y:t.cp2y},r={x:e.cp1x,y:e.cp1y},i=Cs(t,o,n),a=Cs(o,r,n),l=Cs(r,e,n),c=Cs(i,a,n),d=Cs(a,l,n);return Cs(c,d,n)}var M5=function(t,e){return{x(n){return t+t+e-n},setWidth(n){e=n},textAlign(n){return n==="center"?n:n==="right"?"left":"right"},xPlus(n,s){return n-s},leftForLtr(n,s){return n-s}}},A5=function(){return{x(t){return t},setWidth(t){},textAlign(t){return t},xPlus(t,e){return t+e},leftForLtr(t,e){return t}}};function ao(t,e,n){return t?M5(e,n):A5()}function Uu(t,e){let n,s;(e==="ltr"||e==="rtl")&&(n=t.canvas.style,s=[n.getPropertyValue("direction"),n.getPropertyPriority("direction")],n.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ku(t,e){e!==void 0&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Zv(t){return t==="angle"?{between:lr,compare:U_,normalize:Lt}:{between:Bn,compare:(e,n)=>e-n,normalize:e=>e}}function vv({start:t,end:e,count:n,loop:s,style:o}){return{start:t%n,end:e%n,loop:s&&(e-t+1)%n===0,style:o}}function T5(t,e,n){let{property:s,start:o,end:r}=n,{between:i,normalize:a}=Zv(s),l=e.length,{start:c,end:d,loop:u}=t,p,f;if(u){for(c+=l,d+=l,p=0,f=l;pl(o,v,b)&&a(o,v)!==0,$=()=>a(r,b)===0||l(r,v,b),S=()=>m||w(),C=()=>!m||$();for(let _=d,k=d;_<=u;++_)x=e[_%i],!x.skip&&(b=c(x[s]),b!==v&&(m=l(b,o,r),h===null&&S()&&(h=a(b,o)===0?_:k),h!==null&&C()&&(g.push(vv({start:h,end:_,loop:p,count:i,style:f})),h=null),k=_,v=b));return h!==null&&g.push(vv({start:h,end:u,loop:p,count:i,style:f})),g}function qu(t,e){let n=[],s=t.segments;for(let o=0;oo&&t[r%e].skip;)r--;return r%=e,{start:o,end:r}}function R5(t,e,n,s){let o=t.length,r=[],i=e,a=t[e],l;for(l=e+1;l<=n;++l){let c=t[l%o];c.skip||c.stop?a.skip||(s=!1,r.push({start:e%o,end:(l-1)%o,loop:s}),e=i=c.stop?l:null):(i=l,a.skip&&(e=l)),a=c}return i!==null&&r.push({start:e%o,end:i%o,loop:s}),r}function Yv(t,e){let n=t.points,s=t.options.spanGaps,o=n.length;if(!o)return[];let r=!!t._loop,{start:i,end:a}=P5(n,o,r,s);if(s===!0)return $v(t,[{start:i,end:a,loop:r}],n,e);let l=aa({chart:e,initial:n.initial,numSteps:i,currentStep:Math.min(s-n.start,i)}))}_refresh(){this._request||(this._running=!0,this._request=Pu.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(e=Date.now()){let n=0;this._charts.forEach((s,o)=>{if(!s.running||!s.items.length)return;let r=s.items,i=r.length-1,a=!1,l;for(;i>=0;--i)l=r[i],l._active?(l._total>s.duration&&(s.duration=l._total),l.tick(e),a=!0):(r[i]=r[r.length-1],r.pop());a&&(o.draw(),this._notify(o,s,e,"progress")),r.length||(s.running=!1,this._notify(o,s,e,"complete"),s.initial=!1),n+=r.length}),this._lastDate=e,n===0&&(this._running=!1)}_getAnims(e){let n=this._charts,s=n.get(e);return s||(s={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},n.set(e,s)),s}listen(e,n,s){this._getAnims(e).listeners[n].push(s)}add(e,n){!n||!n.length||this._getAnims(e).items.push(...n)}has(e){return this._getAnims(e).items.length>0}start(e){let n=this._charts.get(e);n&&(n.running=!0,n.start=Date.now(),n.duration=n.items.reduce((s,o)=>Math.max(s,o._duration),0),this._refresh())}running(e){if(!this._running)return!1;let n=this._charts.get(e);return!(!n||!n.running||!n.items.length)}stop(e){let n=this._charts.get(e);if(!n||!n.items.length)return;let s=n.items,o=s.length-1;for(;o>=0;--o)s[o].cancel();n.items=[],this._notify(e,n,Date.now(),"complete")}remove(e){return this._charts.delete(e)}},is=new ap,Xv="transparent",D5={boolean(t,e,n){return n>.5?e:t},color(t,e,n){let s=Du(t||Xv),o=s.valid&&Du(e||Xv);return o&&o.valid?o.mix(s,n).hexString():e},number(t,e,n){return t+(e-t)*n}},lp=class{constructor(e,n,s,o){let r=n[s];o=ur([e.to,o,r,e.from]);let i=ur([e.from,r,o]);this._active=!0,this._fn=e.fn||D5[e.type||typeof i],this._easing=nr[e.easing]||nr.linear,this._start=Math.floor(Date.now()+(e.delay||0)),this._duration=this._total=Math.floor(e.duration),this._loop=!!e.loop,this._target=n,this._prop=s,this._from=i,this._to=o,this._promises=void 0}active(){return this._active}update(e,n,s){if(this._active){this._notify(!1);let o=this._target[this._prop],r=s-this._start,i=this._duration-r;this._start=s,this._duration=Math.floor(Math.max(i,e.duration)),this._total+=r,this._loop=!!e.loop,this._to=ur([e.to,n,o,e.from]),this._from=ur([e.from,o,n])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(e){let n=e-this._start,s=this._duration,o=this._prop,r=this._from,i=this._loop,a=this._to,l;if(this._active=r!==a&&(i||n1?2-l:l,l=this._easing(Math.min(1,Math.max(0,l))),this._target[o]=this._fn(r,a,l)}wait(){let e=this._promises||(this._promises=[]);return new Promise((n,s)=>{e.push({res:n,rej:s})})}_notify(e){let n=e?"res":"rej",s=this._promises||[];for(let o=0;o{let r=e[o];if(!Ae(r))return;let i={};for(let a of n)i[a]=r[a];(Ze(r.properties)&&r.properties||[o]).forEach(a=>{(a===o||!s.has(a))&&s.set(a,i)})})}_animateOptions(e,n){let s=n.options,o=O5(e,s);if(!o)return[];let r=this._createAnimations(o,s);return s.$shared&&N5(e.options.$animations,s).then(()=>{e.options=s},()=>{}),r}_createAnimations(e,n){let s=this._properties,o=[],r=e.$animations||(e.$animations={}),i=Object.keys(n),a=Date.now(),l;for(l=i.length-1;l>=0;--l){let c=i[l];if(c.charAt(0)==="$")continue;if(c==="options"){o.push(...this._animateOptions(e,n));continue}let d=n[c],u=r[c],p=s.get(c);if(u)if(p&&u.active()){u.update(p,d,a);continue}else u.cancel();if(!p||!p.duration){e[c]=d;continue}r[c]=u=new lp(p,e,c,d),o.push(u)}return o}update(e,n){if(this._properties.size===0){Object.assign(e,n);return}let s=this._createAnimations(e,n);if(s.length)return is.add(this._chart,s),!0}};function N5(t,e){let n=[],s=Object.keys(e);for(let o=0;o0||!n&&r<0)return o.index}return null}function n$(t,e){let{chart:n,_cachedMeta:s}=t,o=n._stacks||(n._stacks={}),{iScale:r,vScale:i,index:a}=s,l=r.axis,c=i.axis,d=W5(r,i,s),u=e.length,p;for(let f=0;fn[s].axis===e).shift()}function j5(t,e){return rs(t,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}function U5(t,e,n){return rs(t,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:n,index:e,mode:"default",type:"data"})}function yi(t,e){let n=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(let o of e){let r=o._stacks;if(!r||r[s]===void 0||r[s][n]===void 0)return;delete r[s][n],r[s]._visualValues!==void 0&&r[s]._visualValues[n]!==void 0&&delete r[s]._visualValues[n]}}}var Xu=t=>t==="reset"||t==="none",s$=(t,e)=>e?t:Object.assign({},t),K5=(t,e,n)=>t&&!e.hidden&&e._stacked&&{keys:Y$(n,!0),values:null},Wn=class{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(e,n){this.chart=e,this._ctx=e.ctx,this.index=n,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){let e=this._cachedMeta;this.configure(),this.linkScales(),e._stacked=Zu(e.vScale,e),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(e){this.index!==e&&yi(this._cachedMeta),this.index=e}linkScales(){let e=this.chart,n=this._cachedMeta,s=this.getDataset(),o=(u,p,f,g)=>u==="x"?p:u==="r"?g:f,r=n.xAxisID=$e(s.xAxisID,Yu(e,"x")),i=n.yAxisID=$e(s.yAxisID,Yu(e,"y")),a=n.rAxisID=$e(s.rAxisID,Yu(e,"r")),l=n.indexAxis,c=n.iAxisID=o(l,r,i,a),d=n.vAxisID=o(l,i,r,a);n.xScale=this.getScaleForId(r),n.yScale=this.getScaleForId(i),n.rScale=this.getScaleForId(a),n.iScale=this.getScaleForId(c),n.vScale=this.getScaleForId(d)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(e){return this.chart.scales[e]}_getOtherScale(e){let n=this._cachedMeta;return e===n.iScale?n.vScale:n.iScale}reset(){this._update("reset")}_destroy(){let e=this._cachedMeta;this._data&&Au(this._data,this),e._stacked&&yi(e)}_dataCheck(){let e=this.getDataset(),n=e.data||(e.data=[]),s=this._data;if(Ae(n)){let o=this._cachedMeta;this._data=H5(n,o)}else if(s!==n){if(s){Au(s,this);let o=this._cachedMeta;yi(o),o._parsed=[]}n&&Object.isExtensible(n)&&Ev(n,this),this._syncList=[],this._data=n}}addElements(){let e=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(e.dataset=new this.datasetElementType)}buildOrUpdateElements(e){let n=this._cachedMeta,s=this.getDataset(),o=!1;this._dataCheck();let r=n._stacked;n._stacked=Zu(n.vScale,n),n.stack!==s.stack&&(o=!0,yi(n),n.stack=s.stack),this._resyncElements(e),(o||r!==n._stacked)&&(n$(this,n._parsed),n._stacked=Zu(n.vScale,n))}configure(){let e=this.chart.config,n=e.datasetScopeKeys(this._type),s=e.getOptionScopes(this.getDataset(),n,!0);this.options=e.createResolver(s,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(e,n){let{_cachedMeta:s,_data:o}=this,{iScale:r,_stacked:i}=s,a=r.axis,l=e===0&&n===o.length?!0:s._sorted,c=e>0&&s._parsed[e-1],d,u,p;if(this._parsing===!1)s._parsed=o,s._sorted=!0,p=o;else{Ze(o[e])?p=this.parseArrayData(s,o,e,n):Ae(o[e])?p=this.parseObjectData(s,o,e,n):p=this.parsePrimitiveData(s,o,e,n);let f=()=>u[a]===null||c&&u[a]m||u=0;--p)if(!g()){this.updateRangeFromParsed(c,e,f,l);break}}return c}getAllParsedValues(e){let n=this._cachedMeta._parsed,s=[],o,r,i;for(o=0,r=n.length;o=0&&ethis.getContext(s,o,n),m=c.resolveNamedOptions(p,f,g,u);return m.$shared&&(m.$shared=l,r[i]=Object.freeze(s$(m,l))),m}_resolveAnimations(e,n,s){let o=this.chart,r=this._cachedDataOpts,i=`animation-${n}`,a=r[i];if(a)return a;let l;if(o.options.animation!==!1){let d=this.chart.config,u=d.datasetAnimationScopeKeys(this._type,n),p=d.getOptionScopes(this.getDataset(),u);l=d.createResolver(p,this.getContext(e,s,n))}let c=new Yl(o,l&&l.animations);return l&&l._cacheable&&(r[i]=Object.freeze(c)),c}getSharedOptions(e){if(e.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},e))}includeOptions(e,n){return!n||Xu(e)||this.chart._animationsDisabled}_getSharedOptions(e,n){let s=this.resolveDataElementOptions(e,n),o=this._sharedOptions,r=this.getSharedOptions(s),i=this.includeOptions(n,r)||r!==o;return this.updateSharedOptions(r,n,s),{sharedOptions:r,includeOptions:i}}updateElement(e,n,s,o){Xu(o)?Object.assign(e,s):this._resolveAnimations(n,o).update(e,s)}updateSharedOptions(e,n,s){e&&!Xu(n)&&this._resolveAnimations(void 0,n).update(e,s)}_setStyle(e,n,s,o){e.active=o;let r=this.getStyle(n,o);this._resolveAnimations(n,s,o).update(e,{options:!o&&this.getSharedOptions(r)||r})}removeHoverStyle(e,n,s){this._setStyle(e,s,"active",!1)}setHoverStyle(e,n,s){this._setStyle(e,s,"active",!0)}_removeDatasetHoverStyle(){let e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!1)}_setDatasetHoverStyle(){let e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,"active",!0)}_resyncElements(e){let n=this._data,s=this._cachedMeta.data;for(let[a,l,c]of this._syncList)this[a](l,c);this._syncList=[];let o=s.length,r=n.length,i=Math.min(r,o);i&&this.parse(0,i),r>o?this._insertElements(o,r-o,e):r{for(c.length+=n,a=c.length-1;a>=i;a--)c[a]=c[a-n]};for(l(r),a=e;ao-r))}return t._cache.$bar}function q5(t){let e=t.iScale,n=G5(e,t.type),s=e._length,o,r,i,a,l=()=>{i===32767||i===-32768||(ir(a)&&(s=Math.min(s,Math.abs(i-a)||s)),a=i)};for(o=0,r=n.length;o0?o[t-1]:null,a=tMath.abs(a)&&(l=a,c=i),e[n.axis]=c,e._custom={barStart:l,barEnd:c,start:o,end:r,min:i,max:a}}function X$(t,e,n,s){return Ze(t)?Y5(t,e,n,s):e[n.axis]=n.parse(t,s),e}function o$(t,e,n,s){let o=t.iScale,r=t.vScale,i=o.getLabels(),a=o===r,l=[],c,d,u,p;for(c=n,d=n+s;c=n?1:-1)}function Q5(t){let e,n,s,o,r;return t.horizontal?(e=t.base>t.x,n="left",s="right"):(e=t.based.controller.options.grouped),r=s.options.stacked,i=[],a=this._cachedMeta.controller.getParsed(n),l=a&&a[s.axis],c=d=>{let u=d._parsed.find(f=>f[s.axis]===l),p=u&&u[d.vScale.axis];if(Me(p)||isNaN(p))return!0};for(let d of o)if(!(n!==void 0&&c(d))&&((r===!1||i.indexOf(d.stack)===-1||r===void 0&&d.stack===void 0)&&i.push(d.stack),d.index===e))break;return i.length||i.push(void 0),i}_getStackCount(e){return this._getStacks(void 0,e).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){let e=this.chart.scales,n=this.chart.options.indexAxis;return Object.keys(e).filter(s=>e[s].axis===n).shift()}_getAxis(){let e={},n=this.getFirstScaleIdForIndexAxis();for(let s of this.chart.data.datasets)e[$e(this.chart.options.indexAxis==="x"?s.xAxisID:s.yAxisID,n)]=!0;return Object.keys(e)}_getStackIndex(e,n,s){let o=this._getStacks(e,s),r=n!==void 0?o.indexOf(n):-1;return r===-1?o.length-1:r}_getRuler(){let e=this.options,n=this._cachedMeta,s=n.iScale,o=[],r,i;for(r=0,i=n.data.length;r=0;--s)n=Math.max(n,e[s].size(this.resolveDataElementOptions(s))/2);return n>0&&n}getLabelAndValue(e){let n=this._cachedMeta,s=this.chart.data.labels||[],{xScale:o,yScale:r}=n,i=this.getParsed(e),a=o.getLabelForValue(i.x),l=r.getLabelForValue(i.y),c=i._custom;return{label:s[e]||"",value:"("+a+", "+l+(c?", "+c:"")+")"}}update(e){let n=this._cachedMeta.data;this.updateElements(n,0,n.length,e)}updateElements(e,n,s,o){let r=o==="reset",{iScale:i,vScale:a}=this._cachedMeta,{sharedOptions:l,includeOptions:c}=this._getSharedOptions(n,o),d=i.axis,u=a.axis;for(let p=n;plr(v,a,l,!0)?1:Math.max(w,w*n,$,$*n),g=(v,w,$)=>lr(v,a,l,!0)?-1:Math.min(w,w*n,$,$*n),m=f(0,c,u),h=f(dt,d,p),b=g(Oe,c,u),x=g(Oe+dt,d,p);s=(m-b)/2,o=(h-x)/2,r=-(m+b)/2,i=-(h+x)/2}return{ratioX:s,ratioY:o,offsetX:r,offsetY:i}}var _i=class extends Wn{static id="doughnut";static defaults={datasetElementType:!1,dataElementType:"arc",animation:{animateRotate:!0,animateScale:!1},animations:{numbers:{type:"number",properties:["circumference","endAngle","innerRadius","outerRadius","startAngle","x","y","offset","borderWidth","spacing"]}},cutout:"50%",rotation:0,circumference:360,radius:"100%",spacing:0,indexAxis:"r"};static descriptors={_scriptable:e=>e!=="spacing",_indexable:e=>e!=="spacing"&&!e.startsWith("borderDash")&&!e.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){let n=e.data,{labels:{pointStyle:s,textAlign:o,color:r,useBorderRadius:i,borderRadius:a}}=e.legend.options;return n.labels.length&&n.datasets.length?n.labels.map((l,c)=>{let u=e.getDatasetMeta(0).controller.getStyle(c);return{text:l,fillStyle:u.backgroundColor,fontColor:r,hidden:!e.getDataVisibility(c),lineDash:u.borderDash,lineDashOffset:u.borderDashOffset,lineJoin:u.borderJoinStyle,lineWidth:u.borderWidth,strokeStyle:u.borderColor,textAlign:o,pointStyle:s,borderRadius:i&&(a||u.borderRadius),index:c}}):[]}},onClick(e,n,s){s.chart.toggleDataVisibility(n.index),s.chart.update()}}}};constructor(e,n){super(e,n),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(e,n){let s=this.getDataset().data,o=this._cachedMeta;if(this._parsing===!1)o._parsed=s;else{let r=l=>+s[l];if(Ae(s[e])){let{key:l="value"}=this._parsing;r=c=>+os(s[c],l)}let i,a;for(i=e,a=e+n;i0&&!isNaN(e)?Ye*(Math.abs(e)/n):0}getLabelAndValue(e){let n=this._cachedMeta,s=this.chart,o=s.data.labels||[],r=cr(n._parsed[e],s.options.locale);return{label:o[e]||"",value:r}}getMaxBorderWidth(e){let n=0,s=this.chart,o,r,i,a,l;if(!e){for(o=0,r=s.data.datasets.length;o0&&this.getParsed(n-1);for(let $=0;$=x){C.skip=!0;continue}let _=this.getParsed($),k=Me(_[f]),A=C[p]=i.getPixelForValue(_[p],$),O=C[f]=r||k?a.getBasePixel():a.getPixelForValue(l?this.applyStack(a,_,l):_[f],$);C.skip=isNaN(A)||isNaN(O)||k,C.stop=$>0&&Math.abs(_[p]-w[p])>h,m&&(C.parsed=_,C.raw=c.data[$]),u&&(C.options=d||this.resolveDataElementOptions($,S.active?"active":o)),b||this.updateElement(S,$,C,o),w=_}}getMaxOverflow(){let e=this._cachedMeta,n=e.dataset,s=n.options&&n.options.borderWidth||0,o=e.data||[];if(!o.length)return s;let r=o[0].size(this.resolveDataElementOptions(0)),i=o[o.length-1].size(this.resolveDataElementOptions(o.length-1));return Math.max(s,r,i)/2}draw(){let e=this._cachedMeta;e.dataset.updateControlPoints(this.chart.chartArea,e.iScale.axis),super.draw()}},Xl=class extends Wn{static id="polarArea";static defaults={dataElementType:"arc",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:"number",properties:["x","y","startAngle","endAngle","innerRadius","outerRadius"]}},indexAxis:"r",startAngle:0};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){let n=e.data;if(n.labels.length&&n.datasets.length){let{labels:{pointStyle:s,color:o}}=e.legend.options;return n.labels.map((r,i)=>{let l=e.getDatasetMeta(0).controller.getStyle(i);return{text:r,fillStyle:l.backgroundColor,strokeStyle:l.borderColor,fontColor:o,lineWidth:l.borderWidth,pointStyle:s,hidden:!e.getDataVisibility(i),index:i}})}return[]}},onClick(e,n,s){s.chart.toggleDataVisibility(n.index),s.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(e,n){super(e,n),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(e){let n=this._cachedMeta,s=this.chart,o=s.data.labels||[],r=cr(n._parsed[e].r,s.options.locale);return{label:o[e]||"",value:r}}parseObjectData(e,n,s,o){return Vu.bind(this)(e,n,s,o)}update(e){let n=this._cachedMeta.data;this._updateRadius(),this.updateElements(n,0,n.length,e)}getMinMax(){let e=this._cachedMeta,n={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return e.data.forEach((s,o)=>{let r=this.getParsed(o).r;!isNaN(r)&&this.chart.getDataVisibility(o)&&(rn.max&&(n.max=r))}),n}_updateRadius(){let e=this.chart,n=e.chartArea,s=e.options,o=Math.min(n.right-n.left,n.bottom-n.top),r=Math.max(o/2,0),i=Math.max(s.cutoutPercentage?r/100*s.cutoutPercentage:1,0),a=(r-i)/e.getVisibleDatasetCount();this.outerRadius=r-a*this.index,this.innerRadius=this.outerRadius-a}updateElements(e,n,s,o){let r=o==="reset",i=this.chart,l=i.options.animation,c=this._cachedMeta.rScale,d=c.xCenter,u=c.yCenter,p=c.getIndexAngle(0)-.5*Oe,f=p,g,m=360/this.countVisibleElements();for(g=0;g{!isNaN(this.getParsed(o).r)&&this.chart.getDataVisibility(o)&&n++}),n}_computeAngle(e,n,s){return this.chart.getDataVisibility(e)?pn(this.resolveDataElementOptions(e,n).angle||s):0}},pp=class extends _i{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},fp=class extends Wn{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(e){let n=this._cachedMeta.vScale,s=this.getParsed(e);return{label:n.getLabels()[e],value:""+n.getLabelForValue(s[n.axis])}}parseObjectData(e,n,s,o){return Vu.bind(this)(e,n,s,o)}update(e){let n=this._cachedMeta,s=n.dataset,o=n.data||[],r=n.iScale.getLabels();if(s.points=o,e!=="resize"){let i=this.resolveDatasetElementOptions(e);this.options.showLine||(i.borderWidth=0);let a={_loop:!0,_fullLoop:r.length===o.length,options:i};this.updateElement(s,void 0,a,e)}this.updateElements(o,0,o.length,e)}updateElements(e,n,s,o){let r=this._cachedMeta.rScale,i=o==="reset";for(let a=n;a0&&this.getParsed(n-1);for(let w=n;w0&&Math.abs(S[f]-v[f])>b,h&&(C.parsed=S,C.raw=c.data[w]),p&&(C.options=u||this.resolveDataElementOptions(w,$.active?"active":o)),x||this.updateElement($,w,C,o),v=S}this.updateSharedOptions(u,o,d)}getMaxOverflow(){let e=this._cachedMeta,n=e.data||[];if(!this.options.showLine){let a=0;for(let l=n.length-1;l>=0;--l)a=Math.max(a,n[l].size(this.resolveDataElementOptions(l))/2);return a>0&&a}let s=e.dataset,o=s.options&&s.options.borderWidth||0;if(!n.length)return o;let r=n[0].size(this.resolveDataElementOptions(0)),i=n[n.length-1].size(this.resolveDataElementOptions(n.length-1));return Math.max(o,r,i)/2}},oM=Object.freeze({__proto__:null,BarController:cp,BubbleController:dp,DoughnutController:_i,LineController:up,PieController:pp,PolarAreaController:Xl,RadarController:fp,ScatterController:hp});function lo(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}var mp=class t{static override(e){Object.assign(t.prototype,e)}options;constructor(e){this.options=e||{}}init(){}formats(){return lo()}parse(){return lo()}format(){return lo()}add(){return lo()}diff(){return lo()}startOf(){return lo()}endOf(){return lo()}},rM={_date:mp};function iM(t,e,n,s){let{controller:o,data:r,_sorted:i}=t,a=o._cachedMeta.iScale,l=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null;if(a&&e===a.axis&&e!=="r"&&i&&r.length){let c=a._reversePixels?Pv:Dn;if(s){if(o._sharedOptions){let d=r[0],u=typeof d.getRange=="function"&&d.getRange(e);if(u){let p=c(r,e,n-u),f=c(r,e,n+u);return{lo:p.lo,hi:f.hi}}}}else{let d=c(r,e,n);if(l){let{vScale:u}=o._cachedMeta,{_parsed:p}=t,f=p.slice(0,d.lo+1).reverse().findIndex(m=>!Me(m[u.axis]));d.lo-=Math.max(0,f);let g=p.slice(d.hi).findIndex(m=>!Me(m[u.axis]));d.hi+=Math.max(0,g)}return d}}return{lo:0,hi:r.length-1}}function Ri(t,e,n,s,o){let r=t.getSortedVisibleDatasetMetas(),i=n[e];for(let a=0,l=r.length;a{l[i]&&l[i](e[n],o)&&(r.push({element:l,datasetIndex:c,index:d}),a=a||l.inRange(e.x,e.y,o))}),s&&!a?[]:r}var dM={evaluateInteractionItems:Ri,modes:{index(t,e,n,s){let o=Rs(e,t),r=n.axis||"x",i=n.includeInvisible||!1,a=n.intersect?ep(t,o,r,s,i):tp(t,o,r,!1,s,i),l=[];return a.length?(t.getSortedVisibleDatasetMetas().forEach(c=>{let d=a[0].index,u=c.data[d];u&&!u.skip&&l.push({element:u,datasetIndex:c.index,index:d})}),l):[]},dataset(t,e,n,s){let o=Rs(e,t),r=n.axis||"xy",i=n.includeInvisible||!1,a=n.intersect?ep(t,o,r,s,i):tp(t,o,r,!1,s,i);if(a.length>0){let l=a[0].datasetIndex,c=t.getDatasetMeta(l).data;a=[];for(let d=0;dn.pos===e)}function l$(t,e){return t.filter(n=>Q$.indexOf(n.pos)===-1&&n.box.axis===e)}function $i(t,e){return t.sort((n,s)=>{let o=e?s:n,r=e?n:s;return o.weight===r.weight?o.index-r.index:o.weight-r.weight})}function uM(t){let e=[],n,s,o,r,i,a;for(n=0,s=(t||[]).length;nc.box.fullSize),!0),s=$i(vi(e,"left"),!0),o=$i(vi(e,"right")),r=$i(vi(e,"top"),!0),i=$i(vi(e,"bottom")),a=l$(e,"x"),l=l$(e,"y");return{fullSize:n,leftAndTop:s.concat(r),rightAndBottom:o.concat(l).concat(i).concat(a),chartArea:vi(e,"chartArea"),vertical:s.concat(o).concat(l),horizontal:r.concat(i).concat(a)}}function c$(t,e,n,s){return Math.max(t[n],e[n])+Math.max(t[s],e[s])}function e0(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function mM(t,e,n,s){let{pos:o,box:r}=n,i=t.maxPadding;if(!Ae(o)){n.size&&(t[o]-=n.size);let u=s[n.stack]||{size:0,count:1};u.size=Math.max(u.size,n.horizontal?r.height:r.width),n.size=u.size/u.count,t[o]+=n.size}r.getPadding&&e0(i,r.getPadding());let a=Math.max(0,e.outerWidth-c$(i,t,"left","right")),l=Math.max(0,e.outerHeight-c$(i,t,"top","bottom")),c=a!==t.w,d=l!==t.h;return t.w=a,t.h=l,n.horizontal?{same:c,other:d}:{same:d,other:c}}function gM(t){let e=t.maxPadding;function n(s){let o=Math.max(e[s]-t[s],0);return t[s]+=o,o}t.y+=n("top"),t.x+=n("left"),n("right"),n("bottom")}function bM(t,e){let n=e.maxPadding;function s(o){let r={left:0,top:0,right:0,bottom:0};return o.forEach(i=>{r[i]=Math.max(e[i],n[i])}),r}return s(t?["left","right"]:["top","bottom"])}function Si(t,e,n,s){let o=[],r,i,a,l,c,d;for(r=0,i=t.length,c=0;r{typeof m.beforeLayout=="function"&&m.beforeLayout()});let d=l.reduce((m,h)=>h.box.options&&h.box.options.display===!1?m:m+1,0)||1,u=Object.freeze({outerWidth:e,outerHeight:n,padding:o,availableWidth:r,availableHeight:i,vBoxMaxWidth:r/2/d,hBoxMaxHeight:i/2}),p=Object.assign({},o);e0(p,It(s));let f=Object.assign({maxPadding:p,w:r,h:i,x:o.left,y:o.top},o),g=fM(l.concat(c),u);Si(a.fullSize,f,u,g),Si(l,f,u,g),Si(c,f,u,g)&&Si(l,f,u,g),gM(f),d$(a.leftAndTop,f,u,g),f.x+=f.w,f.y+=f.h,d$(a.rightAndBottom,f,u,g),t.chartArea={left:f.left,top:f.top,right:f.left+f.w,bottom:f.top+f.h,height:f.h,width:f.w},je(a.chartArea,m=>{let h=m.box;Object.assign(h,t.chartArea),h.update(f.w,f.h,{left:0,top:0,right:0,bottom:0})})}},Ql=class{acquireContext(e,n){}releaseContext(e){return!1}addEventListener(e,n,s){}removeEventListener(e,n,s){}getDevicePixelRatio(){return 1}getMaximumSize(e,n,s,o){return n=Math.max(0,n||e.width),s=s||e.height,{width:n,height:Math.max(0,o?Math.floor(n/o):s)}}isAttached(e){return!0}updateConfig(e){}},gp=class extends Ql{acquireContext(e){return e&&e.getContext&&e.getContext("2d")||null}updateConfig(e){e.options.animation=!1}},Jl="$chartjs",xM={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},u$=t=>t===null||t==="";function yM(t,e){let n=t.style,s=t.getAttribute("height"),o=t.getAttribute("width");if(t[Jl]={initial:{height:s,width:o,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||"block",n.boxSizing=n.boxSizing||"border-box",u$(o)){let r=ju(t,"width");r!==void 0&&(t.width=r)}if(u$(s))if(t.style.height==="")t.height=t.width/(e||2);else{let r=ju(t,"height");r!==void 0&&(t.height=r)}return t}var t0=Gv?{passive:!0}:!1;function vM(t,e,n){t&&t.addEventListener(e,n,t0)}function $M(t,e,n){t&&t.canvas&&t.canvas.removeEventListener(e,n,t0)}function wM(t,e){let n=xM[t.type]||t.type,{x:s,y:o}=Rs(t,e);return{type:n,chart:e,native:t,x:s!==void 0?s:null,y:o!==void 0?o:null}}function ec(t,e){for(let n of t)if(n===e||n.contains(e))return!0}function kM(t,e,n){let s=t.canvas,o=new MutationObserver(r=>{let i=!1;for(let a of r)i=i||ec(a.addedNodes,s),i=i&&!ec(a.removedNodes,s);i&&n()});return o.observe(document,{childList:!0,subtree:!0}),o}function SM(t,e,n){let s=t.canvas,o=new MutationObserver(r=>{let i=!1;for(let a of r)i=i||ec(a.removedNodes,s),i=i&&!ec(a.addedNodes,s);i&&n()});return o.observe(document,{childList:!0,subtree:!0}),o}var Mi=new Map,p$=0;function n0(){let t=window.devicePixelRatio;t!==p$&&(p$=t,Mi.forEach((e,n)=>{n.currentDevicePixelRatio!==t&&e()}))}function CM(t,e){Mi.size||window.addEventListener("resize",n0),Mi.set(t,e)}function _M(t){Mi.delete(t),Mi.size||window.removeEventListener("resize",n0)}function MM(t,e,n){let s=t.canvas,o=s&&Wl(s);if(!o)return;let r=Ru((a,l)=>{let c=o.clientWidth;n(a,l),c{let l=a[0],c=l.contentRect.width,d=l.contentRect.height;c===0&&d===0||r(c,d)});return i.observe(o),CM(t,r),i}function np(t,e,n){n&&n.disconnect(),e==="resize"&&_M(t)}function AM(t,e,n){let s=t.canvas,o=Ru(r=>{t.ctx!==null&&n(wM(r,t))},t);return vM(s,e,o),o}var bp=class extends Ql{acquireContext(e,n){let s=e&&e.getContext&&e.getContext("2d");return s&&s.canvas===e?(yM(e,n),s):null}releaseContext(e){let n=e.canvas;if(!n[Jl])return!1;let s=n[Jl].initial;["height","width"].forEach(r=>{let i=s[r];Me(i)?n.removeAttribute(r):n.setAttribute(r,i)});let o=s.style||{};return Object.keys(o).forEach(r=>{n.style[r]=o[r]}),n.width=n.width,delete n[Jl],!0}addEventListener(e,n,s){this.removeEventListener(e,n);let o=e.$proxies||(e.$proxies={}),i={attach:kM,detach:SM,resize:MM}[n]||AM;o[n]=i(e,n,s)}removeEventListener(e,n){let s=e.$proxies||(e.$proxies={}),o=s[n];if(!o)return;({attach:np,detach:np,resize:np}[n]||$M)(e,n,o),s[n]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(e,n,s,o){return Kv(e,n,s,o)}isAttached(e){let n=e&&Wl(e);return!!(n&&n.isConnected)}};function TM(t){return!Hl()||typeof OffscreenCanvas<"u"&&t instanceof OffscreenCanvas?gp:bp}var kn=class{static defaults={};static defaultRoutes=void 0;x;y;active=!1;options;$animations;tooltipPosition(e){let{x:n,y:s}=this.getProps(["x","y"],e);return{x:n,y:s}}hasValue(){return io(this.x)&&io(this.y)}getProps(e,n){let s=this.$animations;if(!n||!s)return this;let o={};return e.forEach(r=>{o[r]=s[r]&&s[r].active()?s[r]._to:this[r]}),o}};function PM(t,e){let n=t.options.ticks,s=RM(t),o=Math.min(n.maxTicksLimit||s,s),r=n.major.enabled?EM(e):[],i=r.length,a=r[0],l=r[i-1],c=[];if(i>o)return IM(e,c,r,i/o),c;let d=LM(r,e,o);if(i>0){let u,p,f=i>1?Math.round((l-a)/(i-1)):null;for(jl(e,c,d,Me(f)?0:a-f,a),u=0,p=i-1;uo)return l}return Math.max(o,1)}function EM(t){let e=[],n,s;for(n=0,s=t.length;nt==="left"?"right":t==="right"?"left":t,f$=(t,e,n)=>e==="top"||e==="left"?t[e]+n:t[e]-n,h$=(t,e)=>Math.min(e||t,t);function m$(t,e){let n=[],s=t.length/e,o=t.length,r=0;for(;ri+a)))return l}function BM(t,e){je(t,n=>{let s=n.gc,o=s.length/2,r;if(o>e){for(r=0;rs?s:n,s=o&&n>s?n:s,{min:Zt(n,Zt(s,n)),max:Zt(s,Zt(n,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){let e=this.chart.data;return this.options.labels||(this.isHorizontal()?e.xLabels:e.yLabels)||e.labels||[]}getLabelItems(e=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(e))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){qe(this.options.beforeUpdate,[this])}update(e,n,s){let{beginAtZero:o,grace:r,ticks:i}=this.options,a=i.sampleSize;this.beforeUpdate(),this.maxWidth=e,this.maxHeight=n,this._margins=s=Object.assign({left:0,right:0,top:0,bottom:0},s),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+s.left+s.right:this.height+s.top+s.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Hv(this,r,o),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();let l=a=r||s<=1||!this.isHorizontal()){this.labelRotation=o;return}let d=this._getLabelSizes(),u=d.widest.width,p=d.highest.height,f=yt(this.chart.width-u,0,this.maxWidth);a=e.offset?this.maxWidth/s:f/(s-1),u+6>a&&(a=f/(s-(e.offset?.5:1)),l=this.maxHeight-wi(e.grid)-n.padding-g$(e.title,this.chart.options.font),c=Math.sqrt(u*u+p*p),i=El(Math.min(Math.asin(yt((d.highest.height+6)/a,-1,1)),Math.asin(yt(l/c,-1,1))-Math.asin(yt(p/c,-1,1)))),i=Math.max(o,Math.min(r,i))),this.labelRotation=i}afterCalculateLabelRotation(){qe(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){qe(this.options.beforeFit,[this])}fit(){let e={width:0,height:0},{chart:n,options:{ticks:s,title:o,grid:r}}=this,i=this._isVisible(),a=this.isHorizontal();if(i){let l=g$(o,n.options.font);if(a?(e.width=this.maxWidth,e.height=wi(r)+l):(e.height=this.maxHeight,e.width=wi(r)+l),s.display&&this.ticks.length){let{first:c,last:d,widest:u,highest:p}=this._getLabelSizes(),f=s.padding*2,g=pn(this.labelRotation),m=Math.cos(g),h=Math.sin(g);if(a){let b=s.mirror?0:h*u.width+m*p.height;e.height=Math.min(this.maxHeight,e.height+b+f)}else{let b=s.mirror?0:m*u.width+h*p.height;e.width=Math.min(this.maxWidth,e.width+b+f)}this._calculatePadding(c,d,h,m)}}this._handleMargins(),a?(this.width=this._length=n.width-this._margins.left-this._margins.right,this.height=e.height):(this.width=e.width,this.height=this._length=n.height-this._margins.top-this._margins.bottom)}_calculatePadding(e,n,s,o){let{ticks:{align:r,padding:i},position:a}=this.options,l=this.labelRotation!==0,c=a!=="top"&&this.axis==="x";if(this.isHorizontal()){let d=this.getPixelForTick(0)-this.left,u=this.right-this.getPixelForTick(this.ticks.length-1),p=0,f=0;l?c?(p=o*e.width,f=s*n.height):(p=s*e.height,f=o*n.width):r==="start"?f=n.width:r==="end"?p=e.width:r!=="inner"&&(p=e.width/2,f=n.width/2),this.paddingLeft=Math.max((p-d+i)*this.width/(this.width-d),0),this.paddingRight=Math.max((f-u+i)*this.width/(this.width-u),0)}else{let d=n.height/2,u=e.height/2;r==="start"?(d=0,u=e.height):r==="end"&&(d=n.height,u=0),this.paddingTop=d+i,this.paddingBottom=u+i}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){qe(this.options.afterFit,[this])}isHorizontal(){let{axis:e,position:n}=this.options;return n==="top"||n==="bottom"||e==="x"}isFullSize(){return this.options.fullSize}_convertTicksToLabels(e){this.beforeTickToLabelConversion(),this.generateTickLabels(e);let n,s;for(n=0,s=e.length;n({width:i[k]||0,height:a[k]||0});return{first:_(0),last:_(n-1),widest:_(S),highest:_(C),widths:i,heights:a}}getLabelForValue(e){return e}getPixelForValue(e,n){return NaN}getValueForPixel(e){}getPixelForTick(e){let n=this.ticks;return e<0||e>n.length-1?null:this.getPixelForValue(n[e].value)}getPixelForDecimal(e){this._reversePixels&&(e=1-e);let n=this._startPixel+e*this._length;return Tv(this._alignToPixels?As(this.chart,n,0):n)}getDecimalForPixel(e){let n=(e-this._startPixel)/this._length;return this._reversePixels?1-n:n}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){let{min:e,max:n}=this;return e<0&&n<0?n:e>0&&n>0?e:0}getContext(e){let n=this.ticks||[];if(e>=0&&ea*o?a/s:l/o:l*o0}_computeGridLineItems(e){let n=this.axis,s=this.chart,o=this.options,{grid:r,position:i,border:a}=o,l=r.offset,c=this.isHorizontal(),u=this.ticks.length+(l?1:0),p=wi(r),f=[],g=a.setContext(this.getContext()),m=g.display?g.width:0,h=m/2,b=function(R){return As(s,R,m)},x,v,w,$,S,C,_,k,A,O,D,j;if(i==="top")x=b(this.bottom),C=this.bottom-p,k=x-h,O=b(e.top)+h,j=e.bottom;else if(i==="bottom")x=b(this.top),O=e.top,j=b(e.bottom)-h,C=x+h,k=this.top+p;else if(i==="left")x=b(this.right),S=this.right-p,_=x-h,A=b(e.left)+h,D=e.right;else if(i==="right")x=b(this.left),A=e.left,D=b(e.right)-h,S=x+h,_=this.left+p;else if(n==="x"){if(i==="center")x=b((e.top+e.bottom)/2+.5);else if(Ae(i)){let R=Object.keys(i)[0],B=i[R];x=b(this.chart.scales[R].getPixelForValue(B))}O=e.top,j=e.bottom,C=x+h,k=C+p}else if(n==="y"){if(i==="center")x=b((e.left+e.right)/2);else if(Ae(i)){let R=Object.keys(i)[0],B=i[R];x=b(this.chart.scales[R].getPixelForValue(B))}S=x-h,_=S-p,A=e.left,D=e.right}let E=$e(o.ticks.maxTicksLimit,u),M=Math.max(1,Math.ceil(u/E));for(v=0;v0&&(de-=z/2);break}G={left:de,top:X,width:z+se.width,height:K+se.height,color:M.backdropColor}}h.push({label:w,font:k,textOffset:D,options:{rotation:m,color:B,strokeColor:I,strokeWidth:U,textAlign:W,textBaseline:j,translation:[$,S],backdrop:G}})}return h}_getXAxisLabelAlignment(){let{position:e,ticks:n}=this.options;if(-pn(this.labelRotation))return e==="top"?"left":"right";let o="center";return n.align==="start"?o="left":n.align==="end"?o="right":n.align==="inner"&&(o="inner"),o}_getYAxisLabelAlignment(e){let{position:n,ticks:{crossAlign:s,mirror:o,padding:r}}=this.options,i=this._getLabelSizes(),a=e+r,l=i.widest.width,c,d;return n==="left"?o?(d=this.right+r,s==="near"?c="left":s==="center"?(c="center",d+=l/2):(c="right",d+=l)):(d=this.right-a,s==="near"?c="right":s==="center"?(c="center",d-=l/2):(c="left",d=this.left)):n==="right"?o?(d=this.left+r,s==="near"?c="right":s==="center"?(c="center",d-=l/2):(c="left",d-=l)):(d=this.left+a,s==="near"?c="left":s==="center"?(c="center",d+=l/2):(c="right",d=this.right)):c="right",{textAlign:c,x:d}}_computeLabelArea(){if(this.options.ticks.mirror)return;let e=this.chart,n=this.options.position;if(n==="left"||n==="right")return{top:0,left:this.left,bottom:e.height,right:this.right};if(n==="top"||n==="bottom")return{top:this.top,left:0,bottom:this.bottom,right:e.width}}drawBackground(){let{ctx:e,options:{backgroundColor:n},left:s,top:o,width:r,height:i}=this;n&&(e.save(),e.fillStyle=n,e.fillRect(s,o,r,i),e.restore())}getLineWidthForValue(e){let n=this.options.grid;if(!this._isVisible()||!n.display)return 0;let o=this.ticks.findIndex(r=>r.value===e);return o>=0?n.setContext(this.getContext(o)).lineWidth:0}drawGrid(e){let n=this.options.grid,s=this.ctx,o=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(e)),r,i,a=(l,c,d)=>{!d.width||!d.color||(s.save(),s.lineWidth=d.width,s.strokeStyle=d.color,s.setLineDash(d.borderDash||[]),s.lineDashOffset=d.borderDashOffset,s.beginPath(),s.moveTo(l.x,l.y),s.lineTo(c.x,c.y),s.stroke(),s.restore())};if(n.display)for(r=0,i=o.length;r{this.draw(r)}}]:[{z:s,draw:r=>{this.drawBackground(),this.drawGrid(r),this.drawTitle()}},{z:o,draw:()=>{this.drawBorder()}},{z:n,draw:r=>{this.drawLabels(r)}}]}getMatchingVisibleMetas(e){let n=this.chart.getSortedVisibleDatasetMetas(),s=this.axis+"AxisID",o=[],r,i;for(r=0,i=n.length;r{let s=n.split("."),o=s.pop(),r=[t].concat(s).join("."),i=e[n].split("."),a=i.pop(),l=i.join(".");Qe.route(r,o,l,a)})}function UM(t){return"id"in t&&"defaults"in t}var xp=class{constructor(){this.controllers=new fr(Wn,"datasets",!0),this.elements=new fr(kn,"elements"),this.plugins=new fr(Object,"plugins"),this.scales=new fr(uo,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...e){this._each("register",e)}remove(...e){this._each("unregister",e)}addControllers(...e){this._each("register",e,this.controllers)}addElements(...e){this._each("register",e,this.elements)}addPlugins(...e){this._each("register",e,this.plugins)}addScales(...e){this._each("register",e,this.scales)}getController(e){return this._get(e,this.controllers,"controller")}getElement(e){return this._get(e,this.elements,"element")}getPlugin(e){return this._get(e,this.plugins,"plugin")}getScale(e){return this._get(e,this.scales,"scale")}removeControllers(...e){this._each("unregister",e,this.controllers)}removeElements(...e){this._each("unregister",e,this.elements)}removePlugins(...e){this._each("unregister",e,this.plugins)}removeScales(...e){this._each("unregister",e,this.scales)}_each(e,n,s){[...n].forEach(o=>{let r=s||this._getRegistryForType(o);s||r.isForType(o)||r===this.plugins&&o.id?this._exec(e,r,o):je(o,i=>{let a=s||this._getRegistryForType(i);this._exec(e,a,i)})})}_exec(e,n,s){let o=Ll(e);qe(s["before"+o],[],s),n[e](s),qe(s["after"+o],[],s)}_getRegistryForType(e){for(let n=0;nr.filter(a=>!i.some(l=>a.plugin.id===l.plugin.id));this._notify(o(n,s),e,"stop"),this._notify(o(s,n),e,"start")}};function KM(t){let e={},n=[],s=Object.keys(Hn.plugins.items);for(let r=0;r1&&b$(t[0].toLowerCase());if(s)return s}throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function x$(t,e,n){if(n[e+"AxisID"]===t)return{axis:e}}function QM(t,e){if(e.data&&e.data.datasets){let n=e.data.datasets.filter(s=>s.xAxisID===t||s.yAxisID===t);if(n.length)return x$(t,"x",n[0])||x$(t,"y",n[0])}return{}}function eA(t,e){let n=Ms[t.type]||{scales:{}},s=e.scales||{},o=vp(t.type,e),r=Object.create(null);return Object.keys(s).forEach(i=>{let a=s[i];if(!Ae(a))return console.error(`Invalid scale configuration for scale: ${i}`);if(a._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${i}`);let l=$p(i,a,QM(i,t),Qe.scales[a.type]),c=YM(l,o),d=n.scales||{};r[i]=rr(Object.create(null),[{axis:l},a,d[l],d[c]])}),t.data.datasets.forEach(i=>{let a=i.type||t.type,l=i.indexAxis||vp(a,e),d=(Ms[a]||{}).scales||{};Object.keys(d).forEach(u=>{let p=ZM(u,l),f=i[p+"AxisID"]||p;r[f]=r[f]||Object.create(null),rr(r[f],[{axis:p},s[f],d[u]])})}),Object.keys(r).forEach(i=>{let a=r[i];rr(a,[Qe.scales[a.type],Qe.scale])}),r}function s0(t){let e=t.options||(t.options={});e.plugins=$e(e.plugins,{}),e.scales=eA(t,e)}function o0(t){return t=t||{},t.datasets=t.datasets||[],t.labels=t.labels||[],t}function tA(t){return t=t||{},t.data=o0(t.data),s0(t),t}var y$=new Map,r0=new Set;function Ul(t,e){let n=y$.get(t);return n||(n=e(),y$.set(t,n),r0.add(n)),n}var ki=(t,e,n)=>{let s=os(e,n);s!==void 0&&t.add(s)},wp=class{constructor(e){this._config=tA(e),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(e){this._config.type=e}get data(){return this._config.data}set data(e){this._config.data=o0(e)}get options(){return this._config.options}set options(e){this._config.options=e}get plugins(){return this._config.plugins}update(){let e=this._config;this.clearCache(),s0(e)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(e){return Ul(e,()=>[[`datasets.${e}`,""]])}datasetAnimationScopeKeys(e,n){return Ul(`${e}.transition.${n}`,()=>[[`datasets.${e}.transitions.${n}`,`transitions.${n}`],[`datasets.${e}`,""]])}datasetElementScopeKeys(e,n){return Ul(`${e}-${n}`,()=>[[`datasets.${e}.elements.${n}`,`datasets.${e}`,`elements.${n}`,""]])}pluginScopeKeys(e){let n=e.id,s=this.type;return Ul(`${s}-plugin-${n}`,()=>[[`plugins.${n}`,...e.additionalOptionScopes||[]]])}_cachedScopes(e,n){let s=this._scopeCache,o=s.get(e);return(!o||n)&&(o=new Map,s.set(e,o)),o}getOptionScopes(e,n,s){let{options:o,type:r}=this,i=this._cachedScopes(e,s),a=i.get(n);if(a)return a;let l=new Set;n.forEach(d=>{e&&(l.add(e),d.forEach(u=>ki(l,e,u))),d.forEach(u=>ki(l,o,u)),d.forEach(u=>ki(l,Ms[r]||{},u)),d.forEach(u=>ki(l,Qe,u)),d.forEach(u=>ki(l,Nl,u))});let c=Array.from(l);return c.length===0&&c.push(Object.create(null)),r0.has(n)&&i.set(n,c),c}chartOptionScopes(){let{options:e,type:n}=this;return[e,Ms[n]||{},Qe.datasets[n]||{},{type:n},Qe,Nl]}resolveNamedOptions(e,n,s,o=[""]){let r={$shared:!0},{resolver:i,subPrefixes:a}=v$(this._resolverCache,e,o),l=i;if(sA(i,n)){r.$shared=!1,s=ns(s)?s():s;let c=this.createResolver(e,s,a);l=ro(i,s,c)}for(let c of n)r[c]=l[c];return r}createResolver(e,n,s=[""],o){let{resolver:r}=v$(this._resolverCache,e,s);return Ae(n)?ro(r,n,void 0,o):r}};function v$(t,e,n){let s=t.get(e);s||(s=new Map,t.set(e,s));let o=n.join(),r=s.get(o);return r||(r={resolver:Fl(e,n),subPrefixes:n.filter(a=>!a.toLowerCase().includes("hover"))},s.set(o,r)),r}var nA=t=>Ae(t)&&Object.getOwnPropertyNames(t).some(e=>ns(t[e]));function sA(t,e){let{isScriptable:n,isIndexable:s}=Fu(t);for(let o of e){let r=n(o),i=s(o),a=(i||r)&&t[o];if(r&&(ns(a)||nA(a))||i&&Ze(a))return!0}return!1}var oA="4.5.1",rA=["top","bottom","left","right","chartArea"];function $$(t,e){return t==="top"||t==="bottom"||rA.indexOf(t)===-1&&e==="x"}function w$(t,e){return function(n,s){return n[t]===s[t]?n[e]-s[e]:n[t]-s[t]}}function k$(t){let e=t.chart,n=e.options.animation;e.notifyPlugins("afterRender"),qe(n&&n.onComplete,[t],e)}function iA(t){let e=t.chart,n=e.options.animation;qe(n&&n.onProgress,[t],e)}function i0(t){return Hl()&&typeof t=="string"?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}var Zl={},S$=t=>{let e=i0(t);return Object.values(Zl).filter(n=>n.canvas===e).pop()};function aA(t,e,n){let s=Object.keys(t);for(let o of s){let r=+o;if(r>=e){let i=t[o];delete t[o],(n>0||r>e)&&(t[r+n]=i)}}}function lA(t,e,n,s){return!n||t.type==="mouseout"?null:s?e:t}var hr=class{static defaults=Qe;static instances=Zl;static overrides=Ms;static registry=Hn;static version=oA;static getChart=S$;static register(...e){Hn.add(...e),C$()}static unregister(...e){Hn.remove(...e),C$()}constructor(e,n){let s=this.config=new wp(n),o=i0(e),r=S$(o);if(r)throw new Error("Canvas is already in use. Chart with ID '"+r.id+"' must be destroyed before the canvas with ID '"+r.canvas.id+"' can be reused.");let i=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||TM(o)),this.platform.updateConfig(s);let a=this.platform.acquireContext(o,i.aspectRatio),l=a&&a.canvas,c=l&&l.height,d=l&&l.width;if(this.id=kv(),this.ctx=a,this.canvas=l,this.width=d,this.height=c,this._options=i,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new yp,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=Iv(u=>this.update(u),i.resizeDelay||0),this._dataChanges=[],Zl[this.id]=this,!a||!l){console.error("Failed to create chart: can't acquire context from the given item");return}is.listen(this,"complete",k$),is.listen(this,"progress",iA),this._initialize(),this.attached&&this.update()}get aspectRatio(){let{options:{aspectRatio:e,maintainAspectRatio:n},width:s,height:o,_aspectRatio:r}=this;return Me(e)?n&&r?r:o?s/o:null:e}get data(){return this.config.data}set data(e){this.config.data=e}get options(){return this._options}set options(e){this.config.options=e}get registry(){return Hn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():zu(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Nu(this.canvas,this.ctx),this}stop(){return is.stop(this),this}resize(e,n){is.running(this)?this._resizeBeforeDraw={width:e,height:n}:this._resize(e,n)}_resize(e,n){let s=this.options,o=this.canvas,r=s.maintainAspectRatio&&this.aspectRatio,i=this.platform.getMaximumSize(o,e,n,r),a=s.devicePixelRatio||this.platform.getDevicePixelRatio(),l=this.width?"resize":"attach";this.width=i.width,this.height=i.height,this._aspectRatio=this.aspectRatio,zu(this,a,!0)&&(this.notifyPlugins("resize",{size:i}),qe(s.onResize,[this,i],this),this.attached&&this._doResize(l)&&this.render())}ensureScalesHaveIDs(){let n=this.options.scales||{};je(n,(s,o)=>{s.id=o})}buildOrUpdateScales(){let e=this.options,n=e.scales,s=this.scales,o=Object.keys(s).reduce((i,a)=>(i[a]=!1,i),{}),r=[];n&&(r=r.concat(Object.keys(n).map(i=>{let a=n[i],l=$p(i,a),c=l==="r",d=l==="x";return{options:a,dposition:c?"chartArea":d?"bottom":"left",dtype:c?"radialLinear":d?"category":"linear"}}))),je(r,i=>{let a=i.options,l=a.id,c=$p(l,a),d=$e(a.type,i.dtype);(a.position===void 0||$$(a.position,c)!==$$(i.dposition))&&(a.position=i.dposition),o[l]=!0;let u=null;if(l in s&&s[l].type===d)u=s[l];else{let p=Hn.getScale(d);u=new p({id:l,type:d,ctx:this.ctx,chart:this}),s[u.id]=u}u.init(a,e)}),je(o,(i,a)=>{i||delete s[a]}),je(s,i=>{Ft.configure(this,i,i.options),Ft.addBox(this,i)})}_updateMetasets(){let e=this._metasets,n=this.data.datasets.length,s=e.length;if(e.sort((o,r)=>o.index-r.index),s>n){for(let o=n;on.length&&delete this._stacks,e.forEach((s,o)=>{n.filter(r=>r===s._dataset).length===0&&this._destroyDatasetMeta(o)})}buildOrUpdateControllers(){let e=[],n=this.data.datasets,s,o;for(this._removeUnreferencedMetasets(),s=0,o=n.length;s{this.getDatasetMeta(n).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(e){let n=this.config;n.update();let s=this._options=n.createResolver(n.chartOptionScopes(),this.getContext()),o=this._animationsDisabled=!s.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins("beforeUpdate",{mode:e,cancelable:!0})===!1)return;let r=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let i=0;for(let c=0,d=this.data.datasets.length;c{c.reset()}),this._updateDatasets(e),this.notifyPlugins("afterUpdate",{mode:e}),this._layers.sort(w$("z","_idx"));let{_active:a,_lastEvent:l}=this;l?this._eventHandler(l,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){je(this.scales,e=>{Ft.removeBox(this,e)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){let e=this.options,n=new Set(Object.keys(this._listeners)),s=new Set(e.events);(!ku(n,s)||!!this._responsiveListeners!==e.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){let{_hiddenIndices:e}=this,n=this._getUniformDataChanges()||[];for(let{method:s,start:o,count:r}of n){let i=s==="_removeElements"?-r:r;aA(e,o,i)}}_getUniformDataChanges(){let e=this._dataChanges;if(!e||!e.length)return;this._dataChanges=[];let n=this.data.datasets.length,s=r=>new Set(e.filter(i=>i[0]===r).map((i,a)=>a+","+i.splice(1).join(","))),o=s(0);for(let r=1;rr.split(",")).map(r=>({method:r[1],start:+r[2],count:+r[3]}))}_updateLayout(e){if(this.notifyPlugins("beforeLayout",{cancelable:!0})===!1)return;Ft.update(this,this.width,this.height,e);let n=this.chartArea,s=n.width<=0||n.height<=0;this._layers=[],je(this.boxes,o=>{s&&o.position==="chartArea"||(o.configure&&o.configure(),this._layers.push(...o._layers()))},this),this._layers.forEach((o,r)=>{o._idx=r}),this.notifyPlugins("afterLayout")}_updateDatasets(e){if(this.notifyPlugins("beforeDatasetsUpdate",{mode:e,cancelable:!0})!==!1){for(let n=0,s=this.data.datasets.length;n=0;--n)this._drawDataset(e[n]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(e){let n=this.ctx,s={meta:e,index:e.index,cancelable:!0},o=Ju(this,e);this.notifyPlugins("beforeDatasetDraw",s)!==!1&&(o&&bi(n,o),e.controller.draw(),o&&xi(n),s.cancelable=!1,this.notifyPlugins("afterDatasetDraw",s))}isPointInArea(e){return Nn(e,this.chartArea,this._minPadding)}getElementsAtEventForMode(e,n,s,o){let r=dM.modes[n];return typeof r=="function"?r(this,e,s,o):[]}getDatasetMeta(e){let n=this.data.datasets[e],s=this._metasets,o=s.filter(r=>r&&r._dataset===n).pop();return o||(o={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:n&&n.order||0,index:e,_dataset:n,_parsed:[],_sorted:!1},s.push(o)),o}getContext(){return this.$context||(this.$context=rs(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(e){let n=this.data.datasets[e];if(!n)return!1;let s=this.getDatasetMeta(e);return typeof s.hidden=="boolean"?!s.hidden:!n.hidden}setDatasetVisibility(e,n){let s=this.getDatasetMeta(e);s.hidden=!n}toggleDataVisibility(e){this._hiddenIndices[e]=!this._hiddenIndices[e]}getDataVisibility(e){return!this._hiddenIndices[e]}_updateVisibility(e,n,s){let o=s?"show":"hide",r=this.getDatasetMeta(e),i=r.controller._resolveAnimations(void 0,o);ir(n)?(r.data[n].hidden=!s,this.update()):(this.setDatasetVisibility(e,s),i.update(r,{visible:s}),this.update(a=>a.datasetIndex===e?o:void 0))}hide(e,n){this._updateVisibility(e,n,!1)}show(e,n){this._updateVisibility(e,n,!0)}_destroyDatasetMeta(e){let n=this._metasets[e];n&&n.controller&&n.controller._destroy(),delete this._metasets[e]}_stop(){let e,n;for(this.stop(),is.remove(this),e=0,n=this.data.datasets.length;e{n.addEventListener(this,r,i),e[r]=i},o=(r,i,a)=>{r.offsetX=i,r.offsetY=a,this._eventHandler(r)};je(this.options.events,r=>s(r,o))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});let e=this._responsiveListeners,n=this.platform,s=(l,c)=>{n.addEventListener(this,l,c),e[l]=c},o=(l,c)=>{e[l]&&(n.removeEventListener(this,l,c),delete e[l])},r=(l,c)=>{this.canvas&&this.resize(l,c)},i,a=()=>{o("attach",a),this.attached=!0,this.resize(),s("resize",r),s("detach",i)};i=()=>{this.attached=!1,o("resize",r),this._stop(),this._resize(0,0),s("attach",a)},n.isAttached(this.canvas)?a():i()}unbindEvents(){je(this._listeners,(e,n)=>{this.platform.removeEventListener(this,n,e)}),this._listeners={},je(this._responsiveListeners,(e,n)=>{this.platform.removeEventListener(this,n,e)}),this._responsiveListeners=void 0}updateHoverStyle(e,n,s){let o=s?"set":"remove",r,i,a,l;for(n==="dataset"&&(r=this.getDatasetMeta(e[0].datasetIndex),r.controller["_"+o+"DatasetHoverStyle"]()),a=0,l=e.length;a{let a=this.getDatasetMeta(r);if(!a)throw new Error("No dataset found at index "+r);return{datasetIndex:r,element:a.data[i],index:i}});!mi(s,n)&&(this._active=s,this._lastEvent=null,this._updateHoverStyles(s,n))}notifyPlugins(e,n,s){return this._plugins.notify(this,e,n,s)}isPluginEnabled(e){return this._plugins._cache.filter(n=>n.plugin.id===e).length===1}_updateHoverStyles(e,n,s){let o=this.options.hover,r=(l,c)=>l.filter(d=>!c.some(u=>d.datasetIndex===u.datasetIndex&&d.index===u.index)),i=r(n,e),a=s?e:r(e,n);i.length&&this.updateHoverStyle(i,o.mode,!1),a.length&&o.mode&&this.updateHoverStyle(a,o.mode,!0)}_eventHandler(e,n){let s={event:e,replay:n,cancelable:!0,inChartArea:this.isPointInArea(e)},o=i=>(i.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins("beforeEvent",s,o)===!1)return;let r=this._handleEvent(e,n,s.inChartArea);return s.cancelable=!1,this.notifyPlugins("afterEvent",s,o),(r||s.changed)&&this.render(),this}_handleEvent(e,n,s){let{_active:o=[],options:r}=this,i=n,a=this._getActiveElements(e,o,s,i),l=_v(e),c=lA(e,this._lastEvent,s,l);s&&(this._lastEvent=null,qe(r.onHover,[e,a,this],this),l&&qe(r.onClick,[e,a,this],this));let d=!mi(a,o);return(d||n)&&(this._active=a,this._updateHoverStyles(a,o,n)),this._lastEvent=c,d}_getActiveElements(e,n,s,o){if(e.type==="mouseout")return[];if(!s)return n;let r=this.options.hover;return this.getElementsAtEventForMode(e,r.mode,r,o)}};function C$(){return je(hr.instances,t=>t._plugins.invalidate())}function cA(t,e,n){let{startAngle:s,x:o,y:r,outerRadius:i,innerRadius:a,options:l}=e,{borderWidth:c,borderJoinStyle:d}=l,u=Math.min(c/i,Lt(s-n));if(t.beginPath(),t.arc(o,r,i-c/2,s+u/2,n-u/2),a>0){let p=Math.min(c/a,Lt(s-n));t.arc(o,r,a+c/2,n-p/2,s+p/2,!0)}else{let p=Math.min(c/2,i*Lt(s-n));if(d==="round")t.arc(o,r,p,n-Oe/2,s+Oe/2,!0);else if(d==="bevel"){let f=2*p*p,g=-f*Math.cos(n+Oe/2)+o,m=-f*Math.sin(n+Oe/2)+r,h=f*Math.cos(s+Oe/2)+o,b=f*Math.sin(s+Oe/2)+r;t.lineTo(g,m),t.lineTo(h,b)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}function dA(t,e,n){let{startAngle:s,pixelMargin:o,x:r,y:i,outerRadius:a,innerRadius:l}=e,c=o/a;t.beginPath(),t.arc(r,i,a,s-c,n+c),l>o?(c=o/l,t.arc(r,i,l,n+c,s-c,!0)):t.arc(r,i,o,n+dt,s-dt),t.closePath(),t.clip()}function uA(t){return Bl(t,["outerStart","outerEnd","innerStart","innerEnd"])}function pA(t,e,n,s){let o=uA(t.options.borderRadius),r=(n-e)/2,i=Math.min(r,s*e/2),a=l=>{let c=(n-Math.min(r,l))*s/2;return yt(l,0,Math.min(r,c))};return{outerStart:a(o.outerStart),outerEnd:a(o.outerEnd),innerStart:yt(o.innerStart,0,i),innerEnd:yt(o.innerEnd,0,i)}}function pr(t,e,n,s){return{x:n+t*Math.cos(e),y:s+t*Math.sin(e)}}function tc(t,e,n,s,o,r){let{x:i,y:a,startAngle:l,pixelMargin:c,innerRadius:d}=e,u=Math.max(e.outerRadius+s+n-c,0),p=d>0?d+s+n+c:0,f=0,g=o-l;if(s){let M=d>0?d-s:0,R=u>0?u-s:0,B=(M+R)/2,I=B!==0?g*B/(B+s):g;f=(g-I)/2}let m=Math.max(.001,g*u-n/Oe)/u,h=(g-m)/2,b=l+h+f,x=o-h-f,{outerStart:v,outerEnd:w,innerStart:$,innerEnd:S}=pA(e,p,u,x-b),C=u-v,_=u-w,k=b+v/C,A=x-w/_,O=p+$,D=p+S,j=b+$/O,E=x-S/D;if(t.beginPath(),r){let M=(k+A)/2;if(t.arc(i,a,u,k,M),t.arc(i,a,u,M,A),w>0){let U=pr(_,A,i,a);t.arc(U.x,U.y,w,A,x+dt)}let R=pr(D,x,i,a);if(t.lineTo(R.x,R.y),S>0){let U=pr(D,E,i,a);t.arc(U.x,U.y,S,x+dt,E+Math.PI)}let B=(x-S/p+(b+$/p))/2;if(t.arc(i,a,p,x-S/p,B,!0),t.arc(i,a,p,B,b+$/p,!0),$>0){let U=pr(O,j,i,a);t.arc(U.x,U.y,$,j+Math.PI,b-dt)}let I=pr(C,b,i,a);if(t.lineTo(I.x,I.y),v>0){let U=pr(C,k,i,a);t.arc(U.x,U.y,v,b-dt,k)}}else{t.moveTo(i,a);let M=Math.cos(k)*u+i,R=Math.sin(k)*u+a;t.lineTo(M,R);let B=Math.cos(A)*u+i,I=Math.sin(A)*u+a;t.lineTo(B,I)}t.closePath()}function fA(t,e,n,s,o){let{fullCircles:r,startAngle:i,circumference:a}=e,l=e.endAngle;if(r){tc(t,e,n,s,l,o);for(let c=0;c=Oe&&f===0&&d!=="miter"&&cA(t,e,m),r||(tc(t,e,n,s,m,o),t.stroke())}var kp=class extends kn{static id="arc";static defaults={borderAlign:"center",borderColor:"#fff",borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1};static defaultRoutes={backgroundColor:"backgroundColor"};static descriptors={_scriptable:!0,_indexable:e=>e!=="borderDash"};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(e){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,e&&Object.assign(this,e)}inRange(e,n,s){let o=this.getProps(["x","y"],s),{angle:r,distance:i}=Mu(o,{x:e,y:n}),{startAngle:a,endAngle:l,innerRadius:c,outerRadius:d,circumference:u}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],s),p=(this.options.spacing+this.options.borderWidth)/2,f=$e(u,l-a),g=lr(r,a,l)&&a!==l,m=f>=Ye||g,h=Bn(i,c+p,d+p);return m&&h}getCenterPoint(e){let{x:n,y:s,startAngle:o,endAngle:r,innerRadius:i,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],e),{offset:l,spacing:c}=this.options,d=(o+r)/2,u=(i+a+c+l)/2;return{x:n+Math.cos(d)*u,y:s+Math.sin(d)*u}}tooltipPosition(e){return this.getCenterPoint(e)}draw(e){let{options:n,circumference:s}=this,o=(n.offset||0)/4,r=(n.spacing||0)/2,i=n.circular;if(this.pixelMargin=n.borderAlign==="inner"?.33:0,this.fullCircles=s>Ye?Math.floor(s/Ye):0,s===0||this.innerRadius<0||this.outerRadius<0)return;e.save();let a=(this.startAngle+this.endAngle)/2;e.translate(Math.cos(a)*o,Math.sin(a)*o);let l=1-Math.sin(Math.min(Oe,s||0)),c=o*l;e.fillStyle=n.backgroundColor,e.strokeStyle=n.borderColor,fA(e,this,c,r,i),hA(e,this,c,r,i),e.restore()}};function a0(t,e,n=e){t.lineCap=$e(n.borderCapStyle,e.borderCapStyle),t.setLineDash($e(n.borderDash,e.borderDash)),t.lineDashOffset=$e(n.borderDashOffset,e.borderDashOffset),t.lineJoin=$e(n.borderJoinStyle,e.borderJoinStyle),t.lineWidth=$e(n.borderWidth,e.borderWidth),t.strokeStyle=$e(n.borderColor,e.borderColor)}function mA(t,e,n){t.lineTo(n.x,n.y)}function gA(t){return t.stepped?Bv:t.tension||t.cubicInterpolationMode==="monotone"?Fv:mA}function l0(t,e,n={}){let s=t.length,{start:o=0,end:r=s-1}=n,{start:i,end:a}=e,l=Math.max(o,i),c=Math.min(r,a),d=oa&&r>a;return{count:s,start:l,loop:e.loop,ilen:c(i+(c?a-w:w))%r,v=()=>{m!==h&&(t.lineTo(d,h),t.lineTo(d,m),t.lineTo(d,b))};for(l&&(f=o[x(0)],t.moveTo(f.x,f.y)),p=0;p<=a;++p){if(f=o[x(p)],f.skip)continue;let w=f.x,$=f.y,S=w|0;S===g?($h&&(h=$),d=(u*d+w)/++u):(v(),t.lineTo(w,$),g=S,u=0,m=h=$),b=$}v()}function Sp(t){let e=t.options,n=e.borderDash&&e.borderDash.length;return!t._decimated&&!t._loop&&!e.tension&&e.cubicInterpolationMode!=="monotone"&&!e.stepped&&!n?xA:bA}function yA(t){return t.stepped?qv:t.tension||t.cubicInterpolationMode==="monotone"?Jv:Cs}function vA(t,e,n,s){let o=e._path;o||(o=e._path=new Path2D,e.path(o,n,s)&&o.closePath()),a0(t,e.options),t.stroke(o)}function $A(t,e,n,s){let{segments:o,options:r}=e,i=Sp(e);for(let a of o)a0(t,r,a.style),t.beginPath(),i(t,e,a,{start:n,end:n+s-1})&&t.closePath(),t.stroke()}var wA=typeof Path2D=="function";function kA(t,e,n,s){wA&&!e.options.segment?vA(t,e,n,s):$A(t,e,n,s)}var mr=class extends kn{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:e=>e!=="borderDash"&&e!=="fill"};constructor(e){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,e&&Object.assign(this,e)}updateControlPoints(e,n){let s=this.options;if((s.tension||s.cubicInterpolationMode==="monotone")&&!s.stepped&&!this._pointsUpdated){let o=s.spanGaps?this._loop:this._fullLoop;Uv(this._points,s,e,o,n),this._pointsUpdated=!0}}set points(e){this._points=e,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Yv(this,this.options.segment))}first(){let e=this.segments,n=this.points;return e.length&&n[e[0].start]}last(){let e=this.segments,n=this.points,s=e.length;return s&&n[e[s-1].end]}interpolate(e,n){let s=this.options,o=e[n],r=this.points,i=qu(this,{property:n,start:o,end:o});if(!i.length)return;let a=[],l=yA(s),c,d;for(c=0,d=i.length;ct.replace("rgb(","rgba(").replace(")",", 0.5)"));function d0(t){return Mp[t%Mp.length]}function u0(t){return M$[t%M$.length]}function PA(t,e){return t.borderColor=d0(e),t.backgroundColor=u0(e),++e}function RA(t,e){return t.backgroundColor=t.data.map(()=>d0(e++)),e}function LA(t,e){return t.backgroundColor=t.data.map(()=>u0(e++)),e}function EA(t){let e=0;return(n,s)=>{let o=t.getDatasetMeta(s).controller;o instanceof _i?e=RA(n,e):o instanceof Xl?e=LA(n,e):o&&(e=PA(n,e))}}function A$(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}function IA(t){return t&&(t.borderColor||t.backgroundColor)}function DA(){return Qe.borderColor!=="rgba(0,0,0,0.1)"||Qe.backgroundColor!=="rgba(0,0,0,0.1)"}var NA={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,n){if(!n.enabled)return;let{data:{datasets:s},options:o}=t.config,{elements:r}=o,i=A$(s)||IA(o)||r&&A$(r)||DA();if(!n.forceOverride&&i)return;let a=EA(t);s.forEach(a)}};function OA(t,e,n,s,o){let r=o.samples||s;if(r>=n)return t.slice(e,e+n);let i=[],a=(n-2)/(r-2),l=0,c=e+n-1,d=e,u,p,f,g,m;for(i[l++]=t[d],u=0;uf&&(f=g,p=t[x],m=x);i[l++]=p,d=m}return i[l++]=t[c],i}function BA(t,e,n,s){let o=0,r=0,i,a,l,c,d,u,p,f,g,m,h=[],b=e+n-1,x=t[e].x,w=t[b].x-x;for(i=e;im&&(m=c,p=i),o=(r*o+a.x)/++r;else{let S=i-1;if(!Me(u)&&!Me(p)){let C=Math.min(u,p),_=Math.max(u,p);C!==f&&C!==S&&h.push({...t[C],x:o}),_!==f&&_!==S&&h.push({...t[_],x:o})}i>0&&S!==f&&h.push(t[S]),h.push(a),d=$,r=0,g=m=c,u=p=f=i}}return h}function p0(t){if(t._decimated){let e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function T$(t){t.data.datasets.forEach(e=>{p0(e)})}function FA(t,e){let n=e.length,s=0,o,{iScale:r}=t,{min:i,max:a,minDefined:l,maxDefined:c}=r.getUserBounds();return l&&(s=yt(Dn(e,r.axis,i).lo,0,n-1)),c?o=yt(Dn(e,r.axis,a).hi+1,s,n)-s:o=n-s,{start:s,count:o}}var HA={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,n)=>{if(!n.enabled){T$(t);return}let s=t.width;t.data.datasets.forEach((o,r)=>{let{_data:i,indexAxis:a}=o,l=t.getDatasetMeta(r),c=i||o.data;if(ur([a,t.options.indexAxis])==="y"||!l.controller.supportsDecimation)return;let d=t.scales[l.xAxisID];if(d.type!=="linear"&&d.type!=="time"||t.options.parsing)return;let{start:u,count:p}=FA(l,c),f=n.threshold||4*s;if(p<=f){p0(o);return}Me(i)&&(o._data=c,delete o.data,Object.defineProperty(o,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(m){this._data=m}}));let g;switch(n.algorithm){case"lttb":g=OA(c,u,p,s,n);break;case"min-max":g=BA(c,u,p,s);break;default:throw new Error(`Unsupported decimation algorithm '${n.algorithm}'`)}o._decimated=g})},destroy(t){T$(t)}};function WA(t,e,n){let s=t.segments,o=t.points,r=e.points,i=[];for(let a of s){let{start:l,end:c}=a;c=rc(l,c,o);let d=Ap(n,o[l],o[c],a.loop);if(!e.segments){i.push({source:a,target:d,start:o[l],end:o[c]});continue}let u=qu(e,d);for(let p of u){let f=Ap(n,r[p.start],r[p.end],p.loop),g=Gu(a,o,f);for(let m of g)i.push({source:m,target:p,start:{[n]:P$(d,f,"start",Math.max)},end:{[n]:P$(d,f,"end",Math.min)}})}}return i}function Ap(t,e,n,s){if(s)return;let o=e[t],r=n[t];return t==="angle"&&(o=Lt(o),r=Lt(r)),{property:t,start:o,end:r}}function VA(t,e){let{x:n=null,y:s=null}=t||{},o=e.points,r=[];return e.segments.forEach(({start:i,end:a})=>{a=rc(i,a,o);let l=o[i],c=o[a];s!==null?(r.push({x:l.x,y:s}),r.push({x:c.x,y:s})):n!==null&&(r.push({x:n,y:l.y}),r.push({x:n,y:c.y}))}),r}function rc(t,e,n){for(;e>t;e--){let s=n[e];if(!isNaN(s.x)&&!isNaN(s.y))break}return e}function P$(t,e,n,s){return t&&e?s(t[n],e[n]):t?t[n]:e?e[n]:0}function f0(t,e){let n=[],s=!1;return Ze(t)?(s=!0,n=t):n=VA(t,e),n.length?new mr({points:n,options:{tension:0},_loop:s,_fullLoop:s}):null}function R$(t){return t&&t.fill!==!1}function zA(t,e,n){let o=t[e].fill,r=[e],i;if(!n)return o;for(;o!==!1&&r.indexOf(o)===-1;){if(!at(o))return o;if(i=t[o],!i)return!1;if(i.visible)return o;r.push(o),o=i.fill}return!1}function jA(t,e,n){let s=qA(t);if(Ae(s))return isNaN(s.value)?!1:s;let o=parseFloat(s);return at(o)&&Math.floor(o)===o?UA(s[0],e,o,n):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function UA(t,e,n,s){return(t==="-"||t==="+")&&(n=e+n),n===e||n<0||n>=s?!1:n}function KA(t,e){let n=null;return t==="start"?n=e.bottom:t==="end"?n=e.top:Ae(t)?n=e.getPixelForValue(t.value):e.getBasePixel&&(n=e.getBasePixel()),n}function GA(t,e,n){let s;return t==="start"?s=n:t==="end"?s=e.options.reverse?e.min:e.max:Ae(t)?s=t.value:s=e.getBaseValue(),s}function qA(t){let e=t.options,n=e.fill,s=$e(n&&n.target,n);return s===void 0&&(s=!!e.backgroundColor),s===!1||s===null?!1:s===!0?"origin":s}function JA(t){let{scale:e,index:n,line:s}=t,o=[],r=s.segments,i=s.points,a=ZA(e,n);a.push(f0({x:null,y:e.bottom},s));for(let l=0;l=0;--i){let a=o[i].$filler;a&&(a.line.updateControlPoints(r,a.axis),s&&a.fill&&rp(t.ctx,a,r))}},beforeDatasetsDraw(t,e,n){if(n.drawTime!=="beforeDatasetsDraw")return;let s=t.getSortedVisibleDatasetMetas();for(let o=s.length-1;o>=0;--o){let r=s[o].$filler;R$(r)&&rp(t.ctx,r,t.chartArea)}},beforeDatasetDraw(t,e,n){let s=e.meta.$filler;!R$(s)||n.drawTime!=="beforeDatasetDraw"||rp(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}},D$=(t,e)=>{let{boxHeight:n=e,boxWidth:s=e}=t;return t.usePointStyle&&(n=Math.min(n,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:n,itemHeight:Math.max(e,n)}},aT=(t,e)=>t!==null&&e!==null&&t.datasetIndex===e.datasetIndex&&t.index===e.index,sc=class extends kn{constructor(e){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,n,s){this.maxWidth=e,this.maxHeight=n,this._margins=s,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){let e=this.options.labels||{},n=qe(e.generateLabels,[this.chart],this)||[];e.filter&&(n=n.filter(s=>e.filter(s,this.chart.data))),e.sort&&(n=n.sort((s,o)=>e.sort(s,o,this.chart.data))),this.options.reverse&&n.reverse(),this.legendItems=n}fit(){let{options:e,ctx:n}=this;if(!e.display){this.width=this.height=0;return}let s=e.labels,o=ht(s.font),r=o.size,i=this._computeTitleHeight(),{boxWidth:a,itemHeight:l}=D$(s,r),c,d;n.font=o.string,this.isHorizontal()?(c=this.maxWidth,d=this._fitRows(i,r,a,l)+10):(d=this.maxHeight,c=this._fitCols(i,o,a,l)+10),this.width=Math.min(c,e.maxWidth||this.maxWidth),this.height=Math.min(d,e.maxHeight||this.maxHeight)}_fitRows(e,n,s,o){let{ctx:r,maxWidth:i,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.lineWidths=[0],d=o+a,u=e;r.textAlign="left",r.textBaseline="middle";let p=-1,f=-d;return this.legendItems.forEach((g,m)=>{let h=s+n/2+r.measureText(g.text).width;(m===0||c[c.length-1]+h+2*a>i)&&(u+=d,c[c.length-(m>0?0:1)]=0,f+=d,p++),l[m]={left:0,top:f,row:p,width:h,height:o},c[c.length-1]+=h+a}),u}_fitCols(e,n,s,o){let{ctx:r,maxHeight:i,options:{labels:{padding:a}}}=this,l=this.legendHitBoxes=[],c=this.columnSizes=[],d=i-e,u=a,p=0,f=0,g=0,m=0;return this.legendItems.forEach((h,b)=>{let{itemWidth:x,itemHeight:v}=lT(s,n,r,h,o);b>0&&f+v+2*a>d&&(u+=p+a,c.push({width:p,height:f}),g+=p+a,m++,p=f=0),l[b]={left:g,top:f,col:m,width:x,height:v},p=Math.max(p,x),f+=v+a}),u+=p,c.push({width:p,height:f}),u}adjustHitBoxes(){if(!this.options.display)return;let e=this._computeTitleHeight(),{legendHitBoxes:n,options:{align:s,labels:{padding:o},rtl:r}}=this,i=ao(r,this.left,this.width);if(this.isHorizontal()){let a=0,l=Et(s,this.left+o,this.right-this.lineWidths[a]);for(let c of n)a!==c.row&&(a=c.row,l=Et(s,this.left+o,this.right-this.lineWidths[a])),c.top+=this.top+e+o,c.left=i.leftForLtr(i.x(l),c.width),l+=c.width+o}else{let a=0,l=Et(s,this.top+e+o,this.bottom-this.columnSizes[a].height);for(let c of n)c.col!==a&&(a=c.col,l=Et(s,this.top+e+o,this.bottom-this.columnSizes[a].height)),c.top=l,c.left+=this.left+o,c.left=i.leftForLtr(i.x(c.left),c.width),l+=c.height+o}}isHorizontal(){return this.options.position==="top"||this.options.position==="bottom"}draw(){if(this.options.display){let e=this.ctx;bi(e,this),this._draw(),xi(e)}}_draw(){let{options:e,columnSizes:n,lineWidths:s,ctx:o}=this,{align:r,labels:i}=e,a=Qe.color,l=ao(e.rtl,this.left,this.width),c=ht(i.font),{padding:d}=i,u=c.size,p=u/2,f;this.drawTitle(),o.textAlign=l.textAlign("left"),o.textBaseline="middle",o.lineWidth=.5,o.font=c.string;let{boxWidth:g,boxHeight:m,itemHeight:h}=D$(i,u),b=function(S,C,_){if(isNaN(g)||g<=0||isNaN(m)||m<0)return;o.save();let k=$e(_.lineWidth,1);if(o.fillStyle=$e(_.fillStyle,a),o.lineCap=$e(_.lineCap,"butt"),o.lineDashOffset=$e(_.lineDashOffset,0),o.lineJoin=$e(_.lineJoin,"miter"),o.lineWidth=k,o.strokeStyle=$e(_.strokeStyle,a),o.setLineDash($e(_.lineDash,[])),i.usePointStyle){let A={radius:m*Math.SQRT2/2,pointStyle:_.pointStyle,rotation:_.rotation,borderWidth:k},O=l.xPlus(S,g/2),D=C+p;Ou(o,A,O,D,i.pointStyleWidth&&g)}else{let A=C+Math.max((u-m)/2,0),O=l.leftForLtr(S,g),D=Ps(_.borderRadius);o.beginPath(),Object.values(D).some(j=>j!==0)?dr(o,{x:O,y:A,w:g,h:m,radius:D}):o.rect(O,A,g,m),o.fill(),k!==0&&o.stroke()}o.restore()},x=function(S,C,_){Ts(o,_.text,S,C+h/2,c,{strikethrough:_.hidden,textAlign:l.textAlign(_.textAlign)})},v=this.isHorizontal(),w=this._computeTitleHeight();v?f={x:Et(r,this.left+d,this.right-s[0]),y:this.top+d+w,line:0}:f={x:this.left+d,y:Et(r,this.top+w+d,this.bottom-n[0].height),line:0},Uu(this.ctx,e.textDirection);let $=h+d;this.legendItems.forEach((S,C)=>{o.strokeStyle=S.fontColor,o.fillStyle=S.fontColor;let _=o.measureText(S.text).width,k=l.textAlign(S.textAlign||(S.textAlign=i.textAlign)),A=g+p+_,O=f.x,D=f.y;l.setWidth(this.width),v?C>0&&O+A+d>this.right&&(D=f.y+=$,f.line++,O=f.x=Et(r,this.left+d,this.right-s[f.line])):C>0&&D+$>this.bottom&&(O=f.x=O+n[f.line].width+d,f.line++,D=f.y=Et(r,this.top+w+d,this.bottom-n[f.line].height));let j=l.x(O);if(b(j,D,S),O=Dv(k,O+g+p,v?O+A:this.right,e.rtl),x(l.x(O),D,S),v)f.x+=A+d;else if(typeof S.text!="string"){let E=c.lineHeight;f.y+=h0(S,E)+d}else f.y+=$}),Ku(this.ctx,e.textDirection)}drawTitle(){let e=this.options,n=e.title,s=ht(n.font),o=It(n.padding);if(!n.display)return;let r=ao(e.rtl,this.left,this.width),i=this.ctx,a=n.position,l=s.size/2,c=o.top+l,d,u=this.left,p=this.width;if(this.isHorizontal())p=Math.max(...this.lineWidths),d=this.top+c,u=Et(e.align,u,this.right-p);else{let g=this.columnSizes.reduce((m,h)=>Math.max(m,h.height),0);d=c+Et(e.align,this.top,this.bottom-g-e.labels.padding-this._computeTitleHeight())}let f=Et(a,u,u+p);i.textAlign=r.textAlign(Dl(a)),i.textBaseline="middle",i.strokeStyle=n.color,i.fillStyle=n.color,i.font=s.string,Ts(i,n.text,f,d,s)}_computeTitleHeight(){let e=this.options.title,n=ht(e.font),s=It(e.padding);return e.display?n.lineHeight+s.height:0}_getLegendItemAt(e,n){let s,o,r;if(Bn(e,this.left,this.right)&&Bn(n,this.top,this.bottom)){for(r=this.legendHitBoxes,s=0;sr.length>i.length?r:i)),e+n.size/2+s.measureText(o).width}function dT(t,e,n){let s=t;return typeof e.text!="string"&&(s=h0(e,n)),s}function h0(t,e){let n=t.text?t.text.length:0;return e*n}function uT(t,e){return!!((t==="mousemove"||t==="mouseout")&&(e.onHover||e.onLeave)||e.onClick&&(t==="click"||t==="mouseup"))}var pT={id:"legend",_element:sc,start(t,e,n){let s=t.legend=new sc({ctx:t.ctx,options:n,chart:t});Ft.configure(t,s,n),Ft.addBox(t,s)},stop(t){Ft.removeBox(t,t.legend),delete t.legend},beforeUpdate(t,e,n){let s=t.legend;Ft.configure(t,s,n),s.options=n},afterUpdate(t){let e=t.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(t,e){e.replay||t.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(t,e,n){let s=e.datasetIndex,o=n.chart;o.isDatasetVisible(s)?(o.hide(s),e.hidden=!0):(o.show(s),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:t=>t.chart.options.color,boxWidth:40,padding:10,generateLabels(t){let e=t.data.datasets,{labels:{usePointStyle:n,pointStyle:s,textAlign:o,color:r,useBorderRadius:i,borderRadius:a}}=t.legend.options;return t._getSortedDatasetMetas().map(l=>{let c=l.controller.getStyle(n?0:void 0),d=It(c.borderWidth);return{text:e[l.index].label,fillStyle:c.backgroundColor,fontColor:r,hidden:!l.visible,lineCap:c.borderCapStyle,lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:(d.width+d.height)/4,strokeStyle:c.borderColor,pointStyle:s||c.pointStyle,rotation:c.rotation,textAlign:o||c.textAlign,borderRadius:i&&(a||c.borderRadius),datasetIndex:l.index}},this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}},Ai=class extends kn{constructor(e){super(),this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,n){let s=this.options;if(this.left=0,this.top=0,!s.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=e,this.height=this.bottom=n;let o=Ze(s.text)?s.text.length:1;this._padding=It(s.padding);let r=o*ht(s.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=r:this.width=r}isHorizontal(){let e=this.options.position;return e==="top"||e==="bottom"}_drawArgs(e){let{top:n,left:s,bottom:o,right:r,options:i}=this,a=i.align,l=0,c,d,u;return this.isHorizontal()?(d=Et(a,s,r),u=n+e,c=r-s):(i.position==="left"?(d=s+e,u=Et(a,o,n),l=Oe*-.5):(d=r-e,u=Et(a,n,o),l=Oe*.5),c=o-n),{titleX:d,titleY:u,maxWidth:c,rotation:l}}draw(){let e=this.ctx,n=this.options;if(!n.display)return;let s=ht(n.font),r=s.lineHeight/2+this._padding.top,{titleX:i,titleY:a,maxWidth:l,rotation:c}=this._drawArgs(r);Ts(e,n.text,0,0,s,{color:n.color,maxWidth:l,rotation:c,textAlign:Dl(n.align),textBaseline:"middle",translation:[i,a]})}};function fT(t,e){let n=new Ai({ctx:t.ctx,options:e,chart:t});Ft.configure(t,n,e),Ft.addBox(t,n),t.titleBlock=n}var hT={id:"title",_element:Ai,start(t,e,n){fT(t,n)},stop(t){let e=t.titleBlock;Ft.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,n){let s=t.titleBlock;Ft.configure(t,s,n),s.options=n},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}},Kl=new WeakMap,mT={id:"subtitle",start(t,e,n){let s=new Ai({ctx:t.ctx,options:n,chart:t});Ft.configure(t,s,n),Ft.addBox(t,s),Kl.set(t,s)},stop(t){Ft.removeBox(t,Kl.get(t)),Kl.delete(t)},beforeUpdate(t,e,n){let s=Kl.get(t);Ft.configure(t,s,n),s.options=n},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}},Ci={average(t){if(!t.length)return!1;let e,n,s=new Set,o=0,r=0;for(e=0,n=t.length;ea+l)/s.size,y:o/r}},nearest(t,e){if(!t.length)return!1;let n=e.x,s=e.y,o=Number.POSITIVE_INFINITY,r,i,a;for(r=0,i=t.length;r-1?t.split(` -`):t}function gT(t,e){let{element:n,datasetIndex:s,index:o}=e,r=t.getDatasetMeta(s).controller,{label:i,value:a}=r.getLabelAndValue(o);return{chart:t,label:i,parsed:r.getParsed(o),raw:t.data.datasets[s].data[o],formattedValue:a,dataset:r.getDataset(),dataIndex:o,datasetIndex:s,element:n}}function N$(t,e){let n=t.chart.ctx,{body:s,footer:o,title:r}=t,{boxWidth:i,boxHeight:a}=e,l=ht(e.bodyFont),c=ht(e.titleFont),d=ht(e.footerFont),u=r.length,p=o.length,f=s.length,g=It(e.padding),m=g.height,h=0,b=s.reduce((w,$)=>w+$.before.length+$.lines.length+$.after.length,0);if(b+=t.beforeBody.length+t.afterBody.length,u&&(m+=u*c.lineHeight+(u-1)*e.titleSpacing+e.titleMarginBottom),b){let w=e.displayColors?Math.max(a,l.lineHeight):l.lineHeight;m+=f*w+(b-f)*l.lineHeight+(b-1)*e.bodySpacing}p&&(m+=e.footerMarginTop+p*d.lineHeight+(p-1)*e.footerSpacing);let x=0,v=function(w){h=Math.max(h,n.measureText(w).width+x)};return n.save(),n.font=c.string,ze(t.title,v),n.font=l.string,ze(t.beforeBody.concat(t.afterBody),v),x=e.displayColors?i+2+e.boxPadding:0,ze(s,w=>{ze(w.before,v),ze(w.lines,v),ze(w.after,v)}),x=0,n.font=d.string,ze(t.footer,v),n.restore(),h+=g.width,{width:h,height:m}}function bT(t,e){let{y:n,height:s}=e;return nt.height-s/2?"bottom":"center"}function xT(t,e,n,s){let{x:o,width:r}=s,i=n.caretSize+n.caretPadding;if(t==="left"&&o+r+i>e.width||t==="right"&&o-r-i<0)return!0}function yT(t,e,n,s){let{x:o,width:r}=n,{width:i,chartArea:{left:a,right:l}}=t,c="center";return s==="center"?c=o<=(a+l)/2?"left":"right":o<=r/2?c="left":o>=i-r/2&&(c="right"),xT(c,t,e,n)&&(c="center"),c}function O$(t,e,n){let s=n.yAlign||e.yAlign||bT(t,n);return{xAlign:n.xAlign||e.xAlign||yT(t,e,n,s),yAlign:s}}function vT(t,e){let{x:n,width:s}=t;return e==="right"?n-=s:e==="center"&&(n-=s/2),n}function $T(t,e,n){let{y:s,height:o}=t;return e==="top"?s+=n:e==="bottom"?s-=o+n:s-=o/2,s}function B$(t,e,n,s){let{caretSize:o,caretPadding:r,cornerRadius:i}=t,{xAlign:a,yAlign:l}=n,c=o+r,{topLeft:d,topRight:u,bottomLeft:p,bottomRight:f}=Ps(i),g=vT(e,a),m=$T(e,l,c);return l==="center"?a==="left"?g+=c:a==="right"&&(g-=c):a==="left"?g-=Math.max(d,p)+o:a==="right"&&(g+=Math.max(u,f)+o),{x:yt(g,0,s.width-e.width),y:yt(m,0,s.height-e.height)}}function Kl(t,e,n){let s=It(n.padding);return e==="center"?t.x+t.width/2:e==="right"?t.x+t.width-s.right:t.x+s.left}function F$(t){return Fn([],as(t))}function wT(t,e,n){return rs(t,{tooltip:e,tooltipItems:n,type:"tooltip"})}function H$(t,e){let n=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return n?t.override(n):t}var m0={beforeTitle:On,title(t){if(t.length>0){let e=t[0],n=e.chart.data.labels,s=n?n.length:0;if(this&&this.options&&this.options.mode==="dataset")return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex"u"?m0[e].call(n,s):o}var sc=class extends kn{static positioners=Ci;constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let n=this.chart,s=this.options.setContext(this.getContext()),o=s.enabled&&n.options.animation&&s.animations,r=new Zl(this.chart,o);return o._cacheable&&(this._cachedAnimations=Object.freeze(r)),r}getContext(){return this.$context||(this.$context=wT(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,n){let{callbacks:s}=n,o=Yt(s,"beforeTitle",this,e),r=Yt(s,"title",this,e),i=Yt(s,"afterTitle",this,e),a=[];return a=Fn(a,as(o)),a=Fn(a,as(r)),a=Fn(a,as(i)),a}getBeforeBody(e,n){return F$(Yt(n.callbacks,"beforeBody",this,e))}getBody(e,n){let{callbacks:s}=n,o=[];return ze(e,r=>{let i={before:[],lines:[],after:[]},a=H$(s,r);Fn(i.before,as(Yt(a,"beforeLabel",this,r))),Fn(i.lines,Yt(a,"label",this,r)),Fn(i.after,as(Yt(a,"afterLabel",this,r))),o.push(i)}),o}getAfterBody(e,n){return F$(Yt(n.callbacks,"afterBody",this,e))}getFooter(e,n){let{callbacks:s}=n,o=Yt(s,"beforeFooter",this,e),r=Yt(s,"footer",this,e),i=Yt(s,"afterFooter",this,e),a=[];return a=Fn(a,as(o)),a=Fn(a,as(r)),a=Fn(a,as(i)),a}_createItems(e){let n=this._active,s=this.chart.data,o=[],r=[],i=[],a=[],l,c;for(l=0,c=n.length;le.filter(d,u,p,s))),e.itemSort&&(a=a.sort((d,u)=>e.itemSort(d,u,s))),ze(a,d=>{let u=H$(e.callbacks,d);o.push(Yt(u,"labelColor",this,d)),r.push(Yt(u,"labelPointStyle",this,d)),i.push(Yt(u,"labelTextColor",this,d))}),this.labelColors=o,this.labelPointStyles=r,this.labelTextColors=i,this.dataPoints=a,a}update(e,n){let s=this.options.setContext(this.getContext()),o=this._active,r,i=[];if(!o.length)this.opacity!==0&&(r={opacity:0});else{let a=Ci[s.position].call(this,o,this._eventPosition);i=this._createItems(s),this.title=this.getTitle(i,s),this.beforeBody=this.getBeforeBody(i,s),this.body=this.getBody(i,s),this.afterBody=this.getAfterBody(i,s),this.footer=this.getFooter(i,s);let l=this._size=N$(this,s),c=Object.assign({},a,l),d=O$(this.chart,s,c),u=B$(s,c,d,this.chart);this.xAlign=d.xAlign,this.yAlign=d.yAlign,r={opacity:1,x:u.x,y:u.y,width:l.width,height:l.height,caretX:a.x,caretY:a.y}}this._tooltipItems=i,this.$context=void 0,r&&this._resolveAnimations().update(this,r),e&&s.external&&s.external.call(this,{chart:this.chart,tooltip:this,replay:n})}drawCaret(e,n,s,o){let r=this.getCaretPosition(e,s,o);n.lineTo(r.x1,r.y1),n.lineTo(r.x2,r.y2),n.lineTo(r.x3,r.y3)}getCaretPosition(e,n,s){let{xAlign:o,yAlign:r}=this,{caretSize:i,cornerRadius:a}=s,{topLeft:l,topRight:c,bottomLeft:d,bottomRight:u}=Ps(a),{x:p,y:f}=e,{width:g,height:m}=n,h,b,x,v,w,$;return r==="center"?(w=f+m/2,o==="left"?(h=p,b=h-i,v=w+i,$=w-i):(h=p+g,b=h+i,v=w-i,$=w+i),x=h):(o==="left"?b=p+Math.max(l,d)+i:o==="right"?b=p+g-Math.max(c,u)-i:b=this.caretX,r==="top"?(v=f,w=v-i,h=b-i,x=b+i):(v=f+m,w=v+i,h=b+i,x=b-i),$=v),{x1:h,x2:b,x3:x,y1:v,y2:w,y3:$}}drawTitle(e,n,s){let o=this.title,r=o.length,i,a,l;if(r){let c=ao(s.rtl,this.x,this.width);for(e.x=Kl(this,s.titleAlign,s),n.textAlign=c.textAlign(s.titleAlign),n.textBaseline="middle",i=ht(s.titleFont),a=s.titleSpacing,n.fillStyle=s.titleColor,n.font=i.string,l=0;lx!==0)?(e.beginPath(),e.fillStyle=r.multiKeyBackground,dr(e,{x:m,y:g,w:c,h:l,radius:b}),e.fill(),e.stroke(),e.fillStyle=i.backgroundColor,e.beginPath(),dr(e,{x:h,y:g+1,w:c-2,h:l-2,radius:b}),e.fill()):(e.fillStyle=r.multiKeyBackground,e.fillRect(m,g,c,l),e.strokeRect(m,g,c,l),e.fillStyle=i.backgroundColor,e.fillRect(h,g+1,c-2,l-2))}e.fillStyle=this.labelTextColors[s]}drawBody(e,n,s){let{body:o}=this,{bodySpacing:r,bodyAlign:i,displayColors:a,boxHeight:l,boxWidth:c,boxPadding:d}=s,u=ht(s.bodyFont),p=u.lineHeight,f=0,g=ao(s.rtl,this.x,this.width),m=function(_){n.fillText(_,g.x(e.x+f),e.y+p/2),e.y+=p+r},h=g.textAlign(i),b,x,v,w,$,S,C;for(n.textAlign=i,n.textBaseline="middle",n.font=u.string,e.x=Kl(this,h,s),n.fillStyle=s.bodyColor,ze(this.beforeBody,m),f=a&&h!=="right"?i==="center"?c/2+d:c+2+d:0,w=0,S=o.length;w0&&n.stroke()}_updateAnimationTarget(e){let n=this.chart,s=this.$animations,o=s&&s.x,r=s&&s.y;if(o||r){let i=Ci[e.position].call(this,this._active,this._eventPosition);if(!i)return;let a=this._size=N$(this,e),l=Object.assign({},i,this._size),c=O$(n,e,l),d=B$(e,l,c,n);(o._to!==d.x||r._to!==d.y)&&(this.xAlign=c.xAlign,this.yAlign=c.yAlign,this.width=a.width,this.height=a.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,d))}}_willRender(){return!!this.opacity}draw(e){let n=this.options.setContext(this.getContext()),s=this.opacity;if(!s)return;this._updateAnimationTarget(n);let o={width:this.width,height:this.height},r={x:this.x,y:this.y};s=Math.abs(s)<.001?0:s;let i=It(n.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;n.enabled&&a&&(e.save(),e.globalAlpha=s,this.drawBackground(r,e,o,n),zu(e,n.textDirection),r.y+=i.top,this.drawTitle(r,e,n),this.drawBody(r,e,n),this.drawFooter(r,e,n),Uu(e,n.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,n){let s=this._active,o=e.map(({datasetIndex:a,index:l})=>{let c=this.chart.getDatasetMeta(a);if(!c)throw new Error("Cannot find a dataset at index "+a);return{datasetIndex:a,element:c.data[l],index:l}}),r=!mi(s,o),i=this._positionChanged(o,n);(r||i)&&(this._active=o,this._eventPosition=n,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,n,s=!0){if(n&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;let o=this.options,r=this._active||[],i=this._getActiveElements(e,r,n,s),a=this._positionChanged(i,e),l=n||!mi(i,r)||a;return l&&(this._active=i,(o.enabled||o.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,n))),l}_getActiveElements(e,n,s,o){let r=this.options;if(e.type==="mouseout")return[];if(!o)return n.filter(a=>this.chart.data.datasets[a.datasetIndex]&&this.chart.getDatasetMeta(a.datasetIndex).controller.getParsed(a.index)!==void 0);let i=this.chart.getElementsAtEventForMode(e,r.mode,r,s);return r.reverse&&i.reverse(),i}_positionChanged(e,n){let{caretX:s,caretY:o,options:r}=this,i=Ci[r.position].call(this,e,n);return i!==!1&&(s!==i.x||o!==i.y)}},kT={id:"tooltip",_element:sc,positioners:Ci,afterInit(t,e,n){n&&(t.tooltip=new sc({chart:t,options:n}))},beforeUpdate(t,e,n){t.tooltip&&t.tooltip.initialize(n)},reset(t,e,n){t.tooltip&&t.tooltip.initialize(n)},afterDraw(t){let e=t.tooltip;if(e&&e._willRender()){let n={tooltip:e};if(t.notifyPlugins("beforeTooltipDraw",{...n,cancelable:!0})===!1)return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",n)}},afterEvent(t,e){if(t.tooltip){let n=e.replay;t.tooltip.handleEvent(e.event,n,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:m0},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>t!=="filter"&&t!=="itemSort"&&t!=="external",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]},ST=Object.freeze({__proto__:null,Colors:NA,Decimation:HA,Filler:iT,Legend:pT,SubTitle:mT,Title:hT,Tooltip:kT}),CT=(t,e,n,s)=>(typeof e=="string"?(n=t.push(e)-1,s.unshift({index:n,label:e})):isNaN(e)&&(n=null),n);function _T(t,e,n,s){let o=t.indexOf(e);if(o===-1)return CT(t,e,n,s);let r=t.lastIndexOf(e);return o!==r?n:o}var MT=(t,e)=>t===null?null:yt(Math.round(t),0,e);function W$(t){let e=this.getLabels();return t>=0&&tn.length-1?null:this.getPixelForValue(n[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}};function AT(t,e){let n=[],{bounds:o,step:r,min:i,max:a,precision:l,count:c,maxTicks:d,maxDigits:u,includeBounds:p}=t,f=r||1,g=d-1,{min:m,max:h}=e,b=!Me(i),x=!Me(a),v=!Me(c),w=(h-m)/(u+1),$=ku((h-m)/g/f)*f,S,C,_,k;if($<1e-14&&!b&&!x)return[{value:m},{value:h}];k=Math.ceil(h/$)-Math.floor(m/$),k>g&&($=ku(k*$/g/f)*f),Me(l)||(S=Math.pow(10,l),$=Math.ceil($*S)/S),o==="ticks"?(C=Math.floor(m/$)*$,_=Math.ceil(h/$)*$):(C=m,_=h),b&&x&&r&&Av((a-i)/r,$/1e3)?(k=Math.round(Math.min((a-i)/$,d)),$=(a-i)/k,C=i,_=a):v?(C=b?i:C,_=x?a:_,k=c-1,$=(_-C)/k):(k=(_-C)/$,ar(k,Math.round(k),$/1e3)?k=Math.round(k):k=Math.ceil(k));let A=Math.max(Cu($),Cu(C));S=Math.pow(10,Me(l)?A:l),C=Math.round(C*S)/S,_=Math.round(_*S)/S;let O=0;for(b&&(p&&C!==i?(n.push({value:i}),Ca)break;n.push({value:D})}return x&&p&&_!==a?n.length&&ar(n[n.length-1].value,a,V$(a,w,t))?n[n.length-1].value=a:n.push({value:a}):(!x||_===a)&&n.push({value:_}),n}function V$(t,e,{horizontal:n,minRotation:s}){let o=pn(s),r=(n?Math.sin(o):Math.cos(o))||.001,i=.75*e*(""+t).length;return Math.min(e/r,i)}var gr=class extends uo{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,n){return Me(e)||(typeof e=="number"||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){let{beginAtZero:e}=this.options,{minDefined:n,maxDefined:s}=this.getUserBounds(),{min:o,max:r}=this,i=l=>o=n?o:l,a=l=>r=s?r:l;if(e){let l=wn(o),c=wn(r);l<0&&c<0?a(0):l>0&&c>0&&i(0)}if(o===r){let l=r===0?1:Math.abs(r*.05);a(r+l),e||i(o-l)}this.min=o,this.max=r}getTickLimit(){let e=this.options.ticks,{maxTicksLimit:n,stepSize:s}=e,o;return s?(o=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,o>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${o} ticks. Limiting to 1000.`),o=1e3)):(o=this.computeTickLimit(),n=n||11),n&&(o=Math.min(n,o)),o}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){let e=this.options,n=e.ticks,s=this.getTickLimit();s=Math.max(2,s);let o={maxTicks:s,bounds:e.bounds,min:e.min,max:e.max,precision:n.precision,step:n.stepSize,count:n.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:n.minRotation||0,includeBounds:n.includeBounds!==!1},r=this._range||this,i=AT(o,r);return e.bounds==="ticks"&&Su(i,this,"value"),e.reverse?(i.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),i}configure(){let e=this.ticks,n=this.min,s=this.max;if(super.configure(),this.options.offset&&e.length){let o=(s-n)/Math.max(e.length-1,1)/2;n-=o,s+=o}this._startValue=n,this._endValue=s,this._valueRange=s-n}getLabelForValue(e){return cr(e,this.chart.options.locale,this.options.ticks.format)}},Tp=class extends gr{static id="linear";static defaults={ticks:{callback:gi.formatters.numeric}};determineDataLimits(){let{min:e,max:n}=this.getMinMax(!0);this.min=at(e)?e:0,this.max=at(n)?n:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),n=e?this.width:this.height,s=pn(this.options.ticks.minRotation),o=(e?Math.sin(s):Math.cos(s))||.001,r=this._resolveTickFontOptions(0);return Math.ceil(n/Math.min(40,r.lineHeight/o))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}},Ti=t=>Math.floor(ss(t)),co=(t,e)=>Math.pow(10,Ti(t)+e);function j$(t){return t/Math.pow(10,Ti(t))===1}function z$(t,e,n){let s=Math.pow(10,n),o=Math.floor(t/s);return Math.ceil(e/s)-o}function TT(t,e){let n=e-t,s=Ti(n);for(;z$(t,e,s)>10;)s++;for(;z$(t,e,s)<10;)s--;return Math.min(s,Ti(t))}function PT(t,{min:e,max:n}){e=Zt(t.min,e);let s=[],o=Ti(e),r=TT(e,n),i=r<0?Math.pow(10,Math.abs(r)):1,a=Math.pow(10,r),l=o>r?Math.pow(10,o):0,c=Math.round((e-l)*i)/i,d=Math.floor((e-l)/a/10)*a*10,u=Math.floor((c-d)/Math.pow(10,r)),p=Zt(t.min,Math.round((l+d+u*Math.pow(10,r))*i)/i);for(;p=10?u=u<15?15:20:u++,u>=20&&(r++,u=2,i=r>=0?1:i),p=Math.round((l+d+u*Math.pow(10,r))*i)/i;let f=Zt(t.max,p);return s.push({value:f,major:j$(f),significand:u}),s}var Pp=class extends uo{static id="logarithmic";static defaults={ticks:{callback:gi.formatters.logarithmic,major:{enabled:!0}}};constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(e,n){let s=gr.prototype.parse.apply(this,[e,n]);if(s===0){this._zero=!0;return}return at(s)&&s>0?s:null}determineDataLimits(){let{min:e,max:n}=this.getMinMax(!0);this.min=at(e)?Math.max(0,e):null,this.max=at(n)?Math.max(0,n):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!at(this._userMin)&&(this.min=e===co(this.min,0)?co(this.min,-1):co(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){let{minDefined:e,maxDefined:n}=this.getUserBounds(),s=this.min,o=this.max,r=a=>s=e?s:a,i=a=>o=n?o:a;s===o&&(s<=0?(r(1),i(10)):(r(co(s,-1)),i(co(o,1)))),s<=0&&r(co(o,-1)),o<=0&&i(co(s,1)),this.min=s,this.max=o}buildTicks(){let e=this.options,n={min:this._userMin,max:this._userMax},s=PT(n,this);return e.bounds==="ticks"&&Su(s,this,"value"),e.reverse?(s.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),s}getLabelForValue(e){return e===void 0?"0":cr(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=ss(e),this._valueRange=ss(this.max)-ss(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(ss(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let n=this.getDecimalForPixel(e);return Math.pow(10,this._startValue+n*this._valueRange)}};function Rp(t){let e=t.ticks;if(e.display&&t.display){let n=It(e.backdropPadding);return $e(e.font&&e.font.size,Qe.font.size)+n.height}return 0}function RT(t,e,n){return n=Ze(n)?n:[n],{w:Ov(t,e.string,n),h:n.length*e.lineHeight}}function U$(t,e,n,s,o){return t===s||t===o?{start:e-n/2,end:e+n/2}:to?{start:e-n,end:e}:{start:e,end:e+n}}function LT(t){let e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},n=Object.assign({},e),s=[],o=[],r=t._pointLabels.length,i=t.options.pointLabels,a=i.centerPointLabels?Oe/r:0;for(let l=0;le.r&&(a=(s.end-e.r)/r,t.r=Math.max(t.r,e.r+a)),o.starte.b&&(l=(o.end-e.b)/i,t.b=Math.max(t.b,e.b+l))}function IT(t,e,n){let s=t.drawingArea,{extra:o,additionalAngle:r,padding:i,size:a}=n,l=t.getPointPosition(e,s+o+i,r),c=Math.round(Ll(Lt(l.angle+dt))),d=FT(l.y,a.h,c),u=OT(c),p=BT(l.x,a.w,u);return{visible:!0,x:l.x,y:d,textAlign:u,left:p,top:d,right:p+a.w,bottom:d+a.h}}function DT(t,e){if(!e)return!0;let{left:n,top:s,right:o,bottom:r}=t;return!(Nn({x:n,y:s},e)||Nn({x:n,y:r},e)||Nn({x:o,y:s},e)||Nn({x:o,y:r},e))}function NT(t,e,n){let s=[],o=t._pointLabels.length,r=t.options,{centerPointLabels:i,display:a}=r.pointLabels,l={extra:Rp(r)/2,additionalAngle:i?Oe/o:0},c;for(let d=0;d270||n<90)&&(t-=e),t}function HT(t,e,n){let{left:s,top:o,right:r,bottom:i}=n,{backdropColor:a}=e;if(!Me(a)){let l=Ps(e.borderRadius),c=It(e.backdropPadding);t.fillStyle=a;let d=s-c.left,u=o-c.top,p=r-s+c.width,f=i-o+c.height;Object.values(l).some(g=>g!==0)?(t.beginPath(),dr(t,{x:d,y:u,w:p,h:f,radius:l}),t.fill()):t.fillRect(d,u,p,f)}}function WT(t,e){let{ctx:n,options:{pointLabels:s}}=t;for(let o=e-1;o>=0;o--){let r=t._pointLabelItems[o];if(!r.visible)continue;let i=s.setContext(t.getPointLabelContext(o));HT(n,i,r);let a=ht(i.font),{x:l,y:c,textAlign:d}=r;Ts(n,t._pointLabels[o],l,c+a.lineHeight/2,a,{color:i.color,textAlign:d,textBaseline:"middle"})}}function g0(t,e,n,s){let{ctx:o}=t;if(n)o.arc(t.xCenter,t.yCenter,e,0,Ye);else{let r=t.getPointPosition(0,e);o.moveTo(r.x,r.y);for(let i=1;i{let o=qe(this.options.pointLabels.callback,[n,s],this);return o||o===0?o:""}).filter((n,s)=>this.chart.getDataVisibility(s))}fit(){let e=this.options;e.display&&e.pointLabels.display?LT(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(e,n,s,o){this.xCenter+=Math.floor((e-n)/2),this.yCenter+=Math.floor((s-o)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(e,n,s,o))}getIndexAngle(e){let n=Ye/(this._pointLabels.length||1),s=this.options.startAngle||0;return Lt(e*n+pn(s))}getDistanceFromCenterForValue(e){if(Me(e))return NaN;let n=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-e)*n:(e-this.min)*n}getValueForDistanceFromCenter(e){if(Me(e))return NaN;let n=e/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-n:this.min+n}getPointLabelContext(e){let n=this._pointLabels||[];if(e>=0&&e{if(u!==0||u===0&&this.min<0){l=this.getDistanceFromCenterForValue(d.value);let p=this.getContext(u),f=o.setContext(p),g=r.setContext(p);VT(this,f,l,i,g)}}),s.display){for(e.save(),a=i-1;a>=0;a--){let d=s.setContext(this.getPointLabelContext(a)),{color:u,lineWidth:p}=d;!p||!u||(e.lineWidth=p,e.strokeStyle=u,e.setLineDash(d.borderDash),e.lineDashOffset=d.borderDashOffset,l=this.getDistanceFromCenterForValue(n.reverse?this.min:this.max),c=this.getPointPosition(a,l),e.beginPath(),e.moveTo(this.xCenter,this.yCenter),e.lineTo(c.x,c.y),e.stroke())}e.restore()}}drawBorder(){}drawLabels(){let e=this.ctx,n=this.options,s=n.ticks;if(!s.display)return;let o=this.getIndexAngle(0),r,i;e.save(),e.translate(this.xCenter,this.yCenter),e.rotate(o),e.textAlign="center",e.textBaseline="middle",this.ticks.forEach((a,l)=>{if(l===0&&this.min>=0&&!n.reverse)return;let c=s.setContext(this.getContext(l)),d=ht(c.font);if(r=this.getDistanceFromCenterForValue(this.ticks[l].value),c.showLabelBackdrop){e.font=d.string,i=e.measureText(a.label).width,e.fillStyle=c.backdropColor;let u=It(c.backdropPadding);e.fillRect(-i/2-u.left,-r-d.size/2-u.top,i+u.width,d.size+u.height)}Ts(e,a.label,0,-r,d,{color:c.color,strokeColor:c.textStrokeColor,strokeWidth:c.textStrokeWidth})}),e.restore()}drawTitle(){}},rc={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Xt=Object.keys(rc);function K$(t,e){return t-e}function G$(t,e){if(Me(e))return null;let n=t._adapter,{parser:s,round:o,isoWeekday:r}=t._parseOpts,i=e;return typeof s=="function"&&(i=s(i)),at(i)||(i=typeof s=="string"?n.parse(i,s):n.parse(i)),i===null?null:(o&&(i=o==="week"&&(io(r)||r===!0)?n.startOf(i,"isoWeek",r):n.startOf(i,o)),+i)}function q$(t,e,n,s){let o=Xt.length;for(let r=Xt.indexOf(t);r=Xt.indexOf(n);r--){let i=Xt[r];if(rc[i].common&&t._adapter.diff(o,s,i)>=e-1)return i}return Xt[n?Xt.indexOf(n):0]}function UT(t){for(let e=Xt.indexOf(t)+1,n=Xt.length;e=e?n[s]:n[o];t[r]=!0}}function KT(t,e,n,s){let o=t._adapter,r=+o.startOf(e[0].value,s),i=e[e.length-1].value,a,l;for(a=r;a<=i;a=+o.add(a,1,s))l=n[a],l>=0&&(e[l].major=!0);return e}function Z$(t,e,n){let s=[],o={},r=e.length,i,a;for(i=0;i+e.value))}initOffsets(e=[]){let n=0,s=0,o,r;this.options.offset&&e.length&&(o=this.getDecimalForValue(e[0]),e.length===1?n=1-o:n=(this.getDecimalForValue(e[1])-o)/2,r=this.getDecimalForValue(e[e.length-1]),e.length===1?s=r:s=(r-this.getDecimalForValue(e[e.length-2]))/2);let i=e.length<3?.5:.25;n=yt(n,0,i),s=yt(s,0,i),this._offsets={start:n,end:s,factor:1/(n+1+s)}}_generate(){let e=this._adapter,n=this.min,s=this.max,o=this.options,r=o.time,i=r.unit||q$(r.minUnit,n,s,this._getLabelCapacity(n)),a=$e(o.ticks.stepSize,1),l=i==="week"?r.isoWeekday:!1,c=io(l)||l===!0,d={},u=n,p,f;if(c&&(u=+e.startOf(u,"isoWeek",l)),u=+e.startOf(u,c?"day":i),e.diff(s,n,i)>1e5*a)throw new Error(n+" and "+s+" are too far apart with stepSize of "+a+" "+i);let g=o.ticks.source==="data"&&this.getDataTimestamps();for(p=u,f=0;p+m)}getLabelForValue(e){let n=this._adapter,s=this.options.time;return s.tooltipFormat?n.format(e,s.tooltipFormat):n.format(e,s.displayFormats.datetime)}format(e,n){let o=this.options.time.displayFormats,r=this._unit,i=n||o[r];return this._adapter.format(e,i)}_tickFormatFunction(e,n,s,o){let r=this.options,i=r.ticks.callback;if(i)return qe(i,[e,n,s],this);let a=r.time.displayFormats,l=this._unit,c=this._majorUnit,d=l&&a[l],u=c&&a[c],p=s[n],f=c&&u&&p&&p.major;return this._adapter.format(e,o||(f?u:d))}generateTickLabels(e){let n,s,o;for(n=0,s=e.length;n0?a:1}getDataTimestamps(){let e=this._cache.data||[],n,s;if(e.length)return e;let o=this.getMatchingVisibleMetas();if(this._normalized&&o.length)return this._cache.data=o[0].controller.getAllParsedValues(this);for(n=0,s=o.length;n=t[s].pos&&e<=t[o].pos&&({lo:s,hi:o}=Dn(t,"pos",e)),{pos:r,time:a}=t[s],{pos:i,time:l}=t[o]):(e>=t[s].time&&e<=t[o].time&&({lo:s,hi:o}=Dn(t,"time",e)),{time:r,pos:a}=t[s],{time:i,pos:l}=t[o]);let c=i-r;return c?a+(l-a)*(e-r)/c:a}var Ep=class extends Pi{static id="timeseries";static defaults=Pi.defaults;constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){let e=this._getTimestampsForTable(),n=this._table=this.buildLookupTable(e);this._minPos=Gl(n,this.min),this._tableRange=Gl(n,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){let{min:n,max:s}=this,o=[],r=[],i,a,l,c,d;for(i=0,a=e.length;i=n&&c<=s&&o.push(c);if(o.length<2)return[{time:n,pos:0},{time:s,pos:1}];for(i=0,a=o.length;io-r)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;let n=this.getDataTimestamps(),s=this.getLabelTimestamps();return n.length&&s.length?e=this.normalize(n.concat(s)):e=n.length?n:s,e=this._cache.all=e,e}getDecimalForValue(e){return(Gl(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let n=this._offsets,s=this.getDecimalForPixel(e)/n.factor-n.end;return Gl(this._table,s*this._tableRange+this._minPos,!0)}},GT=Object.freeze({__proto__:null,CategoryScale:Ap,LinearScale:Tp,LogarithmicScale:Pp,RadialLinearScale:Lp,TimeScale:Pi,TimeSeriesScale:Ep}),b0=[oM,TA,ST,GT];hr.register(...b0);var ls=hr;var Ip=P.bind(T),br="outcomes",Li="tokens",Dp="duration",Es="cost",qT="24h",Np="7d",JT="30d",ZT=[{label:"24h",value:qT},{label:"7d",value:Np},{label:"30d",value:JT}],YT=[{label:"outcomes",value:br},{label:"tokens",value:Li},{label:"duration",value:Dp},{label:"cost",value:Es}],XT=({trends:t=null,metric:e=br,selectedBucketKey:n=""}={})=>{let s=Array.isArray(t?.points)?t.points:[],o=String(t?.range||Np),r=s.map(d=>Xn(d.startMs,{range:o,valueType:"epoch-ms"})),i="0.22",a="0.86",l=d=>n&&String(s[d]?.key||"")!==n;if(e===br)return{labels:r,datasets:[{label:"ok",data:s.map(d=>Number(d?.ok||0)),stack:"outcomes",backgroundColor:s.map((d,u)=>`rgba(34,255,170,${l(u)?i:a})`),borderColor:s.map((d,u)=>`rgba(34,255,170,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"error",data:s.map(d=>Number(d?.error||0)),stack:"outcomes",backgroundColor:s.map((d,u)=>`rgba(255,74,138,${l(u)?i:a})`),borderColor:s.map((d,u)=>`rgba(255,74,138,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"skipped",data:s.map(d=>Number(d?.skipped||0)),stack:"outcomes",backgroundColor:s.map((d,u)=>`rgba(255,214,64,${l(u)?i:a})`),borderColor:s.map((d,u)=>`rgba(255,214,64,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]};let c=s.map(d=>Number(e===Li?d?.totalTokens||0:e===Es?d?.totalCost||0:d?.avgDurationMs||0));return{labels:r,datasets:[{label:e===Li?"tokens":e===Es?"cost":"avg duration",data:c,backgroundColor:s.map((d,u)=>e===Li?`rgba(34,211,238,${l(u)?i:"0.72"})`:e===Es?`rgba(167,139,250,${l(u)?i:"0.72"})`:`rgba(148,163,184,${l(u)?i:"0.72"})`),borderColor:s.map((d,u)=>e===Li?`rgba(34,211,238,${l(u)?"0.35":"1"})`:e===Es?`rgba(167,139,250,${l(u)?"0.35":"1"})`:`rgba(148,163,184,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]}},x0=({trends:t=null,range:e=Np,onChangeRange:n=()=>{},selectedBucketFilter:s=null,onChangeSelectedBucketFilter:o=()=>{}})=>{let r=ee(null),i=ee(null),[a,l]=y(br),c=F(()=>Array.isArray(t?.points)?t.points.map((f,g)=>({...f,key:String(f?.key||`point:${g}:${f?.startMs||0}`)})):[],[t?.points]),d=F(()=>s&&c.find(g=>Number(g.startMs)===Number(s.startMs)&&Number(g.endMs)===Number(s.endMs))?.key||"",[c,s]),u=F(()=>c.some(f=>Number(f?.totalRuns||0)>0||Number(f?.totalTokens||0)>0||Number(f?.totalCost||0)>0||Number(f?.avgDurationMs||0)>0),[c]),p=F(()=>XT({trends:{...t,points:c},metric:a,selectedBucketKey:d}),[a,c,d,t]);return L(()=>{let f=r.current;if(!f||!ls)return;i.current&&(i.current.destroy(),i.current=null);let g=m=>{let h=c[m];return h?{key:h.key,label:Xn(h.startMs,{range:e,valueType:"epoch-ms"}),startMs:Number(h.startMs||0),endMs:Number(h.endMs||0),range:e}:null};return i.current=new ls(f,{type:"bar",data:p,options:{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},animation:!1,onHover:(m,h)=>{let b=m?.native?.target;!b||!b.style||(b.style.cursor=Array.isArray(h)&&h.length>0?"pointer":"default")},onClick:(m,h)=>{let b=Number(h?.[0]?.index);if(!Number.isFinite(b))return;let x=g(b);if(x){if(x.key===d){o(null);return}o(x)}},scales:{x:{stacked:a===br,grid:{color:"rgba(148,163,184,0.08)"},ticks:{color:"rgba(156,163,175,1)",maxRotation:0,autoSkip:!0}},y:{stacked:a===br,beginAtZero:!0,grid:{color:"rgba(148,163,184,0.12)"},ticks:{precision:a===Es?void 0:0,color:"rgba(156,163,175,1)",callback:m=>{let h=Number(m||0);return a===Es?it(h):a===Dp?h>0?bn(h):"0":Te(h)}}}},plugins:{legend:{position:"bottom",labels:{color:"rgba(209,213,219,1)",boxWidth:10,boxHeight:10}},tooltip:{callbacks:{title:m=>String(m?.[0]?.label||""),label:m=>{let h=Number(m.parsed.y||0);return a===Es?`${m.dataset.label}: ${it(h)}`:a===Dp?`${m.dataset.label}: ${h>0?bn(h):"\u2014"}`:`${m.dataset.label}: ${Te(h)}`},footer:m=>{let h=Number(m?.[0]?.dataIndex),b=c[h];if(!b)return"";let x=`runs: ${Te(b.totalRuns||0)}`,v=`tokens: ${Te(b.totalTokens||0)}`,w=`cost: ${it(b.totalCost||0)}`;return`${x} +`):t}function gT(t,e){let{element:n,datasetIndex:s,index:o}=e,r=t.getDatasetMeta(s).controller,{label:i,value:a}=r.getLabelAndValue(o);return{chart:t,label:i,parsed:r.getParsed(o),raw:t.data.datasets[s].data[o],formattedValue:a,dataset:r.getDataset(),dataIndex:o,datasetIndex:s,element:n}}function N$(t,e){let n=t.chart.ctx,{body:s,footer:o,title:r}=t,{boxWidth:i,boxHeight:a}=e,l=ht(e.bodyFont),c=ht(e.titleFont),d=ht(e.footerFont),u=r.length,p=o.length,f=s.length,g=It(e.padding),m=g.height,h=0,b=s.reduce((w,$)=>w+$.before.length+$.lines.length+$.after.length,0);if(b+=t.beforeBody.length+t.afterBody.length,u&&(m+=u*c.lineHeight+(u-1)*e.titleSpacing+e.titleMarginBottom),b){let w=e.displayColors?Math.max(a,l.lineHeight):l.lineHeight;m+=f*w+(b-f)*l.lineHeight+(b-1)*e.bodySpacing}p&&(m+=e.footerMarginTop+p*d.lineHeight+(p-1)*e.footerSpacing);let x=0,v=function(w){h=Math.max(h,n.measureText(w).width+x)};return n.save(),n.font=c.string,je(t.title,v),n.font=l.string,je(t.beforeBody.concat(t.afterBody),v),x=e.displayColors?i+2+e.boxPadding:0,je(s,w=>{je(w.before,v),je(w.lines,v),je(w.after,v)}),x=0,n.font=d.string,je(t.footer,v),n.restore(),h+=g.width,{width:h,height:m}}function bT(t,e){let{y:n,height:s}=e;return nt.height-s/2?"bottom":"center"}function xT(t,e,n,s){let{x:o,width:r}=s,i=n.caretSize+n.caretPadding;if(t==="left"&&o+r+i>e.width||t==="right"&&o-r-i<0)return!0}function yT(t,e,n,s){let{x:o,width:r}=n,{width:i,chartArea:{left:a,right:l}}=t,c="center";return s==="center"?c=o<=(a+l)/2?"left":"right":o<=r/2?c="left":o>=i-r/2&&(c="right"),xT(c,t,e,n)&&(c="center"),c}function O$(t,e,n){let s=n.yAlign||e.yAlign||bT(t,n);return{xAlign:n.xAlign||e.xAlign||yT(t,e,n,s),yAlign:s}}function vT(t,e){let{x:n,width:s}=t;return e==="right"?n-=s:e==="center"&&(n-=s/2),n}function $T(t,e,n){let{y:s,height:o}=t;return e==="top"?s+=n:e==="bottom"?s-=o+n:s-=o/2,s}function B$(t,e,n,s){let{caretSize:o,caretPadding:r,cornerRadius:i}=t,{xAlign:a,yAlign:l}=n,c=o+r,{topLeft:d,topRight:u,bottomLeft:p,bottomRight:f}=Ps(i),g=vT(e,a),m=$T(e,l,c);return l==="center"?a==="left"?g+=c:a==="right"&&(g-=c):a==="left"?g-=Math.max(d,p)+o:a==="right"&&(g+=Math.max(u,f)+o),{x:yt(g,0,s.width-e.width),y:yt(m,0,s.height-e.height)}}function Gl(t,e,n){let s=It(n.padding);return e==="center"?t.x+t.width/2:e==="right"?t.x+t.width-s.right:t.x+s.left}function F$(t){return Fn([],as(t))}function wT(t,e,n){return rs(t,{tooltip:e,tooltipItems:n,type:"tooltip"})}function H$(t,e){let n=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return n?t.override(n):t}var m0={beforeTitle:On,title(t){if(t.length>0){let e=t[0],n=e.chart.data.labels,s=n?n.length:0;if(this&&this.options&&this.options.mode==="dataset")return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex"u"?m0[e].call(n,s):o}var oc=class extends kn{static positioners=Ci;constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let n=this.chart,s=this.options.setContext(this.getContext()),o=s.enabled&&n.options.animation&&s.animations,r=new Yl(this.chart,o);return o._cacheable&&(this._cachedAnimations=Object.freeze(r)),r}getContext(){return this.$context||(this.$context=wT(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,n){let{callbacks:s}=n,o=Yt(s,"beforeTitle",this,e),r=Yt(s,"title",this,e),i=Yt(s,"afterTitle",this,e),a=[];return a=Fn(a,as(o)),a=Fn(a,as(r)),a=Fn(a,as(i)),a}getBeforeBody(e,n){return F$(Yt(n.callbacks,"beforeBody",this,e))}getBody(e,n){let{callbacks:s}=n,o=[];return je(e,r=>{let i={before:[],lines:[],after:[]},a=H$(s,r);Fn(i.before,as(Yt(a,"beforeLabel",this,r))),Fn(i.lines,Yt(a,"label",this,r)),Fn(i.after,as(Yt(a,"afterLabel",this,r))),o.push(i)}),o}getAfterBody(e,n){return F$(Yt(n.callbacks,"afterBody",this,e))}getFooter(e,n){let{callbacks:s}=n,o=Yt(s,"beforeFooter",this,e),r=Yt(s,"footer",this,e),i=Yt(s,"afterFooter",this,e),a=[];return a=Fn(a,as(o)),a=Fn(a,as(r)),a=Fn(a,as(i)),a}_createItems(e){let n=this._active,s=this.chart.data,o=[],r=[],i=[],a=[],l,c;for(l=0,c=n.length;le.filter(d,u,p,s))),e.itemSort&&(a=a.sort((d,u)=>e.itemSort(d,u,s))),je(a,d=>{let u=H$(e.callbacks,d);o.push(Yt(u,"labelColor",this,d)),r.push(Yt(u,"labelPointStyle",this,d)),i.push(Yt(u,"labelTextColor",this,d))}),this.labelColors=o,this.labelPointStyles=r,this.labelTextColors=i,this.dataPoints=a,a}update(e,n){let s=this.options.setContext(this.getContext()),o=this._active,r,i=[];if(!o.length)this.opacity!==0&&(r={opacity:0});else{let a=Ci[s.position].call(this,o,this._eventPosition);i=this._createItems(s),this.title=this.getTitle(i,s),this.beforeBody=this.getBeforeBody(i,s),this.body=this.getBody(i,s),this.afterBody=this.getAfterBody(i,s),this.footer=this.getFooter(i,s);let l=this._size=N$(this,s),c=Object.assign({},a,l),d=O$(this.chart,s,c),u=B$(s,c,d,this.chart);this.xAlign=d.xAlign,this.yAlign=d.yAlign,r={opacity:1,x:u.x,y:u.y,width:l.width,height:l.height,caretX:a.x,caretY:a.y}}this._tooltipItems=i,this.$context=void 0,r&&this._resolveAnimations().update(this,r),e&&s.external&&s.external.call(this,{chart:this.chart,tooltip:this,replay:n})}drawCaret(e,n,s,o){let r=this.getCaretPosition(e,s,o);n.lineTo(r.x1,r.y1),n.lineTo(r.x2,r.y2),n.lineTo(r.x3,r.y3)}getCaretPosition(e,n,s){let{xAlign:o,yAlign:r}=this,{caretSize:i,cornerRadius:a}=s,{topLeft:l,topRight:c,bottomLeft:d,bottomRight:u}=Ps(a),{x:p,y:f}=e,{width:g,height:m}=n,h,b,x,v,w,$;return r==="center"?(w=f+m/2,o==="left"?(h=p,b=h-i,v=w+i,$=w-i):(h=p+g,b=h+i,v=w-i,$=w+i),x=h):(o==="left"?b=p+Math.max(l,d)+i:o==="right"?b=p+g-Math.max(c,u)-i:b=this.caretX,r==="top"?(v=f,w=v-i,h=b-i,x=b+i):(v=f+m,w=v+i,h=b+i,x=b-i),$=v),{x1:h,x2:b,x3:x,y1:v,y2:w,y3:$}}drawTitle(e,n,s){let o=this.title,r=o.length,i,a,l;if(r){let c=ao(s.rtl,this.x,this.width);for(e.x=Gl(this,s.titleAlign,s),n.textAlign=c.textAlign(s.titleAlign),n.textBaseline="middle",i=ht(s.titleFont),a=s.titleSpacing,n.fillStyle=s.titleColor,n.font=i.string,l=0;lx!==0)?(e.beginPath(),e.fillStyle=r.multiKeyBackground,dr(e,{x:m,y:g,w:c,h:l,radius:b}),e.fill(),e.stroke(),e.fillStyle=i.backgroundColor,e.beginPath(),dr(e,{x:h,y:g+1,w:c-2,h:l-2,radius:b}),e.fill()):(e.fillStyle=r.multiKeyBackground,e.fillRect(m,g,c,l),e.strokeRect(m,g,c,l),e.fillStyle=i.backgroundColor,e.fillRect(h,g+1,c-2,l-2))}e.fillStyle=this.labelTextColors[s]}drawBody(e,n,s){let{body:o}=this,{bodySpacing:r,bodyAlign:i,displayColors:a,boxHeight:l,boxWidth:c,boxPadding:d}=s,u=ht(s.bodyFont),p=u.lineHeight,f=0,g=ao(s.rtl,this.x,this.width),m=function(_){n.fillText(_,g.x(e.x+f),e.y+p/2),e.y+=p+r},h=g.textAlign(i),b,x,v,w,$,S,C;for(n.textAlign=i,n.textBaseline="middle",n.font=u.string,e.x=Gl(this,h,s),n.fillStyle=s.bodyColor,je(this.beforeBody,m),f=a&&h!=="right"?i==="center"?c/2+d:c+2+d:0,w=0,S=o.length;w0&&n.stroke()}_updateAnimationTarget(e){let n=this.chart,s=this.$animations,o=s&&s.x,r=s&&s.y;if(o||r){let i=Ci[e.position].call(this,this._active,this._eventPosition);if(!i)return;let a=this._size=N$(this,e),l=Object.assign({},i,this._size),c=O$(n,e,l),d=B$(e,l,c,n);(o._to!==d.x||r._to!==d.y)&&(this.xAlign=c.xAlign,this.yAlign=c.yAlign,this.width=a.width,this.height=a.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,d))}}_willRender(){return!!this.opacity}draw(e){let n=this.options.setContext(this.getContext()),s=this.opacity;if(!s)return;this._updateAnimationTarget(n);let o={width:this.width,height:this.height},r={x:this.x,y:this.y};s=Math.abs(s)<.001?0:s;let i=It(n.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;n.enabled&&a&&(e.save(),e.globalAlpha=s,this.drawBackground(r,e,o,n),Uu(e,n.textDirection),r.y+=i.top,this.drawTitle(r,e,n),this.drawBody(r,e,n),this.drawFooter(r,e,n),Ku(e,n.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,n){let s=this._active,o=e.map(({datasetIndex:a,index:l})=>{let c=this.chart.getDatasetMeta(a);if(!c)throw new Error("Cannot find a dataset at index "+a);return{datasetIndex:a,element:c.data[l],index:l}}),r=!mi(s,o),i=this._positionChanged(o,n);(r||i)&&(this._active=o,this._eventPosition=n,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,n,s=!0){if(n&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;let o=this.options,r=this._active||[],i=this._getActiveElements(e,r,n,s),a=this._positionChanged(i,e),l=n||!mi(i,r)||a;return l&&(this._active=i,(o.enabled||o.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,n))),l}_getActiveElements(e,n,s,o){let r=this.options;if(e.type==="mouseout")return[];if(!o)return n.filter(a=>this.chart.data.datasets[a.datasetIndex]&&this.chart.getDatasetMeta(a.datasetIndex).controller.getParsed(a.index)!==void 0);let i=this.chart.getElementsAtEventForMode(e,r.mode,r,s);return r.reverse&&i.reverse(),i}_positionChanged(e,n){let{caretX:s,caretY:o,options:r}=this,i=Ci[r.position].call(this,e,n);return i!==!1&&(s!==i.x||o!==i.y)}},kT={id:"tooltip",_element:oc,positioners:Ci,afterInit(t,e,n){n&&(t.tooltip=new oc({chart:t,options:n}))},beforeUpdate(t,e,n){t.tooltip&&t.tooltip.initialize(n)},reset(t,e,n){t.tooltip&&t.tooltip.initialize(n)},afterDraw(t){let e=t.tooltip;if(e&&e._willRender()){let n={tooltip:e};if(t.notifyPlugins("beforeTooltipDraw",{...n,cancelable:!0})===!1)return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",n)}},afterEvent(t,e){if(t.tooltip){let n=e.replay;t.tooltip.handleEvent(e.event,n,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:m0},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>t!=="filter"&&t!=="itemSort"&&t!=="external",_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]},ST=Object.freeze({__proto__:null,Colors:NA,Decimation:HA,Filler:iT,Legend:pT,SubTitle:mT,Title:hT,Tooltip:kT}),CT=(t,e,n,s)=>(typeof e=="string"?(n=t.push(e)-1,s.unshift({index:n,label:e})):isNaN(e)&&(n=null),n);function _T(t,e,n,s){let o=t.indexOf(e);if(o===-1)return CT(t,e,n,s);let r=t.lastIndexOf(e);return o!==r?n:o}var MT=(t,e)=>t===null?null:yt(Math.round(t),0,e);function W$(t){let e=this.getLabels();return t>=0&&tn.length-1?null:this.getPixelForValue(n[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}};function AT(t,e){let n=[],{bounds:o,step:r,min:i,max:a,precision:l,count:c,maxTicks:d,maxDigits:u,includeBounds:p}=t,f=r||1,g=d-1,{min:m,max:h}=e,b=!Me(i),x=!Me(a),v=!Me(c),w=(h-m)/(u+1),$=Su((h-m)/g/f)*f,S,C,_,k;if($<1e-14&&!b&&!x)return[{value:m},{value:h}];k=Math.ceil(h/$)-Math.floor(m/$),k>g&&($=Su(k*$/g/f)*f),Me(l)||(S=Math.pow(10,l),$=Math.ceil($*S)/S),o==="ticks"?(C=Math.floor(m/$)*$,_=Math.ceil(h/$)*$):(C=m,_=h),b&&x&&r&&Av((a-i)/r,$/1e3)?(k=Math.round(Math.min((a-i)/$,d)),$=(a-i)/k,C=i,_=a):v?(C=b?i:C,_=x?a:_,k=c-1,$=(_-C)/k):(k=(_-C)/$,ar(k,Math.round(k),$/1e3)?k=Math.round(k):k=Math.ceil(k));let A=Math.max(_u($),_u(C));S=Math.pow(10,Me(l)?A:l),C=Math.round(C*S)/S,_=Math.round(_*S)/S;let O=0;for(b&&(p&&C!==i?(n.push({value:i}),Ca)break;n.push({value:D})}return x&&p&&_!==a?n.length&&ar(n[n.length-1].value,a,V$(a,w,t))?n[n.length-1].value=a:n.push({value:a}):(!x||_===a)&&n.push({value:_}),n}function V$(t,e,{horizontal:n,minRotation:s}){let o=pn(s),r=(n?Math.sin(o):Math.cos(o))||.001,i=.75*e*(""+t).length;return Math.min(e/r,i)}var gr=class extends uo{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,n){return Me(e)||(typeof e=="number"||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){let{beginAtZero:e}=this.options,{minDefined:n,maxDefined:s}=this.getUserBounds(),{min:o,max:r}=this,i=l=>o=n?o:l,a=l=>r=s?r:l;if(e){let l=wn(o),c=wn(r);l<0&&c<0?a(0):l>0&&c>0&&i(0)}if(o===r){let l=r===0?1:Math.abs(r*.05);a(r+l),e||i(o-l)}this.min=o,this.max=r}getTickLimit(){let e=this.options.ticks,{maxTicksLimit:n,stepSize:s}=e,o;return s?(o=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,o>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${o} ticks. Limiting to 1000.`),o=1e3)):(o=this.computeTickLimit(),n=n||11),n&&(o=Math.min(n,o)),o}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){let e=this.options,n=e.ticks,s=this.getTickLimit();s=Math.max(2,s);let o={maxTicks:s,bounds:e.bounds,min:e.min,max:e.max,precision:n.precision,step:n.stepSize,count:n.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:n.minRotation||0,includeBounds:n.includeBounds!==!1},r=this._range||this,i=AT(o,r);return e.bounds==="ticks"&&Cu(i,this,"value"),e.reverse?(i.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),i}configure(){let e=this.ticks,n=this.min,s=this.max;if(super.configure(),this.options.offset&&e.length){let o=(s-n)/Math.max(e.length-1,1)/2;n-=o,s+=o}this._startValue=n,this._endValue=s,this._valueRange=s-n}getLabelForValue(e){return cr(e,this.chart.options.locale,this.options.ticks.format)}},Pp=class extends gr{static id="linear";static defaults={ticks:{callback:gi.formatters.numeric}};determineDataLimits(){let{min:e,max:n}=this.getMinMax(!0);this.min=at(e)?e:0,this.max=at(n)?n:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),n=e?this.width:this.height,s=pn(this.options.ticks.minRotation),o=(e?Math.sin(s):Math.cos(s))||.001,r=this._resolveTickFontOptions(0);return Math.ceil(n/Math.min(40,r.lineHeight/o))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}},Ti=t=>Math.floor(ss(t)),co=(t,e)=>Math.pow(10,Ti(t)+e);function z$(t){return t/Math.pow(10,Ti(t))===1}function j$(t,e,n){let s=Math.pow(10,n),o=Math.floor(t/s);return Math.ceil(e/s)-o}function TT(t,e){let n=e-t,s=Ti(n);for(;j$(t,e,s)>10;)s++;for(;j$(t,e,s)<10;)s--;return Math.min(s,Ti(t))}function PT(t,{min:e,max:n}){e=Zt(t.min,e);let s=[],o=Ti(e),r=TT(e,n),i=r<0?Math.pow(10,Math.abs(r)):1,a=Math.pow(10,r),l=o>r?Math.pow(10,o):0,c=Math.round((e-l)*i)/i,d=Math.floor((e-l)/a/10)*a*10,u=Math.floor((c-d)/Math.pow(10,r)),p=Zt(t.min,Math.round((l+d+u*Math.pow(10,r))*i)/i);for(;p=10?u=u<15?15:20:u++,u>=20&&(r++,u=2,i=r>=0?1:i),p=Math.round((l+d+u*Math.pow(10,r))*i)/i;let f=Zt(t.max,p);return s.push({value:f,major:z$(f),significand:u}),s}var Rp=class extends uo{static id="logarithmic";static defaults={ticks:{callback:gi.formatters.logarithmic,major:{enabled:!0}}};constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(e,n){let s=gr.prototype.parse.apply(this,[e,n]);if(s===0){this._zero=!0;return}return at(s)&&s>0?s:null}determineDataLimits(){let{min:e,max:n}=this.getMinMax(!0);this.min=at(e)?Math.max(0,e):null,this.max=at(n)?Math.max(0,n):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!at(this._userMin)&&(this.min=e===co(this.min,0)?co(this.min,-1):co(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){let{minDefined:e,maxDefined:n}=this.getUserBounds(),s=this.min,o=this.max,r=a=>s=e?s:a,i=a=>o=n?o:a;s===o&&(s<=0?(r(1),i(10)):(r(co(s,-1)),i(co(o,1)))),s<=0&&r(co(o,-1)),o<=0&&i(co(s,1)),this.min=s,this.max=o}buildTicks(){let e=this.options,n={min:this._userMin,max:this._userMax},s=PT(n,this);return e.bounds==="ticks"&&Cu(s,this,"value"),e.reverse?(s.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),s}getLabelForValue(e){return e===void 0?"0":cr(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=ss(e),this._valueRange=ss(this.max)-ss(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(ss(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let n=this.getDecimalForPixel(e);return Math.pow(10,this._startValue+n*this._valueRange)}};function Lp(t){let e=t.ticks;if(e.display&&t.display){let n=It(e.backdropPadding);return $e(e.font&&e.font.size,Qe.font.size)+n.height}return 0}function RT(t,e,n){return n=Ze(n)?n:[n],{w:Ov(t,e.string,n),h:n.length*e.lineHeight}}function U$(t,e,n,s,o){return t===s||t===o?{start:e-n/2,end:e+n/2}:to?{start:e-n,end:e}:{start:e,end:e+n}}function LT(t){let e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},n=Object.assign({},e),s=[],o=[],r=t._pointLabels.length,i=t.options.pointLabels,a=i.centerPointLabels?Oe/r:0;for(let l=0;le.r&&(a=(s.end-e.r)/r,t.r=Math.max(t.r,e.r+a)),o.starte.b&&(l=(o.end-e.b)/i,t.b=Math.max(t.b,e.b+l))}function IT(t,e,n){let s=t.drawingArea,{extra:o,additionalAngle:r,padding:i,size:a}=n,l=t.getPointPosition(e,s+o+i,r),c=Math.round(El(Lt(l.angle+dt))),d=FT(l.y,a.h,c),u=OT(c),p=BT(l.x,a.w,u);return{visible:!0,x:l.x,y:d,textAlign:u,left:p,top:d,right:p+a.w,bottom:d+a.h}}function DT(t,e){if(!e)return!0;let{left:n,top:s,right:o,bottom:r}=t;return!(Nn({x:n,y:s},e)||Nn({x:n,y:r},e)||Nn({x:o,y:s},e)||Nn({x:o,y:r},e))}function NT(t,e,n){let s=[],o=t._pointLabels.length,r=t.options,{centerPointLabels:i,display:a}=r.pointLabels,l={extra:Lp(r)/2,additionalAngle:i?Oe/o:0},c;for(let d=0;d270||n<90)&&(t-=e),t}function HT(t,e,n){let{left:s,top:o,right:r,bottom:i}=n,{backdropColor:a}=e;if(!Me(a)){let l=Ps(e.borderRadius),c=It(e.backdropPadding);t.fillStyle=a;let d=s-c.left,u=o-c.top,p=r-s+c.width,f=i-o+c.height;Object.values(l).some(g=>g!==0)?(t.beginPath(),dr(t,{x:d,y:u,w:p,h:f,radius:l}),t.fill()):t.fillRect(d,u,p,f)}}function WT(t,e){let{ctx:n,options:{pointLabels:s}}=t;for(let o=e-1;o>=0;o--){let r=t._pointLabelItems[o];if(!r.visible)continue;let i=s.setContext(t.getPointLabelContext(o));HT(n,i,r);let a=ht(i.font),{x:l,y:c,textAlign:d}=r;Ts(n,t._pointLabels[o],l,c+a.lineHeight/2,a,{color:i.color,textAlign:d,textBaseline:"middle"})}}function g0(t,e,n,s){let{ctx:o}=t;if(n)o.arc(t.xCenter,t.yCenter,e,0,Ye);else{let r=t.getPointPosition(0,e);o.moveTo(r.x,r.y);for(let i=1;i{let o=qe(this.options.pointLabels.callback,[n,s],this);return o||o===0?o:""}).filter((n,s)=>this.chart.getDataVisibility(s))}fit(){let e=this.options;e.display&&e.pointLabels.display?LT(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(e,n,s,o){this.xCenter+=Math.floor((e-n)/2),this.yCenter+=Math.floor((s-o)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(e,n,s,o))}getIndexAngle(e){let n=Ye/(this._pointLabels.length||1),s=this.options.startAngle||0;return Lt(e*n+pn(s))}getDistanceFromCenterForValue(e){if(Me(e))return NaN;let n=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-e)*n:(e-this.min)*n}getValueForDistanceFromCenter(e){if(Me(e))return NaN;let n=e/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-n:this.min+n}getPointLabelContext(e){let n=this._pointLabels||[];if(e>=0&&e{if(u!==0||u===0&&this.min<0){l=this.getDistanceFromCenterForValue(d.value);let p=this.getContext(u),f=o.setContext(p),g=r.setContext(p);VT(this,f,l,i,g)}}),s.display){for(e.save(),a=i-1;a>=0;a--){let d=s.setContext(this.getPointLabelContext(a)),{color:u,lineWidth:p}=d;!p||!u||(e.lineWidth=p,e.strokeStyle=u,e.setLineDash(d.borderDash),e.lineDashOffset=d.borderDashOffset,l=this.getDistanceFromCenterForValue(n.reverse?this.min:this.max),c=this.getPointPosition(a,l),e.beginPath(),e.moveTo(this.xCenter,this.yCenter),e.lineTo(c.x,c.y),e.stroke())}e.restore()}}drawBorder(){}drawLabels(){let e=this.ctx,n=this.options,s=n.ticks;if(!s.display)return;let o=this.getIndexAngle(0),r,i;e.save(),e.translate(this.xCenter,this.yCenter),e.rotate(o),e.textAlign="center",e.textBaseline="middle",this.ticks.forEach((a,l)=>{if(l===0&&this.min>=0&&!n.reverse)return;let c=s.setContext(this.getContext(l)),d=ht(c.font);if(r=this.getDistanceFromCenterForValue(this.ticks[l].value),c.showLabelBackdrop){e.font=d.string,i=e.measureText(a.label).width,e.fillStyle=c.backdropColor;let u=It(c.backdropPadding);e.fillRect(-i/2-u.left,-r-d.size/2-u.top,i+u.width,d.size+u.height)}Ts(e,a.label,0,-r,d,{color:c.color,strokeColor:c.textStrokeColor,strokeWidth:c.textStrokeWidth})}),e.restore()}drawTitle(){}},ic={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Xt=Object.keys(ic);function K$(t,e){return t-e}function G$(t,e){if(Me(e))return null;let n=t._adapter,{parser:s,round:o,isoWeekday:r}=t._parseOpts,i=e;return typeof s=="function"&&(i=s(i)),at(i)||(i=typeof s=="string"?n.parse(i,s):n.parse(i)),i===null?null:(o&&(i=o==="week"&&(io(r)||r===!0)?n.startOf(i,"isoWeek",r):n.startOf(i,o)),+i)}function q$(t,e,n,s){let o=Xt.length;for(let r=Xt.indexOf(t);r=Xt.indexOf(n);r--){let i=Xt[r];if(ic[i].common&&t._adapter.diff(o,s,i)>=e-1)return i}return Xt[n?Xt.indexOf(n):0]}function UT(t){for(let e=Xt.indexOf(t)+1,n=Xt.length;e=e?n[s]:n[o];t[r]=!0}}function KT(t,e,n,s){let o=t._adapter,r=+o.startOf(e[0].value,s),i=e[e.length-1].value,a,l;for(a=r;a<=i;a=+o.add(a,1,s))l=n[a],l>=0&&(e[l].major=!0);return e}function Z$(t,e,n){let s=[],o={},r=e.length,i,a;for(i=0;i+e.value))}initOffsets(e=[]){let n=0,s=0,o,r;this.options.offset&&e.length&&(o=this.getDecimalForValue(e[0]),e.length===1?n=1-o:n=(this.getDecimalForValue(e[1])-o)/2,r=this.getDecimalForValue(e[e.length-1]),e.length===1?s=r:s=(r-this.getDecimalForValue(e[e.length-2]))/2);let i=e.length<3?.5:.25;n=yt(n,0,i),s=yt(s,0,i),this._offsets={start:n,end:s,factor:1/(n+1+s)}}_generate(){let e=this._adapter,n=this.min,s=this.max,o=this.options,r=o.time,i=r.unit||q$(r.minUnit,n,s,this._getLabelCapacity(n)),a=$e(o.ticks.stepSize,1),l=i==="week"?r.isoWeekday:!1,c=io(l)||l===!0,d={},u=n,p,f;if(c&&(u=+e.startOf(u,"isoWeek",l)),u=+e.startOf(u,c?"day":i),e.diff(s,n,i)>1e5*a)throw new Error(n+" and "+s+" are too far apart with stepSize of "+a+" "+i);let g=o.ticks.source==="data"&&this.getDataTimestamps();for(p=u,f=0;p+m)}getLabelForValue(e){let n=this._adapter,s=this.options.time;return s.tooltipFormat?n.format(e,s.tooltipFormat):n.format(e,s.displayFormats.datetime)}format(e,n){let o=this.options.time.displayFormats,r=this._unit,i=n||o[r];return this._adapter.format(e,i)}_tickFormatFunction(e,n,s,o){let r=this.options,i=r.ticks.callback;if(i)return qe(i,[e,n,s],this);let a=r.time.displayFormats,l=this._unit,c=this._majorUnit,d=l&&a[l],u=c&&a[c],p=s[n],f=c&&u&&p&&p.major;return this._adapter.format(e,o||(f?u:d))}generateTickLabels(e){let n,s,o;for(n=0,s=e.length;n0?a:1}getDataTimestamps(){let e=this._cache.data||[],n,s;if(e.length)return e;let o=this.getMatchingVisibleMetas();if(this._normalized&&o.length)return this._cache.data=o[0].controller.getAllParsedValues(this);for(n=0,s=o.length;n=t[s].pos&&e<=t[o].pos&&({lo:s,hi:o}=Dn(t,"pos",e)),{pos:r,time:a}=t[s],{pos:i,time:l}=t[o]):(e>=t[s].time&&e<=t[o].time&&({lo:s,hi:o}=Dn(t,"time",e)),{time:r,pos:a}=t[s],{time:i,pos:l}=t[o]);let c=i-r;return c?a+(l-a)*(e-r)/c:a}var Ip=class extends Pi{static id="timeseries";static defaults=Pi.defaults;constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){let e=this._getTimestampsForTable(),n=this._table=this.buildLookupTable(e);this._minPos=ql(n,this.min),this._tableRange=ql(n,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){let{min:n,max:s}=this,o=[],r=[],i,a,l,c,d;for(i=0,a=e.length;i=n&&c<=s&&o.push(c);if(o.length<2)return[{time:n,pos:0},{time:s,pos:1}];for(i=0,a=o.length;io-r)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;let n=this.getDataTimestamps(),s=this.getLabelTimestamps();return n.length&&s.length?e=this.normalize(n.concat(s)):e=n.length?n:s,e=this._cache.all=e,e}getDecimalForValue(e){return(ql(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let n=this._offsets,s=this.getDecimalForPixel(e)/n.factor-n.end;return ql(this._table,s*this._tableRange+this._minPos,!0)}},GT=Object.freeze({__proto__:null,CategoryScale:Tp,LinearScale:Pp,LogarithmicScale:Rp,RadialLinearScale:Ep,TimeScale:Pi,TimeSeriesScale:Ip}),b0=[oM,TA,ST,GT];hr.register(...b0);var ls=hr;var Dp=P.bind(T),br="outcomes",Li="tokens",Np="duration",Es="cost",qT="24h",Op="7d",JT="30d",ZT=[{label:"24h",value:qT},{label:"7d",value:Op},{label:"30d",value:JT}],YT=[{label:"outcomes",value:br},{label:"tokens",value:Li},{label:"duration",value:Np},{label:"cost",value:Es}],XT=({trends:t=null,metric:e=br,selectedBucketKey:n=""}={})=>{let s=Array.isArray(t?.points)?t.points:[],o=String(t?.range||Op),r=s.map(d=>Xn(d.startMs,{range:o,valueType:"epoch-ms"})),i="0.22",a="0.86",l=d=>n&&String(s[d]?.key||"")!==n;if(e===br)return{labels:r,datasets:[{label:"ok",data:s.map(d=>Number(d?.ok||0)),stack:"outcomes",backgroundColor:s.map((d,u)=>`rgba(34,255,170,${l(u)?i:a})`),borderColor:s.map((d,u)=>`rgba(34,255,170,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"error",data:s.map(d=>Number(d?.error||0)),stack:"outcomes",backgroundColor:s.map((d,u)=>`rgba(255,74,138,${l(u)?i:a})`),borderColor:s.map((d,u)=>`rgba(255,74,138,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"skipped",data:s.map(d=>Number(d?.skipped||0)),stack:"outcomes",backgroundColor:s.map((d,u)=>`rgba(255,214,64,${l(u)?i:a})`),borderColor:s.map((d,u)=>`rgba(255,214,64,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]};let c=s.map(d=>Number(e===Li?d?.totalTokens||0:e===Es?d?.totalCost||0:d?.avgDurationMs||0));return{labels:r,datasets:[{label:e===Li?"tokens":e===Es?"cost":"avg duration",data:c,backgroundColor:s.map((d,u)=>e===Li?`rgba(34,211,238,${l(u)?i:"0.72"})`:e===Es?`rgba(167,139,250,${l(u)?i:"0.72"})`:`rgba(148,163,184,${l(u)?i:"0.72"})`),borderColor:s.map((d,u)=>e===Li?`rgba(34,211,238,${l(u)?"0.35":"1"})`:e===Es?`rgba(167,139,250,${l(u)?"0.35":"1"})`:`rgba(148,163,184,${l(u)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]}},x0=({trends:t=null,range:e=Op,onChangeRange:n=()=>{},selectedBucketFilter:s=null,onChangeSelectedBucketFilter:o=()=>{}})=>{let r=ee(null),i=ee(null),[a,l]=y(br),c=F(()=>Array.isArray(t?.points)?t.points.map((f,g)=>({...f,key:String(f?.key||`point:${g}:${f?.startMs||0}`)})):[],[t?.points]),d=F(()=>s&&c.find(g=>Number(g.startMs)===Number(s.startMs)&&Number(g.endMs)===Number(s.endMs))?.key||"",[c,s]),u=F(()=>c.some(f=>Number(f?.totalRuns||0)>0||Number(f?.totalTokens||0)>0||Number(f?.totalCost||0)>0||Number(f?.avgDurationMs||0)>0),[c]),p=F(()=>XT({trends:{...t,points:c},metric:a,selectedBucketKey:d}),[a,c,d,t]);return L(()=>{let f=r.current;if(!f||!ls)return;i.current&&(i.current.destroy(),i.current=null);let g=m=>{let h=c[m];return h?{key:h.key,label:Xn(h.startMs,{range:e,valueType:"epoch-ms"}),startMs:Number(h.startMs||0),endMs:Number(h.endMs||0),range:e}:null};return i.current=new ls(f,{type:"bar",data:p,options:{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},animation:!1,onHover:(m,h)=>{let b=m?.native?.target;!b||!b.style||(b.style.cursor=Array.isArray(h)&&h.length>0?"pointer":"default")},onClick:(m,h)=>{let b=Number(h?.[0]?.index);if(!Number.isFinite(b))return;let x=g(b);if(x){if(x.key===d){o(null);return}o(x)}},scales:{x:{stacked:a===br,grid:{color:"rgba(148,163,184,0.08)"},ticks:{color:"rgba(156,163,175,1)",maxRotation:0,autoSkip:!0}},y:{stacked:a===br,beginAtZero:!0,grid:{color:"rgba(148,163,184,0.12)"},ticks:{precision:a===Es?void 0:0,color:"rgba(156,163,175,1)",callback:m=>{let h=Number(m||0);return a===Es?it(h):a===Np?h>0?bn(h):"0":Te(h)}}}},plugins:{legend:{position:"bottom",labels:{color:"rgba(209,213,219,1)",boxWidth:10,boxHeight:10}},tooltip:{callbacks:{title:m=>String(m?.[0]?.label||""),label:m=>{let h=Number(m.parsed.y||0);return a===Es?`${m.dataset.label}: ${it(h)}`:a===Np?`${m.dataset.label}: ${h>0?bn(h):"\u2014"}`:`${m.dataset.label}: ${Te(h)}`},footer:m=>{let h=Number(m?.[0]?.dataIndex),b=c[h];if(!b)return"";let x=`runs: ${Te(b.totalRuns||0)}`,v=`tokens: ${Te(b.totalTokens||0)}`,w=`cost: ${it(b.totalCost||0)}`;return`${x} ${v} -${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d,t?.bucket]),Ip` +${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d,t?.bucket]),Dp`

      Trends

      @@ -4482,18 +4482,18 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d />
      - ${u?Ip` + ${u?Dp`
      - `:Ip`
      No run data in this window yet.
      `} + `:Dp`
      No run data in this window yet.
      `} - `};var Ct=P.bind(T),QT=(t="")=>{let e=String(t||"").trim().toLowerCase();return e==="ok"?"text-status-success":e==="error"?"text-status-error":e==="skipped"?"text-status-warning":"text-fg-muted"},e3=t=>String(t?.deliveryStatus||"not-requested"),Bp=t=>Yn(t,{fallback:"\u2014",valueIsEpochMs:!0}).replace(/\s([AP])M\b/g,(e,n)=>`${String(n||"").toLowerCase()}m`),t3=t=>Yn(t,{fallback:"\u2014",valueIsEpochMs:!0}),Op=(t,e="overview")=>e==="detail"?t3(t):Bp(t),n3=({runEntry:t={},variant:e="overview"})=>{let n=String(t?.status||"unknown"),s=rn(t),o=$n(t),r=String(t?.jobName||"").trim(),i=r.length>0,a=e==="detail";return Ct` + `};var Ct=P.bind(T),QT=(t="")=>{let e=String(t||"").trim().toLowerCase();return e==="ok"?"text-status-success":e==="error"?"text-status-error":e==="skipped"?"text-status-warning":"text-fg-muted"},eP=t=>String(t?.deliveryStatus||"not-requested"),Fp=t=>Yn(t,{fallback:"\u2014",valueIsEpochMs:!0}).replace(/\s([AP])M\b/g,(e,n)=>`${String(n||"").toLowerCase()}m`),tP=t=>Yn(t,{fallback:"\u2014",valueIsEpochMs:!0}),Bp=(t,e="overview")=>e==="detail"?tP(t):Fp(t),nP=({runEntry:t={},variant:e="overview"})=>{let n=String(t?.status||"unknown"),s=rn(t),o=$n(t),r=String(t?.jobName||"").trim(),i=r.length>0,a=e==="detail";return Ct`
      ${a?Ct` - ${Op(t.ts,e)} + ${Bp(t.ts,e)} `:i?Ct` @@ -4501,13 +4501,13 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d >${r} - ${Op(t.ts,e)} + ${Bp(t.ts,e)} `:Ct` ${t.jobId} - - ${Op(t.ts,e)} + ${Bp(t.ts,e)} `} @@ -4518,7 +4518,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d > ${Te(s)} tk ${a?Ct`${e3(t)}${eP(t)}`:Ct` ${o==null?"\u2014":`~${it(o)}`} @@ -4526,7 +4526,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d `}
      - `},s3=(t=[])=>t.reduce((e,n)=>{e.totalTokens+=rn(n);let s=$n(n);return s!=null&&(e.totalCost+=s,e.hasAnyCost=!0),e},{totalTokens:0,totalCost:0,hasAnyCost:!1}),o3=({row:t,rowIndex:e,onSelectJob:n=()=>{}})=>{let s=Array.isArray(t?.entries)?t.entries:[],{totalTokens:o,totalCost:r,hasAnyCost:i}=s3(s),a=`${Bp(t.oldestTs)} - ${Bp(t.newestTs)}`;return Ct` + `},sP=(t=[])=>t.reduce((e,n)=>{e.totalTokens+=rn(n);let s=$n(n);return s!=null&&(e.totalCost+=s,e.hasAnyCost=!0),e},{totalTokens:0,totalCost:0,hasAnyCost:!1}),oP=({row:t,rowIndex:e,onSelectJob:n=()=>{}})=>{let s=Array.isArray(t?.entries)?t.entries:[],{totalTokens:o,totalCost:r,hasAnyCost:i}=sP(s),a=`${Fp(t.oldestTs)} - ${Fp(t.newestTs)}`;return Ct`
      {i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      - ${n3({runEntry:i,variant:n})} + ${nP({runEntry:i,variant:n})}
      @@ -4642,7 +4642,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d `:null}
      - `},ic=({entryCountLabel:t="",primaryFilterOptions:e=[],primaryFilterValue:n="all",onChangePrimaryFilter:s=()=>{},secondaryFilterOptions:o=[],secondaryFilterValue:r="all",onChangeSecondaryFilter:i=()=>{},activeFilterLabel:a="",onClearActiveFilter:l=()=>{},rows:c=[],emptyText:d="No runs found.",variant:u="overview",onSelectJob:p=()=>{},showOpenJobButton:f=!1,footer:g=null})=>Ct` + `},ac=({entryCountLabel:t="",primaryFilterOptions:e=[],primaryFilterValue:n="all",onChangePrimaryFilter:s=()=>{},secondaryFilterOptions:o=[],secondaryFilterValue:r="all",onChangeSecondaryFilter:i=()=>{},activeFilterLabel:a="",onClearActiveFilter:l=()=>{},rows:c=[],emptyText:d="No runs found.",variant:u="overview",onSelectJob:p=()=>{},showOpenJobButton:f=!1,footer:g=null})=>Ct`
      @@ -4683,12 +4683,12 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d `:null} ${c.length===0?Ct`
      ${d}
      `:Ct`
      - ${c.map((m,h)=>m?.type==="collapsed-group"?o3({row:m,rowIndex:h,onSelectJob:p}):y0({row:m,rowIndex:h,variant:u,onSelectJob:p,showOpenJobButton:f}))} + ${c.map((m,h)=>m?.type==="collapsed-group"?oP({row:m,rowIndex:h,onSelectJob:p}):y0({row:m,rowIndex:h,variant:u,onSelectJob:p,showOpenJobButton:f}))}
      `} ${g}
      -`;var v0=P.bind(T),$0="cronPromptEditorHeightPx",r3=280,i3=180,w0=t=>{let e=Number(t),n=Number.isFinite(e)?Math.round(e):r3;return Math.max(i3,n)},a3=t=>{if(!t)return 0;let e=Number.parseFloat(window.getComputedStyle(t).height||"0");return Number.isFinite(e)?e:0},k0=({promptValue:t="",savedPromptValue:e="",onChangePrompt:n=()=>{},onSaveChanges:s=()=>{}})=>{let o=ee(null),r=ee(null),i=ee(null),a=ee([]),l=ee(null),c=ee([]),[d,u]=y(()=>{let w=Fe();return w0(w?.[$0])}),p=ml(t),f=!gl({contentLength:t.length,lineCount:p,charThreshold:pl,lineThreshold:fl}),g=F(()=>f?dl(t,"markdown"):[],[t,f]),m=Math.max(p,Array.isArray(g)?g.length:0),h=F(()=>Array.from({length:m},(w,$)=>$+1),[m]),b=t!==e;bl({enabled:f,syncKey:`${t.length}:${g.length}`,editorLineNumberRowRefs:a,editorHighlightLineRefs:c});let x=w=>{let $=w.currentTarget.scrollTop;i.current&&(i.current.scrollTop=$),l.current&&(l.current.scrollTop=$)},v=w=>{if((w.metaKey||w.ctrlKey)&&w.key.toLowerCase()==="s"&&(w.preventDefault(),s()),w.key==="Tab"){w.preventDefault();let $=r.current;if(!$)return;let S=$.selectionStart,C=$.selectionEnd,_=`${t.slice(0,S)} ${t.slice(C)}`;n(_),window.requestAnimationFrame(()=>{$.selectionStart=S+2,$.selectionEnd=S+2})}};return L(()=>{let w=o.current;if(!w||typeof ResizeObserver>"u")return()=>{};let $=null,S=new ResizeObserver(C=>{let _=C?.[0],k=w0(a3(_?.target));u(A=>Math.abs(A-k)>=1?k:A),$&&window.clearTimeout($),$=window.setTimeout(()=>{let A=Fe();A[$0]=k,St(A)},120)});return S.observe(w),()=>{S.disconnect(),$&&window.clearTimeout($)}},[]),v0` +`;var v0=P.bind(T),$0="cronPromptEditorHeightPx",rP=280,iP=180,w0=t=>{let e=Number(t),n=Number.isFinite(e)?Math.round(e):rP;return Math.max(iP,n)},aP=t=>{if(!t)return 0;let e=Number.parseFloat(window.getComputedStyle(t).height||"0");return Number.isFinite(e)?e:0},k0=({promptValue:t="",savedPromptValue:e="",onChangePrompt:n=()=>{},onSaveChanges:s=()=>{}})=>{let o=ee(null),r=ee(null),i=ee(null),a=ee([]),l=ee(null),c=ee([]),[d,u]=y(()=>{let w=Fe();return w0(w?.[$0])}),p=gl(t),f=!bl({contentLength:t.length,lineCount:p,charThreshold:fl,lineThreshold:hl}),g=F(()=>f?ul(t,"markdown"):[],[t,f]),m=Math.max(p,Array.isArray(g)?g.length:0),h=F(()=>Array.from({length:m},(w,$)=>$+1),[m]),b=t!==e;xl({enabled:f,syncKey:`${t.length}:${g.length}`,editorLineNumberRowRefs:a,editorHighlightLineRefs:c});let x=w=>{let $=w.currentTarget.scrollTop;i.current&&(i.current.scrollTop=$),l.current&&(l.current.scrollTop=$)},v=w=>{if((w.metaKey||w.ctrlKey)&&w.key.toLowerCase()==="s"&&(w.preventDefault(),s()),w.key==="Tab"){w.preventDefault();let $=r.current;if(!$)return;let S=$.selectionStart,C=$.selectionEnd,_=`${t.slice(0,S)} ${t.slice(C)}`;n(_),window.requestAnimationFrame(()=>{$.selectionStart=S+2,$.selectionEnd=S+2})}};return L(()=>{let w=o.current;if(!w||typeof ResizeObserver>"u")return()=>{};let $=null,S=new ResizeObserver(C=>{let _=C?.[0],k=w0(aP(_?.target));u(A=>Math.abs(A-k)>=1?k:A),$&&window.clearTimeout($),$=window.setTimeout(()=>{let A=Fe();A[$0]=k,St(A)},120)});return S.observe(w),()=>{S.disconnect(),$&&window.clearTimeout($)}},[]),v0`

      @@ -4721,7 +4721,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d />

      - `};var ac=P.bind(T),Ei="ac-surface-inset rounded-lg p-2.5 space-y-1.5",l3=[{label:"main",value:"main"},{label:"isolated",value:"isolated"}],c3=[{label:"now",value:"now"},{label:"next-heartbeat",value:"next-heartbeat"}],Fp="__none__",S0=(t,e)=>t.getFullYear()===e.getFullYear()&&t.getMonth()===e.getMonth()&&t.getDate()===e.getDate(),d3=t=>t.toLocaleTimeString([],{hour:"numeric",minute:"2-digit"}).replace(/\s*([AP])M$/i,(e,n)=>`${String(n||"").toLowerCase()}m`).replace(/\s+/g,""),u3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"\u2014";let n=new Date(e);if(Number.isNaN(n.getTime()))return"\u2014";let s=new Date,o=new Date(s);o.setDate(s.getDate()+1);let r=S0(n,s),i=S0(n,o),a=d3(n);return r?a:i?`Tomorrow ${a}`:`${n.toLocaleDateString()} ${a}`},C0=({job:t=null,routingDraft:e=null,onChangeRoutingDraft:n=()=>{},destinationSessionKey:s="",onChangeDestinationSessionKey:o=()=>{},deliverySessions:r=[],loadingDeliverySessions:i=!1,deliverySessionsError:a="",savingChanges:l=!1,togglingJobEnabled:c=!1,onToggleEnabled:d=()=>{},onRunNow:u=()=>{},runningJob:p=!1,hasUnsavedChanges:f=!1})=>{if(!t)return null;let g=String(e?.sessionTarget||t?.sessionTarget||"main"),m=String(e?.wakeMode||t?.wakeMode||"now"),h=String(e?.deliveryMode||t?.delivery?.mode||"none"),b=F(()=>{let v=new Set,w=[],$=String(s||"").trim(),S=!1;if((Array.isArray(r)?r:[]).forEach(C=>{let _=String(C?.key||"").trim();if(!_)return;_===$&&(S=!0);let A=String(C?.label||C?.key||"Session").trim().toLowerCase();v.has(A)||(v.add(A),w.push(C))}),!S&&$){let C=(Array.isArray(r)?r:[]).find(_=>String(_?.key||"").trim()===$);C&&w.unshift(C)}return w},[r,s]),x=h==="announce"&&String(s||"").trim()?String(s||""):Fp;return ac` + `};var lc=P.bind(T),Ei="ac-surface-inset rounded-lg p-2.5 space-y-1.5",lP=[{label:"main",value:"main"},{label:"isolated",value:"isolated"}],cP=[{label:"now",value:"now"},{label:"next-heartbeat",value:"next-heartbeat"}],Hp="__none__",S0=(t,e)=>t.getFullYear()===e.getFullYear()&&t.getMonth()===e.getMonth()&&t.getDate()===e.getDate(),dP=t=>t.toLocaleTimeString([],{hour:"numeric",minute:"2-digit"}).replace(/\s*([AP])M$/i,(e,n)=>`${String(n||"").toLowerCase()}m`).replace(/\s+/g,""),uP=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"\u2014";let n=new Date(e);if(Number.isNaN(n.getTime()))return"\u2014";let s=new Date,o=new Date(s);o.setDate(s.getDate()+1);let r=S0(n,s),i=S0(n,o),a=dP(n);return r?a:i?`Tomorrow ${a}`:`${n.toLocaleDateString()} ${a}`},C0=({job:t=null,routingDraft:e=null,onChangeRoutingDraft:n=()=>{},destinationSessionKey:s="",onChangeDestinationSessionKey:o=()=>{},deliverySessions:r=[],loadingDeliverySessions:i=!1,deliverySessionsError:a="",savingChanges:l=!1,togglingJobEnabled:c=!1,onToggleEnabled:d=()=>{},onRunNow:u=()=>{},runningJob:p=!1,hasUnsavedChanges:f=!1})=>{if(!t)return null;let g=String(e?.sessionTarget||t?.sessionTarget||"main"),m=String(e?.wakeMode||t?.wakeMode||"now"),h=String(e?.deliveryMode||t?.delivery?.mode||"none"),b=F(()=>{let v=new Set,w=[],$=String(s||"").trim(),S=!1;if((Array.isArray(r)?r:[]).forEach(C=>{let _=String(C?.key||"").trim();if(!_)return;_===$&&(S=!0);let A=String(C?.label||C?.key||"Session").trim().toLowerCase();v.has(A)||(v.add(A),w.push(C))}),!S&&$){let C=(Array.isArray(r)?r:[]).find(_=>String(_?.key||"").trim()===$);C&&w.unshift(C)}return w},[r,s]),x=h==="announce"&&String(s||"").trim()?String(s||""):Hp;return lc`
      ID: ${t.id}
      @@ -4736,7 +4736,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      Next run
      - ${u3(t?.state?.nextRunAtMs)} + ${uP(t?.state?.nextRunAtMs)} ${` (${Yy(t?.state?.nextRunAtMs)})`} @@ -4748,7 +4748,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      Session target
      <${Xe} - options=${l3} + options=${lP} value=${g} onChange=${v=>n((w={})=>({...w,sessionTarget:String(v||"main")}))} /> @@ -4758,7 +4758,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      Wake mode
      <${Xe} - options=${c3} + options=${cP} value=${m} onChange=${v=>n((w={})=>({...w,wakeMode:String(v||"now")}))} /> @@ -4769,22 +4769,22 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      - ${i?ac`
      + ${i?lc`
      Loading delivery sessions...
      `:null} - ${a?ac`
      + ${a?lc`
      ${a}
      `:null}
      @@ -4807,11 +4807,11 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d />
      - `};var Hp=P.bind(T),p3=[{label:"all",value:"all"},{label:"ok",value:"ok"},{label:"error",value:"error"},{label:"skipped",value:"skipped"}],_0=({job:t=null,runEntries:e=[],filteredRunEntries:n=[],runTotal:s=0,runHasMore:o=!1,loadingMoreRuns:r=!1,runStatusFilter:i="all",onSetRunStatusFilter:a=()=>{},onLoadMoreRuns:l=()=>{},onRunNow:c=()=>{},runningJob:d=!1,onToggleEnabled:u=()=>{},togglingJobEnabled:p=!1,usage:f=null,jobTrends:g=null,jobTrendRange:m="7d",selectedJobTrendBucketFilter:h=null,usageDays:b=30,onSetUsageDays:x=()=>{},onSetJobTrendRange:v=()=>{},onSetSelectedJobTrendBucketFilter:w=()=>{},promptValue:$="",savedPromptValue:S="",onChangePrompt:C=()=>{},onSaveChanges:_=()=>{},savingChanges:k=!1,routingDraft:A=null,onChangeRoutingDraft:O=()=>{},deliverySessions:D=[],loadingDeliverySessions:z=!1,deliverySessionsError:E="",destinationSessionKey:M="",onChangeDestinationSessionKey:R=()=>{}})=>{if(!t)return Hp` + `};var Wp=P.bind(T),pP=[{label:"all",value:"all"},{label:"ok",value:"ok"},{label:"error",value:"error"},{label:"skipped",value:"skipped"}],_0=({job:t=null,runEntries:e=[],filteredRunEntries:n=[],runTotal:s=0,runHasMore:o=!1,loadingMoreRuns:r=!1,runStatusFilter:i="all",onSetRunStatusFilter:a=()=>{},onLoadMoreRuns:l=()=>{},onRunNow:c=()=>{},runningJob:d=!1,onToggleEnabled:u=()=>{},togglingJobEnabled:p=!1,usage:f=null,jobTrends:g=null,jobTrendRange:m="7d",selectedJobTrendBucketFilter:h=null,usageDays:b=30,onSetUsageDays:x=()=>{},onSetJobTrendRange:v=()=>{},onSetSelectedJobTrendBucketFilter:w=()=>{},promptValue:$="",savedPromptValue:S="",onChangePrompt:C=()=>{},onSaveChanges:_=()=>{},savingChanges:k=!1,routingDraft:A=null,onChangeRoutingDraft:O=()=>{},deliverySessions:D=[],loadingDeliverySessions:j=!1,deliverySessionsError:E="",destinationSessionKey:M="",onChangeDestinationSessionKey:R=()=>{}})=>{if(!t)return Wp`
      Select a cron job to view details.
      - `;let U=F(()=>{let W=String(A?.sessionTarget||t?.sessionTarget||"main"),G=String(A?.wakeMode||t?.wakeMode||"now"),se=String(A?.deliveryMode||t?.delivery?.mode||"none"),K=String(t?.sessionTarget||"main"),j=String(t?.wakeMode||"now"),X=String(t?.delivery?.mode||"none");return W!==K||G!==j||se!==X},[t,A?.deliveryMode,A?.sessionTarget,A?.wakeMode])||$!==S;return Hp` + `;let U=F(()=>{let W=String(A?.sessionTarget||t?.sessionTarget||"main"),G=String(A?.wakeMode||t?.wakeMode||"now"),se=String(A?.deliveryMode||t?.delivery?.mode||"none"),K=String(t?.sessionTarget||"main"),z=String(t?.wakeMode||"now"),X=String(t?.delivery?.mode||"none");return W!==K||G!==z||se!==X},[t,A?.deliveryMode,A?.sessionTarget,A?.wakeMode])||$!==S;return Wp`
      <${C0} @@ -4821,7 +4821,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d destinationSessionKey=${M} onChangeDestinationSessionKey=${R} deliverySessions=${D} - loadingDeliverySessions=${z} + loadingDeliverySessions=${j} deliverySessionsError=${E} savingChanges=${k} togglingJobEnabled=${p} @@ -4851,16 +4851,16 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d onChangeSelectedBucketFilter=${w} /> - <${ic} + <${ac} entryCountLabel=${`${Te(h?n.length:s)} entries`} - primaryFilterOptions=${p3} + primaryFilterOptions=${pP} primaryFilterValue=${i} onChangePrimaryFilter=${a} activeFilterLabel=${h?.label||""} onClearActiveFilter=${()=>w(null)} rows=${h?n:e} variant="detail" - footer=${o?Hp` + footer=${o?Wp`
      <${Z} onClick=${l} @@ -4875,7 +4875,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d />
      - `};var Vn=(t,e=0)=>{let n=Number(t);return Number.isFinite(n)?n:e},M0=t=>{let e=new Date(Vn(t,Date.now()));return e.setMinutes(0,0,0),e.getTime()},Vp=t=>{let e=new Date(Vn(t,Date.now()));return e.setHours(0,0,0,0),e.getTime()},T0=(t={})=>{let n=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(n.length<5)return null;let[s,o,r,i,a]=n;return{minuteField:s,hourField:o,dayOfMonthField:r,monthField:i,dayOfWeekField:a}},f3=(t="",e=0,n=0)=>{if(/^\d+$/.test(t)){let r=Number.parseInt(t,10);return Number.isFinite(r)&&r>=e&&r<=n?[r]:[]}let s=t.match(/^(\d+)-(\d+)$/);if(s){let r=Number.parseInt(s[1],10),i=Number.parseInt(s[2],10);if(!Number.isFinite(r)||!Number.isFinite(i))return[];let a=Math.max(e,Math.min(n,r)),l=Math.max(e,Math.min(n,i));return a>l?[]:Array.from({length:l-a+1},(c,d)=>a+d)}let o=t.match(/^\*\/(\d+)$/);if(o){let r=Number.parseInt(o[1],10);if(!Number.isFinite(r)||r<=0)return[];let i=[];for(let a=e;a<=n;a+=r)i.push(a);return i}return t==="*"?Array.from({length:n-e+1},(r,i)=>e+i):[]},po=(t="",e=0,n=0)=>{let s=String(t||"").trim();if(!s)return new Set;let r=s.split(",").map(i=>i.trim()).filter(Boolean).flatMap(i=>f3(i,e,n));return new Set(r)},h3=(t=null)=>t?{minuteSet:po(t.minuteField,0,59),hourSet:po(t.hourField,0,23),dayOfMonthSet:po(t.dayOfMonthField,1,31),monthSet:po(t.monthField,1,12),dayOfWeekSet:po(t.dayOfWeekField,0,7)}:null,m3=(t,e=null)=>{if(!e)return!1;let{minuteSet:n,hourSet:s,dayOfMonthSet:o,monthSet:r,dayOfWeekSet:i}=e,a=t.getMinutes(),l=t.getHours(),c=t.getDate(),d=t.getMonth()+1,u=t.getDay(),p=u===0?[0,7]:[u],f=n.size===0||n.has(a),g=s.size===0||s.has(l),m=o.size===0||o.has(c),h=r.size===0||r.has(d),b=i.size===0||p.some(x=>i.has(x));return f&&g&&m&&h&&b},Wp=t=>{let e=new Date(t),n=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return`${n}-${s}-${o}`},g3=({nowMs:t=Date.now(),pastDays:e=3,futureDays:n=3}={})=>{let s=Vn(t,Date.now()),o=Math.max(0,Number.parseInt(String(e),10)||3),r=Math.max(0,Number.parseInt(String(n),10)||3),i=Vp(s-o*864e5),a=Vp(s+r*864e5)+864e5-1;return{nowMs:s,rangeStartMs:i,rangeEndMs:a,dayCount:o+r+1}},A0=({jobId:t="",scheduledAtMs:e=0}={})=>`${String(t||"")}:${Vn(e,0)}`,b3=(t={})=>{if(String(t?.schedule?.kind||"").trim().toLowerCase()!=="cron")return!1;let n=T0(t?.schedule||{});if(!n||n.dayOfMonthField!=="*"||n.monthField!=="*")return!1;let s=String(n.minuteField||"").trim().match(/^\*\/(\d+)$/),o=po(n.minuteField,0,59),r=po(n.hourField,0,23),i=r.size>0?r.size:24,a=o.size>0?o.size:1;if(s){let l=Number.parseInt(s[1],10);return!Number.isFinite(l)||l<=0?!1:l<=30&&i>=4}return a>=3&&i>=4},P0=(t=[])=>{let e=[],n=[];return t.forEach(s=>{String(s?.schedule?.kind||"").trim().toLowerCase()==="every"||b3(s)?e.push(s):n.push(s)}),{repeatingJobs:e,scheduledJobs:n}},R0=({jobs:t=[],nowMs:e=Date.now(),pastDays:n=3,futureDays:s=3}={})=>{let o=g3({nowMs:e,pastDays:n,futureDays:s}),r=[],i=Array.from({length:o.dayCount},(a,l)=>{let c=Vp(o.rangeStartMs+l*864e5);return{dayStartMs:c,dayKey:Wp(c),label:new Date(c).toLocaleDateString([],{weekday:"short",month:"numeric",day:"numeric"})}});return t.forEach(a=>{let l=String(a?.schedule?.kind||"").trim().toLowerCase();if(l==="every")return;if(l==="at"){let u=Vn(a?.schedule?.at,0);if(uo.rangeEndMs)return;let p=M0(u);r.push({key:A0({jobId:a.id,scheduledAtMs:u}),jobId:String(a?.id||""),jobName:String(a?.name||a?.id||""),scheduledAtMs:u,hourBucketMs:p,dayKey:Wp(p),hourOfDay:new Date(p).getHours()});return}let c=T0(a?.schedule||{});if(!c)return;let d=h3(c);if(d)for(let u=o.rangeStartMs;u<=o.rangeEndMs;u+=6e4){let p=new Date(u);if(!m3(p,d))continue;let f=M0(u);r.push({key:A0({jobId:a.id,scheduledAtMs:u}),jobId:String(a?.id||""),jobName:String(a?.name||a?.id||""),scheduledAtMs:u,hourBucketMs:f,dayKey:Wp(u),hourOfDay:p.getHours()})}}),r.sort((a,l)=>a.scheduledAtMs-l.scheduledAtMs),{range:o,days:i,slots:r}},x3=(t="")=>{let e=String(t||"").trim().toLowerCase();return e==="ok"||e==="error"||e==="skipped"?e:""},L0=({slots:t=[],bulkRunsByJobId:e={},nowMs:n=Date.now(),toleranceMs:s=27e5}={})=>{let o={},r=Vn(n,Date.now()),i=Math.max(0,Vn(s,45*6e4)),a={};Object.entries(e||{}).forEach(([c,d])=>{let p=(Array.isArray(d?.entries)?d.entries:[]).map(f=>({ts:Vn(f?.ts,0),status:x3(f?.status)})).filter(f=>f.ts>0&&f.status).sort((f,g)=>f.ts-g.ts);a[c]=p});let l={};return t.forEach(c=>{if(c.scheduledAtMs>r)return;let d=String(c.jobId||""),u=a[d]||[];if(u.length===0)return;let p=l[d]||new Set;l[d]=p;let f=null,g=Number.MAX_SAFE_INTEGER;u.forEach(m=>{if(p.has(m.ts))return;let h=Math.abs(m.ts-c.scheduledAtMs);h>i||h{let o=Vn(e,Date.now()),r=o+Vn(n,864e5);return t.filter(i=>i.scheduledAtMs>o&&i.scheduledAtMs<=r).sort((i,a)=>i.scheduledAtMs-a.scheduledAtMs).slice(0,s)};var vt=P.bind(T),y3=t=>{let e=new Date;return e.setHours(t,0,0,0),e.toLocaleTimeString([],{hour:"numeric",minute:"2-digit"})},I0=(t,e)=>`${String(t||"")}:${e}`,v3=t=>{let e=new Date(t),n=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return`${n}-${s}-${o}`},jp=({isPast:t=!1,mappedStatus:e="",tokenTier:n="low"}={})=>{let s={unknown:"cron-calendar-slot-tier-unknown",low:"cron-calendar-slot-tier-low",medium:"cron-calendar-slot-tier-medium",high:"cron-calendar-slot-tier-high","very-high":"cron-calendar-slot-tier-very-high",disabled:"cron-calendar-slot-tier-disabled"},o=s[n]||s.low;return t?e==="ok"?`${o} cron-calendar-slot-ok`:e==="error"?`${o} cron-calendar-slot-error`:e==="skipped"?`${o} cron-calendar-slot-skipped`:`${o} cron-calendar-slot-past`:`${o} cron-calendar-slot-upcoming`},D0=()=>vt` + `};var Vn=(t,e=0)=>{let n=Number(t);return Number.isFinite(n)?n:e},M0=t=>{let e=new Date(Vn(t,Date.now()));return e.setMinutes(0,0,0),e.getTime()},zp=t=>{let e=new Date(Vn(t,Date.now()));return e.setHours(0,0,0,0),e.getTime()},T0=(t={})=>{let n=String(t?.expr||t?.cron||t?.cronExpr||"").trim().split(/\s+/);if(n.length<5)return null;let[s,o,r,i,a]=n;return{minuteField:s,hourField:o,dayOfMonthField:r,monthField:i,dayOfWeekField:a}},fP=(t="",e=0,n=0)=>{if(/^\d+$/.test(t)){let r=Number.parseInt(t,10);return Number.isFinite(r)&&r>=e&&r<=n?[r]:[]}let s=t.match(/^(\d+)-(\d+)$/);if(s){let r=Number.parseInt(s[1],10),i=Number.parseInt(s[2],10);if(!Number.isFinite(r)||!Number.isFinite(i))return[];let a=Math.max(e,Math.min(n,r)),l=Math.max(e,Math.min(n,i));return a>l?[]:Array.from({length:l-a+1},(c,d)=>a+d)}let o=t.match(/^\*\/(\d+)$/);if(o){let r=Number.parseInt(o[1],10);if(!Number.isFinite(r)||r<=0)return[];let i=[];for(let a=e;a<=n;a+=r)i.push(a);return i}return t==="*"?Array.from({length:n-e+1},(r,i)=>e+i):[]},po=(t="",e=0,n=0)=>{let s=String(t||"").trim();if(!s)return new Set;let r=s.split(",").map(i=>i.trim()).filter(Boolean).flatMap(i=>fP(i,e,n));return new Set(r)},hP=(t=null)=>t?{minuteSet:po(t.minuteField,0,59),hourSet:po(t.hourField,0,23),dayOfMonthSet:po(t.dayOfMonthField,1,31),monthSet:po(t.monthField,1,12),dayOfWeekSet:po(t.dayOfWeekField,0,7)}:null,mP=(t,e=null)=>{if(!e)return!1;let{minuteSet:n,hourSet:s,dayOfMonthSet:o,monthSet:r,dayOfWeekSet:i}=e,a=t.getMinutes(),l=t.getHours(),c=t.getDate(),d=t.getMonth()+1,u=t.getDay(),p=u===0?[0,7]:[u],f=n.size===0||n.has(a),g=s.size===0||s.has(l),m=o.size===0||o.has(c),h=r.size===0||r.has(d),b=i.size===0||p.some(x=>i.has(x));return f&&g&&m&&h&&b},Vp=t=>{let e=new Date(t),n=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return`${n}-${s}-${o}`},gP=({nowMs:t=Date.now(),pastDays:e=3,futureDays:n=3}={})=>{let s=Vn(t,Date.now()),o=Math.max(0,Number.parseInt(String(e),10)||3),r=Math.max(0,Number.parseInt(String(n),10)||3),i=zp(s-o*864e5),a=zp(s+r*864e5)+864e5-1;return{nowMs:s,rangeStartMs:i,rangeEndMs:a,dayCount:o+r+1}},A0=({jobId:t="",scheduledAtMs:e=0}={})=>`${String(t||"")}:${Vn(e,0)}`,bP=(t={})=>{if(String(t?.schedule?.kind||"").trim().toLowerCase()!=="cron")return!1;let n=T0(t?.schedule||{});if(!n||n.dayOfMonthField!=="*"||n.monthField!=="*")return!1;let s=String(n.minuteField||"").trim().match(/^\*\/(\d+)$/),o=po(n.minuteField,0,59),r=po(n.hourField,0,23),i=r.size>0?r.size:24,a=o.size>0?o.size:1;if(s){let l=Number.parseInt(s[1],10);return!Number.isFinite(l)||l<=0?!1:l<=30&&i>=4}return a>=3&&i>=4},P0=(t=[])=>{let e=[],n=[];return t.forEach(s=>{String(s?.schedule?.kind||"").trim().toLowerCase()==="every"||bP(s)?e.push(s):n.push(s)}),{repeatingJobs:e,scheduledJobs:n}},R0=({jobs:t=[],nowMs:e=Date.now(),pastDays:n=3,futureDays:s=3}={})=>{let o=gP({nowMs:e,pastDays:n,futureDays:s}),r=[],i=Array.from({length:o.dayCount},(a,l)=>{let c=zp(o.rangeStartMs+l*864e5);return{dayStartMs:c,dayKey:Vp(c),label:new Date(c).toLocaleDateString([],{weekday:"short",month:"numeric",day:"numeric"})}});return t.forEach(a=>{let l=String(a?.schedule?.kind||"").trim().toLowerCase();if(l==="every")return;if(l==="at"){let u=Vn(a?.schedule?.at,0);if(uo.rangeEndMs)return;let p=M0(u);r.push({key:A0({jobId:a.id,scheduledAtMs:u}),jobId:String(a?.id||""),jobName:String(a?.name||a?.id||""),scheduledAtMs:u,hourBucketMs:p,dayKey:Vp(p),hourOfDay:new Date(p).getHours()});return}let c=T0(a?.schedule||{});if(!c)return;let d=hP(c);if(d)for(let u=o.rangeStartMs;u<=o.rangeEndMs;u+=6e4){let p=new Date(u);if(!mP(p,d))continue;let f=M0(u);r.push({key:A0({jobId:a.id,scheduledAtMs:u}),jobId:String(a?.id||""),jobName:String(a?.name||a?.id||""),scheduledAtMs:u,hourBucketMs:f,dayKey:Vp(u),hourOfDay:p.getHours()})}}),r.sort((a,l)=>a.scheduledAtMs-l.scheduledAtMs),{range:o,days:i,slots:r}},xP=(t="")=>{let e=String(t||"").trim().toLowerCase();return e==="ok"||e==="error"||e==="skipped"?e:""},L0=({slots:t=[],bulkRunsByJobId:e={},nowMs:n=Date.now(),toleranceMs:s=27e5}={})=>{let o={},r=Vn(n,Date.now()),i=Math.max(0,Vn(s,45*6e4)),a={};Object.entries(e||{}).forEach(([c,d])=>{let p=(Array.isArray(d?.entries)?d.entries:[]).map(f=>({ts:Vn(f?.ts,0),status:xP(f?.status)})).filter(f=>f.ts>0&&f.status).sort((f,g)=>f.ts-g.ts);a[c]=p});let l={};return t.forEach(c=>{if(c.scheduledAtMs>r)return;let d=String(c.jobId||""),u=a[d]||[];if(u.length===0)return;let p=l[d]||new Set;l[d]=p;let f=null,g=Number.MAX_SAFE_INTEGER;u.forEach(m=>{if(p.has(m.ts))return;let h=Math.abs(m.ts-c.scheduledAtMs);h>i||h{let o=Vn(e,Date.now()),r=o+Vn(n,864e5);return t.filter(i=>i.scheduledAtMs>o&&i.scheduledAtMs<=r).sort((i,a)=>i.scheduledAtMs-a.scheduledAtMs).slice(0,s)};var vt=P.bind(T),yP=t=>{let e=new Date;return e.setHours(t,0,0,0),e.toLocaleTimeString([],{hour:"numeric",minute:"2-digit"})},I0=(t,e)=>`${String(t||"")}:${e}`,vP=t=>{let e=new Date(t),n=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return`${n}-${s}-${o}`},jp=({isPast:t=!1,mappedStatus:e="",tokenTier:n="low"}={})=>{let s={unknown:"cron-calendar-slot-tier-unknown",low:"cron-calendar-slot-tier-low",medium:"cron-calendar-slot-tier-medium",high:"cron-calendar-slot-tier-high","very-high":"cron-calendar-slot-tier-very-high",disabled:"cron-calendar-slot-tier-disabled"},o=s[n]||s.low;return t?e==="ok"?`${o} cron-calendar-slot-ok`:e==="error"?`${o} cron-calendar-slot-error`:e==="skipped"?`${o} cron-calendar-slot-skipped`:`${o} cron-calendar-slot-past`:`${o} cron-calendar-slot-upcoming`},D0=()=>vt`
      Token intensity {i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d >Very high
      -`,$3=60*1e3,w3=10080*60*1e3,k3=2700*1e3,S3="unknown",C3=t=>new Date(t).toLocaleTimeString([],{hour:"numeric",minute:"2-digit"}),_3=({runsByJobId:t={},nowMs:e=Date.now()}={})=>{let n=Number(e||Date.now())-w3;return Object.entries(t||{}).reduce((s,[o,r])=>{let a=(Array.isArray(r?.entries)?r.entries:[]).filter(u=>{let p=Number(u?.ts||0);return Number.isFinite(p)&&p>=n&&p<=e}),l=a.length,c=a.reduce((u,p)=>u+Number(rn(p)||0),0),d=a.reduce((u,p)=>{let f=$n(p);return u+Number(f??0)},0);return s[String(o||"")]={runCount:l,totalTokens:c,totalCost:d,avgTokensPerRun:l>0?Math.round(c/l):0,avgCostPerRun:l>0?d/l:0},s},{})},M3=({slots:t=[],runsByJobId:e={},nowMs:n=Date.now()}={})=>{let s={},o={},r=Object.entries(e||{}).reduce((i,[a,l])=>{let d=(Array.isArray(l?.entries)?l.entries:[]).map(u=>({...u,ts:Number(u?.ts||0)})).filter(u=>Number.isFinite(u.ts)&&u.ts>0).sort((u,p)=>u.ts-p.ts);return i[String(a||"")]=d,i},{});return t.forEach(i=>{if(Number(i?.scheduledAtMs||0)>n)return;let a=String(i?.jobId||""),l=r[a]||[];if(l.length===0)return;let c=o[a]||new Set;o[a]=c;let d=null,u=Number.MAX_SAFE_INTEGER;l.forEach(p=>{if(c.has(p.ts))return;let f=Math.abs(p.ts-Number(i?.scheduledAtMs||0));f>k3||f{let e=t.map(s=>Number(s||0)).filter(s=>Number.isFinite(s)&&s>0).sort((s,o)=>s-o);if(e.length===0)return null;let n=(s=0)=>{let o=Math.min(e.length-1,Math.floor((e.length-1)*s));return e[Math.max(0,o)]};return{q1:n(.25),q2:n(.5),p90:n(.9)}},zp=({enabled:t=!0,tokenValue:e=0,thresholds:n=null}={})=>{if(!t)return"disabled";let s=Number(e||0);return!Number.isFinite(s)||s<=0||!n?S3:s<=n.q1?"low":s<=n.q2?"medium":s<=n.p90?"high":"very-high"},Up=({jobName:t="",job:e=null,runSummary7d:n={},slotRun:s=null,latestRun:o=null,scheduledAtMs:r=0,scheduledStatus:i="",nowMs:a=Date.now()}={})=>{let l=Number(r||0)>0&&Number(r||0)<=a,c=Number(n?.runCount||0),d=Number(n?.avgTokensPerRun||0),u=Number(n?.avgCostPerRun||0),p=rn(s||{}),f=$n(s||{}),g=String(s?.status||"").trim().toLowerCase(),m=[String(t||"Job")];if(l?(m.push(`Run tokens: ${s?Te(p):"\u2014"}`),m.push(`Run cost: ${f==null?"\u2014":it(f)}`),m.push(`Run status: ${g||i||"unknown"}`),s?.ts&&m.push(`Run time: ${new Date(Number(s.ts||0)).toLocaleString()}`)):(m.push(`Avg tokens/run (last 7d): ${c>0?Te(d):"\u2014"}`),m.push(`Avg cost/run (last 7d): ${c>0?it(u):"\u2014"}`),m.push(`Runs (last 7d): ${c>0?Te(c):"none"}`)),!l&&o?.status?m.push(`Latest run: ${o.status} (${new Date(Number(o.ts||0)).toLocaleString()})`):l||m.push("Latest run: none"),Number(e?.state?.runningAtMs||0)>0&&m.push(`Current run: active (${new Date(Number(e.state.runningAtMs)).toLocaleString()})`),r>0){let h=new Date(r).toLocaleString(),b=i||(r<=Date.now()?"past":"upcoming");m.push(`Slot: ${b} (${h})`)}return m.join(` -`)},N0=({jobs:t=[],runsByJobId:e={},onSelectJob:n=()=>{}})=>{let[s,o]=y(!1),[r,i]=y(!1),[a,l]=y(()=>Date.now());L(()=>{let M=window.setInterval(()=>{l(Date.now())},$3);return()=>{window.clearInterval(M)}},[]);let c=v3(a),d=F(()=>new Date(a),[a]),u=d.getHours(),p=d.getMinutes()/60,{repeatingJobs:f,scheduledJobs:g}=F(()=>P0(t),[t]),m=F(()=>R0({jobs:g,nowMs:a}),[g,a]),h=F(()=>L0({slots:m.slots,bulkRunsByJobId:e,nowMs:a}),[m.slots,e,a]),b=F(()=>t.reduce((M,R)=>{let B=String(R?.id||"");return B&&(M[B]=R),M},{}),[t]),x=F(()=>Object.entries(e||{}).reduce((M,[R,B])=>{let U=(Array.isArray(B?.entries)?B.entries:[]).filter(W=>Number(W?.ts||0)>0).sort((W,G)=>Number(G?.ts||0)-Number(W?.ts||0))[0];return M[R]=U||null,M},{}),[e]),v=F(()=>_3({runsByJobId:e,nowMs:a}),[e,a]),w=F(()=>M3({slots:m.slots,runsByJobId:e,nowMs:a}),[m.slots,e,a]),$=F(()=>{let M=[];return m.slots.forEach(R=>{let B=b[R.jobId]||null;if(!B||B.enabled===!1)return;if(Number(R?.scheduledAtMs||0)<=a){let W=rn(w[R.key]||{});W>0&&M.push(W);return}let U=Number(v[R.jobId]?.avgTokensPerRun||0);U>0&&M.push(U)}),f.forEach(R=>{let B=String(R?.id||""),I=Number(v[B]?.avgTokensPerRun||0);I>0&&M.push(I)}),A3(M)},[b,a,f,w,v,m.slots]),S=q((M=null)=>{let R=String(M?.jobId||""),I=(b[R]||null)?.enabled!==!1;if(Number(M?.scheduledAtMs||0)<=a){let G=rn(w[String(M?.key||"")]||{});return zp({enabled:I,tokenValue:G,thresholds:$})}let W=Number(v[R]?.avgTokensPerRun||0);return zp({enabled:I,tokenValue:W,thresholds:$})},[b,a,w,v,$]),C=q((M="")=>{let R=b[M]||null;return zp({enabled:R?.enabled!==!1,tokenValue:Number(v[M]?.avgTokensPerRun||0),thresholds:$})},[b,v,$]),_=F(()=>E0({slots:m.slots,nowMs:a,limit:3}),[m.slots,a]),k=F(()=>{let M=a+864e5;return f.map(R=>{let B=String(R?.id||""),I=Number(R?.state?.nextRunAtMs||0);return!B||!Number.isFinite(I)||I<=a||I>M?null:{key:`noisy:${B}:${I}`,jobId:B,jobName:String(R?.name||B),scheduledAtMs:I}}).filter(Boolean).sort((R,B)=>R.scheduledAtMs-B.scheduledAtMs)},[f,a]),A=F(()=>r?[..._,...k].sort((M,R)=>Number(M?.scheduledAtMs||0)-Number(R?.scheduledAtMs||0)):_,[k,r,_]),O=F(()=>[...new Set(m.slots.map(R=>R.hourOfDay))].sort((R,B)=>R-B),[m.slots]),D=F(()=>m.slots.reduce((M,R)=>{let B=I0(R.dayKey,R.hourOfDay),I=M[B]||[];return I.push(R),M[B]=I,M},{}),[m.slots]),z=()=>vt` +`,$P=60*1e3,wP=10080*60*1e3,kP=2700*1e3,SP="unknown",CP=t=>new Date(t).toLocaleTimeString([],{hour:"numeric",minute:"2-digit"}),_P=({runsByJobId:t={},nowMs:e=Date.now()}={})=>{let n=Number(e||Date.now())-wP;return Object.entries(t||{}).reduce((s,[o,r])=>{let a=(Array.isArray(r?.entries)?r.entries:[]).filter(u=>{let p=Number(u?.ts||0);return Number.isFinite(p)&&p>=n&&p<=e}),l=a.length,c=a.reduce((u,p)=>u+Number(rn(p)||0),0),d=a.reduce((u,p)=>{let f=$n(p);return u+Number(f??0)},0);return s[String(o||"")]={runCount:l,totalTokens:c,totalCost:d,avgTokensPerRun:l>0?Math.round(c/l):0,avgCostPerRun:l>0?d/l:0},s},{})},MP=({slots:t=[],runsByJobId:e={},nowMs:n=Date.now()}={})=>{let s={},o={},r=Object.entries(e||{}).reduce((i,[a,l])=>{let d=(Array.isArray(l?.entries)?l.entries:[]).map(u=>({...u,ts:Number(u?.ts||0)})).filter(u=>Number.isFinite(u.ts)&&u.ts>0).sort((u,p)=>u.ts-p.ts);return i[String(a||"")]=d,i},{});return t.forEach(i=>{if(Number(i?.scheduledAtMs||0)>n)return;let a=String(i?.jobId||""),l=r[a]||[];if(l.length===0)return;let c=o[a]||new Set;o[a]=c;let d=null,u=Number.MAX_SAFE_INTEGER;l.forEach(p=>{if(c.has(p.ts))return;let f=Math.abs(p.ts-Number(i?.scheduledAtMs||0));f>kP||f{let e=t.map(s=>Number(s||0)).filter(s=>Number.isFinite(s)&&s>0).sort((s,o)=>s-o);if(e.length===0)return null;let n=(s=0)=>{let o=Math.min(e.length-1,Math.floor((e.length-1)*s));return e[Math.max(0,o)]};return{q1:n(.25),q2:n(.5),p90:n(.9)}},Up=({enabled:t=!0,tokenValue:e=0,thresholds:n=null}={})=>{if(!t)return"disabled";let s=Number(e||0);return!Number.isFinite(s)||s<=0||!n?SP:s<=n.q1?"low":s<=n.q2?"medium":s<=n.p90?"high":"very-high"},Kp=({jobName:t="",job:e=null,runSummary7d:n={},slotRun:s=null,latestRun:o=null,scheduledAtMs:r=0,scheduledStatus:i="",nowMs:a=Date.now()}={})=>{let l=Number(r||0)>0&&Number(r||0)<=a,c=Number(n?.runCount||0),d=Number(n?.avgTokensPerRun||0),u=Number(n?.avgCostPerRun||0),p=rn(s||{}),f=$n(s||{}),g=String(s?.status||"").trim().toLowerCase(),m=[String(t||"Job")];if(l?(m.push(`Run tokens: ${s?Te(p):"\u2014"}`),m.push(`Run cost: ${f==null?"\u2014":it(f)}`),m.push(`Run status: ${g||i||"unknown"}`),s?.ts&&m.push(`Run time: ${new Date(Number(s.ts||0)).toLocaleString()}`)):(m.push(`Avg tokens/run (last 7d): ${c>0?Te(d):"\u2014"}`),m.push(`Avg cost/run (last 7d): ${c>0?it(u):"\u2014"}`),m.push(`Runs (last 7d): ${c>0?Te(c):"none"}`)),!l&&o?.status?m.push(`Latest run: ${o.status} (${new Date(Number(o.ts||0)).toLocaleString()})`):l||m.push("Latest run: none"),Number(e?.state?.runningAtMs||0)>0&&m.push(`Current run: active (${new Date(Number(e.state.runningAtMs)).toLocaleString()})`),r>0){let h=new Date(r).toLocaleString(),b=i||(r<=Date.now()?"past":"upcoming");m.push(`Slot: ${b} (${h})`)}return m.join(` +`)},N0=({jobs:t=[],runsByJobId:e={},onSelectJob:n=()=>{}})=>{let[s,o]=y(!1),[r,i]=y(!1),[a,l]=y(()=>Date.now());L(()=>{let M=window.setInterval(()=>{l(Date.now())},$P);return()=>{window.clearInterval(M)}},[]);let c=vP(a),d=F(()=>new Date(a),[a]),u=d.getHours(),p=d.getMinutes()/60,{repeatingJobs:f,scheduledJobs:g}=F(()=>P0(t),[t]),m=F(()=>R0({jobs:g,nowMs:a}),[g,a]),h=F(()=>L0({slots:m.slots,bulkRunsByJobId:e,nowMs:a}),[m.slots,e,a]),b=F(()=>t.reduce((M,R)=>{let B=String(R?.id||"");return B&&(M[B]=R),M},{}),[t]),x=F(()=>Object.entries(e||{}).reduce((M,[R,B])=>{let U=(Array.isArray(B?.entries)?B.entries:[]).filter(W=>Number(W?.ts||0)>0).sort((W,G)=>Number(G?.ts||0)-Number(W?.ts||0))[0];return M[R]=U||null,M},{}),[e]),v=F(()=>_P({runsByJobId:e,nowMs:a}),[e,a]),w=F(()=>MP({slots:m.slots,runsByJobId:e,nowMs:a}),[m.slots,e,a]),$=F(()=>{let M=[];return m.slots.forEach(R=>{let B=b[R.jobId]||null;if(!B||B.enabled===!1)return;if(Number(R?.scheduledAtMs||0)<=a){let W=rn(w[R.key]||{});W>0&&M.push(W);return}let U=Number(v[R.jobId]?.avgTokensPerRun||0);U>0&&M.push(U)}),f.forEach(R=>{let B=String(R?.id||""),I=Number(v[B]?.avgTokensPerRun||0);I>0&&M.push(I)}),AP(M)},[b,a,f,w,v,m.slots]),S=q((M=null)=>{let R=String(M?.jobId||""),I=(b[R]||null)?.enabled!==!1;if(Number(M?.scheduledAtMs||0)<=a){let G=rn(w[String(M?.key||"")]||{});return Up({enabled:I,tokenValue:G,thresholds:$})}let W=Number(v[R]?.avgTokensPerRun||0);return Up({enabled:I,tokenValue:W,thresholds:$})},[b,a,w,v,$]),C=q((M="")=>{let R=b[M]||null;return Up({enabled:R?.enabled!==!1,tokenValue:Number(v[M]?.avgTokensPerRun||0),thresholds:$})},[b,v,$]),_=F(()=>E0({slots:m.slots,nowMs:a,limit:3}),[m.slots,a]),k=F(()=>{let M=a+864e5;return f.map(R=>{let B=String(R?.id||""),I=Number(R?.state?.nextRunAtMs||0);return!B||!Number.isFinite(I)||I<=a||I>M?null:{key:`noisy:${B}:${I}`,jobId:B,jobName:String(R?.name||B),scheduledAtMs:I}}).filter(Boolean).sort((R,B)=>R.scheduledAtMs-B.scheduledAtMs)},[f,a]),A=F(()=>r?[..._,...k].sort((M,R)=>Number(M?.scheduledAtMs||0)-Number(R?.scheduledAtMs||0)):_,[k,r,_]),O=F(()=>[...new Set(m.slots.map(R=>R.hourOfDay))].sort((R,B)=>R-B),[m.slots]),D=F(()=>m.slots.reduce((M,R)=>{let B=I0(R.dayKey,R.hourOfDay),I=M[B]||[];return I.push(R),M[B]=I,M},{}),[m.slots]),j=()=>vt`
      ${A.length===0?vt`
      No upcoming jobs in the next 24 hours.
      `:vt`
      - ${A.map(M=>{let R=v[M.jobId]||{},B=Number(R?.avgTokensPerRun||0),I=Number(R?.avgCostPerRun||0),U=B>0||I>0?`Est. ${B>0?`${Te(B)} tk`:"\u2014 tk"} \xB7 ${I>0?it(I):"\u2014"}`:"Est. \u2014",W=Up({jobName:M.jobName,job:b[M.jobId]||null,runSummary7d:v[M.jobId]||{},slotRun:w[M.key]||null,latestRun:x[M.jobId],scheduledAtMs:M.scheduledAtMs,nowMs:a});return vt` + ${A.map(M=>{let R=v[M.jobId]||{},B=Number(R?.avgTokensPerRun||0),I=Number(R?.avgCostPerRun||0),U=B>0||I>0?`Est. ${B>0?`${Te(B)} tk`:"\u2014 tk"} \xB7 ${I>0?it(I):"\u2014"}`:"Est. \u2014",W=Kp({jobName:M.jobName,job:b[M.jobId]||null,runSummary7d:v[M.jobId]||{},slotRun:w[M.key]||null,latestRun:x[M.jobId],scheduledAtMs:M.scheduledAtMs,nowMs:a});return vt` <${Bt} text=${W} widthClass="w-72" @@ -4913,7 +4913,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d > ${C3(M.scheduledAtMs)}${CP(M.scheduledAtMs)} ${M.jobName}{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d ${O.map(M=>vt`
      - ${y3(M)} + ${yP(M)}
      ${m.days.map(R=>{let B=I0(R.dayKey,M),I=D[B]||[],U=I.slice(0,3),W=Math.max(0,I.length-U.length);return vt`
      {i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      `:null} - ${U.map(G=>{let se=h[G.key]||"",K=G.scheduledAtMs<=a,j=S(G),X=Up({jobName:G.jobName,job:b[G.jobId]||null,runSummary7d:v[G.jobId]||{},slotRun:w[G.key]||null,latestRun:x[G.jobId],scheduledAtMs:G.scheduledAtMs,scheduledStatus:se,nowMs:a});return vt` + ${U.map(G=>{let se=h[G.key]||"",K=G.scheduledAtMs<=a,z=S(G),X=Kp({jobName:G.jobName,job:b[G.jobId]||null,runSummary7d:v[G.jobId]||{},slotRun:w[G.key]||null,latestRun:x[G.jobId],scheduledAtMs:G.scheduledAtMs,scheduledStatus:se,nowMs:a});return vt` <${Bt} text=${X} widthClass="w-72" @@ -4986,7 +4986,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d >
      n(G.jobId)} @@ -5010,7 +5010,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      Repeating
      - ${f.map(M=>{let R=String(M?.id||""),B=Number(v[R]?.avgTokensPerRun||0),I=Up({jobName:M.name||M.id,job:M,runSummary7d:v[R]||{},slotRun:null,latestRun:x[R],nowMs:a});return vt` + ${f.map(M=>{let R=String(M?.id||""),B=Number(v[R]?.avgTokensPerRun||0),I=Kp({jobName:M.name||M.id,job:M,runSummary7d:v[R]||{},slotRun:null,latestRun:x[R],nowMs:a});return vt` <${Bt} text=${I} widthClass="w-72" @@ -5049,7 +5049,7 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d
      - ${z()} + ${j()} <${De} visible=${s} @@ -5074,20 +5074,20 @@ ${w}`}}}}}}),()=>{i.current&&(i.current.destroy(),i.current=null)}},[p,a,o,c,e,d ${E()}
      - `};var T3=P.bind(T),Ni="24h",xr="7d",Oi="30d",P3=[{label:"24h",value:Ni},{label:"7d",value:xr},{label:"30d",value:Oi}],Ii="outcomes",Di="tokens",lc="cost",R3=[{label:"outcomes",value:Ii},{label:"tokens",value:Di},{label:"cost",value:lc}],Kp=t=>{let e=new Date(t);return e.setHours(0,0,0,0),e.getTime()},Gp=(t,e=0)=>{let n=new Date(t);return n.setDate(n.getDate()+Number(e||0)),n.getTime()},L3=(t=xr)=>t===Ni?{bucketCount:24,bucketMs:3600*1e3,formatLabel:e=>Xn(e,{range:Ni,valueType:"epoch-ms"}),showLabel:(e,n,s)=>n%3===0||n===s-1,alignToLocalDay:!1}:t===Oi?{bucketCount:30,bucketMs:1440*60*1e3,formatLabel:e=>Xn(e,{range:Oi,valueType:"epoch-ms"}),showLabel:(e,n,s)=>n%5===0||n===s-1,alignToLocalDay:!0}:{bucketCount:7,bucketMs:1440*60*1e3,formatLabel:e=>Xn(e,{range:xr,valueType:"epoch-ms"}),showLabel:()=>!0,alignToLocalDay:!0},E3=({bulkRunsByJobId:t={},nowMs:e=Date.now(),range:n=xr}={})=>{let s=L3(n),o=Number.isFinite(Number(e))?Number(e):Date.now(),r=s.alignToLocalDay?Gp(Kp(o),-(s.bucketCount-1)):o-s.bucketCount*s.bucketMs,i=Array.from({length:s.bucketCount},(d,u)=>{let p=s.alignToLocalDay?Gp(r,u):r+u*s.bucketMs,f=u===s.bucketCount-1?o:s.alignToLocalDay?Gp(r,u+1):r+(u+1)*s.bucketMs;return{key:`trend-point-${u}`,startMs:p,endMs:f,ok:0,error:0,skipped:0,totalTokens:0,totalCost:0,costCount:0}}),a=s.alignToLocalDay?new Map(i.map((d,u)=>[Kp(d.startMs),u])):null,l=i[0]?.startMs||r;Object.values(t||{}).forEach(d=>{(Array.isArray(d?.entries)?d.entries:[]).forEach(p=>{let f=Number(p?.ts||0);if(!Number.isFinite(f)||fo)return;let g=String(p?.status||"").trim().toLowerCase();if(!["ok","error","skipped"].includes(g))return;let m=s.alignToLocalDay?a?.get(Kp(f)):Math.floor((f-l)/s.bucketMs);if(!Number.isFinite(Number(m))||m<0||m>=s.bucketCount)return;i[m][g]+=1,i[m].totalTokens+=rn(p);let h=$n(p);h!=null&&(i[m].totalCost+=h,i[m].costCount+=1)})});let c=i.map((d,u)=>{let p=d.ok+d.error+d.skipped;return{...d,total:p,label:s.formatLabel(d.startMs),showLabel:s.showLabel(d,u,i.length)}});return{points:c,maxTotal:Math.max(1,...c.map(d=>d.total))}},O0=({bulkRunsByJobId:t={},initialRange:e=Ni,selectedBucketFilter:n=null,onBucketFilterChange:s=()=>{}})=>{let o=ee(null),r=ee(null),[i,a]=y(Ii),[l,c]=y(e===Oi?Oi:e===xr?xr:Ni),d=F(()=>E3({bulkRunsByJobId:t,nowMs:Date.now(),range:l}),[t,l]);L(()=>{s(null)},[l,s]);let u=F(()=>!n||n.range!==l?"":d.points.find(m=>Number(m.startMs)===Number(n.startMs)&&Number(m.endMs)===Number(n.endMs))?.key||"",[l,n,d.points]),p=F(()=>d.points.findIndex(g=>g.key===u),[u,d.points]),f=F(()=>{let g="0.22",m="0.86",h=v=>p>=0&&p!==v,b=d.points.map(v=>v.showLabel?v.label:"");return i===Ii?{labels:b,datasets:[{label:"ok",data:d.points.map(v=>Number(v.ok||0)),stack:"outcomes",backgroundColor:d.points.map((v,w)=>`rgba(34,255,170,${h(w)?g:m})`),borderColor:d.points.map((v,w)=>`rgba(34,255,170,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"error",data:d.points.map(v=>Number(v.error||0)),stack:"outcomes",backgroundColor:d.points.map((v,w)=>`rgba(255,74,138,${h(w)?g:m})`),borderColor:d.points.map((v,w)=>`rgba(255,74,138,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"skipped",data:d.points.map(v=>Number(v.skipped||0)),stack:"outcomes",backgroundColor:d.points.map((v,w)=>`rgba(255,214,64,${h(w)?g:m})`),borderColor:d.points.map((v,w)=>`rgba(255,214,64,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]}:{labels:b,datasets:[{label:i===Di?"tokens":"cost",data:d.points.map(v=>Number(i===Di?v.totalTokens||0:v.totalCost||0)),backgroundColor:d.points.map((v,w)=>i===Di?`rgba(34,211,238,${h(w)?g:"0.72"})`:`rgba(167,139,250,${h(w)?g:"0.72"})`),borderColor:d.points.map((v,w)=>i===Di?`rgba(34,211,238,${h(w)?"0.35":"1"})`:`rgba(167,139,250,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]}},[i,p,d.points]);return L(()=>{let g=o.current;if(!g||!ls)return;r.current&&(r.current.destroy(),r.current=null);let m=h=>{let b=d.points[h];return b?{key:b.key,label:b.label,range:l,startMs:Number(b.startMs||0),endMs:Number(b.endMs||0)}:null};return r.current=new ls(g,{type:"bar",data:f,options:{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},animation:!1,onHover:(h,b)=>{let x=h?.native?.target;!x||!x.style||(x.style.cursor=Array.isArray(b)&&b.length>0?"pointer":"default")},onClick:(h,b)=>{let x=Number(b?.[0]?.index);if(!Number.isFinite(x))return;let v=m(x);if(v){if(v.key===u){s(null);return}s(v)}},scales:{x:{stacked:i===Ii,grid:{color:"rgba(148,163,184,0.08)"},ticks:{color:"rgba(156,163,175,1)",maxRotation:0,autoSkip:!1}},y:{stacked:i===Ii,beginAtZero:!0,grid:{color:"rgba(148,163,184,0.12)"},ticks:{precision:i===lc?void 0:0,color:"rgba(156,163,175,1)",callback:h=>i===lc?it(Number(h||0)):Te(Number(h||0))}}},plugins:{legend:{position:"bottom",labels:{color:"rgba(209,213,219,1)",boxWidth:10,boxHeight:10}},tooltip:{callbacks:{title:h=>String(h?.[0]?.label||""),label:h=>{let b=Number(h.parsed.y||0);return i===lc?`${h.dataset.label}: ${it(b)}`:`${h.dataset.label}: ${Te(b)}`},footer:h=>{let b=Number(h?.[0]?.dataIndex),x=d.points[b];if(!x)return"";let v=x.costCount>0?`~${it(x.totalCost)}`:"\u2014",w=Te(x.totalTokens||0);return`runs: ${x.total} + `};var TP=P.bind(T),Ni="24h",xr="7d",Oi="30d",PP=[{label:"24h",value:Ni},{label:"7d",value:xr},{label:"30d",value:Oi}],Ii="outcomes",Di="tokens",cc="cost",RP=[{label:"outcomes",value:Ii},{label:"tokens",value:Di},{label:"cost",value:cc}],Gp=t=>{let e=new Date(t);return e.setHours(0,0,0,0),e.getTime()},qp=(t,e=0)=>{let n=new Date(t);return n.setDate(n.getDate()+Number(e||0)),n.getTime()},LP=(t=xr)=>t===Ni?{bucketCount:24,bucketMs:3600*1e3,formatLabel:e=>Xn(e,{range:Ni,valueType:"epoch-ms"}),showLabel:(e,n,s)=>n%3===0||n===s-1,alignToLocalDay:!1}:t===Oi?{bucketCount:30,bucketMs:1440*60*1e3,formatLabel:e=>Xn(e,{range:Oi,valueType:"epoch-ms"}),showLabel:(e,n,s)=>n%5===0||n===s-1,alignToLocalDay:!0}:{bucketCount:7,bucketMs:1440*60*1e3,formatLabel:e=>Xn(e,{range:xr,valueType:"epoch-ms"}),showLabel:()=>!0,alignToLocalDay:!0},EP=({bulkRunsByJobId:t={},nowMs:e=Date.now(),range:n=xr}={})=>{let s=LP(n),o=Number.isFinite(Number(e))?Number(e):Date.now(),r=s.alignToLocalDay?qp(Gp(o),-(s.bucketCount-1)):o-s.bucketCount*s.bucketMs,i=Array.from({length:s.bucketCount},(d,u)=>{let p=s.alignToLocalDay?qp(r,u):r+u*s.bucketMs,f=u===s.bucketCount-1?o:s.alignToLocalDay?qp(r,u+1):r+(u+1)*s.bucketMs;return{key:`trend-point-${u}`,startMs:p,endMs:f,ok:0,error:0,skipped:0,totalTokens:0,totalCost:0,costCount:0}}),a=s.alignToLocalDay?new Map(i.map((d,u)=>[Gp(d.startMs),u])):null,l=i[0]?.startMs||r;Object.values(t||{}).forEach(d=>{(Array.isArray(d?.entries)?d.entries:[]).forEach(p=>{let f=Number(p?.ts||0);if(!Number.isFinite(f)||fo)return;let g=String(p?.status||"").trim().toLowerCase();if(!["ok","error","skipped"].includes(g))return;let m=s.alignToLocalDay?a?.get(Gp(f)):Math.floor((f-l)/s.bucketMs);if(!Number.isFinite(Number(m))||m<0||m>=s.bucketCount)return;i[m][g]+=1,i[m].totalTokens+=rn(p);let h=$n(p);h!=null&&(i[m].totalCost+=h,i[m].costCount+=1)})});let c=i.map((d,u)=>{let p=d.ok+d.error+d.skipped;return{...d,total:p,label:s.formatLabel(d.startMs),showLabel:s.showLabel(d,u,i.length)}});return{points:c,maxTotal:Math.max(1,...c.map(d=>d.total))}},O0=({bulkRunsByJobId:t={},initialRange:e=Ni,selectedBucketFilter:n=null,onBucketFilterChange:s=()=>{}})=>{let o=ee(null),r=ee(null),[i,a]=y(Ii),[l,c]=y(e===Oi?Oi:e===xr?xr:Ni),d=F(()=>EP({bulkRunsByJobId:t,nowMs:Date.now(),range:l}),[t,l]);L(()=>{s(null)},[l,s]);let u=F(()=>!n||n.range!==l?"":d.points.find(m=>Number(m.startMs)===Number(n.startMs)&&Number(m.endMs)===Number(n.endMs))?.key||"",[l,n,d.points]),p=F(()=>d.points.findIndex(g=>g.key===u),[u,d.points]),f=F(()=>{let g="0.22",m="0.86",h=v=>p>=0&&p!==v,b=d.points.map(v=>v.showLabel?v.label:"");return i===Ii?{labels:b,datasets:[{label:"ok",data:d.points.map(v=>Number(v.ok||0)),stack:"outcomes",backgroundColor:d.points.map((v,w)=>`rgba(34,255,170,${h(w)?g:m})`),borderColor:d.points.map((v,w)=>`rgba(34,255,170,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"error",data:d.points.map(v=>Number(v.error||0)),stack:"outcomes",backgroundColor:d.points.map((v,w)=>`rgba(255,74,138,${h(w)?g:m})`),borderColor:d.points.map((v,w)=>`rgba(255,74,138,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1},{label:"skipped",data:d.points.map(v=>Number(v.skipped||0)),stack:"outcomes",backgroundColor:d.points.map((v,w)=>`rgba(255,214,64,${h(w)?g:m})`),borderColor:d.points.map((v,w)=>`rgba(255,214,64,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]}:{labels:b,datasets:[{label:i===Di?"tokens":"cost",data:d.points.map(v=>Number(i===Di?v.totalTokens||0:v.totalCost||0)),backgroundColor:d.points.map((v,w)=>i===Di?`rgba(34,211,238,${h(w)?g:"0.72"})`:`rgba(167,139,250,${h(w)?g:"0.72"})`),borderColor:d.points.map((v,w)=>i===Di?`rgba(34,211,238,${h(w)?"0.35":"1"})`:`rgba(167,139,250,${h(w)?"0.35":"1"})`),borderWidth:1,borderRadius:0,borderSkipped:!1}]}},[i,p,d.points]);return L(()=>{let g=o.current;if(!g||!ls)return;r.current&&(r.current.destroy(),r.current=null);let m=h=>{let b=d.points[h];return b?{key:b.key,label:b.label,range:l,startMs:Number(b.startMs||0),endMs:Number(b.endMs||0)}:null};return r.current=new ls(g,{type:"bar",data:f,options:{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},animation:!1,onHover:(h,b)=>{let x=h?.native?.target;!x||!x.style||(x.style.cursor=Array.isArray(b)&&b.length>0?"pointer":"default")},onClick:(h,b)=>{let x=Number(b?.[0]?.index);if(!Number.isFinite(x))return;let v=m(x);if(v){if(v.key===u){s(null);return}s(v)}},scales:{x:{stacked:i===Ii,grid:{color:"rgba(148,163,184,0.08)"},ticks:{color:"rgba(156,163,175,1)",maxRotation:0,autoSkip:!1}},y:{stacked:i===Ii,beginAtZero:!0,grid:{color:"rgba(148,163,184,0.12)"},ticks:{precision:i===cc?void 0:0,color:"rgba(156,163,175,1)",callback:h=>i===cc?it(Number(h||0)):Te(Number(h||0))}}},plugins:{legend:{position:"bottom",labels:{color:"rgba(209,213,219,1)",boxWidth:10,boxHeight:10}},tooltip:{callbacks:{title:h=>String(h?.[0]?.label||""),label:h=>{let b=Number(h.parsed.y||0);return i===cc?`${h.dataset.label}: ${it(b)}`:`${h.dataset.label}: ${Te(b)}`},footer:h=>{let b=Number(h?.[0]?.dataIndex),x=d.points[b];if(!x)return"";let v=x.costCount>0?`~${it(x.totalCost)}`:"\u2014",w=Te(x.totalTokens||0);return`runs: ${x.total} tokens: ${w} -cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s,l,u,d.points]),T3` +cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s,l,u,d.points]),TP`

      Trends

      <${Xe} - options=${R3} + options=${RP} value=${i} onChange=${a} /> <${Xe} - options=${P3} + options=${PP} value=${l} onChange=${c} /> @@ -5097,7 +5097,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      - `};var cc=P.bind(T),F0="24h",Bi="7d",H0="30d",I3=[{label:"24h",value:F0},{label:"7d",value:Bi},{label:"30d",value:H0}],B0={[F0]:1440*60*1e3,[Bi]:10080*60*1e3,[H0]:720*60*60*1e3},qp=3,D3={"Token hungry":"warning","Potentially wasteful":"danger","Most expensive":"accent"},N3=10,O3=1e5,B3=3,Jp=(t=0)=>{let e=Number(t||0);return`${Te(e)} ${e===1?"run":"runs"}`},F3=(t=null)=>String(t?.delivery?.mode||t?.deliveryMode||"none").trim().toLowerCase(),Zp=(t=[],e=[])=>[...t].sort((n,s)=>{for(let o of e){let r=Number(o(n)||0),i=Number(o(s)||0);if(r!==i)return i-r}return String(n?.jobName||"").localeCompare(String(s?.jobName||""))}),H3=({jobs:t=[],bulkRunsByJobId:e={},rangeValue:n=Bi})=>{let s=Date.now(),o=Number(B0[n]||B0[Bi]),r=s-o,i=t.reduce((a,l)=>{let c=String(l?.id||"");return c&&(a[c]={jobId:c,jobName:String(l?.name||c),runCount:0,totalTokens:0,totalCost:0,hasCostData:!1,hasDelivery:F3(l)!=="none"}),a},{});return Object.entries(e||{}).forEach(([a,l])=>{let c=String(a||"");if(!c)return;i[c]||(i[c]={jobId:c,jobName:c,runCount:0,totalTokens:0,totalCost:0,hasCostData:!1,hasDelivery:!1}),(Array.isArray(l?.entries)?l.entries:[]).forEach(u=>{let p=Number(u?.ts||0);if(!Number.isFinite(p)||ps)return;i[c].runCount+=1,i[c].totalTokens+=Number(rn(u)||0);let f=$n(u);f!=null&&(i[c].hasCostData=!0,i[c].totalCost+=Number(f||0))})}),Object.values(i).map(a=>({...a,avgTokensPerRun:a.runCount>0?Math.round(a.totalTokens/a.runCount):0,avgCostPerRun:a.runCount>0?a.totalCost/a.runCount:0}))},W3=({title:t="",rows:e=[],onSelectJob:n=()=>{}})=>{let s=D3[t]||"neutral",o=e[0],r=e.slice(1);return cc` + `};var dc=P.bind(T),F0="24h",Bi="7d",H0="30d",IP=[{label:"24h",value:F0},{label:"7d",value:Bi},{label:"30d",value:H0}],B0={[F0]:1440*60*1e3,[Bi]:10080*60*1e3,[H0]:720*60*60*1e3},Jp=3,DP={"Token hungry":"warning","Potentially wasteful":"danger","Most expensive":"accent"},NP=10,OP=1e5,BP=3,Zp=(t=0)=>{let e=Number(t||0);return`${Te(e)} ${e===1?"run":"runs"}`},FP=(t=null)=>String(t?.delivery?.mode||t?.deliveryMode||"none").trim().toLowerCase(),Yp=(t=[],e=[])=>[...t].sort((n,s)=>{for(let o of e){let r=Number(o(n)||0),i=Number(o(s)||0);if(r!==i)return i-r}return String(n?.jobName||"").localeCompare(String(s?.jobName||""))}),HP=({jobs:t=[],bulkRunsByJobId:e={},rangeValue:n=Bi})=>{let s=Date.now(),o=Number(B0[n]||B0[Bi]),r=s-o,i=t.reduce((a,l)=>{let c=String(l?.id||"");return c&&(a[c]={jobId:c,jobName:String(l?.name||c),runCount:0,totalTokens:0,totalCost:0,hasCostData:!1,hasDelivery:FP(l)!=="none"}),a},{});return Object.entries(e||{}).forEach(([a,l])=>{let c=String(a||"");if(!c)return;i[c]||(i[c]={jobId:c,jobName:c,runCount:0,totalTokens:0,totalCost:0,hasCostData:!1,hasDelivery:!1}),(Array.isArray(l?.entries)?l.entries:[]).forEach(u=>{let p=Number(u?.ts||0);if(!Number.isFinite(p)||ps)return;i[c].runCount+=1,i[c].totalTokens+=Number(rn(u)||0);let f=$n(u);f!=null&&(i[c].hasCostData=!0,i[c].totalCost+=Number(f||0))})}),Object.values(i).map(a=>({...a,avgTokensPerRun:a.runCount>0?Math.round(a.totalTokens/a.runCount):0,avgCostPerRun:a.runCount>0?a.totalCost/a.runCount:0}))},WP=({title:t="",rows:e=[],onSelectJob:n=()=>{}})=>{let s=DP[t]||"neutral",o=e[0],r=e.slice(1);return dc`
      - ${r.length>0?cc` + ${r.length>0?dc`
      {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s Show more
      - ${r.map((i,a)=>cc` + ${r.map((i,a)=>dc`
      `:null}
      - `},W0=({jobs:t=[],bulkRunsByJobId:e={},onSelectJob:n=()=>{}})=>{let[s,o]=y(Bi),r=F(()=>H3({jobs:t,bulkRunsByJobId:e,rangeValue:s}),[e,t,s]),i=F(()=>Zp(r.filter(d=>d.runCount>=B3&&d.avgTokensPerRun>=O3),[d=>d.avgTokensPerRun,d=>d.totalTokens]).slice(0,qp).map(d=>({...d,primaryLabel:`${Te(d.avgTokensPerRun)} avg tokens/run`,secondaryLabel:`${Te(d.totalTokens)} total tokens \xB7 ${Jp(d.runCount)}`})),[r]),a=F(()=>Zp(r.filter(d=>d.runCount>=N3&&d.hasDelivery===!1&&(d.totalTokens>0||d.totalCost>0)),[d=>d.totalTokens,d=>d.runCount]).slice(0,qp).map(d=>({...d,primaryLabel:`${Jp(d.runCount)} with no delivery`,secondaryLabel:`${Te(d.totalTokens)} total tokens${d.hasCostData?` \xB7 ${it(d.totalCost)}`:""}`})),[r]),l=F(()=>Zp(r.filter(d=>d.runCount>0&&d.totalCost>0),[d=>d.totalCost,d=>d.avgCostPerRun]).slice(0,qp).map(d=>({...d,primaryLabel:`${it(d.totalCost)} total estimated cost`,secondaryLabel:`${it(d.avgCostPerRun)} avg/run \xB7 ${Jp(d.runCount)}`})),[r]),c=[{title:"Token hungry",rows:i},{title:"Potentially wasteful",rows:a},{title:"Most expensive",rows:l}].filter(d=>Array.isArray(d.rows)&&d.rows.length>0);return c.length===0?null:cc` + `},W0=({jobs:t=[],bulkRunsByJobId:e={},onSelectJob:n=()=>{}})=>{let[s,o]=y(Bi),r=F(()=>HP({jobs:t,bulkRunsByJobId:e,rangeValue:s}),[e,t,s]),i=F(()=>Yp(r.filter(d=>d.runCount>=BP&&d.avgTokensPerRun>=OP),[d=>d.avgTokensPerRun,d=>d.totalTokens]).slice(0,Jp).map(d=>({...d,primaryLabel:`${Te(d.avgTokensPerRun)} avg tokens/run`,secondaryLabel:`${Te(d.totalTokens)} total tokens \xB7 ${Zp(d.runCount)}`})),[r]),a=F(()=>Yp(r.filter(d=>d.runCount>=NP&&d.hasDelivery===!1&&(d.totalTokens>0||d.totalCost>0)),[d=>d.totalTokens,d=>d.runCount]).slice(0,Jp).map(d=>({...d,primaryLabel:`${Zp(d.runCount)} with no delivery`,secondaryLabel:`${Te(d.totalTokens)} total tokens${d.hasCostData?` \xB7 ${it(d.totalCost)}`:""}`})),[r]),l=F(()=>Yp(r.filter(d=>d.runCount>0&&d.totalCost>0),[d=>d.totalCost,d=>d.avgCostPerRun]).slice(0,Jp).map(d=>({...d,primaryLabel:`${it(d.totalCost)} total estimated cost`,secondaryLabel:`${it(d.avgCostPerRun)} avg/run \xB7 ${Zp(d.runCount)}`})),[r]),c=[{title:"Token hungry",rows:i},{title:"Potentially wasteful",rows:a},{title:"Most expensive",rows:l}].filter(d=>Array.isArray(d.rows)&&d.rows.length>0);return c.length===0?null:dc`

      Insights

      <${Xe} - options=${I3} + options=${IP} value=${s} onChange=${o} />
      - ${c.map(d=>W3({title:d.title,rows:d.rows,onSelectJob:n}))} + ${c.map(d=>WP({title:d.title,rows:d.rows,onSelectJob:n}))}
      - `};var V3=P.bind(T),cs=({title:t="",value:e="\u2014",toneClassName:n="",valueClassName:s="text-lg font-semibold text-body",monospace:o=!1}={})=>V3` + `};var VP=P.bind(T),cs=({title:t="",value:e="\u2014",toneClassName:n="",valueClassName:s="text-lg font-semibold text-body",monospace:o=!1}={})=>VP`

      ${t}

      ${e}
      -`;var dc=P.bind(T),j3=100,V0=20,z3=2,Fi="24h",Hi="7d",Wi="30d",Yp="trendStart",Xp="trendEnd",Qp="trendRange",ef="trendLabel",U3=[{label:"all",value:"all"},{label:"ok",value:"ok"},{label:"error",value:"error"},{label:"skipped",value:"skipped"}],K3=t=>t==="error"?"border-status-error-border bg-status-error-bg text-status-error":t==="warning"?"border-status-warning-border bg-status-warning-bg text-status-warning":"border-border bg-field text-body",G3=(t=[])=>{let e=t.filter(r=>r?.tone==="error").length,n=t.filter(r=>r?.tone==="warning").length;if(e+n<=0)return"No warnings currently need your attention";let o=[];return e>0&&o.push(`${e} error${e===1?"":"s"}`),n>0&&o.push(`${n} warning${n===1?"":"s"}`),`${o.join(" and ")} may need your attention`},q3=({bulkRunsByJobId:t={},jobs:e=[],limit:n=0}={})=>{let s=e.reduce((o,r)=>{let i=String(r?.id||"");return i&&(o[i]=String(r?.name||i)),o},{});return Object.entries(t||{}).flatMap(([o,r])=>(Array.isArray(r?.entries)?r.entries:[]).map(a=>({...a,jobId:String(o||""),jobName:s[o]||String(o||"")}))).filter(o=>Number(o?.ts||0)>0).sort((o,r)=>Number(r?.ts||0)-Number(o?.ts||0)).slice(0,Number(n||0)>0?Number(n||0):void 0)},J3=(t=[])=>{let e=[],n=0;for(;n=z3){let i=r.reduce((a,l)=>{let c=String(l?.status||"unknown");return a[c]=Number(a[c]||0)+1,a},{});e.push({type:"collapsed-group",jobId:String(s?.jobId||""),jobName:String(s?.jobName||s?.jobId||""),count:r.length,newestTs:Number(r[0]?.ts||0),oldestTs:Number(r[r.length-1]?.ts||0),statusCounts:i,entries:r}),n=o;continue}for(let i of r){if(e.length>=V0)break;e.push({type:"entry",entry:i})}n=o}return e},j0=()=>{let e=String(window.location.hash||"").replace(/^#/,"")||"/cron",[n,s=""]=e.split("?");return{pathPart:n||"/cron",params:new URLSearchParams(s)}},Z3=()=>{let{params:t}=j0(),e=Number(t.get(Yp)||0),n=Number(t.get(Xp)||0),s=String(t.get(Qp)||Fi),o=String(t.get(ef)||""),r=s===Fi||s===Hi||s===Wi;return!Number.isFinite(e)||!Number.isFinite(n)||n<=e?null:{startMs:e,endMs:n,range:r?s:Fi,label:o||"selected period"}},Y3=(t=null)=>{let{pathPart:e,params:n}=j0();t?(n.set(Yp,String(Number(t.startMs||0))),n.set(Xp,String(Number(t.endMs||0))),n.set(Qp,t.range===Wi?Wi:t.range===Hi?Hi:Fi),n.set(ef,String(t.label||""))):(n.delete(Yp),n.delete(Xp),n.delete(Qp),n.delete(ef));let s=n.toString(),o=s?`#${e}?${s}`:`#${e}`,r=`${window.location.pathname}${window.location.search}${o}`;window.history.replaceState(window.history.state,"",r)},z0=({jobs:t=[],bulkUsageByJobId:e={},bulkRunsByJobId:n={},onSelectJob:s=()=>{}})=>{let[o,r]=y("all"),[i,a]=y(()=>Z3()),l=t.filter(x=>x.enabled!==!1).length,c=t.length-l,d=tv(t),u=ev(t,n),p=F(()=>q3({bulkRunsByJobId:n,jobs:t}),[n,t]),f=F(()=>p.slice(0,j3),[p]),g=F(()=>{if(!i)return f;let x=Number(i?.startMs||0),v=Number(i?.endMs||0);return!Number.isFinite(x)||!Number.isFinite(v)||v<=x?f:p.filter(w=>{let $=Number(w?.ts||0);return Number.isFinite($)&&$>=x&&$g.filter(x=>o==="all"?!0:String(x?.status||"").trim().toLowerCase()===o),[o,g]),h=F(()=>J3(m),[m]),b=i?.range===Wi?Wi:i?.range===Hi?Hi:Fi;return L(()=>{Y3(i)},[i]),dc` +`;var uc=P.bind(T),zP=100,V0=20,jP=2,Fi="24h",Hi="7d",Wi="30d",Xp="trendStart",Qp="trendEnd",ef="trendRange",tf="trendLabel",UP=[{label:"all",value:"all"},{label:"ok",value:"ok"},{label:"error",value:"error"},{label:"skipped",value:"skipped"}],KP=t=>t==="error"?"border-status-error-border bg-status-error-bg text-status-error":t==="warning"?"border-status-warning-border bg-status-warning-bg text-status-warning":"border-border bg-field text-body",GP=(t=[])=>{let e=t.filter(r=>r?.tone==="error").length,n=t.filter(r=>r?.tone==="warning").length;if(e+n<=0)return"No warnings currently need your attention";let o=[];return e>0&&o.push(`${e} error${e===1?"":"s"}`),n>0&&o.push(`${n} warning${n===1?"":"s"}`),`${o.join(" and ")} may need your attention`},qP=({bulkRunsByJobId:t={},jobs:e=[],limit:n=0}={})=>{let s=e.reduce((o,r)=>{let i=String(r?.id||"");return i&&(o[i]=String(r?.name||i)),o},{});return Object.entries(t||{}).flatMap(([o,r])=>(Array.isArray(r?.entries)?r.entries:[]).map(a=>({...a,jobId:String(o||""),jobName:s[o]||String(o||"")}))).filter(o=>Number(o?.ts||0)>0).sort((o,r)=>Number(r?.ts||0)-Number(o?.ts||0)).slice(0,Number(n||0)>0?Number(n||0):void 0)},JP=(t=[])=>{let e=[],n=0;for(;n=jP){let i=r.reduce((a,l)=>{let c=String(l?.status||"unknown");return a[c]=Number(a[c]||0)+1,a},{});e.push({type:"collapsed-group",jobId:String(s?.jobId||""),jobName:String(s?.jobName||s?.jobId||""),count:r.length,newestTs:Number(r[0]?.ts||0),oldestTs:Number(r[r.length-1]?.ts||0),statusCounts:i,entries:r}),n=o;continue}for(let i of r){if(e.length>=V0)break;e.push({type:"entry",entry:i})}n=o}return e},z0=()=>{let e=String(window.location.hash||"").replace(/^#/,"")||"/cron",[n,s=""]=e.split("?");return{pathPart:n||"/cron",params:new URLSearchParams(s)}},ZP=()=>{let{params:t}=z0(),e=Number(t.get(Xp)||0),n=Number(t.get(Qp)||0),s=String(t.get(ef)||Fi),o=String(t.get(tf)||""),r=s===Fi||s===Hi||s===Wi;return!Number.isFinite(e)||!Number.isFinite(n)||n<=e?null:{startMs:e,endMs:n,range:r?s:Fi,label:o||"selected period"}},YP=(t=null)=>{let{pathPart:e,params:n}=z0();t?(n.set(Xp,String(Number(t.startMs||0))),n.set(Qp,String(Number(t.endMs||0))),n.set(ef,t.range===Wi?Wi:t.range===Hi?Hi:Fi),n.set(tf,String(t.label||""))):(n.delete(Xp),n.delete(Qp),n.delete(ef),n.delete(tf));let s=n.toString(),o=s?`#${e}?${s}`:`#${e}`,r=`${window.location.pathname}${window.location.search}${o}`;window.history.replaceState(window.history.state,"",r)},j0=({jobs:t=[],bulkUsageByJobId:e={},bulkRunsByJobId:n={},onSelectJob:s=()=>{}})=>{let[o,r]=y("all"),[i,a]=y(()=>ZP()),l=t.filter(x=>x.enabled!==!1).length,c=t.length-l,d=tv(t),u=ev(t,n),p=F(()=>qP({bulkRunsByJobId:n,jobs:t}),[n,t]),f=F(()=>p.slice(0,zP),[p]),g=F(()=>{if(!i)return f;let x=Number(i?.startMs||0),v=Number(i?.endMs||0);return!Number.isFinite(x)||!Number.isFinite(v)||v<=x?f:p.filter(w=>{let $=Number(w?.ts||0);return Number.isFinite($)&&$>=x&&$g.filter(x=>o==="all"?!0:String(x?.status||"").trim().toLowerCase()===o),[o,g]),h=F(()=>JP(m),[m]),b=i?.range===Wi?Wi:i?.range===Hi?Hi:Fi;return L(()=>{YP(i)},[i]),uc`
      @@ -5200,11 +5200,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      - <${bb} + <${xb} className="w-4 h-4 text-status-warning shrink-0" />
      - ${G3(u)} + ${GP(u)}
      {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      - ${u.length===0?dc`
      + ${u.length===0?uc`
      No warnings right now. -
      `:dc` +
      `:uc`
      - ${u.map((x,v)=>dc` + ${u.map((x,v)=>uc`
      {x?.jobId&&s(x.jobId)}} @@ -5257,9 +5257,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onBucketFilterChange=${a} /> - <${ic} + <${ac} entryCountLabel=${`${Te(m.length)} entries`} - primaryFilterOptions=${U3} + primaryFilterOptions=${UP} primaryFilterValue=${o} onChangePrimaryFilter=${r} activeFilterLabel=${i?.label||""} @@ -5271,7 +5271,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
      - `};var tf=(t="")=>String(t||"").trim(),Je=(t=null)=>tf(t?.key||t?.sessionKey||""),nf=(t="")=>{let n=tf(t).match(/^agent:([^:]+):/);return String(n?.[1]||"").trim()},uc=(t="")=>{let e=tf(t).toLowerCase();return e.includes(":direct:")||e.includes(":group:")},pc=t=>!!(String(t?.replyChannel||"").trim()&&String(t?.replyTo||"").trim())||uc(Je(t)),U0={destination:0,other:1},K0=(t=null)=>uc(Je(t))?U0.destination:U0.other,G0=(t=[])=>[...Array.isArray(t)?t:[]].sort((e,n)=>{let s=K0(e)-K0(n);if(s!==0)return s;let o=Number(n?.updatedAt||0)-Number(e?.updatedAt||0);return o!==0?o:Je(e).localeCompare(Je(n))}),q0=(t=null)=>{let e=String(t?.replyChannel||"").trim(),n=String(t?.replyTo||"").trim();if(!e||!n)return null;let s=nf(Je(t));return{channel:e,to:n,...s?{agentId:s}:{}}};var X3=()=>{try{let t=localStorage.getItem(Ad);if(!t)return[];let e=JSON.parse(t);return Array.isArray(e)?e:[]}catch{return[]}},Q3=t=>{try{localStorage.setItem(Ad,JSON.stringify(t))}catch{}},J0=()=>{try{return localStorage.getItem(Td)||""}catch{return""}},eP=t=>{try{localStorage.setItem(Td,String(t||""))}catch{}},sf=(t,e)=>{if(e){let n=t.find(s=>Je(s)===e);if(n)return n}return t.find(n=>Je(n).toLowerCase()==="agent:main:main")||t.find(n=>uc(Je(n)))||t[0]||null},yr=({enabled:t=!1,filter:e}={})=>{let[n,s]=y([]),[o,r]=y(""),[i,a]=y(!1),[l,c]=y(""),d=q(f=>{let g=String(f||"");r(g),eP(g)},[]);L(()=>{if(!t)return;let f=!0,g=X3(),m=J0();if(g.length>0){s(g);let b=sf(g,m);r(Je(b))}return(async()=>{try{g.length===0&&a(!0),c("");let b=await Qh();if(!f)return;let x=Array.isArray(b?.sessions)?b.sessions:[];if(s(x),Q3(x),g.length===0||!m){let v=sf(x,m);r(Je(v))}}catch(b){if(!f)return;g.length===0&&(s([]),r(""),c(b.message||"Could not load agent sessions"))}finally{f&&a(!1)}})(),()=>{f=!1}},[t]);let u=F(()=>G0(e?n.filter(e):n),[n,e]);L(()=>{if(!t)return;if(u.length===0){o&&r("");return}if(u.some(m=>Je(m)===String(o||"")))return;let g=sf(u,J0());r(Je(g))},[t,u,o]);let p=F(()=>u.find(f=>Je(f)===o)||null,[u,o]);return{sessions:u,selectedSessionKey:o,setSelectedSessionKey:d,selectedSession:p,loading:i,error:l}};var Is="__none__",Ds=({enabled:t=!1,resetKey:e=""}={})=>{let[n,s]=y(""),[o,r]=y(!1),{sessions:i,selectedSessionKey:a,setSelectedSessionKey:l,loading:c,error:d}=yr({enabled:t,filter:pc});L(()=>{t&&(s(""),r(!1))},[t,e]);let u=F(()=>{let h=i.find(b=>Je(b)===String(a||"").trim());return String(Je(h)||Je(i[0])).trim()},[i,a]),p=o?n:u,f=F(()=>i.find(h=>Je(h)===String(p||"").trim())||null,[p,i]),g=F(()=>q0(f),[f]),m=q(h=>{let b=String(h||"");s(b),r(!0),l(b)},[l]);return{sessions:i,loading:c,error:d,destinationSessionKey:p,setDestinationSessionKey:m,selectedDestinationSession:f,selectedDestination:g}};var tP=372,nP=220,sP=480,of="cronListPanelWidthPx",Z0=25,oP=30,rP=30,Y0="24h",X0="7d",Q0="30d",ds={sessionTarget:"main",wakeMode:"now",deliveryMode:"none",deliveryChannel:"",deliveryTo:""},rf=(t=null)=>({sessionTarget:String(t?.sessionTarget||ds.sessionTarget),wakeMode:String(t?.wakeMode||ds.wakeMode),deliveryMode:String(t?.delivery?.mode||ds.deliveryMode),deliveryChannel:String(t?.delivery?.channel||""),deliveryTo:String(t?.delivery?.to||"")}),ew=t=>Math.max(nP,Math.min(sP,t)),iP=(t="")=>String(t||"").trim()||ws,tw=({jobId:t="",onSetLocation:e=()=>{}}={})=>{let n=iP(t),s=n===ws?"":n,o=ee(null),[r,i]=y(()=>{let H=Fe();return Number.isFinite(H?.[of])?ew(H[of]):tP}),[a,l]=y(!1),[c,d]=y("all"),[u,p]=y([]),[f,g]=y(!1),[m,h]=y(0),[b,x]=y(0),[v,w]=y(!1),[$,S]=y(""),[C,_]=y(""),[k,A]=y(!1),[O,D]=y(!1),[z,E]=y(!1),[M,R]=y(ds),[B,I]=y(30),[U,W]=y(X0),[G,se]=y(null),{sessions:K,loading:j,error:X,destinationSessionKey:de,setDestinationSessionKey:le,selectedDestination:ie}=Ds({enabled:!!s,resetKey:String(s||"")}),ne=Re(()=>$m({sortBy:"nextRunAtMs",sortDir:"asc"}),15e3),xe=Re(wm,3e4),he=Re(()=>s?ld(s,{limit:Z0,offset:0,status:c,sortDir:"desc"}):Promise.resolve({ok:!0,runs:{entries:[],hasMore:!1,nextOffset:0}}),1e4,{enabled:!!s}),ue=Re(()=>s?km(s,{days:B}):Promise.resolve({ok:!0,usage:null}),6e4,{enabled:!!s}),me=Re(()=>s?Sm(s,{range:U}):Promise.resolve({ok:!0,trends:null}),6e4,{enabled:!!s}),V=Re(()=>Cm({days:oP}),6e4,{enabled:!s}),J=Re(()=>_m({sinceMs:Date.now()-rP*24*60*60*1e3,limitPerJob:1200}),3e4,{enabled:!s});L(()=>{let H=Fe();H[of]=r,St(H)},[r]),L(()=>{he.data?.runs&&(p(Array.isArray(he.data.runs.entries)?he.data.runs.entries:[]),g(!!he.data.runs.hasMore),h(Number(he.data.runs.nextOffset||0)),x(Number(he.data.runs.total||0)))},[he.data]);let oe=F(()=>Array.isArray(ne.data?.jobs)?ne.data.jobs:[],[ne.data]),ce=F(()=>oe.find(H=>String(H?.id||"")===s)||null,[oe,s]);L(()=>{if(!s){S(""),_(""),R(ds);return}let H=String(ce?.payload?.message||"");S(H),_(H),R(rf(ce))},[s,ce?.payload?.message]),L(()=>{s&&R(rf(ce))},[s,ce?.sessionTarget,ce?.wakeMode,ce?.delivery?.mode]),L(()=>{p([]),g(!1),h(0),x(0),s&&he.refresh()},[s,c]),L(()=>{s&&ue.refresh()},[s,B]),L(()=>{s&&(se(null),me.refresh())},[U,s]);let be=F(()=>{let H=Array.isArray(u)?u:[],re=G;if(!re)return H;let ye=Number(re?.startMs||0),ve=Number(re?.endMs||0);return!Number.isFinite(ye)||!Number.isFinite(ve)||ve<=ye?H:H.filter(Ie=>{let gt=Number(Ie?.ts||0);return Number.isFinite(gt)&>>=ye&>{let re=o.current;if(!re)return;let ye=re.parentElement?.getBoundingClientRect();if(!ye)return;let ve=ew(Math.round(H-ye.left));i(ve)},[]),et=q(H=>{H.preventDefault(),l(!0),Ve(H.clientX)},[Ve]);L(()=>{if(!a)return()=>{};let H=Ie=>Ve(Ie.clientX),re=()=>l(!1);window.addEventListener("pointermove",H),window.addEventListener("pointerup",re);let ye=document.body.style.userSelect,ve=document.body.style.cursor;return document.body.style.userSelect="none",document.body.style.cursor="col-resize",()=>{window.removeEventListener("pointermove",H),window.removeEventListener("pointerup",re),document.body.style.userSelect=ye,document.body.style.cursor=ve}},[a,Ve]);let wt=q(()=>{e("/cron")},[e]),Nt=q(H=>{e(`/cron/${encodeURIComponent(String(H||""))}`)},[e]),Q=q(()=>{ne.refresh(),xe.refresh(),he.refresh(),ue.refresh(),me.refresh(),V.refresh(),J.refresh()},[J.refresh,V.refresh,ne.refresh,he.refresh,xe.refresh,me.refresh,ue.refresh]),pe=q(async()=>{if(!(!s||O)){D(!0);try{await Mm(s),N("Cron run triggered","success"),Q()}catch(H){N(H.message||"Could not run cron job","error")}finally{D(!1)}}},[Q,O,s]),te=q(async H=>{if(!(!s||z)){E(!0);try{await Am(s,H),N(H?"Cron job enabled":"Cron job disabled","success"),Q()}catch(re){N(re.message||"Could not update cron job","error")}finally{E(!1)}}},[Q,s,z]),Ee=q(async()=>{if(!(!s||!f||v)){w(!0);try{let H=await ld(s,{limit:Z0,offset:m,status:c,sortDir:"desc"}),re=Array.isArray(H?.runs?.entries)?H.runs.entries:[];p(ye=>[...ye,...re]),g(!!H?.runs?.hasMore),h(Number(H?.runs?.nextOffset||0)),x(Number(H?.runs?.total||0))}catch(H){N(H.message||"Could not load more runs","error")}finally{w(!1)}}},[v,f,m,c,s]),ke=q(async()=>{if(!s||!ce||k)return;let H=rf(ce),re={sessionTarget:String(M?.sessionTarget||ds.sessionTarget),wakeMode:String(M?.wakeMode||ds.wakeMode),deliveryMode:String(M?.deliveryMode||ds.deliveryMode),deliveryChannel:String(M?.deliveryChannel||""),deliveryTo:String(M?.deliveryTo||"")},ye=re.sessionTarget===H.sessionTarget&&re.wakeMode===H.wakeMode&&re.deliveryMode===H.deliveryMode&&re.deliveryChannel===H.deliveryChannel&&re.deliveryTo===H.deliveryTo,ve=$===C;if(!(ye&&ve)){A(!0);try{ye||await Pm(s,re),ve||(await Tm(s,$),_($)),N("Changes saved","success"),Q()}catch(Ie){N(Ie.message||"Could not save changes","error")}finally{A(!1)}}},[$,Q,M,C,k,ce,s]);return L(()=>{s&&String(M?.deliveryMode||"none")==="announce"&&(!ie?.channel&&!ie?.to||R((H=ds)=>{let re=String(ie?.channel||H.deliveryChannel||""),ye=String(ie?.to||H.deliveryTo||"");return re===String(H.deliveryChannel||"")&&ye===String(H.deliveryTo||"")?H:{...H,deliveryChannel:re,deliveryTo:ye}}))},[M?.deliveryMode,ie?.channel,ie?.to,s]),{refs:{listPanelRef:o},state:{jobs:oe,jobsError:ne.error,status:xe.data?.status||null,statusError:xe.error,selectedRouteKey:n,selectedJobId:s,selectedJob:ce,listPanelWidthPx:r,isResizingListPanel:a,runEntries:u,filteredRunEntries:be,runHasMore:f,runNextOffset:m,runTotal:b,runStatusFilter:c,runsError:he.error,loadingMoreRuns:v,usage:ue.data?.usage||null,jobTrends:me.data?.trends||null,usageError:ue.error,trendsError:me.error,usageDays:B,jobTrendRange:U===Q0?Q0:U===Y0?Y0:X0,selectedJobTrendBucketFilter:G,bulkUsageByJobId:V.data?.usage?.byJobId||{},bulkUsageError:V.error,bulkRunsByJobId:J.data?.runs?.byJobId||{},bulkRunsError:J.error,promptValue:$,savedPromptValue:C,savingChanges:k,runningJob:O,togglingJobEnabled:z,routingDraft:M,deliverySessions:K,loadingDeliverySessions:j,deliverySessionsError:X,destinationSessionKey:de},actions:{setRunStatusFilter:d,setUsageDays:I,setJobTrendRange:W,setSelectedJobTrendBucketFilter:se,setPromptValue:S,saveChanges:ke,refreshAll:Q,loadMoreRuns:Ee,runSelectedJobNow:pe,setSelectedJobEnabled:te,selectAllJobs:wt,selectJob:Nt,onListResizerPointerDown:et,setRoutingDraft:R,setDestinationSessionKey:le}}};var us=P.bind(T),nw=({jobId:t="",onSetLocation:e=()=>{}})=>{let{state:n,actions:s}=tw({jobId:t,onSetLocation:e}),[o,r]=y(!1),i=ee(null),a=n.selectedRouteKey===ws,l=n.jobs.length===0,c=n.selectedJob,d=F(()=>{if(a)return"All jobs";let g=n.jobs.find(m=>String(m?.id||"")===String(n.selectedRouteKey||""));return String(g?.name||g?.id||"All jobs")},[a,n.jobs,n.selectedRouteKey]),u=F(()=>{if(a||!c)return!1;let g=String(n.routingDraft?.sessionTarget||c?.sessionTarget||"main"),m=String(n.routingDraft?.wakeMode||c?.wakeMode||"now"),h=String(n.routingDraft?.deliveryMode||c?.delivery?.mode||"none"),b=String(c?.sessionTarget||"main"),x=String(c?.wakeMode||"now"),v=String(c?.delivery?.mode||"none"),w=g!==b||m!==x||h!==v,$=n.promptValue!==n.savedPromptValue;return w||$},[a,c,n.promptValue,n.routingDraft?.deliveryMode,n.routingDraft?.sessionTarget,n.routingDraft?.wakeMode,n.savedPromptValue]);L(()=>{if(!o)return()=>{};let g=h=>{i.current?.contains(h.target)||r(!1)},m=h=>{h.key==="Escape"&&r(!1)};return window.addEventListener("pointerdown",g),window.addEventListener("keydown",m),()=>{window.removeEventListener("pointerdown",g),window.removeEventListener("keydown",m)}},[o]);let p=()=>{s.selectAllJobs(),r(!1)},f=g=>{s.selectJob(g),r(!1)};return us` + `};var nf=(t="")=>String(t||"").trim(),Je=(t=null)=>nf(t?.key||t?.sessionKey||""),sf=(t="")=>{let n=nf(t).match(/^agent:([^:]+):/);return String(n?.[1]||"").trim()},pc=(t="")=>{let e=nf(t).toLowerCase();return e.includes(":direct:")||e.includes(":group:")},fc=t=>!!(String(t?.replyChannel||"").trim()&&String(t?.replyTo||"").trim())||pc(Je(t)),U0={destination:0,other:1},K0=(t=null)=>pc(Je(t))?U0.destination:U0.other,G0=(t=[])=>[...Array.isArray(t)?t:[]].sort((e,n)=>{let s=K0(e)-K0(n);if(s!==0)return s;let o=Number(n?.updatedAt||0)-Number(e?.updatedAt||0);return o!==0?o:Je(e).localeCompare(Je(n))}),q0=(t=null)=>{let e=String(t?.replyChannel||"").trim(),n=String(t?.replyTo||"").trim();if(!e||!n)return null;let s=sf(Je(t));return{channel:e,to:n,...s?{agentId:s}:{}}};var XP=()=>{try{let t=localStorage.getItem(Td);if(!t)return[];let e=JSON.parse(t);return Array.isArray(e)?e:[]}catch{return[]}},QP=t=>{try{localStorage.setItem(Td,JSON.stringify(t))}catch{}},J0=()=>{try{return localStorage.getItem(Pd)||""}catch{return""}},e3=t=>{try{localStorage.setItem(Pd,String(t||""))}catch{}},of=(t,e)=>{if(e){let n=t.find(s=>Je(s)===e);if(n)return n}return t.find(n=>Je(n).toLowerCase()==="agent:main:main")||t.find(n=>pc(Je(n)))||t[0]||null},yr=({enabled:t=!1,filter:e}={})=>{let[n,s]=y([]),[o,r]=y(""),[i,a]=y(!1),[l,c]=y(""),d=q(f=>{let g=String(f||"");r(g),e3(g)},[]);L(()=>{if(!t)return;let f=!0,g=XP(),m=J0();if(g.length>0){s(g);let b=of(g,m);r(Je(b))}return(async()=>{try{g.length===0&&a(!0),c("");let b=await em();if(!f)return;let x=Array.isArray(b?.sessions)?b.sessions:[];if(s(x),QP(x),g.length===0||!m){let v=of(x,m);r(Je(v))}}catch(b){if(!f)return;g.length===0&&(s([]),r(""),c(b.message||"Could not load agent sessions"))}finally{f&&a(!1)}})(),()=>{f=!1}},[t]);let u=F(()=>G0(e?n.filter(e):n),[n,e]);L(()=>{if(!t)return;if(u.length===0){o&&r("");return}if(u.some(m=>Je(m)===String(o||"")))return;let g=of(u,J0());r(Je(g))},[t,u,o]);let p=F(()=>u.find(f=>Je(f)===o)||null,[u,o]);return{sessions:u,selectedSessionKey:o,setSelectedSessionKey:d,selectedSession:p,loading:i,error:l}};var Is="__none__",Ds=({enabled:t=!1,resetKey:e=""}={})=>{let[n,s]=y(""),[o,r]=y(!1),{sessions:i,selectedSessionKey:a,setSelectedSessionKey:l,loading:c,error:d}=yr({enabled:t,filter:fc});L(()=>{t&&(s(""),r(!1))},[t,e]);let u=F(()=>{let h=i.find(b=>Je(b)===String(a||"").trim());return String(Je(h)||Je(i[0])).trim()},[i,a]),p=o?n:u,f=F(()=>i.find(h=>Je(h)===String(p||"").trim())||null,[p,i]),g=F(()=>q0(f),[f]),m=q(h=>{let b=String(h||"");s(b),r(!0),l(b)},[l]);return{sessions:i,loading:c,error:d,destinationSessionKey:p,setDestinationSessionKey:m,selectedDestinationSession:f,selectedDestination:g}};var t3=372,n3=220,s3=480,rf="cronListPanelWidthPx",Z0=25,o3=30,r3=30,Y0="24h",X0="7d",Q0="30d",ds={sessionTarget:"main",wakeMode:"now",deliveryMode:"none",deliveryChannel:"",deliveryTo:""},af=(t=null)=>({sessionTarget:String(t?.sessionTarget||ds.sessionTarget),wakeMode:String(t?.wakeMode||ds.wakeMode),deliveryMode:String(t?.delivery?.mode||ds.deliveryMode),deliveryChannel:String(t?.delivery?.channel||""),deliveryTo:String(t?.delivery?.to||"")}),ew=t=>Math.max(n3,Math.min(s3,t)),i3=(t="")=>String(t||"").trim()||ws,tw=({jobId:t="",onSetLocation:e=()=>{}}={})=>{let n=i3(t),s=n===ws?"":n,o=ee(null),[r,i]=y(()=>{let H=Fe();return Number.isFinite(H?.[rf])?ew(H[rf]):t3}),[a,l]=y(!1),[c,d]=y("all"),[u,p]=y([]),[f,g]=y(!1),[m,h]=y(0),[b,x]=y(0),[v,w]=y(!1),[$,S]=y(""),[C,_]=y(""),[k,A]=y(!1),[O,D]=y(!1),[j,E]=y(!1),[M,R]=y(ds),[B,I]=y(30),[U,W]=y(X0),[G,se]=y(null),{sessions:K,loading:z,error:X,destinationSessionKey:de,setDestinationSessionKey:le,selectedDestination:ie}=Ds({enabled:!!s,resetKey:String(s||"")}),ne=Re(()=>wm({sortBy:"nextRunAtMs",sortDir:"asc"}),15e3),xe=Re(km,3e4),he=Re(()=>s?cd(s,{limit:Z0,offset:0,status:c,sortDir:"desc"}):Promise.resolve({ok:!0,runs:{entries:[],hasMore:!1,nextOffset:0}}),1e4,{enabled:!!s}),ue=Re(()=>s?Sm(s,{days:B}):Promise.resolve({ok:!0,usage:null}),6e4,{enabled:!!s}),me=Re(()=>s?Cm(s,{range:U}):Promise.resolve({ok:!0,trends:null}),6e4,{enabled:!!s}),V=Re(()=>_m({days:o3}),6e4,{enabled:!s}),J=Re(()=>Mm({sinceMs:Date.now()-r3*24*60*60*1e3,limitPerJob:1200}),3e4,{enabled:!s});L(()=>{let H=Fe();H[rf]=r,St(H)},[r]),L(()=>{he.data?.runs&&(p(Array.isArray(he.data.runs.entries)?he.data.runs.entries:[]),g(!!he.data.runs.hasMore),h(Number(he.data.runs.nextOffset||0)),x(Number(he.data.runs.total||0)))},[he.data]);let oe=F(()=>Array.isArray(ne.data?.jobs)?ne.data.jobs:[],[ne.data]),ce=F(()=>oe.find(H=>String(H?.id||"")===s)||null,[oe,s]);L(()=>{if(!s){S(""),_(""),R(ds);return}let H=String(ce?.payload?.message||"");S(H),_(H),R(af(ce))},[s,ce?.payload?.message]),L(()=>{s&&R(af(ce))},[s,ce?.sessionTarget,ce?.wakeMode,ce?.delivery?.mode]),L(()=>{p([]),g(!1),h(0),x(0),s&&he.refresh()},[s,c]),L(()=>{s&&ue.refresh()},[s,B]),L(()=>{s&&(se(null),me.refresh())},[U,s]);let be=F(()=>{let H=Array.isArray(u)?u:[],re=G;if(!re)return H;let ye=Number(re?.startMs||0),ve=Number(re?.endMs||0);return!Number.isFinite(ye)||!Number.isFinite(ve)||ve<=ye?H:H.filter(Ie=>{let gt=Number(Ie?.ts||0);return Number.isFinite(gt)&>>=ye&>{let re=o.current;if(!re)return;let ye=re.parentElement?.getBoundingClientRect();if(!ye)return;let ve=ew(Math.round(H-ye.left));i(ve)},[]),et=q(H=>{H.preventDefault(),l(!0),Ve(H.clientX)},[Ve]);L(()=>{if(!a)return()=>{};let H=Ie=>Ve(Ie.clientX),re=()=>l(!1);window.addEventListener("pointermove",H),window.addEventListener("pointerup",re);let ye=document.body.style.userSelect,ve=document.body.style.cursor;return document.body.style.userSelect="none",document.body.style.cursor="col-resize",()=>{window.removeEventListener("pointermove",H),window.removeEventListener("pointerup",re),document.body.style.userSelect=ye,document.body.style.cursor=ve}},[a,Ve]);let wt=q(()=>{e("/cron")},[e]),Nt=q(H=>{e(`/cron/${encodeURIComponent(String(H||""))}`)},[e]),Q=q(()=>{ne.refresh(),xe.refresh(),he.refresh(),ue.refresh(),me.refresh(),V.refresh(),J.refresh()},[J.refresh,V.refresh,ne.refresh,he.refresh,xe.refresh,me.refresh,ue.refresh]),pe=q(async()=>{if(!(!s||O)){D(!0);try{await Am(s),N("Cron run triggered","success"),Q()}catch(H){N(H.message||"Could not run cron job","error")}finally{D(!1)}}},[Q,O,s]),te=q(async H=>{if(!(!s||j)){E(!0);try{await Tm(s,H),N(H?"Cron job enabled":"Cron job disabled","success"),Q()}catch(re){N(re.message||"Could not update cron job","error")}finally{E(!1)}}},[Q,s,j]),Ee=q(async()=>{if(!(!s||!f||v)){w(!0);try{let H=await cd(s,{limit:Z0,offset:m,status:c,sortDir:"desc"}),re=Array.isArray(H?.runs?.entries)?H.runs.entries:[];p(ye=>[...ye,...re]),g(!!H?.runs?.hasMore),h(Number(H?.runs?.nextOffset||0)),x(Number(H?.runs?.total||0))}catch(H){N(H.message||"Could not load more runs","error")}finally{w(!1)}}},[v,f,m,c,s]),ke=q(async()=>{if(!s||!ce||k)return;let H=af(ce),re={sessionTarget:String(M?.sessionTarget||ds.sessionTarget),wakeMode:String(M?.wakeMode||ds.wakeMode),deliveryMode:String(M?.deliveryMode||ds.deliveryMode),deliveryChannel:String(M?.deliveryChannel||""),deliveryTo:String(M?.deliveryTo||"")},ye=re.sessionTarget===H.sessionTarget&&re.wakeMode===H.wakeMode&&re.deliveryMode===H.deliveryMode&&re.deliveryChannel===H.deliveryChannel&&re.deliveryTo===H.deliveryTo,ve=$===C;if(!(ye&&ve)){A(!0);try{ye||await Rm(s,re),ve||(await Pm(s,$),_($)),N("Changes saved","success"),Q()}catch(Ie){N(Ie.message||"Could not save changes","error")}finally{A(!1)}}},[$,Q,M,C,k,ce,s]);return L(()=>{s&&String(M?.deliveryMode||"none")==="announce"&&(!ie?.channel&&!ie?.to||R((H=ds)=>{let re=String(ie?.channel||H.deliveryChannel||""),ye=String(ie?.to||H.deliveryTo||"");return re===String(H.deliveryChannel||"")&&ye===String(H.deliveryTo||"")?H:{...H,deliveryChannel:re,deliveryTo:ye}}))},[M?.deliveryMode,ie?.channel,ie?.to,s]),{refs:{listPanelRef:o},state:{jobs:oe,jobsError:ne.error,status:xe.data?.status||null,statusError:xe.error,selectedRouteKey:n,selectedJobId:s,selectedJob:ce,listPanelWidthPx:r,isResizingListPanel:a,runEntries:u,filteredRunEntries:be,runHasMore:f,runNextOffset:m,runTotal:b,runStatusFilter:c,runsError:he.error,loadingMoreRuns:v,usage:ue.data?.usage||null,jobTrends:me.data?.trends||null,usageError:ue.error,trendsError:me.error,usageDays:B,jobTrendRange:U===Q0?Q0:U===Y0?Y0:X0,selectedJobTrendBucketFilter:G,bulkUsageByJobId:V.data?.usage?.byJobId||{},bulkUsageError:V.error,bulkRunsByJobId:J.data?.runs?.byJobId||{},bulkRunsError:J.error,promptValue:$,savedPromptValue:C,savingChanges:k,runningJob:O,togglingJobEnabled:j,routingDraft:M,deliverySessions:K,loadingDeliverySessions:z,deliverySessionsError:X,destinationSessionKey:de},actions:{setRunStatusFilter:d,setUsageDays:I,setJobTrendRange:W,setSelectedJobTrendBucketFilter:se,setPromptValue:S,saveChanges:ke,refreshAll:Q,loadMoreRuns:Ee,runSelectedJobNow:pe,setSelectedJobEnabled:te,selectAllJobs:wt,selectJob:Nt,onListResizerPointerDown:et,setRoutingDraft:R,setDestinationSessionKey:le}}};var us=P.bind(T),nw=({jobId:t="",onSetLocation:e=()=>{}})=>{let{state:n,actions:s}=tw({jobId:t,onSetLocation:e}),[o,r]=y(!1),i=ee(null),a=n.selectedRouteKey===ws,l=n.jobs.length===0,c=n.selectedJob,d=F(()=>{if(a)return"All jobs";let g=n.jobs.find(m=>String(m?.id||"")===String(n.selectedRouteKey||""));return String(g?.name||g?.id||"All jobs")},[a,n.jobs,n.selectedRouteKey]),u=F(()=>{if(a||!c)return!1;let g=String(n.routingDraft?.sessionTarget||c?.sessionTarget||"main"),m=String(n.routingDraft?.wakeMode||c?.wakeMode||"now"),h=String(n.routingDraft?.deliveryMode||c?.delivery?.mode||"none"),b=String(c?.sessionTarget||"main"),x=String(c?.wakeMode||"now"),v=String(c?.delivery?.mode||"none"),w=g!==b||m!==x||h!==v,$=n.promptValue!==n.savedPromptValue;return w||$},[a,c,n.promptValue,n.routingDraft?.deliveryMode,n.routingDraft?.sessionTarget,n.routingDraft?.wakeMode,n.savedPromptValue]);L(()=>{if(!o)return()=>{};let g=h=>{i.current?.contains(h.target)||r(!1)},m=h=>{h.key==="Escape"&&r(!1)};return window.addEventListener("pointerdown",g),window.addEventListener("keydown",m),()=>{window.removeEventListener("pointerdown",g),window.removeEventListener("keydown",m)}},[o]);let p=()=>{s.selectAllJobs(),r(!1)},f=g=>{s.selectJob(g),r(!1)};return us`
      @@ -5344,7 +5344,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      `:a?us` - <${z0} + <${j0} jobs=${n.jobs} status=${n.status} bulkUsageByJobId=${n.bulkUsageByJobId} @@ -5392,24 +5392,24 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      - `};var aP=P.bind(T),af=({jobId:t="",onSetLocation:e=()=>{}})=>aP` + `};var a3=P.bind(T),lf=({jobId:t="",onSetLocation:e=()=>{}})=>a3` <${nw} jobId=${t} onSetLocation=${e} /> -`;var rw=(t="")=>{let e=String(t||"").trim().toUpperCase();return e==="P0"?"danger":e==="P1"?"warning":"neutral"};var iw=(t="")=>{let e=String(t||"").trim().toLowerCase().replace(/[_-]+/g," ");return e==="token efficiency"?"info":e==="redundancy"?"accent":e==="mixed concerns"?"cyan":e==="workspace state"?"secondary":"info"},aw=(t="")=>{let e=String(t||"").trim().replace(/[_-]+/g," ");return e?e.replace(/\b\w/g,n=>n.toUpperCase()):"Workspace"},lw=(t=[])=>t.reduce((e,n)=>{let s=String(n?.priority||"").trim().toUpperCase();return(s==="P0"||s==="P1"||s==="P2")&&(e[s]+=1),e},{P0:0,P1:0,P2:0});var fc=(t=null,e=0)=>!t||t.runInProgress||t.needsInitialRun||!t.stale||!t.changeSummary?.hasMeaningfulChanges?!1:Number(e||0)<=Date.now(),cw=(t=null)=>{if(!t)return"";let e=Number(t.changeSummary?.changedFilesCount||0);return e>0?`Drift Doctor has not been run in the last week and ${e} file${e===1?"":"s"} changed since the last review.`:"Doctor has not been run in the last week."},sw=(t=0)=>`${Number(t||0).toLocaleString()} chars`,ow=(t="")=>String(t||"").startsWith("hooks/bootstrap/"),hc=(t=null)=>{let e=t?.bootstrapContext,n=(e?.activeTruncatedFiles||[]).filter(o=>!ow(o?.path)),s=(e?.activeNearLimitFiles||[]).filter(o=>!ow(o?.path));return[...n.map(o=>({path:o.path,size:sw(o.rawChars),statusText:`-${Number(Math.max(0,Number(o.rawChars||0)-Number(o.injectedChars||0))).toLocaleString()} cut`,statusTone:"danger"})),...s.map(o=>({path:o.path,size:sw(o.rawChars),statusText:"Near limit",statusTone:"warning"}))]},dw=(t=null)=>hc(t).length>0,uw=(t=null)=>{let e=hc(t);if(!e.length)return"";let n=e.some(o=>o.statusTone==="danger"),s=e.some(o=>o.statusTone==="warning");return n&&s?"Some of your main files are being truncated or nearing the limit:":s?e.length===1?"One of your main files is nearing the limit:":"Some of your main files are nearing the limit:":e.length===1?"One of your main files is being truncated:":"Some of your main files are being truncated:"},pw=(t=null)=>{let e=Number(t?.changedFilesCount||0);return e===0?"No changes since last run":`${e} change${e===1?"":"s"} since last run`},fw=(t=null)=>!t||typeof t!="object"?"":t.status==="running"?"Running":t.status==="failed"?"Failed":(t.cardCount||0)===0?"No findings":`${t.cardCount||0} finding${t.cardCount===1?"":"s"}`,hw=(t=null)=>{if(!t||typeof t!="object")return[];if(t.status==="running")return[{tone:"cyan",count:0,label:"Running"}];if(t.status==="failed")return[{tone:"neutral",count:0,label:"Failed"}];if((t.cardCount||0)===0)return[{tone:"success",count:0,label:"No findings"}];let e=[];return Number(t?.priorityCounts?.P0||0)>0&&e.push({tone:"danger",count:0,label:"P0"}),Number(t?.priorityCounts?.P1||0)>0&&e.push({tone:"warning",count:0,label:"P1"}),e.length>0?e.slice(0,2):[{tone:"neutral",count:0,label:"P2"}]},mw=()=>[{value:"open",label:"Open"},{value:"dismissed",label:"Dismissed"},{value:"fixed",label:"Fixed"}];var lP=P.bind(T),gw=({cards:t=[]})=>{let e=lw(t);return lP` +`;var rw=(t="")=>{let e=String(t||"").trim().toUpperCase();return e==="P0"?"danger":e==="P1"?"warning":"neutral"};var iw=(t="")=>{let e=String(t||"").trim().toLowerCase().replace(/[_-]+/g," ");return e==="token efficiency"?"info":e==="redundancy"?"accent":e==="mixed concerns"?"cyan":e==="workspace state"?"secondary":"info"},aw=(t="")=>{let e=String(t||"").trim().replace(/[_-]+/g," ");return e?e.replace(/\b\w/g,n=>n.toUpperCase()):"Workspace"},lw=(t=[])=>t.reduce((e,n)=>{let s=String(n?.priority||"").trim().toUpperCase();return(s==="P0"||s==="P1"||s==="P2")&&(e[s]+=1),e},{P0:0,P1:0,P2:0});var hc=(t=null,e=0)=>!t||t.runInProgress||t.needsInitialRun||!t.stale||!t.changeSummary?.hasMeaningfulChanges?!1:Number(e||0)<=Date.now(),cw=(t=null)=>{if(!t)return"";let e=Number(t.changeSummary?.changedFilesCount||0);return e>0?`Drift Doctor has not been run in the last week and ${e} file${e===1?"":"s"} changed since the last review.`:"Doctor has not been run in the last week."},sw=(t=0)=>`${Number(t||0).toLocaleString()} chars`,ow=(t="")=>String(t||"").startsWith("hooks/bootstrap/"),mc=(t=null)=>{let e=t?.bootstrapContext,n=(e?.activeTruncatedFiles||[]).filter(o=>!ow(o?.path)),s=(e?.activeNearLimitFiles||[]).filter(o=>!ow(o?.path));return[...n.map(o=>({path:o.path,size:sw(o.rawChars),statusText:`-${Number(Math.max(0,Number(o.rawChars||0)-Number(o.injectedChars||0))).toLocaleString()} cut`,statusTone:"danger"})),...s.map(o=>({path:o.path,size:sw(o.rawChars),statusText:"Near limit",statusTone:"warning"}))]},dw=(t=null)=>mc(t).length>0,uw=(t=null)=>{let e=mc(t);if(!e.length)return"";let n=e.some(o=>o.statusTone==="danger"),s=e.some(o=>o.statusTone==="warning");return n&&s?"Some of your main files are being truncated or nearing the limit:":s?e.length===1?"One of your main files is nearing the limit:":"Some of your main files are nearing the limit:":e.length===1?"One of your main files is being truncated:":"Some of your main files are being truncated:"},pw=(t=null)=>{let e=Number(t?.changedFilesCount||0);return e===0?"No changes since last run":`${e} change${e===1?"":"s"} since last run`},fw=(t=null)=>!t||typeof t!="object"?"":t.status==="running"?"Running":t.status==="failed"?"Failed":(t.cardCount||0)===0?"No findings":`${t.cardCount||0} finding${t.cardCount===1?"":"s"}`,hw=(t=null)=>{if(!t||typeof t!="object")return[];if(t.status==="running")return[{tone:"cyan",count:0,label:"Running"}];if(t.status==="failed")return[{tone:"neutral",count:0,label:"Failed"}];if((t.cardCount||0)===0)return[{tone:"success",count:0,label:"No findings"}];let e=[];return Number(t?.priorityCounts?.P0||0)>0&&e.push({tone:"danger",count:0,label:"P0"}),Number(t?.priorityCounts?.P1||0)>0&&e.push({tone:"warning",count:0,label:"P1"}),e.length>0?e.slice(0,2):[{tone:"neutral",count:0,label:"P2"}]},mw=()=>[{value:"open",label:"Open"},{value:"dismissed",label:"Dismissed"},{value:"fixed",label:"Fixed"}];var l3=P.bind(T),gw=({cards:t=[]})=>{let e=lw(t);return l3`
      <${cs} title="Open Findings" value=${t.length} /> <${cs} title="P0" value=${e.P0} toneClassName="text-status-error-muted" /> <${cs} title="P1" value=${e.P1} toneClassName="text-status-warning-muted" /> <${cs} title="P2" value=${e.P2} toneClassName="text-body" />
      - `};var lt=P.bind(T),cP=t=>t?typeof t=="string"?{path:t}:typeof t=="object"&&t.path?t:null:null,lf=(t,e,n)=>e&&n&&n>e?`${t}:${e}-${n}`:e?`${t}:${e}`:t,cf=(t,e)=>{let n={};return t&&(n.line=t),e&&e>t&&(n.lineEnd=e),n},dP=(t,e,{startLine:n,endLine:s}={})=>{let o=lf(t,n,s);return lt` + `};var lt=P.bind(T),c3=t=>t?typeof t=="string"?{path:t}:typeof t=="object"&&t.path?t:null:null,cf=(t,e,n)=>e&&n&&n>e?`${t}:${e}-${n}`:e?`${t}:${e}`:t,df=(t,e)=>{let n={};return t&&(n.line=t),e&&e>t&&(n.lineEnd=e),n},d3=(t,e,{startLine:n,endLine:s}={})=>{let o=cf(t,n,s);return lt` - `},bw=7,uP=({item:t,onOpenFile:e,isOutdated:n})=>{let s=t.snippet,o=String(s.text||"").split(` + `},bw=7,u3=({item:t,onOpenFile:e,isOutdated:n})=>{let s=t.snippet,o=String(s.text||"").split(` `),r=o.length>bw,[i,a]=y(!r),l=i?o:o.slice(0,bw),c=String(s.endLine||s.startLine||1).length;return lt`
      {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${n?lt`file changed since scan{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `:null}
      - `},pP=(t={},e,n)=>{if(t?.path&&t?.snippet){let s=n.has(t.path);return lt`<${uP} + `},p3=(t={},e,n)=>{if(t?.path&&t?.snippet){let s=n.has(t.path);return lt`<${u3} item=${t} onOpenFile=${e} isOutdated=${s} - />`}return t?.path?dP(t.path,e,{startLine:t.startLine,endLine:t.endLine}):t?.text?t.text:JSON.stringify(t)},xw=({cards:t=[],busyCardId:e=0,onAskAgentFix:n=()=>{},onUpdateStatus:s=()=>{},onOpenFile:o=()=>{},changedPaths:r=[],showRunMeta:i=!1,hideEmptyState:a=!1})=>{let l=new Set(r);return lt` + />`}return t?.path?d3(t.path,e,{startLine:t.startLine,endLine:t.endLine}):t?.text?t.text:JSON.stringify(t)},xw=({cards:t=[],busyCardId:e=0,onAskAgentFix:n=()=>{},onUpdateStatus:s=()=>{},onOpenFile:o=()=>{},changedPaths:r=[],showRunMeta:i=!1,hideEmptyState:a=!1})=>{let l=new Set(r);return lt`
      ${t.length?lt`
      @@ -5543,11 +5543,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s Target paths
      - ${c.targetPaths.map(d=>{let u=cP(d);if(!u)return null;let p=lf(u.path,u.startLine,u.endLine);return lt` + ${c.targetPaths.map(d=>{let u=c3(d);if(!u)return null;let p=cf(u.path,u.startLine,u.endLine);return lt` @@ -5561,7 +5561,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      ${c.evidence.map(d=>lt`
      - ${pP(d,o,l)} + ${p3(d,o,l)}
      `)}
      @@ -5615,30 +5615,30 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      `}
      - `};var jn=P.bind(T),vr=({label:t="Send to session",sessions:e=[],selectedSessionKey:n="",onChangeSessionKey:s=()=>{},disabled:o=!1,loading:r=!1,error:i="",allowNone:a=!1,noneValue:l="__none__",noneLabel:c="None",emptyOptionLabel:d="No sessions available",helperText:u="",emptyStateText:p="",loadingLabel:f="Loading sessions...",containerClassName:g="space-y-2",labelClassName:m="text-xs text-fg-muted",selectClassName:h="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body focus:border-fg-muted",helperClassName:b="text-xs text-fg-muted",statusClassName:x="text-xs text-fg-muted",errorClassName:v="text-xs text-status-error-muted"})=>{let w=n||(a?l:""),$=o||r;return jn` + `};var zn=P.bind(T),vr=({label:t="Send to session",sessions:e=[],selectedSessionKey:n="",onChangeSessionKey:s=()=>{},disabled:o=!1,loading:r=!1,error:i="",allowNone:a=!1,noneValue:l="__none__",noneLabel:c="None",emptyOptionLabel:d="No sessions available",helperText:u="",emptyStateText:p="",loadingLabel:f="Loading sessions...",containerClassName:g="space-y-2",labelClassName:m="text-xs text-fg-muted",selectClassName:h="w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body focus:border-fg-muted",helperClassName:b="text-xs text-fg-muted",statusClassName:x="text-xs text-fg-muted",errorClassName:v="text-xs text-status-error-muted"})=>{let w=n||(a?l:""),$=o||r;return zn`
      - ${t?jn``:null} + ${t?zn``:null} - ${u?jn`
      ${u}
      `:null} - ${i?jn`
      ${i}
      `:null} - ${!r&&!i&&p&&e.length===0?jn`
      ${p}
      `:null} + ${u?zn`
      ${u}
      `:null} + ${i?zn`
      ${i}
      `:null} + ${!r&&!i&&p&&e.length===0?zn`
      ${p}
      `:null}
      - `};var yw=P.bind(T),mc=({visible:t=!1,title:e="Send to agent",messageLabel:n="Message",messageRows:s=8,initialMessage:o="",resetKey:r="",submitLabel:i="Send message",loadingLabel:a="Sending...",cancelLabel:l="Cancel",onClose:c=()=>{},onSubmit:d=async()=>!0,sessionFilter:u=void 0})=>{let{sessions:p,selectedSessionKey:f,setSelectedSessionKey:g,selectedSession:m,loading:h,error:b}=yr({enabled:t,filter:u}),[x,v]=y(""),[w,$]=y(!1);L(()=>{t&&v(String(o||""))},[t,o,r]);let S=async()=>{if(!m||w)return;let C=String(x||"").trim();if(C){$(!0);try{await d({selectedSession:m,selectedSessionKey:f,message:C})!==!1&&c()}finally{$(!1)}}};return yw` + `};var yw=P.bind(T),gc=({visible:t=!1,title:e="Send to agent",messageLabel:n="Message",messageRows:s=8,initialMessage:o="",resetKey:r="",submitLabel:i="Send message",loadingLabel:a="Sending...",cancelLabel:l="Cancel",onClose:c=()=>{},onSubmit:d=async()=>!0,sessionFilter:u=void 0})=>{let{sessions:p,selectedSessionKey:f,setSelectedSessionKey:g,selectedSession:m,loading:h,error:b}=yr({enabled:t,filter:u}),[x,v]=y(""),[w,$]=y(!1);L(()=>{t&&v(String(o||""))},[t,o,r]);let S=async()=>{if(!m||w)return;let C=String(x||"").trim();if(C){$(!0);try{await d({selectedSession:m,selectedSessionKey:f,message:C})!==!1&&c()}finally{$(!1)}}};return yw` <${De} visible=${t} onClose=${()=>{w||c()}} @@ -5696,8 +5696,8 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
      - `};var fP=P.bind(T),vw=({visible:t=!1,card:e=null,onClose:n=()=>{},onComplete:s=()=>{}})=>{let o=async({selectedSession:r,message:i})=>{if(!e?.id)return!1;try{await sm({cardId:e.id,sessionId:r?.sessionId||"",replyChannel:r?.replyChannel||"",replyTo:r?.replyTo||"",prompt:i});try{await pa({cardId:e.id,status:"fixed"}),N("Doctor fix request sent and finding marked fixed","success")}catch(a){N(a.message||"Doctor fix request sent, but could not mark the finding fixed","warning")}return await s(),!0}catch(a){return N(a.message||"Could not send Doctor fix request","error"),!1}};return fP` - <${mc} + `};var f3=P.bind(T),vw=({visible:t=!1,card:e=null,onClose:n=()=>{},onComplete:s=()=>{}})=>{let o=async({selectedSession:r,message:i})=>{if(!e?.id)return!1;try{await om({cardId:e.id,sessionId:r?.sessionId||"",replyChannel:r?.replyChannel||"",replyTo:r?.replyTo||"",prompt:i});try{await pa({cardId:e.id,status:"fixed"}),N("Doctor fix request sent and finding marked fixed","success")}catch(a){N(a.message||"Doctor fix request sent, but could not mark the finding fixed","warning")}return await s(),!0}catch(a){return N(a.message||"Could not send Doctor fix request","error"),!1}};return f3` + <${gc} visible=${t} title="Ask agent to fix" messageLabel="Instructions" @@ -5708,7 +5708,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onClose=${n} onSubmit=${o} /> - `};var $t=P.bind(T),df=15e3,$w=2e3,hP=()=>$t` + `};var $t=P.bind(T),uf=15e3,$w=2e3,h3=()=>$t` {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s d="M8 20V14H16V20H19V4H5V20H8ZM10 20H14V16H10V20ZM21 20H23V22H1V20H3V3C3 2.44772 3.44772 2 4 2H20C20.5523 2 21 2.44772 21 3V20ZM11 8V6H13V8H15V10H13V12H11V10H9V8H11Z" > -`,ww=({isActive:t=!1,onOpenFile:e=()=>{}})=>{let n=Re(ua,df,{enabled:t}),s=n.data?.status||null,o=s?.runInProgress?$w:df,r=Re(()=>tm(10),o,{enabled:t}),[i,a]=y("all"),[l,c]=y("open"),[d,u]=y(0),[p,f]=y(null),[g,m]=y(""),h=r.data?.runs||[],b=String(s?.activeRunId||""),x=String(i||""),w=x!==""&&x!=="all"&&!h.some(J=>String(J.id||"")===x)&&(g===x||s?.runInProgress&&b===x)?{id:Number(x||0),status:"running",summary:"",priorityCounts:{P0:0,P1:0,P2:0},statusCounts:{open:0,dismissed:0,fixed:0}}:null,$=w?[w,...h]:h,S=i!=="all"&&!!b&&String(i||"")===b,C=i==="all"?null:$.find(J=>String(J.id||"")===String(i||""))||null,_=Re(()=>nm({runId:i||"all"}),s?.runInProgress||C?.status==="running"?$w:df,{enabled:t}),k=_.data?.cards||[];L(()=>{t&&(n.refresh(),r.refresh())},[t]),L(()=>{if(!h.length){if(g&&x===g||S&&s?.runInProgress)return;i!=="all"&&a("all");return}i==="all"||h.some(oe=>String(oe.id||"")===String(i||""))||S&&s?.runInProgress||a("all")},[h,x,i,S,g,s?.runInProgress]),L(()=>{if(!g)return;if(i!==g){a(g);return}let J=h.some(ce=>String(ce.id||"")===String(g||"")),oe=!!b&&b===g&&!!s?.runInProgress;!J&&!oe||m("")},[b,s?.runInProgress,g,h,i]),L(()=>{_.refresh()},[i]);let A=C?.status==="running"||S&&s?.runInProgress,O=F(()=>A?"":C?.summary||"",[C,A]),D=F(()=>mw(),[]),z=F(()=>pw(s?.changeSummary||null),[s]),E=F(()=>s?.runInProgress||s?.needsInitialRun?!0:Number(s?.changeSummary?.changedFilesCount||0)>0,[s]),M=E?"":"No workspace changes since the last completed Drift Doctor run.",R=F(()=>fc(s,0),[s]),B=F(()=>dw(s),[s]),I=F(()=>uw(s),[s]),U=F(()=>hc(s),[s]),W=!!s?.lastRunAt,G=h.length>0,se=r.data!==null||r.error!==null,K=_.data!==null||_.error!==null,j=!se||G&&!K,X=F(()=>l==="all"?k:k.filter(J=>String(J?.status||"open").trim().toLowerCase()===l),[k,l]),de=F(()=>k.filter(J=>String(J?.status||"open").trim().toLowerCase()==="open"),[k]),le=F(()=>$.slice(0,2),[$]),ie=F(()=>$.slice(2),[$]),ne=F(()=>i==="all"?"":ie.some(J=>String(J.id||"")===String(i||""))?String(i||""):"",[ie,i]),xe=(J=!1)=>["inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",J?"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]":"border-border bg-field text-body hover:border-fg-muted hover:text-bright"].join(" "),he=(J="neutral")=>J==="success"?"bg-green-400":J==="warning"?"bg-yellow-400":J==="danger"?"bg-red-400":J==="cyan"?"ac-status-dot ac-status-dot--info":"bg-gray-500",ue=!j&&(h.length>0||!!g||!!b||!!s?.runInProgress),me=async()=>{try{let J=await em();if(N(J?.reusedPreviousRun?"No workspace changes since the last scan; reused previous findings":"Doctor run started","success"),J?.runId){let oe=String(J.runId);m(oe),a(oe)}n.refresh(),r.refresh(),_.refresh(),setTimeout(n.refresh,1200),setTimeout(r.refresh,1200),setTimeout(_.refresh,1200)}catch(J){N(J.message||"Could not start Doctor run","error")}},V=async(J,oe)=>{if(!(!J?.id||d))try{u(J.id),await pa({cardId:J.id,status:oe}),N("Doctor card updated","success"),await _.refresh(),await r.refresh(),await n.refresh()}catch(ce){N(ce.message||"Could not update Doctor card","error")}finally{u(0)}};return $t` +`,ww=({isActive:t=!1,onOpenFile:e=()=>{}})=>{let n=Re(ua,uf,{enabled:t}),s=n.data?.status||null,o=s?.runInProgress?$w:uf,r=Re(()=>nm(10),o,{enabled:t}),[i,a]=y("all"),[l,c]=y("open"),[d,u]=y(0),[p,f]=y(null),[g,m]=y(""),h=r.data?.runs||[],b=String(s?.activeRunId||""),x=String(i||""),w=x!==""&&x!=="all"&&!h.some(J=>String(J.id||"")===x)&&(g===x||s?.runInProgress&&b===x)?{id:Number(x||0),status:"running",summary:"",priorityCounts:{P0:0,P1:0,P2:0},statusCounts:{open:0,dismissed:0,fixed:0}}:null,$=w?[w,...h]:h,S=i!=="all"&&!!b&&String(i||"")===b,C=i==="all"?null:$.find(J=>String(J.id||"")===String(i||""))||null,_=Re(()=>sm({runId:i||"all"}),s?.runInProgress||C?.status==="running"?$w:uf,{enabled:t}),k=_.data?.cards||[];L(()=>{t&&(n.refresh(),r.refresh())},[t]),L(()=>{if(!h.length){if(g&&x===g||S&&s?.runInProgress)return;i!=="all"&&a("all");return}i==="all"||h.some(oe=>String(oe.id||"")===String(i||""))||S&&s?.runInProgress||a("all")},[h,x,i,S,g,s?.runInProgress]),L(()=>{if(!g)return;if(i!==g){a(g);return}let J=h.some(ce=>String(ce.id||"")===String(g||"")),oe=!!b&&b===g&&!!s?.runInProgress;!J&&!oe||m("")},[b,s?.runInProgress,g,h,i]),L(()=>{_.refresh()},[i]);let A=C?.status==="running"||S&&s?.runInProgress,O=F(()=>A?"":C?.summary||"",[C,A]),D=F(()=>mw(),[]),j=F(()=>pw(s?.changeSummary||null),[s]),E=F(()=>s?.runInProgress||s?.needsInitialRun?!0:Number(s?.changeSummary?.changedFilesCount||0)>0,[s]),M=E?"":"No workspace changes since the last completed Drift Doctor run.",R=F(()=>hc(s,0),[s]),B=F(()=>dw(s),[s]),I=F(()=>uw(s),[s]),U=F(()=>mc(s),[s]),W=!!s?.lastRunAt,G=h.length>0,se=r.data!==null||r.error!==null,K=_.data!==null||_.error!==null,z=!se||G&&!K,X=F(()=>l==="all"?k:k.filter(J=>String(J?.status||"open").trim().toLowerCase()===l),[k,l]),de=F(()=>k.filter(J=>String(J?.status||"open").trim().toLowerCase()==="open"),[k]),le=F(()=>$.slice(0,2),[$]),ie=F(()=>$.slice(2),[$]),ne=F(()=>i==="all"?"":ie.some(J=>String(J.id||"")===String(i||""))?String(i||""):"",[ie,i]),xe=(J=!1)=>["inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",J?"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]":"border-border bg-field text-body hover:border-fg-muted hover:text-bright"].join(" "),he=(J="neutral")=>J==="success"?"bg-green-400":J==="warning"?"bg-yellow-400":J==="danger"?"bg-red-400":J==="cyan"?"ac-status-dot ac-status-dot--info":"bg-gray-500",ue=!z&&(h.length>0||!!g||!!b||!!s?.runInProgress),me=async()=>{try{let J=await tm();if(N(J?.reusedPreviousRun?"No workspace changes since the last scan; reused previous findings":"Doctor run started","success"),J?.runId){let oe=String(J.runId);m(oe),a(oe)}n.refresh(),r.refresh(),_.refresh(),setTimeout(n.refresh,1200),setTimeout(r.refresh,1200),setTimeout(_.refresh,1200)}catch(J){N(J.message||"Could not start Doctor run","error")}},V=async(J,oe)=>{if(!(!J?.id||d))try{u(J.id),await pa({cardId:J.id,status:oe}),N("Doctor card updated","success"),await _.refresh(),await r.refresh(),await n.refresh()}catch(ce){N(ce.message||"Could not update Doctor card","error")}finally{u(0)}};return $t`
      ${ue?$t` <${Ne} @@ -5736,7 +5736,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `} /> `:null} - ${j?$t` + ${z?$t`
      <${Ce} className="h-4 w-4" /> @@ -5744,7 +5744,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      `:null} - ${!j&&G?$t` + ${!z&&G?$t`
      <${gw} cards=${de} />
      @@ -5759,7 +5759,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s - ${z} + ${j}
      ${B?$t` @@ -5918,12 +5918,12 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      `:null} - ${!j&&!ue?$t` + ${!z&&!ue?$t`
      - <${hP} /> + <${h3} />

      Workspace health review @@ -5959,14 +5959,14 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onComplete=${async()=>{await n.refresh(),await r.refresh(),await _.refresh()}} />

      - `};var mP=P.bind(T),uf=({onNavigateToBrowseFile:t=()=>{}})=>mP` + `};var m3=P.bind(T),pf=({onNavigateToBrowseFile:t=()=>{}})=>m3`
      <${ww} isActive=${!0} onOpenFile=${(e,n={})=>{let s=`workspace/${String(e||"").trim().replace(/^workspace\//,"")}`;t(s,{view:"edit",...n.line?{line:n.line}:{},...n.lineEnd?{lineEnd:n.lineEnd}:{}})}} />
      -`;var gP=P.bind(T),zn=({header:t,children:e})=>gP` +`;var g3=P.bind(T),jn=({header:t,children:e})=>g3`
      @@ -5979,7 +5979,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      -`;var Ue=P.bind(T),kw={ai:"AI Provider Keys",github:"GitHub",channels:"Channels",tools:"Tools",custom:"Custom"},bP=["ai","github","channels","tools","custom"],xP=new Set(["OPENAI_API_KEY","GEMINI_API_KEY"]),_w={Embeddings:{Icon:_a,label:"Memory embeddings"},Image:{Icon:sb,label:"Image generation"},TTS:{Icon:ob,label:"Text to speech"},STT:{Icon:rb,label:"Speech to text"}},Sw=t=>t.trim().toUpperCase().replace(/[^A-Z0-9_]/g,"_"),yP=/^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/,vP=t=>{let e=String(t||"").trim();if(e.length<2)return e;let n=e.startsWith('"'),s=e.endsWith('"');if(n&&s)return e.slice(1,-1);let o=e.startsWith("'"),r=e.endsWith("'");return o&&r?e.slice(1,-1):e},gc=(t="")=>yP.test(String(t||"").trim().toUpperCase()),Cw=t=>JSON.stringify((t||[]).map(e=>({key:String(e?.key||""),value:String(e?.value||"")})).sort((e,n)=>e.key.localeCompare(n.key))),$P=t=>{let e=Array.isArray(t)?[...t]:[],n=e.filter(o=>(o?.group||"custom")==="custom").sort((o,r)=>String(o?.key||"").localeCompare(String(r?.key||""))),s=0;return e.map(o=>{if((o?.group||"custom")!=="custom")return o;let r=n[s];return s+=1,r})},wP={ANTHROPIC_API_KEY:Ue`from${" "} +`;var Ue=P.bind(T),kw={ai:"AI Provider Keys",github:"GitHub",channels:"Channels",tools:"Tools",custom:"Custom"},b3=["ai","github","channels","tools","custom"],x3=new Set(["OPENAI_API_KEY","GEMINI_API_KEY"]),_w={Embeddings:{Icon:_a,label:"Memory embeddings"},Image:{Icon:ob,label:"Image generation"},TTS:{Icon:rb,label:"Text to speech"},STT:{Icon:ib,label:"Speech to text"}},Sw=t=>t.trim().toUpperCase().replace(/[^A-Z0-9_]/g,"_"),y3=/^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/,v3=t=>{let e=String(t||"").trim();if(e.length<2)return e;let n=e.startsWith('"'),s=e.endsWith('"');if(n&&s)return e.slice(1,-1);let o=e.startsWith("'"),r=e.endsWith("'");return o&&r?e.slice(1,-1):e},bc=(t="")=>y3.test(String(t||"").trim().toUpperCase()),Cw=t=>JSON.stringify((t||[]).map(e=>({key:String(e?.key||""),value:String(e?.value||"")})).sort((e,n)=>e.key.localeCompare(n.key))),$3=t=>{let e=Array.isArray(t)?[...t]:[],n=e.filter(o=>(o?.group||"custom")==="custom").sort((o,r)=>String(o?.key||"").localeCompare(String(r?.key||""))),s=0;return e.map(o=>{if((o?.group||"custom")!=="custom")return o;let r=n[s];return s+=1,r})},w3={ANTHROPIC_API_KEY:Ue`from${" "} {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s class="hover:underline" style="color: var(--accent-link)" >brave.com/search/api${" "} — free tier available`},kP=t=>wP[t.key]||t.hint||"",SP=t=>(Array.isArray(t?.features)?t.features:[]).filter(e=>!!_w[e]),CP=t=>{let e=[],n=[];return(t||[]).forEach(s=>{let o=!!String(s?.value||"").trim();if(xP.has(s?.key)||o){e.push(s);return}n.push(s)}),{visible:e,hidden:n}},_P=({feature:t})=>{let e=_w[t];if(!e)return null;let{Icon:n,label:s}=e;return Ue` + >${" "} — free tier available`},k3=t=>w3[t.key]||t.hint||"",S3=t=>(Array.isArray(t?.features)?t.features:[]).filter(e=>!!_w[e]),C3=t=>{let e=[],n=[];return(t||[]).forEach(s=>{let o=!!String(s?.value||"").trim();if(x3.has(s?.key)||o){e.push(s);return}n.push(s)}),{visible:e,hidden:n}},_3=({feature:t})=>{let e=_w[t];if(!e)return null;let{Icon:n,label:s}=e;return Ue` <${Bt} text=${s} widthClass="w-auto" tooltipClassName="whitespace-nowrap"> {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s <${n} className="w-3.5 h-3.5" /> - `},MP=({envVar:t,onChange:e,onDelete:n,disabled:s})=>{let o=kP(t),r=SP(t);return Ue` + `},M3=({envVar:t,onChange:e,onDelete:n,disabled:s})=>{let o=k3(t),r=S3(t);return Ue`
      @@ -6100,7 +6100,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      ${r.length>0?Ue`
      - ${r.map(i=>Ue`<${_P} key=${i} feature=${i} />`)} + ${r.map(i=>Ue`<${_3} key=${i} feature=${i} />`)}
      `:null}
      @@ -6125,17 +6125,17 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${o?Ue`

      ${o}

      `:null}
      - `},Mw=({onRestartRequired:t=()=>{}})=>{let[e,n]=y([]),[s,o]=y(()=>new Set),[r,i]=y([]),[a,l]=y(0),[c,d]=y(!1),[u,p]=y(!1),[f,g]=y(!1),[m,h]=y(""),b=ee("[]"),{data:x,error:v,loading:w,refresh:$}=ct("/api/env",Vr,{maxAgeMs:3e4}),S=q(j=>{if(!j)return;let X=$P(j.vars||[]);b.current=Cw(X),n(X),i([]),o(new Set(j.reservedKeys||[])),j.restartRequired&&t(!0)},[t]),C=q(async()=>{try{let j=await $({force:!0});S(j)}catch(j){console.error("Failed to load env vars:",j)}},[S,$]);L(()=>{x&&(c||u||S(x))},[S,c,x,u]),L(()=>{v&&console.error("Failed to load env vars:",v)},[v]),L(()=>{d(Cw(e)!==b.current)},[e]);let _=(j,X)=>{n(de=>de.map(le=>le.key===j?{...le,value:X}:le))},k=j=>{n(X=>X.filter(de=>de.key!==j)),i(X=>X.filter(de=>de!==j))},A=async()=>{if(!u){p(!0);try{let j=e.filter(ie=>ie.editable&&!gc(ie?.key)).map(ie=>({key:ie.key,value:ie.value})),de=!!(await cd(j))?.restartRequired;de&&t(!0),N(de?"Environment variables saved. Restart gateway to apply.":"Environment variables saved","success");let le=await $({force:!0});S(le),l(ie=>ie+1),d(!1)}catch(j){N("Failed to save: "+j.message,"error")}finally{p(!1)}}},[O,D]=y(""),z=j=>{let X=j.split(` -`).map(le=>le.trim()).filter(Boolean).filter(le=>!le.startsWith("#")),de=[];for(let le of X){let ie=le.indexOf("=");ie>0&&de.push({key:le.slice(0,ie).trim(),value:vP(le.slice(ie+1))})}return de},E=j=>{let X=0,de=[],le=[],ie=[];return n(ne=>{let xe=[...ne];for(let{key:he,value:ue}of j){let me=Sw(he);if(!me)continue;if(gc(me)){le.push(me);continue}if(s.has(me)){de.push(me);continue}let V=xe.find(J=>J.key===me);V?V.value=ue:(xe.push({key:me,value:ue,label:me,group:"custom",hint:"",source:"env_file",editable:!0}),ie.push(me)),X++}return xe}),ie.length&&i(ne=>[...ne,...ie]),{added:X,blocked:de,managedChannelKeys:le}},M=(j,X)=>{let de=(j.clipboardData||window.clipboardData).getData("text"),le=z(de);if(le.length>1){j.preventDefault();let{added:ie,blocked:ne,managedChannelKeys:xe}=E(le);if(h(""),D(""),ne.length){let he=Array.from(new Set(ne));N(`Reserved vars can't be added: ${he.join(", ")}`,"error")}if(xe.length){let he=Array.from(new Set(xe));N(`Channel tokens are managed from Channels: ${he.join(", ")}`,"error")}ie&&N(`Added ${ie} variable${ie!==1?"s":""}`,"success");return}if(le.length===1){j.preventDefault(),h(le[0].key),D(le[0].value);return}},R=j=>{let X=z(j);if(X.length===1){h(X[0].key),D(X[0].value);return}h(j)},B=j=>{let X=z(j);if(X.length===1){h(X[0].key),D(X[0].value);return}D(j)},I=()=>{let j=Sw(m);if(j){if(gc(j)){N(`Channel tokens are managed from Channels: ${j}`,"error");return}if(s.has(j)){N(`Reserved var can't be added: ${j}`,"error");return}E([{key:j,value:O}]),h(""),D("")}},U=e.filter(j=>!gc(j?.key)),W={};for(let j of U){let X=j.group||"custom";W[X]||(W[X]=[]),W[X].push(j)}if(W.custom?.length){let j=new Set(r),X=W.custom.filter(le=>!j.has(le.key)).sort((le,ie)=>String(le?.key||"").localeCompare(String(ie?.key||""))),de=W.custom.filter(le=>j.has(le.key));W.custom=[...X,...de]}let G=CP(W.ai||[]),se=j=>j.map(X=>Ue`<${MP} + `},Mw=({onRestartRequired:t=()=>{}})=>{let[e,n]=y([]),[s,o]=y(()=>new Set),[r,i]=y([]),[a,l]=y(0),[c,d]=y(!1),[u,p]=y(!1),[f,g]=y(!1),[m,h]=y(""),b=ee("[]"),{data:x,error:v,loading:w,refresh:$}=ct("/api/env",Vr,{maxAgeMs:3e4}),S=q(z=>{if(!z)return;let X=$3(z.vars||[]);b.current=Cw(X),n(X),i([]),o(new Set(z.reservedKeys||[])),z.restartRequired&&t(!0)},[t]),C=q(async()=>{try{let z=await $({force:!0});S(z)}catch(z){console.error("Failed to load env vars:",z)}},[S,$]);L(()=>{x&&(c||u||S(x))},[S,c,x,u]),L(()=>{v&&console.error("Failed to load env vars:",v)},[v]),L(()=>{d(Cw(e)!==b.current)},[e]);let _=(z,X)=>{n(de=>de.map(le=>le.key===z?{...le,value:X}:le))},k=z=>{n(X=>X.filter(de=>de.key!==z)),i(X=>X.filter(de=>de!==z))},A=async()=>{if(!u){p(!0);try{let z=e.filter(ie=>ie.editable&&!bc(ie?.key)).map(ie=>({key:ie.key,value:ie.value})),de=!!(await dd(z))?.restartRequired;de&&t(!0),N(de?"Environment variables saved. Restart gateway to apply.":"Environment variables saved","success");let le=await $({force:!0});S(le),l(ie=>ie+1),d(!1)}catch(z){N("Failed to save: "+z.message,"error")}finally{p(!1)}}},[O,D]=y(""),j=z=>{let X=z.split(` +`).map(le=>le.trim()).filter(Boolean).filter(le=>!le.startsWith("#")),de=[];for(let le of X){let ie=le.indexOf("=");ie>0&&de.push({key:le.slice(0,ie).trim(),value:v3(le.slice(ie+1))})}return de},E=z=>{let X=0,de=[],le=[],ie=[];return n(ne=>{let xe=[...ne];for(let{key:he,value:ue}of z){let me=Sw(he);if(!me)continue;if(bc(me)){le.push(me);continue}if(s.has(me)){de.push(me);continue}let V=xe.find(J=>J.key===me);V?V.value=ue:(xe.push({key:me,value:ue,label:me,group:"custom",hint:"",source:"env_file",editable:!0}),ie.push(me)),X++}return xe}),ie.length&&i(ne=>[...ne,...ie]),{added:X,blocked:de,managedChannelKeys:le}},M=(z,X)=>{let de=(z.clipboardData||window.clipboardData).getData("text"),le=j(de);if(le.length>1){z.preventDefault();let{added:ie,blocked:ne,managedChannelKeys:xe}=E(le);if(h(""),D(""),ne.length){let he=Array.from(new Set(ne));N(`Reserved vars can't be added: ${he.join(", ")}`,"error")}if(xe.length){let he=Array.from(new Set(xe));N(`Channel tokens are managed from Channels: ${he.join(", ")}`,"error")}ie&&N(`Added ${ie} variable${ie!==1?"s":""}`,"success");return}if(le.length===1){z.preventDefault(),h(le[0].key),D(le[0].value);return}},R=z=>{let X=j(z);if(X.length===1){h(X[0].key),D(X[0].value);return}h(z)},B=z=>{let X=j(z);if(X.length===1){h(X[0].key),D(X[0].value);return}D(z)},I=()=>{let z=Sw(m);if(z){if(bc(z)){N(`Channel tokens are managed from Channels: ${z}`,"error");return}if(s.has(z)){N(`Reserved var can't be added: ${z}`,"error");return}E([{key:z,value:O}]),h(""),D("")}},U=e.filter(z=>!bc(z?.key)),W={};for(let z of U){let X=z.group||"custom";W[X]||(W[X]=[]),W[X].push(z)}if(W.custom?.length){let z=new Set(r),X=W.custom.filter(le=>!z.has(le.key)).sort((le,ie)=>String(le?.key||"").localeCompare(String(ie?.key||""))),de=W.custom.filter(le=>z.has(le.key));W.custom=[...X,...de]}let G=C3(W.ai||[]),se=z=>z.map(X=>Ue`<${M3} key=${`${a}:${X.key}`} envVar=${X} onChange=${_} onDelete=${k} disabled=${u} - />`),K=j=>{let X=W[j]||[];if(!X.length)return null;if(j==="ai"){let{visible:de,hidden:le}=G,ie=f&&le.length>0;return Ue` + />`),K=z=>{let X=W[z]||[];if(!X.length)return null;if(z==="ai"){let{visible:de,hidden:le}=G,ie=f&&le.length>0;return Ue`

      - ${kw[j]||j} + ${kw[z]||z}

      ${se(de)}
      ${le.length>0?Ue` @@ -6159,20 +6159,20 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `}return Ue`

      - ${kw[j]||j} + ${kw[z]||z}

      ${se(X)}
      `};return w&&!e.length?Ue` - <${zn} + <${jn} header=${Ue`<${Ne} title="Envars" />`} >
      Loading environment variables...
      - + `:Ue` - <${zn} + <${jn} header=${Ue` <${Ne} title="Envars" @@ -6202,7 +6202,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s /> `} > - ${bP.filter(j=>W[j]?.length).map(j=>K(j))} + ${b3.filter(z=>W[z]?.length).map(z=>K(z))}
      {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s type="text" value=${m} placeholder="KEY" - onInput=${j=>R(j.target.value)} - onPaste=${j=>M(j,"key")} - onKeyDown=${j=>j.key==="Enter"&&I()} + onInput=${z=>R(z.target.value)} + onPaste=${z=>M(z,"key")} + onKeyDown=${z=>z.key==="Enter"&&I()} class="w-full bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono uppercase" />
      @@ -6232,9 +6232,9 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s type="text" value=${O} placeholder="value" - onInput=${j=>B(j.target.value)} - onPaste=${j=>M(j,"val")} - onKeyDown=${j=>j.key==="Enter"&&I()} + onInput=${z=>B(z.target.value)} + onPaste=${z=>M(z,"val")} + onKeyDown=${z=>z.key==="Enter"&&I()} class="flex-1 bg-field border border-border rounded-lg px-3 py-1.5 text-sm text-body outline-none focus:border-fg-muted font-mono" />
      - - `};var AP=P.bind(T),pf=({onRestartRequired:t=()=>{}})=>AP` + + `};var A3=P.bind(T),ff=({onRestartRequired:t=()=>{}})=>A3` <${Mw} onRestartRequired=${t} /> -`;var Qt=P.bind(T),TP=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},PP=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",O=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(I=!1)=>{try{let U=await n(I);if(!R)return;c(U.currentVersion||e||null),u(U.latestVersion||null),f(!!U.hasUpdate),m(U.ok?"":U.error||"")}catch(U){if(!R)return;m(U.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let I=await n(!0);if(!R)return;c(I.currentVersion||e||null),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let D=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),m("");try{let I=R?await s():await n(!0);c(I.currentVersion||l),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||""),R?I.ok?I.updated||I.restarting?N(I.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${I.currentVersion}`,"success"):N(`Already at latest ${t} version`,"success"):N(I.error||`${t} update failed`,"error"):I.hasUpdate&&I.latestVersion?N(`${t} update available: ${I.latestVersion}`,"warning"):N(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!I?.ok,result:I})}catch(I){m(I.message||(R?`Could not update ${t}`:"Could not check updates")),N(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:I})}finally{a(!1)}}},z=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}D()}},E=()=>{v(!1),D()},M=_?i||o:i;return Qt` +`;var Qt=P.bind(T),T3=t=>{let e=Number(t||0);if(!Number.isFinite(e)||e<=0)return"0s";let n=Math.floor(e/1e3),s=Math.floor(n/86400),o=Math.floor(n/3600),r=Math.floor(n%3600/60),i=n%60;return s>0?`${s}d ${o%24}h ${r}m ${i}s`:o>0?`${o}h ${r}m ${i}s`:r>0?`${r}m ${i}s`:`${i}s`},P3=({label:t,currentVersion:e,fetchVersion:n,applyUpdate:s,updateInProgress:o=!1,onActionComplete:r=()=>{}})=>{let[i,a]=y(!1),[l,c]=y(e||null),[d,u]=y(null),[p,f]=y(!1),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),w=(()=>{try{return new URLSearchParams(window.location.search).get("simulateUpdate")==="1"}catch{return!1}})(),$=(()=>{if(!w)return null;try{return new URLSearchParams(window.location.search).get("simulateVersion")||"v0.0.0-preview"}catch{return"v0.0.0-preview"}})(),S=w||p,C=$||d,_=o||S,k=C?`Update to ${C}`:"Update",A="https://github.com/openclaw/openclaw/tags",O=S&&C;L(()=>{c(e||null)},[e]),L(()=>{let R=!0;return(async(I=!1)=>{try{let U=await n(I);if(!R)return;c(U.currentVersion||e||null),u(U.latestVersion||null),f(!!U.hasUpdate),m(U.ok?"":U.error||"")}catch(U){if(!R)return;m(U.message||"Could not check updates")}})(!1),()=>{R=!1}},[e,n]),L(()=>{if(o)return()=>{};let R=!0,B=setTimeout(async()=>{try{let I=await n(!0);if(!R)return;c(I.currentVersion||e||null),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||"")}catch{}},1200);return()=>{R=!1,clearTimeout(B)}},[o,e,n]),L(()=>{if(!S||!C){b(!1);return}b(!1)},[S,C]);let D=async()=>{let R=!!S;if(!(_?i||o:i)){a(!0),m("");try{let I=R?await s():await n(!0);c(I.currentVersion||l),u(I.latestVersion||null),f(!!I.hasUpdate),m(I.ok?"":I.error||""),R?I.ok?I.updated||I.restarting?N(I.restarting?`${t} updated \u2014 restarting...`:`Updated ${t} to ${I.currentVersion}`,"success"):N(`Already at latest ${t} version`,"success"):N(I.error||`${t} update failed`,"error"):I.hasUpdate&&I.latestVersion?N(`${t} update available: ${I.latestVersion}`,"warning"):N(`${t} is up to date`,"success"),await r({type:R?"update":"check",ok:!!I?.ok,result:I})}catch(I){m(I.message||(R?`Could not update ${t}`:"Could not check updates")),N(R?`Could not update ${t}`:"Could not check updates","error"),await r({type:R?"update":"check",ok:!1,error:I})}finally{a(!1)}}},j=()=>{if(!(_?i||o:i)){if(S&&C&&!h){v(!0);return}D()}},E=()=>{v(!1),D()},M=_?i||o:i;return Qt`

      @@ -6274,7 +6274,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s `} ${O?Qt` <${tn} - onClick=${z} + onClick=${j} loading=${M} warning=${_} idleLabel=${_?k:"Check updates"} @@ -6283,7 +6283,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s /> `:Qt` <${tn} - onClick=${z} + onClick=${j} loading=${M} warning=${_} idleLabel=${_?k:"Check updates"} @@ -6303,7 +6303,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >View changelog <${tn} - onClick=${z} + onClick=${j} loading=${M} warning=${_} idleLabel=${k} @@ -6322,7 +6322,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s onCancel=${()=>v(!1)} onConfirm=${E} /> - `},bc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ma})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,w=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),Qt`

      + `},xc=({status:t,openclawVersion:e,restarting:n=!1,onRestart:s,watchdogStatus:o=null,onOpenWatchdog:r,onRepair:i,repairing:a=!1,openclawUpdateInProgress:l=!1,onOpenclawVersionActionComplete:c=()=>{},onOpenclawUpdate:d=ma})=>{let[u,p]=y(()=>Date.now()),f=t==="running"&&!n,g=f?"ac-status-dot ac-status-dot--healthy":"w-2 h-2 rounded-full bg-yellow-500 animate-pulse",m=o?.lifecycle==="crash_loop"?"crash_loop":o?.health,h=m==="healthy"?"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset":m==="degraded"?"bg-yellow-500":m==="unhealthy"||m==="crash_loop"?"bg-red-500":"bg-gray-500",b=m==="unknown"?"initializing":m||"unknown",x=a||!!o?.operationInProgress,v=m==="degraded"&&!!r,w=x||o?.health==="degraded"&&!r||o?.lifecycle==="crash_loop"||o?.health==="unhealthy"||o?.health==="crashed",$=F(()=>{let S=o?.uptimeStartedAt?Date.parse(o.uptimeStartedAt):null;return Number.isFinite(S)?Math.max(0,u-S):o?.uptimeMs||0},[o?.uptimeStartedAt,o?.uptimeMs,u]);return L(()=>{let S=setInterval(()=>{p(Date.now())},1e3);return()=>clearInterval(S)},[]),Qt`
      @@ -6335,7 +6335,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      ${!n&&f?Qt` Uptime: ${TP($)}Uptime: ${T3($)} `:null} <${tn} @@ -6398,16 +6398,16 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s
      - <${PP} + <${P3} label="OpenClaw" currentVersion=${e} - fetchVersion=${gm} + fetchVersion=${bm} applyUpdate=${d} updateInProgress=${l} onActionComplete=${c} />
      -
      `};var $r=P.bind(T),RP={webchat:"Browser",cli:"CLI"},LP=t=>RP[t.clientMode]||t.clientId||"Device",EP=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},IP=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=LP(t),a=EP(t);return s==="approve"?$r` +
      `};var $r=P.bind(T),R3={webchat:"Browser",cli:"CLI"},L3=t=>R3[t.clientMode]||t.clientId||"Device",E3=t=>{let e=[];return t.platform&&e.push(t.platform),t.role&&e.push(t.role),e.join(" \xB7 ")},I3=({d:t,onApprove:e,onReject:n})=>{let[s,o]=y(null),r=async l=>{o(l);try{l==="approve"?await e(t.id):await n(t.id)}catch{o(null)}},i=L3(t),a=E3(t);return s==="approve"?$r`
      Approved ${i} @@ -6437,11 +6437,11 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s className="font-medium px-3 py-1.5" />
      -
      `},xc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r` +
      `},yc=({pending:t,onApprove:e,onReject:n})=>!t||t.length===0?null:$r`

      Pending device pairings

      - ${t.map(s=>$r`<${IP} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} -
      `;var fo=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],DP={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Aw(t){return`https://console.developers.google.com/apis/api/${DP[t]||""}/overview`}function Tw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return fo`
      + ${t.map(s=>$r`<${I3} key=${s.id} d=${s} onApprove=${e} onReject=${n} />`)} +
      `;var fo=P.bind(T),Vi=[{key:"gmail",icon:"\u{1F4E7}",label:"Gmail",defaultRead:!0,defaultWrite:!1},{key:"calendar",icon:"\u{1F4C5}",label:"Calendar",defaultRead:!0,defaultWrite:!0},{key:"drive",icon:"\u{1F4C1}",label:"Drive",defaultRead:!0,defaultWrite:!1},{key:"sheets",icon:"\u{1F4CA}",label:"Sheets",defaultRead:!0,defaultWrite:!1},{key:"docs",icon:"\u{1F4DD}",label:"Docs",defaultRead:!0,defaultWrite:!1},{key:"tasks",icon:"\u2705",label:"Tasks",defaultRead:!1,defaultWrite:!1},{key:"contacts",icon:"\u{1F464}",label:"Contacts",defaultRead:!1,defaultWrite:!1},{key:"meet",icon:"\u{1F3A5}",label:"Meet",defaultRead:!1,defaultWrite:!1}],D3={gmail:"gmail.googleapis.com",calendar:"calendar-json.googleapis.com",tasks:"tasks.googleapis.com",drive:"drive.googleapis.com",contacts:"people.googleapis.com",sheets:"sheets.googleapis.com",docs:"docs.googleapis.com",meet:"meet.googleapis.com"};function Aw(t){return`https://console.developers.google.com/apis/api/${D3[t]||""}/overview`}function Tw({scopes:t,onToggle:e,apiStatus:n,loading:s}){let[o,r]=y(!1),i=n||{},a=5,l=Vi.length>a,c=o?Vi:Vi.slice(0,a);return fo`
      ${c.map(d=>{let u=t.includes(`${d.key}:read`),p=t.includes(`${d.key}:write`),f=i[d.key],g=null;return s&&!f&&(u||p)?g=fo``:f&&(f.status==="ok"?g=fo`API ✓`:f.status==="not_enabled"?g=fo`Enable API`:f.status==="error"&&(g=fo`Enable API`)),fo`
      ${d.icon} ${d.label} @@ -6460,7 +6460,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s ${o?"Show fewer services":`Show more services (${Vi.length-a})`} `:null} -
      `}function Pw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Rw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let I=await B.text(),U=JSON.parse(I),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&f(W.client_secret)}catch{b("Invalid JSON file")}},D=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await Uh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},z=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr` +
      `}function Pw(t,e){let n=t.includes(e),s=n?t.filter(o=>o!==e):[...t,e];if(e.endsWith(":write")&&!n){let o=e.replace(":write",":read");s.includes(o)||s.push(o)}if(e.endsWith(":read")&&n){let o=e.replace(":read",":write");s=s.filter(r=>r!==o)}return s}function wr(){let t=[];for(let e of Vi)e.defaultRead&&t.push(`${e.key}:read`),e.defaultWrite&&t.push(`${e.key}:write`);return t}var kr=P.bind(T),Rw=({visible:t,onClose:e,onSaved:n,title:s="Connect Google Workspace",submitLabel:o="Connect Google",defaultInstrType:r="workspace",client:i="default",personal:a=!1,accountId:l="",initialValues:c={}})=>{let[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(""),[x,v]=y(!1),[w,$]=y(r),[S,C]=y(!1),_=ee(null);if(L(()=>{t&&(u(String(c.clientId||"")),f(String(c.clientSecret||"")),m(String(c.email||"")),$(r),b(""),C(!1))},[t,c,r]),!t)return null;let k=`${window.location.origin}/auth/google/callback`,A=async()=>{try{await navigator.clipboard.writeText(k),C(!0),window.setTimeout(()=>C(!1),1500)}catch{b("Unable to copy redirect URI")}},O=async R=>{let B=R.target.files[0];if(B)try{let I=await B.text(),U=JSON.parse(I),W=U.installed||U.web||U;W.client_id&&u(W.client_id),W.client_secret&&f(W.client_secret)}catch{b("Invalid JSON file")}},D=async()=>{if(b(""),!d||!p||!g){b("Client ID, Client Secret, and Email are required");return}v(!0);try{let R=await Kh({clientId:d,clientSecret:p,email:g,client:i,personal:a,accountId:l});R.ok?(e(),n?.(R.account)):b(R.error||"Failed to save credentials")}catch{b("Request failed")}finally{v(!1)}},j=R=>`flex-1 text-center border-0 cursor-pointer transition-colors ${w===R?"":"hover:text-white"}`,E=R=>"font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;"+(w===R?" color: var(--accent); background: var(--bg-active);":" color: var(--text-muted); background: transparent;"),M=()=>kr`
      {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s >
      ${h?kr`
      ${h}
      `:null} - `};var Lw=P.bind(T),NP=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Ew=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Lw` + `};var Lw=P.bind(T),N3=({watchStatus:t,busy:e=!1})=>e?{label:t?.enabled?"Stopping":"Starting",tone:"warning"}:t?.enabled?t.enabled&&!t.running?{label:"Error",tone:"danger"}:{label:"Watching",tone:"success"}:{label:"Stopped",tone:"neutral"},Ew=({account:t,watchStatus:e=null,busy:n=!1,onEnable:s=()=>{},onDisable:o=()=>{},onOpenWebhook:r=()=>{}})=>{if(!(Array.isArray(t?.activeScopes)?t.activeScopes.includes("gmail:read"):Array.isArray(t?.services)?t.services.includes("gmail:read"):!1))return Lw`
      Gmail watch requires gmail:read. Add it in permissions above, then update permissions.
      - `;let a=NP({watchStatus:e,busy:n}),l=!!e?.enabled;return Lw` + `;let a=N3({watchStatus:e,busy:n}),l=!!e?.enabled;return Lw`
      {r.current&&(r.current.destroy(),r.current=null)}},[f,i,s />
      - `};var ji=P.bind(T),OP=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Iw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!OP(o,r);return ji` + `};var zi=P.bind(T),O3=(t=[],e=[])=>t.length===e.length&&t.every(n=>e.includes(n)),Iw=({account:t,personal:e=!1,expanded:n,onToggleExpanded:s,scopes:o=[],savedScopes:r=[],apiStatus:i={},checkingApis:a=!1,onToggleScope:l,onCheckApis:c,onUpdatePermissions:d,onEditCredentials:u,onDisconnect:p,gmailWatchStatus:f=null,gmailWatchBusy:g=!1,onEnableGmailWatch:m,onDisableGmailWatch:h,onOpenGmailSetup:b,onOpenGmailWebhook:x})=>{let v=!O3(o,r);return zi`
      - ${e?ji`<${ae} tone="neutral">Personal`:null} + ${e?zi`<${ae} tone="neutral">Personal`:null} <${ae} tone=${t.authenticated?"success":"warning"}> ${t.authenticated?"Connected":"Awaiting sign-in"} ${n?"\u25BE":"\u25B8"}
      - ${n?ji` + ${n?zi`
      Select permissions - ${t.authenticated?ji`
      - ${l?ff`
      ${l}
      `:null} + ${l?hf`
      ${l}
      `:null}
      <${Z} @@ -6898,7 +6898,7 @@ cost: ${v}`}}}}}}),()=>{r.current&&(r.current.destroy(),r.current=null)}},[f,i,s className="w-full px-4 py-2 rounded-lg text-sm" />
      - `};var Nw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",Vh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Ow=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",qh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await Zh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Yh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Xh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await Jh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),BP=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Bw=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],FP=Bw.slice(0,3),yc=Is,vc=(t="",e=()=>{})=>mt` + `};var Nw=({gatewayStatus:t})=>{let e=ee(!1),{data:n,loading:s,refresh:o}=ct("/api/google/accounts",zh,{maxAgeMs:3e4}),r=F(()=>Array.isArray(n?.accounts)?n.accounts:[],[n?.accounts]),i=!!n?.hasCompanyCredentials,a=!!n?.hasPersonalCredentials,l=q(async()=>o({force:!0}),[o]);return L(()=>{if(t!=="running"){e.current=!1;return}e.current||(e.current=!0,l().catch(()=>{}))},[t,l]),{accounts:r,loading:s,hasCompanyCredentials:i,hasPersonalCredentials:a,refreshAccounts:l}};var Ow=({gatewayStatus:t,accounts:e=[]})=>{let[n,s]=y({}),[o,r]=y(!1),i=F(()=>e.map(x=>String(x?.id||"").trim()).filter(Boolean).sort().join("|"),[e]),{data:a,loading:l,refresh:c}=ct("/api/gmail/config",Jh,{enabled:t==="running",maxAgeMs:3e4}),d=q(async()=>c({force:!0}),[c]);L(()=>{t==="running"&&e.length&&d().catch(()=>{})},[i,e.length,t,d]);let u=F(()=>{let x=new Map;for(let v of a?.accounts||[])x.set(String(v.accountId||""),v);return x},[a]),p=F(()=>{let x=new Map;for(let v of a?.clients||[])x.set(String(v.client||"default"),v);return x},[a]),f=(x,v)=>{s(w=>{let $=String(x||"");if(!$)return w;if(v)return{...w,[$]:!0};if(!w[$])return w;let S={...w};return delete S[$],S})},g=q(async(x,{destination:v=null}={})=>{let w=String(x||"");f(w,!0);try{let $=await Yh(w,{destination:v});return await d(),$}finally{f(w,!1)}},[d]),m=q(async x=>{let v=String(x||"");f(v,!0);try{await Xh(v),await d()}finally{f(v,!1)}},[d]),h=q(async(x="")=>{let v=String(x||"");v&&f(v,!0);try{await Qh({accountId:v,force:!0}),await d()}finally{v&&f(v,!1)}},[d]),b=q(async({client:x="default",projectId:v="",regeneratePushToken:w=!1}={})=>{r(!0);try{let $=await Zh({client:x,projectId:v,regeneratePushToken:w});return await d(),$}catch($){throw String($?.message||"").toLowerCase().includes("not found")?new Error("Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded."):$}finally{r(!1)}},[d]);return{loading:l,config:a,watchByAccountId:u,clientConfigByClient:p,busyByAccountId:n,savingClient:o,refresh:d,saveClientSetup:b,startWatchForAccount:g,stopWatchForAccount:m,renewForAccount:h}};var mt=P.bind(T),B3=async t=>{let e=String(t||"");if(!e)return!1;try{if(navigator?.clipboard?.writeText)return await navigator.clipboard.writeText(e),!0}catch{}try{let n=document.createElement("textarea");return n.value=e,n.setAttribute("readonly",""),n.style.position="fixed",n.style.opacity="0",document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}catch{return!1}},Bw=["Install + Authenticate gcloud","Enable APIs","Create Topic + IAM","Create Push Subscription","Build with your Agent"],F3=Bw.slice(0,3),vc=Is,$c=(t="",e=()=>{})=>mt`
           
      -`,Fw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:O,error:D}=yr({enabled:t,filter:pc}),{sessions:z,destinationSessionKey:E,setDestinationSessionKey:M,selectedDestination:R}=Ds({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,I=!!(String(n?.projectId||"").trim()||B),U=u||!I&&!m,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,se=G?FP:Bw,K=se.length,j=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async he=>{if(await BP(he)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:j,projectId:String(c||"").trim(),destination:R}),x(!0),l(he=>Math.min(he+1,K-1))}catch(he){g(he.message||"Could not finish setup")}},ne=async()=>{if(!s){if(U){if(!X)return;g("");try{await r({client:j,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,K-1))}},xe=async()=>{if(!(v||$))try{w(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ue,sessionKey:k}),S(!0),N("Message sent to your agent","success")}catch(he){N(he.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` +`,Fw=({visible:t=!1,account:e=null,clientConfig:n=null,saving:s=!1,onClose:o=()=>{},onSaveSetup:r=async()=>{},onFinish:i=async()=>{}})=>{let[a,l]=y(0),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(!1),[b,x]=y(!1),[v,w]=y(!1),[$,S]=y(!1),[C,_]=y(!1),{selectedSessionKey:k,setSelectedSessionKey:A,loading:O,error:D}=yr({enabled:t,filter:fc}),{sessions:j,destinationSessionKey:E,setDestinationSessionKey:M,selectedDestination:R}=Ds({enabled:t,resetKey:String(e?.id||"")});L(()=>{t&&(l(0),g(""),d(""),p(!1),h(!1),x(!1),w(!1),S(!1),_(!!n?.webhookExists))},[t,e?.id]);let B=n?.commands||null,I=!!(String(n?.projectId||"").trim()||B),U=u||!I&&!m,W=String(c||"").trim()||String(n?.projectId||"").trim()||"",G=C,se=G?F3:Bw,K=se.length,z=String(e?.client||n?.client||"default").trim()||"default",X=F(()=>U?String(c||"").trim().length>0:!0,[U,c]),de=q(async he=>{if(await B3(he)){N("Copied to clipboard","success");return}N("Could not copy text","error")},[]),le=q(()=>{g(""),d(String(n?.projectId||"").trim()),h(!1),p(!0)},[n?.projectId]),ie=async()=>{try{g(""),await i({client:z,projectId:String(c||"").trim(),destination:R}),x(!0),l(he=>Math.min(he+1,K-1))}catch(he){g(he.message||"Could not finish setup")}},ne=async()=>{if(!s){if(U){if(!X)return;g("");try{await r({client:z,projectId:String(c||"").trim()}),p(!1),h(!0)}catch(he){g(he.message||"Could not save project id");return}return}l(he=>Math.min(he+1,K-1))}},xe=async()=>{if(!(v||$))try{w(!0);let ue=`I just enabled Gmail watch for "${String(e?.email||"this account").trim()||"this account"}", set up the webhook, and created the transform file. Help me set up what I want to do with incoming email.`;await fa({message:ue,sessionKey:k}),S(!0),N("Message sent to your agent","success")}catch(he){N(he.message||"Could not send message to agent","error")}finally{w(!1)}};return mt` <${De} visible=${t} onClose=${o} @@ -6991,33 +6991,33 @@ ${t}
      - ${vc(`gcloud auth login + ${$c(`gcloud auth login gcloud config set project ${W}`,()=>de(`gcloud auth login gcloud config set project ${W}`))} `:null} - ${!U&&a===1?vc(B?.enableApis||"",()=>de(B?.enableApis||"")):null} + ${!U&&a===1?$c(B?.enableApis||"",()=>de(B?.enableApis||"")):null} ${!U&&a===2?mt` - ${vc(`${B?.createTopic||""} + ${$c(`${B?.createTopic||""} ${B?.grantPublisher||""}`.trim(),()=>de(`${B?.createTopic||""} ${B?.grantPublisher||""}`.trim()))} `:null} ${!G&&!U&&a===3?mt` - ${vc(B?.createSubscription||"",()=>de(B?.createSubscription||""))} + ${$c(B?.createSubscription||"",()=>de(B?.createSubscription||""))}
      <${vr} label="Deliver to" - sessions=${z} + sessions=${j} selectedSessionKey=${E} onChangeSessionKey=${M} disabled=${G||O||s} loading=${O} error=${D} allowNone=${!0} - noneValue=${yc} + noneValue=${vc} noneLabel="Default" loadingLabel="Loading sessions..." helperText=${G?"This Gmail webhook has already been created. To edit delivery routing, ask your agent.":null} @@ -7044,15 +7044,15 @@ ${B?.grantPublisher||""}`.trim()))}
      -`,xf=({accountId:t,groupId:e,groupName:n,initialTopics:s,configAgentMaxConcurrent:o,configSubagentMaxConcurrent:r,debugEnabled:i,onResetOnboarding:a})=>{let[l,c]=y(s||{}),[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(null),[S,C]=y(""),[_,k]=y(""),[A,O]=y(""),[D,z]=y(""),[E,M]=y(""),[R,B]=y(null),[I,U]=y(null),[W,G]=y([]),se=async()=>{let V=await _c(e,{accountId:t});V.ok&&c(V.topics||{})};L(()=>{se()},[e]),L(()=>{s&&Object.keys(s).length>0&&c(s)},[s]),L(()=>{Ao().then(V=>G(Array.isArray(V?.agents)?V.agents:[])).catch(()=>{})},[]);let K=async()=>{let V=d.trim(),J=p.trim(),oe=g.trim();if(V){v(!0),B(null);try{let ce=await Mc(e,[{name:V,...J?{systemInstructions:J}:{},...oe?{agentId:oe}:{}}],{accountId:t});if(!ce.ok)throw new Error(ce.results?.[0]?.error||"Failed to create topic");let be=ce.results.filter(Ve=>!Ve.ok);if(be.length>0)throw new Error(be[0].error);u(""),f(""),m(""),b(!1),await se(),N(`Created topic: ${V}`,"success")}catch(ce){B(ce.message)}v(!1)}},j=async(V,J)=>{$(V);try{let oe=await Ac(e,V,{accountId:t});if(!oe.ok)throw new Error(oe.error);await se(),oe.removedFromRegistryOnly?N(`Removed stale topic from registry: ${J}`,"success"):N(`Deleted topic: ${J}`,"success")}catch(oe){N(`Failed to delete: ${oe.message}`,"error")}$(null)},X=(V,J,oe="",ce="")=>{C(String(V)),k(String(J||"")),O(String(oe||"")),z(String(ce||""))},de=()=>{C(""),k(""),O(""),z("")},le=async V=>{let J=_.trim(),oe=A.trim(),ce=D.trim();if(!J){B("Topic name is required");return}M(String(V)),B(null);try{let be=await p1(e,V,{name:J,systemInstructions:oe,agentId:ce},{accountId:t});if(!be.ok)throw new Error(be.error||"Failed to update topic");await se(),N(`Updated topic: ${J}`,"success"),de()}catch(be){B(be.message)}M("")},ie=Object.entries(l||{}),ne=ie.length,xe=Math.max(ne*3,8),he=Math.max(xe-2,4),ue=Number.isFinite(o)?o:xe,me=Number.isFinite(r)?r:he;return At` +`,yf=({accountId:t,groupId:e,groupName:n,initialTopics:s,configAgentMaxConcurrent:o,configSubagentMaxConcurrent:r,debugEnabled:i,onResetOnboarding:a})=>{let[l,c]=y(s||{}),[d,u]=y(""),[p,f]=y(""),[g,m]=y(""),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(null),[S,C]=y(""),[_,k]=y(""),[A,O]=y(""),[D,j]=y(""),[E,M]=y(""),[R,B]=y(null),[I,U]=y(null),[W,G]=y([]),se=async()=>{let V=await Mc(e,{accountId:t});V.ok&&c(V.topics||{})};L(()=>{se()},[e]),L(()=>{s&&Object.keys(s).length>0&&c(s)},[s]),L(()=>{Ao().then(V=>G(Array.isArray(V?.agents)?V.agents:[])).catch(()=>{})},[]);let K=async()=>{let V=d.trim(),J=p.trim(),oe=g.trim();if(V){v(!0),B(null);try{let ce=await Ac(e,[{name:V,...J?{systemInstructions:J}:{},...oe?{agentId:oe}:{}}],{accountId:t});if(!ce.ok)throw new Error(ce.results?.[0]?.error||"Failed to create topic");let be=ce.results.filter(Ve=>!Ve.ok);if(be.length>0)throw new Error(be[0].error);u(""),f(""),m(""),b(!1),await se(),N(`Created topic: ${V}`,"success")}catch(ce){B(ce.message)}v(!1)}},z=async(V,J)=>{$(V);try{let oe=await Tc(e,V,{accountId:t});if(!oe.ok)throw new Error(oe.error);await se(),oe.removedFromRegistryOnly?N(`Removed stale topic from registry: ${J}`,"success"):N(`Deleted topic: ${J}`,"success")}catch(oe){N(`Failed to delete: ${oe.message}`,"error")}$(null)},X=(V,J,oe="",ce="")=>{C(String(V)),k(String(J||"")),O(String(oe||"")),j(String(ce||""))},de=()=>{C(""),k(""),O(""),j("")},le=async V=>{let J=_.trim(),oe=A.trim(),ce=D.trim();if(!J){B("Topic name is required");return}M(String(V)),B(null);try{let be=await p1(e,V,{name:J,systemInstructions:oe,agentId:ce},{accountId:t});if(!be.ok)throw new Error(be.error||"Failed to update topic");await se(),N(`Updated topic: ${J}`,"success"),de()}catch(be){B(be.message)}M("")},ie=Object.entries(l||{}),ne=ie.length,xe=Math.max(ne*3,8),he=Math.max(xe-2,4),ue=Number.isFinite(o)?o:xe,me=Number.isFinite(r)?r:he;return At`
      ${i&&At`
      @@ -8623,7 +8623,7 @@ ${t}
      `} @@ -8793,10 +8793,10 @@ ${t}{w||U(null)}} - onConfirm=${async()=>{if(!I)return;let V=I;U(null),await j(V.id,V.name)}} + onConfirm=${async()=>{if(!I)return;let V=I;U(null),await z(V.id,V.name)}} />
      - `};var jt=P.bind(T),Tc=[{id:"verify-bot",label:"Verify Bot"},{id:"create-group",label:"Create Group"},{id:"add-bot",label:"Add Bot"},{id:"topics",label:"Topics"},{id:"summary",label:"Summary"}],Sr=(t,e)=>{let n=String(e||"").trim();return!n||n==="default"?t:`${t}.${n}`},bR=t=>{try{let e=window.localStorage.getItem(Sr(Ha,t));if(!e)return{};let n=JSON.parse(e);return n&&typeof n=="object"?n:{}}catch{return{}}},xR=(t,e)=>{try{window.localStorage.setItem(Sr(Ha,t),JSON.stringify(e))}catch{}},$1=t=>{try{window.localStorage.removeItem(Sr(Ha,t))}catch{}},yR=t=>{try{let e=window.localStorage.getItem(Sr(Wa,t));if(!e)return null;let s=JSON.parse(e)?.data;return!s||typeof s!="object"?null:s}catch{return null}},yf=(t,e)=>{try{window.localStorage.setItem(Sr(Wa,t),JSON.stringify({cachedAt:Date.now(),data:e}))}catch{}},vR=t=>{try{window.localStorage.removeItem(Sr(Wa,t))}catch{}},$R=({onBack:t})=>jt` + `};var zt=P.bind(T),Pc=[{id:"verify-bot",label:"Verify Bot"},{id:"create-group",label:"Create Group"},{id:"add-bot",label:"Add Bot"},{id:"topics",label:"Topics"},{id:"summary",label:"Summary"}],Sr=(t,e)=>{let n=String(e||"").trim();return!n||n==="default"?t:`${t}.${n}`},bR=t=>{try{let e=window.localStorage.getItem(Sr(Ha,t));if(!e)return{};let n=JSON.parse(e);return n&&typeof n=="object"?n:{}}catch{return{}}},xR=(t,e)=>{try{window.localStorage.setItem(Sr(Ha,t),JSON.stringify(e))}catch{}},$1=t=>{try{window.localStorage.removeItem(Sr(Ha,t))}catch{}},yR=t=>{try{let e=window.localStorage.getItem(Sr(Wa,t));if(!e)return null;let s=JSON.parse(e)?.data;return!s||typeof s!="object"?null:s}catch{return null}},vf=(t,e)=>{try{window.localStorage.setItem(Sr(Wa,t),JSON.stringify({cachedAt:Date.now(),data:e}))}catch{}},vR=t=>{try{window.localStorage.removeItem(Sr(Wa,t))}catch{}},$R=({onBack:t})=>zt` -`,wR=({accountId:t,groups:e,concurrency:n,debugEnabled:s,onResetOnboarding:o})=>{let[r,i]=y(()=>e[0]?.groupId||""),a=l=>i(c=>c===l?"":l);return jt` +`,wR=({accountId:t,groups:e,concurrency:n,debugEnabled:s,onResetOnboarding:o})=>{let[r,i]=y(()=>e[0]?.groupId||""),a=l=>i(c=>c===l?"":l);return zt`
      - ${e.map(l=>jt` + ${e.map(l=>zt`
      - ${r===l.groupId&&jt` + ${r===l.groupId&&zt`
      - <${xf} + <${yf} accountId=${t} groupId=${l.groupId} groupName=${l.groupName} @@ -8854,11 +8854,11 @@ ${t} `)}
      - `},w1=({accountId:t="default",onBack:e})=>{let n=bR(t),s=yR(t),[o,r]=y(()=>{let _=Number.parseInt(String(n.step??0),10);return Number.isFinite(_)?Math.min(Math.max(_,0),Tc.length-1):0}),[i,a]=y(null),[l,c]=y(n.groupId||""),[d,u]=y(n.groupInfo||null),[p,f]=y(n.verifyGroupError||null),[g,m]=y(n.allowUserId||""),[h,b]=y(n.topics||{}),[x,v]=y(()=>({ready:!!s,configured:!!s?.configured,groups:s?.groups||[],groupId:s?.groupId||"",groupName:s?.groupName||"",topics:s?.topics||{},debugEnabled:!!s?.debugEnabled,concurrency:s?.concurrency||{agentMaxConcurrent:null,subagentMaxConcurrent:null}})),w=()=>r(_=>Math.min(Tc.length-1,_+1)),$=()=>r(_=>Math.max(0,_-1)),S=async()=>{try{let _=await d1({accountId:t});if(!_.ok)throw new Error(_.error||"Failed to reset onboarding");$1(t),vR(t),r(0),a(null),c(""),u(null),f(null),m(""),b({}),v({ready:!0,configured:!1,groups:[],groupId:"",groupName:"",topics:{},debugEnabled:!!x?.debugEnabled,concurrency:{agentMaxConcurrent:null,subagentMaxConcurrent:null}}),N("Telegram onboarding reset","success")}catch(_){N(_.message||"Failed to reset onboarding","error")}},C=()=>{$1(t);let _=d?.chat?.title||l;yf(t,{ready:!0,configured:!0,groups:[{groupId:l,groupName:_,topics:h||{}}],groupId:l,groupName:_,topics:h||{},debugEnabled:!!x?.debugEnabled,concurrency:x?.concurrency||{agentMaxConcurrent:null,subagentMaxConcurrent:null}}),window.location.reload()};return L(()=>{xR(t,{step:o,groupId:l,groupInfo:d,verifyGroupError:p,allowUserId:g,topics:h})},[t,o,l,d,p,g,h]),L(()=>{let _=!0;return(async()=>{try{let A=await c1({accountId:t});if(!_||!A?.ok)return;let O=Array.isArray(A.groups)?A.groups:[];if(!A.configured||O.length===0){let E={ready:!0,configured:!1,groups:[],groupId:"",groupName:"",topics:{},debugEnabled:!!A?.debugEnabled,concurrency:{agentMaxConcurrent:null,subagentMaxConcurrent:null}};v(E),yf(t,E);return}let D=O[0],z={ready:!0,configured:!0,groups:O,groupId:D.groupId,groupName:D.groupName||D.groupId,topics:D.topics||{},debugEnabled:!!A.debugEnabled,concurrency:A.concurrency||{agentMaxConcurrent:null,subagentMaxConcurrent:null}};v(z),yf(t,z),c(D.groupId),b(D.topics||{}),u({chat:{id:D.groupId,title:D.groupName||D.groupId,isForum:!0},bot:{status:"administrator",isAdmin:!0,canManageTopics:!0}}),f(null),m(""),r(E=>E<3?3:E)}catch{}})(),()=>{_=!1}},[t]),jt` + `},w1=({accountId:t="default",onBack:e})=>{let n=bR(t),s=yR(t),[o,r]=y(()=>{let _=Number.parseInt(String(n.step??0),10);return Number.isFinite(_)?Math.min(Math.max(_,0),Pc.length-1):0}),[i,a]=y(null),[l,c]=y(n.groupId||""),[d,u]=y(n.groupInfo||null),[p,f]=y(n.verifyGroupError||null),[g,m]=y(n.allowUserId||""),[h,b]=y(n.topics||{}),[x,v]=y(()=>({ready:!!s,configured:!!s?.configured,groups:s?.groups||[],groupId:s?.groupId||"",groupName:s?.groupName||"",topics:s?.topics||{},debugEnabled:!!s?.debugEnabled,concurrency:s?.concurrency||{agentMaxConcurrent:null,subagentMaxConcurrent:null}})),w=()=>r(_=>Math.min(Pc.length-1,_+1)),$=()=>r(_=>Math.max(0,_-1)),S=async()=>{try{let _=await d1({accountId:t});if(!_.ok)throw new Error(_.error||"Failed to reset onboarding");$1(t),vR(t),r(0),a(null),c(""),u(null),f(null),m(""),b({}),v({ready:!0,configured:!1,groups:[],groupId:"",groupName:"",topics:{},debugEnabled:!!x?.debugEnabled,concurrency:{agentMaxConcurrent:null,subagentMaxConcurrent:null}}),N("Telegram onboarding reset","success")}catch(_){N(_.message||"Failed to reset onboarding","error")}},C=()=>{$1(t);let _=d?.chat?.title||l;vf(t,{ready:!0,configured:!0,groups:[{groupId:l,groupName:_,topics:h||{}}],groupId:l,groupName:_,topics:h||{},debugEnabled:!!x?.debugEnabled,concurrency:x?.concurrency||{agentMaxConcurrent:null,subagentMaxConcurrent:null}}),window.location.reload()};return L(()=>{xR(t,{step:o,groupId:l,groupInfo:d,verifyGroupError:p,allowUserId:g,topics:h})},[t,o,l,d,p,g,h]),L(()=>{let _=!0;return(async()=>{try{let A=await c1({accountId:t});if(!_||!A?.ok)return;let O=Array.isArray(A.groups)?A.groups:[];if(!A.configured||O.length===0){let E={ready:!0,configured:!1,groups:[],groupId:"",groupName:"",topics:{},debugEnabled:!!A?.debugEnabled,concurrency:{agentMaxConcurrent:null,subagentMaxConcurrent:null}};v(E),vf(t,E);return}let D=O[0],j={ready:!0,configured:!0,groups:O,groupId:D.groupId,groupName:D.groupName||D.groupId,topics:D.topics||{},debugEnabled:!!A.debugEnabled,concurrency:A.concurrency||{agentMaxConcurrent:null,subagentMaxConcurrent:null}};v(j),vf(t,j),c(D.groupId),b(D.topics||{}),u({chat:{id:D.groupId,title:D.groupName||D.groupId,isForum:!0},bot:{status:"administrator",isAdmin:!0,canManageTopics:!0}}),f(null),m(""),r(E=>E<3?3:E)}catch{}})(),()=>{_=!1}},[t]),zt`
      <${$R} onBack=${e} />
      - ${x.ready?x.configured?jt` + ${x.ready?x.configured?zt`
      - ${(x.groups||[]).length<=1?jt` - <${xf} + ${(x.groups||[]).length<=1?zt` + <${yf} accountId=${t} groupId=${x.groupId} groupName=${x.groupName} @@ -8882,7 +8882,7 @@ ${t} - `:jt` + `:zt` <${wR} accountId=${t} groups=${x.groups} @@ -8891,7 +8891,7 @@ ${t} `} - `:jt` + `:zt`
      Step ${o+1} of ${Tc.length}Step ${o+1} of ${Pc.length}
      - <${h1} currentStep=${o} steps=${Tc} /> + <${h1} currentStep=${o} steps=${Pc} /> - ${o===0&&jt` + ${o===0&&zt` <${m1} accountId=${t} botInfo=${i} @@ -8918,10 +8918,10 @@ ${t} `} - ${o===1&&jt` + ${o===1&&zt` <${g1} onNext=${w} onBack=${$} /> `} - ${o===2&&jt` + ${o===2&&zt` <${b1} accountId=${t} groupId=${l} @@ -8936,7 +8936,7 @@ ${t} `} - ${o===3&&jt` + ${o===3&&zt` <${x1} accountId=${t} groupId=${l} @@ -8946,7 +8946,7 @@ ${t} `} - ${o===4&&jt` + ${o===4&&zt` <${y1} groupId=${l} groupInfo=${d} @@ -8955,18 +8955,18 @@ ${t} `} - `:jt` + `:zt`

      Loading workspace...

      `}
      - `};var kR=P.bind(T),vf=({accountId:t="default",onBack:e=()=>{}})=>kR` + `};var kR=P.bind(T),$f=({accountId:t="default",onBack:e=()=>{}})=>kR`
      <${w1} key=${t} accountId=${t} onBack=${e} />
      -`;var $f=["#7dd3fc","#22d3ee","#fbbf24","#34d399","#fb7185","#a78bfa","#f472b6","#60a5fa","#4ade80","#f97316"],wf={cyan:"border-cyan-400/30 text-status-info bg-cyan-400/10",blue:"border-blue-400/30 text-blue-300 bg-blue-400/10",purple:"border-purple-400/30 text-purple-300 bg-purple-400/10",gray:"border-gray-400/30 text-fg-muted bg-gray-400/10"},k1=[{label:"7d",value:7},{label:"30d",value:30},{label:"90d",value:90}],S1=30,C1="tokens",_1="model",kf="usageDays",Sf="usageMetric",Cf="usageBreakdown",M1=["chat","hooks","cron"],A1=[{label:"Model breakdown",value:"model"},{label:"Type breakdown",value:"source"},{label:"Agent breakdown",value:"agent"}];var Pc=t=>{let e=t instanceof Date?t:new Date(t??Date.now()),n=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return`${n}-${s}-${o}`},T1=t=>{let e=String(t||""),n=0;for(let s=0;st==="hooks"?"Hooks":t==="cron"?"Cron":"Chat",P1=(t,e)=>{let n=String(e||"model"),s=String(t||"").trim();return s?n==="source"?_f(s):n==="agent"&&s==="unknown"?"Unknown agent":s:"Unknown"};var Sn=P.bind(T),Tf=(t,e,n)=>{let s=Number(t||0),o=s===1?e:n;return`${Jt(s)} ${o}`},SR=t=>`${(Number(t||0)*100).toFixed(1)}%`,CR=t=>{let e=Number(t||0)*100;return e<=0?"text-body":e>=70?"text-status-success":e>=40?"text-amber-300":"text-status-error-muted"},_R=t=>{let e=t?.totals||{},n=Number(e.cacheReadTokens||0),s=Number(e.cacheWriteTokens||0),r=Number(e.inputTokens||0)+n+s,i=Number(e.turnCount||0),a=Number(e.totalTokens||0),l=Number(e.totalCost||0);return{cacheHitRate:r>0?n/r:0,cacheReadTokens:n,promptTokens:r,avgTokensPerTurn:i>0?a/i:0,avgCostPerTurn:i>0?l/i:0,turnCount:i}},Mf=({title:t,tokens:e,cost:n})=>Sn` +`;var wf=["#7dd3fc","#22d3ee","#fbbf24","#34d399","#fb7185","#a78bfa","#f472b6","#60a5fa","#4ade80","#f97316"],kf={cyan:"border-cyan-400/30 text-status-info bg-cyan-400/10",blue:"border-blue-400/30 text-blue-300 bg-blue-400/10",purple:"border-purple-400/30 text-purple-300 bg-purple-400/10",gray:"border-gray-400/30 text-fg-muted bg-gray-400/10"},k1=[{label:"7d",value:7},{label:"30d",value:30},{label:"90d",value:90}],S1=30,C1="tokens",_1="model",Sf="usageDays",Cf="usageMetric",_f="usageBreakdown",M1=["chat","hooks","cron"],A1=[{label:"Model breakdown",value:"model"},{label:"Type breakdown",value:"source"},{label:"Agent breakdown",value:"agent"}];var Rc=t=>{let e=t instanceof Date?t:new Date(t??Date.now()),n=e.getFullYear(),s=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return`${n}-${s}-${o}`},T1=t=>{let e=String(t||""),n=0;for(let s=0;st==="hooks"?"Hooks":t==="cron"?"Cron":"Chat",P1=(t,e)=>{let n=String(e||"model"),s=String(t||"").trim();return s?n==="source"?Mf(s):n==="agent"&&s==="unknown"?"Unknown agent":s:"Unknown"};var Sn=P.bind(T),Pf=(t,e,n)=>{let s=Number(t||0),o=s===1?e:n;return`${Jt(s)} ${o}`},SR=t=>`${(Number(t||0)*100).toFixed(1)}%`,CR=t=>{let e=Number(t||0)*100;return e<=0?"text-body":e>=70?"text-status-success":e>=40?"text-amber-300":"text-status-error-muted"},_R=t=>{let e=t?.totals||{},n=Number(e.cacheReadTokens||0),s=Number(e.cacheWriteTokens||0),r=Number(e.inputTokens||0)+n+s,i=Number(e.turnCount||0),a=Number(e.totalTokens||0),l=Number(e.totalCost||0);return{cacheHitRate:r>0?n/r:0,cacheReadTokens:n,promptTokens:r,avgTokensPerTurn:i>0?a/i:0,avgCostPerTurn:i>0?l/i:0,turnCount:i}},Af=({title:t,tokens:e,cost:n})=>Sn`

      ${t}

      @@ -8975,7 +8975,7 @@ ${t}
      ${gn(n)}
      -`,Af=({title:t,value:e,detail:n="",valueClass:s="",valueSuffix:o=""})=>Sn` +`,Tf=({title:t,value:e,detail:n="",valueClass:s="",valueSuffix:o=""})=>Sn`

      ${t}

      @@ -9025,14 +9025,14 @@ ${t}{let d=(l?.sourceBreakdown||[]).find(u=>String(u.source||"")===c)||{source:c,totalCost:0,totalTokens:0,turnCount:0};return Sn`

      - ${_f(d.source)} + ${Mf(d.source)}

      ${gn(d.totalCost)}

      ${Jt(d.totalTokens)} tok · - ${Tf(d.turnCount,"turn","turns")} + ${Pf(d.turnCount,"turn","turns")}

      `})} @@ -9054,39 +9054,39 @@ ${t}{},onMetricChange:a=()=>{},onBreakdownChange:l=()=>{}})=>{let c=_R(t);return Sn`
      - <${Mf} + <${Af} title="Today" tokens=${e.today.tokens} cost=${e.today.cost} /> - <${Mf} + <${Af} title="Last 7 days" tokens=${e.week.tokens} cost=${e.week.cost} /> - <${Mf} + <${Af} title="Last 30 days" tokens=${e.month.tokens} cost=${e.month.cost} />
      - <${Af} + <${Tf} title="Cache hit rate" value=${SR(c.cacheHitRate)} valueClass=${CR(c.cacheHitRate)} - detail=${`${qa(c.cacheReadTokens)} cached \xB7 ${qa(c.promptTokens)} prompt`} + detail=${`${Ja(c.cacheReadTokens)} cached \xB7 ${Ja(c.promptTokens)} prompt`} /> - <${Af} + <${Tf} title="Avg tokens per turn" - value=${qa(c.avgTokensPerTurn)} + value=${Ja(c.avgTokensPerTurn)} valueSuffix="tokens" - detail=${`${Tf(c.turnCount,"turn","turns")} last ${o} days`} + detail=${`${Pf(c.turnCount,"turn","turns")} last ${o} days`} /> - <${Af} + <${Tf} title="Avg cost per turn" value=${gn(c.avgCostPerTurn)} - detail=${`${Tf(c.turnCount,"turn","turns")} last ${o} days`} + detail=${`${Pf(c.turnCount,"turn","turns")} last ${o} days`} />
      @@ -9124,25 +9124,25 @@ ${t} <${MR} summary=${t} />
      - `};var zt=P.bind(T),L1=(t,e,n)=>{let s=Number(t||0),o=s===1?e:n;return`${Jt(s)} ${o}`},AR=({session:t})=>{let e=t?.labels;if(!Array.isArray(e)||e.length===0){let n=String(t?.sessionKey||t?.sessionId||"");return zt`${n}`}return zt` + `};var jt=P.bind(T),L1=(t,e,n)=>{let s=Number(t||0),o=s===1?e:n;return`${Jt(s)} ${o}`},AR=({session:t})=>{let e=t?.labels;if(!Array.isArray(e)||e.length===0){let n=String(t?.sessionKey||t?.sessionId||"");return jt`${n}`}return jt` - ${e.map(n=>zt` + ${e.map(n=>jt` ${n.label} `)} - `},TR=({item:t,expandedSessionIds:e,loadingDetailById:n,sessionDetailById:s})=>{let o=String(t.sessionId||"");if(!e.includes(o))return null;let i=s[o];if(!!n[o])return zt` + `},TR=({item:t,expandedSessionIds:e,loadingDetailById:n,sessionDetailById:s})=>{let o=String(t.sessionId||"");if(!e.includes(o))return null;let i=s[o];if(!!n[o])return jt`

      Loading session detail...

      - `;if(!i)return zt` + `;if(!i)return jt`

      Session detail not available.

      - `;let l=String(i.sessionKey||t.sessionKey||i.sessionId||t.sessionId||"").trim();return zt` + `;let l=String(i.sessionKey||t.sessionKey||i.sessionId||t.sessionId||"").trim();return jt`

      Session key

      @@ -9150,9 +9150,9 @@ ${t}

      Model breakdown

      - ${(i.modelBreakdown||[]).length===0?zt`

      No model usage recorded.

      `:zt` + ${(i.modelBreakdown||[]).length===0?jt`

      No model usage recorded.

      `:jt`
      - ${(i.modelBreakdown||[]).map(c=>zt` + ${(i.modelBreakdown||[]).map(c=>jt`
      ${c.model||"unknown"} @@ -9167,9 +9167,9 @@ ${t}

      Tool usage

      - ${(i.toolUsage||[]).length===0?zt`

      No tool calls recorded.

      `:zt` + ${(i.toolUsage||[]).length===0?jt`

      No tool calls recorded.

      `:jt`
      - ${(i.toolUsage||[]).map(c=>zt` + ${(i.toolUsage||[]).map(c=>jt`
      ${c.toolName} @@ -9183,13 +9183,13 @@ ${t}
      - `},E1=({sessions:t=[],loadingSessions:e=!1,expandedSessionIds:n=[],loadingDetailById:s={},sessionDetailById:o={},onToggleSession:r=()=>{}})=>zt` + `},E1=({sessions:t=[],loadingSessions:e=!1,expandedSessionIds:n=[],loadingDetailById:s={},sessionDetailById:o={},onToggleSession:r=()=>{}})=>jt`

      Sessions

      - ${t.length===0?zt`

      + ${t.length===0?jt`

      ${e?"Loading sessions...":"No sessions recorded yet."} -

      `:t.map(i=>zt` +

      `:t.map(i=>jt`
      -`;var I1=({sessionId:t=""})=>{let[e,n]=y(()=>{let E=Fe(),M=Number.parseInt(String(E[kf]??""),10);return[7,30,90].includes(M)?M:S1}),[s,o]=y(()=>Fe()[Sf]==="cost"?"cost":C1),[r,i]=y(()=>{let E=Fe(),M=String(E[Cf]||"").trim();return M==="source"||M==="agent"?M:_1}),[a,l]=y(null),[c,d]=y([]),[u,p]=y({}),[f,g]=y(!1),[m,h]=y(!1),[b,x]=y({}),[v,w]=y(()=>t?[String(t)]:[]),[$,S]=y(""),C=ee(null),_=ee(null),k=q(async()=>{g(!0),S("");try{let E=await lm(e);l(E.summary||null)}catch(E){S(E.message||"Could not load usage summary")}finally{g(!1)}},[e]),A=q(async()=>{h(!0);try{let E=await cm(100);d(Array.isArray(E.sessions)?E.sessions:[])}catch(E){S(E.message||"Could not load sessions")}finally{h(!1)}},[]),O=q(async E=>{let M=String(E||"").trim();if(M){x(R=>({...R,[M]:!0}));try{let R=await dm(M);p(B=>({...B,[M]:R.detail||null}))}catch(R){S(R.message||"Could not load session detail")}finally{x(R=>({...R,[M]:!1}))}}},[]);L(()=>{k()},[k]),L(()=>{let E=Fe();E[kf]=e,E[Sf]=s,E[Cf]=r,St(E)},[e,s,r]),L(()=>{A()},[A]),L(()=>{let E=String(t||"").trim();E&&(w(M=>M.includes(E)?M:[...M,E]),!u[E]&&!b[E]&&O(E))},[t,u,b,O]);let D=F(()=>{let E=Array.isArray(a?.daily)?a.daily:[],M=new Date,R=Pc(M),B=Pc(new Date(M.getTime()-10080*60*1e3)),I=Pc(new Date(M.getTime()-720*60*60*1e3)),U={tokens:0,cost:0};return E.reduce((W,G)=>{let se=Number(G.totalTokens||0),K=Number(G.totalCost||0);return String(G.date)===R&&(W.today.tokens+=se,W.today.cost+=K),String(G.date)>=B&&(W.week.tokens+=se,W.week.cost+=K),String(G.date)>=I&&(W.month.tokens+=se,W.month.cost+=K),W},{today:{...U},week:{...U},month:{...U}})},[a]),z=F(()=>{let E=Array.isArray(a?.daily)?a.daily:[],M=new Set,R=new Map,B=r==="source"?"sources":r==="agent"?"agents":"models",I=r==="source"?"source":r==="agent"?"agent":"model";for(let se of E)for(let K of se[B]||[]){let j=String(K[I]||"unknown");M.add(j),R.set(j,Number(R.get(j)||0)+Number(s==="cost"?K.totalCost||0:K.totalTokens||0))}let U=E.map(se=>Xn(String(se.date||""),{range:e<=7?"7d":"30d",valueType:"day-key"})),G=Array.from(M).sort((se,K)=>{let j=Number(R.get(se)||0),X=Number(R.get(K)||0);return X!==j?X-j:se.localeCompare(K)}).map(se=>({label:se,data:E.map(K=>{let j=(K[B]||[]).find(X=>String(X[I]||"")===se);return j?Number(s==="cost"?j.totalCost||0:j.totalTokens||0):0}),backgroundColor:T1(`${r}:${se}`)}));return{labels:U,datasets:G}},[a,s,r,e]);return L(()=>{let E=C.current;if(!(!E||!ls))return _.current&&(_.current.destroy(),_.current=null),_.current=new ls(E,{type:"bar",data:z,options:{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},scales:{x:{stacked:!0,ticks:{color:"rgba(156,163,175,1)"}},y:{stacked:!0,ticks:{color:"rgba(156,163,175,1)",callback:M=>s==="cost"?`$${Number(M).toFixed(2)}`:Jt(M)}}},plugins:{legend:{labels:{color:"rgba(209,213,219,1)",boxWidth:10,boxHeight:10}},tooltip:{callbacks:{label:M=>{let R=Number(M.parsed.y||0),B=P1(M.dataset.label,r);return s==="cost"?`${B}: ${gn(R)}`:`${B}: ${Jt(R)} tokens`}}}}}}),()=>{_.current&&(_.current.destroy(),_.current=null)}},[z,s,r]),{state:{days:e,metric:s,breakdown:r,summary:a,sessions:c,sessionDetailById:u,loadingSummary:f,loadingSessions:m,loadingDetailById:b,expandedSessionIds:v,error:$,periodSummary:D,overviewCanvasRef:C},actions:{setDays:n,setMetric:o,setBreakdown:i,loadSummary:k,loadSessionDetail:O,setExpandedSessionIds:w}}};var qi=P.bind(T),D1=({sessionId:t=""})=>{let{state:e,actions:n}=I1({sessionId:t}),s=(o,r)=>{if(r){n.setExpandedSessionIds(i=>i.includes(o)?i:[...i,o]),!e.sessionDetailById[o]&&!e.loadingDetailById[o]&&n.loadSessionDetail(o);return}n.setExpandedSessionIds(i=>i.filter(a=>a!==o))};return qi` +`;var I1=({sessionId:t=""})=>{let[e,n]=y(()=>{let E=Fe(),M=Number.parseInt(String(E[Sf]??""),10);return[7,30,90].includes(M)?M:S1}),[s,o]=y(()=>Fe()[Cf]==="cost"?"cost":C1),[r,i]=y(()=>{let E=Fe(),M=String(E[_f]||"").trim();return M==="source"||M==="agent"?M:_1}),[a,l]=y(null),[c,d]=y([]),[u,p]=y({}),[f,g]=y(!1),[m,h]=y(!1),[b,x]=y({}),[v,w]=y(()=>t?[String(t)]:[]),[$,S]=y(""),C=ee(null),_=ee(null),k=q(async()=>{g(!0),S("");try{let E=await cm(e);l(E.summary||null)}catch(E){S(E.message||"Could not load usage summary")}finally{g(!1)}},[e]),A=q(async()=>{h(!0);try{let E=await dm(100);d(Array.isArray(E.sessions)?E.sessions:[])}catch(E){S(E.message||"Could not load sessions")}finally{h(!1)}},[]),O=q(async E=>{let M=String(E||"").trim();if(M){x(R=>({...R,[M]:!0}));try{let R=await um(M);p(B=>({...B,[M]:R.detail||null}))}catch(R){S(R.message||"Could not load session detail")}finally{x(R=>({...R,[M]:!1}))}}},[]);L(()=>{k()},[k]),L(()=>{let E=Fe();E[Sf]=e,E[Cf]=s,E[_f]=r,St(E)},[e,s,r]),L(()=>{A()},[A]),L(()=>{let E=String(t||"").trim();E&&(w(M=>M.includes(E)?M:[...M,E]),!u[E]&&!b[E]&&O(E))},[t,u,b,O]);let D=F(()=>{let E=Array.isArray(a?.daily)?a.daily:[],M=new Date,R=Rc(M),B=Rc(new Date(M.getTime()-10080*60*1e3)),I=Rc(new Date(M.getTime()-720*60*60*1e3)),U={tokens:0,cost:0};return E.reduce((W,G)=>{let se=Number(G.totalTokens||0),K=Number(G.totalCost||0);return String(G.date)===R&&(W.today.tokens+=se,W.today.cost+=K),String(G.date)>=B&&(W.week.tokens+=se,W.week.cost+=K),String(G.date)>=I&&(W.month.tokens+=se,W.month.cost+=K),W},{today:{...U},week:{...U},month:{...U}})},[a]),j=F(()=>{let E=Array.isArray(a?.daily)?a.daily:[],M=new Set,R=new Map,B=r==="source"?"sources":r==="agent"?"agents":"models",I=r==="source"?"source":r==="agent"?"agent":"model";for(let se of E)for(let K of se[B]||[]){let z=String(K[I]||"unknown");M.add(z),R.set(z,Number(R.get(z)||0)+Number(s==="cost"?K.totalCost||0:K.totalTokens||0))}let U=E.map(se=>Xn(String(se.date||""),{range:e<=7?"7d":"30d",valueType:"day-key"})),G=Array.from(M).sort((se,K)=>{let z=Number(R.get(se)||0),X=Number(R.get(K)||0);return X!==z?X-z:se.localeCompare(K)}).map(se=>({label:se,data:E.map(K=>{let z=(K[B]||[]).find(X=>String(X[I]||"")===se);return z?Number(s==="cost"?z.totalCost||0:z.totalTokens||0):0}),backgroundColor:T1(`${r}:${se}`)}));return{labels:U,datasets:G}},[a,s,r,e]);return L(()=>{let E=C.current;if(!(!E||!ls))return _.current&&(_.current.destroy(),_.current=null),_.current=new ls(E,{type:"bar",data:j,options:{responsive:!0,maintainAspectRatio:!1,interaction:{mode:"index",intersect:!1},scales:{x:{stacked:!0,ticks:{color:"rgba(156,163,175,1)"}},y:{stacked:!0,ticks:{color:"rgba(156,163,175,1)",callback:M=>s==="cost"?`$${Number(M).toFixed(2)}`:Jt(M)}}},plugins:{legend:{labels:{color:"rgba(209,213,219,1)",boxWidth:10,boxHeight:10}},tooltip:{callbacks:{label:M=>{let R=Number(M.parsed.y||0),B=P1(M.dataset.label,r);return s==="cost"?`${B}: ${gn(R)}`:`${B}: ${Jt(R)} tokens`}}}}}}),()=>{_.current&&(_.current.destroy(),_.current=null)}},[j,s,r]),{state:{days:e,metric:s,breakdown:r,summary:a,sessions:c,sessionDetailById:u,loadingSummary:f,loadingSessions:m,loadingDetailById:b,expandedSessionIds:v,error:$,periodSummary:D,overviewCanvasRef:C},actions:{setDays:n,setMetric:o,setBreakdown:i,loadSummary:k,loadSessionDetail:O,setExpandedSessionIds:w}}};var qi=P.bind(T),D1=({sessionId:t=""})=>{let{state:e,actions:n}=I1({sessionId:t}),s=(o,r)=>{if(r){n.setExpandedSessionIds(i=>i.includes(o)?i:[...i,o]),!e.sessionDetailById[o]&&!e.loadingDetailById[o]&&n.loadSessionDetail(o);return}n.setExpandedSessionIds(i=>i.filter(a=>a!==o))};return qi`
      <${Ne} title="Usage" @@ -9260,7 +9260,7 @@ ${t}
      - `};var PR=P.bind(T),Rc=({sessionId:t="",onSetLocation:e=()=>{}})=>PR` + `};var PR=P.bind(T),Lc=({sessionId:t="",onSetLocation:e=()=>{}})=>PR`
      <${D1} sessionId=${t} @@ -9268,7 +9268,7 @@ ${t}e("/usage")} />
      -`;var Un="logs",Cn="terminal",Rf="watchdogConsoleTab",Lf="watchdogLogsPanelHeightPx";var RR="/css/vendor/xterm.css",N1="/api/watchdog/terminal/ws",Pf=null,O1=()=>(Pf||(Pf=Promise.all([import("./chunks/xterm-DK3X7FZB.js"),import("./chunks/addon-fit-4LH2IIZ4.js")])),Pf),B1=()=>{if(typeof document>"u"||document.getElementById("ac-xterm-css"))return;let t=document.createElement("link");t.id="ac-xterm-css",t.rel="stylesheet",t.href=RR,document.head.appendChild(t)},Ji=({panel:t=null,fitAddon:e=null,minWidthPx:n=120,minHeightPx:s=80}={})=>{if(!t||!e)return!1;let o=Number(t.clientWidth||0),r=Number(t.clientHeight||0);return ot===Cn?Cn:Un,Ef=t=>{let e=Number(t),n=Number.isFinite(e)?Math.round(e):320;return Math.max(160,n)},F1=t=>{if(!t)return 0;let e=Number.parseFloat(window.getComputedStyle(t).height||"0");return Number.isFinite(e)?e:0},Kn=t=>t==null?"\u2014":t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(0)} KB`:t<1024*1024*1024?`${(t/(1024*1024)).toFixed(0)} MB`:`${(t/(1024*1024*1024)).toFixed(1)} GB`,H1=t=>{let e=String(t?.eventType||"").trim().toLowerCase(),n=String(t?.status||"").trim().toLowerCase();return n==="failed"?{dotClass:"bg-red-500/90",label:"Failed"}:n==="ok"&&e==="health_check"?{dotClass:"bg-green-500/90",label:"Healthy"}:n==="warn"||n==="warning"?{dotClass:"bg-yellow-400/90",label:"Warning"}:{dotClass:"bg-gray-500/70",label:"Unknown"}};var W1=({active:t=!1,panelRef:e=null,hostRef:n=null}={})=>{let[s,o]=y(!1),[r,i]=y(!1),[a,l]=y(!1),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(0),b=ee(null),x=ee(null),v=ee(null),w=ee("");return L(()=>{w.current=String(f||"")},[f]),L(()=>{if(!t)return;let k=!1,A=null;(async()=>{try{o(!0),B1();let[{Terminal:z},{FitAddon:E}]=await O1();if(k)return;if(!b.current&&n?.current){let I=new z({cursorBlink:!0,fontFamily:"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",fontSize:12,lineHeight:1.2,letterSpacing:0,convertEol:!1,theme:{background:"rgba(0, 0, 0, 0)",foreground:"#d1d5db",cursor:"#67e8f9"}}),U=new E;I.loadAddon(U),I.open(n.current),U.fit(),I.attachCustomKeyEventHandler(W=>{if(W.type!=="keydown")return!0;let G=String(W.key||"").toLowerCase();return!W.metaKey||W.ctrlKey||W.altKey||W.shiftKey||G!=="k"?!0:(W.preventDefault(),I.clear(),!1)}),window.setTimeout(()=>{x.current?.fit()},120),I.focus(),I.onData(W=>{let G=v.current;!G||G.readyState!==1||G.send(JSON.stringify({type:"input",data:W}))}),b.current=I,x.current=U}let M=v.current;if(M&&M.readyState<=1){o(!1),p(!1),Ji({panel:e?.current,fitAddon:x.current}),b.current?.focus();return}let R=window.location.protocol==="https:"?"wss":"ws",B=new WebSocket(`${R}://${window.location.host}${N1}`);v.current=B,B.onopen=()=>{k||(o(!1),p(!1),i(!0),l(!1),d("Connected"),Ji({panel:e?.current,fitAddon:x.current}),b.current?.focus())},B.onmessage=I=>{let U=null;try{U=JSON.parse(String(I.data||""))}catch{return}let W=String(U?.type||"");if(W==="session"){let G=String(U?.session?.id||"");G&&g(G),d("Connected");return}if(W==="output"){b.current?.write(String(U?.data||""));return}W==="exit"&&(l(!0),i(!1),d("Session ended"))},B.onclose=()=>{k||(o(!1),p(!1),i(!1),a||d("Disconnected"))},B.onerror=()=>{k||(o(!1),p(!1),i(!1),d("Connection error"),N("Watchdog terminal connection failed","error"))}}catch{if(k)return;o(!1),p(!1),i(!1),d("Terminal failed to load"),N("Could not initialize terminal","error")}})();let D=()=>{A&&window.clearTimeout(A),A=window.setTimeout(()=>{Ji({panel:e?.current,fitAddon:x.current})},60)};return window.addEventListener("resize",D),()=>{k=!0,A&&window.clearTimeout(A),window.removeEventListener("resize",D)}},[t,a,m,e,n]),L(()=>()=>{let k=String(w.current||"");k&&id(k).catch(()=>{});let A=v.current;A&&A.readyState<=1&&A.close(),v.current=null,x.current=null,b.current&&b.current.dispose(),b.current=null},[]),{connectingTerminal:s,terminalConnected:r,terminalEnded:a,terminalStatusText:c,terminalUiSettling:u,terminalInstanceRef:b,fitNow:()=>{Ji({panel:e?.current,fitAddon:x.current})},prepareForActivate:()=>{if(!!v.current&&v.current.readyState<=1&&r){p(!1),o(!1);return}p(!0),o(!0)},clearSettling:()=>{p(!1)},restartSession:()=>{let k=String(f||"");k&&id(k).catch(()=>{});let A=v.current;A&&A.readyState<=1&&A.close(),v.current=null,b.current?.clear(),o(!0),p(!0),l(!1),i(!1),g(""),d("Connecting..."),h(O=>O+1)}}};var V1=()=>{let[t,e]=y(""),[n,s]=y(!0),[o,r]=y(!0),[i,a]=y(()=>{let h=Fe();return Lc(h?.[Rf])}),[l,c]=y(()=>{let h=Fe();return Ef(h?.[Lf])}),d=ee(null),u=ee(null),p=ee(null),f=W1({active:i===Cn,panelRef:u,hostRef:p});return L(()=>{let h=Fe();h[Rf]=Lc(i),St(h)},[i]),L(()=>{let h=!0,b=null,x=async()=>{try{let v=await pm(65536);if(!h)return;e(v||""),s(!1)}catch{if(!h)return;s(!1)}h&&(b=setTimeout(x,3e3))};return x(),()=>{h=!1,b&&clearTimeout(b)}},[]),L(()=>{let h=d.current;!h||!o||(h.scrollTop=h.scrollHeight)},[t,o]),L(()=>{let h=i===Un?d.current:u.current;if(!h||typeof ResizeObserver>"u")return()=>{};let b=null,x=new ResizeObserver(v=>{let w=v?.[0],$=Ef(F1(w?.target));c(S=>Math.abs(S-$)>=1?$:S),b&&window.clearTimeout(b),b=window.setTimeout(()=>{let S=Fe();S[Lf]=$,St(S)},120),i===Cn&&window.requestAnimationFrame(()=>{f.fitNow()})});return x.observe(h),()=>{x.disconnect(),b&&window.clearTimeout(b)}},[i]),{logs:t,loadingLogs:n,stickToBottom:o,setStickToBottom:r,activeConsoleTab:i,handleSelectConsoleTab:(h=Un)=>{let b=Lc(h);b===Cn?f.prepareForActivate():f.clearSettling(),a(b)},logsPanelHeightPx:l,logsRef:d,terminalPanelRef:u,terminalHostRef:p,onRestartTerminalSession:()=>{f.restartSession(),a(Cn)},...f}};var j1=({restartSignal:t=0,onRefreshStatuses:e=()=>{}}={})=>{let n=Re(()=>um(20),15e3);return L(()=>{if(!t)return;e(),n.refresh();let s=setTimeout(()=>{e(),n.refresh()},1200),o=setTimeout(()=>{e(),n.refresh()},3500);return()=>{clearTimeout(s),clearTimeout(o)}},[t,e,n.refresh]),{events:n.data?.events||[],refreshEvents:n.refresh}};var z1=()=>{let t=Re(()=>fm(),5e3),[e,n]=y(!1);return{resources:t.data?.resources||null,memoryExpanded:e,setMemoryExpanded:n}};var U1=({watchdogStatus:t=null,onRefreshStatuses:e=()=>{},onRefreshIncidents:n=()=>{}}={})=>{let[s,o]=y({autoRepair:!1,notificationsEnabled:!0}),[r,i]=y(!1),[a,l]=y(!1),c=a||!!(t||{})?.operationInProgress;return L(()=>{let f=!0;return(async()=>{try{let m=await hm();if(!f)return;o(m.settings||{autoRepair:!1,notificationsEnabled:!0})}catch(m){if(!f)return;N(m.message||"Could not load watchdog settings","error")}})(),()=>{f=!1}},[]),{settings:s,savingSettings:r,isRepairInProgress:c,onToggleAutoRepair:async f=>{if(!r){i(!0);try{let g=await ad({autoRepair:!!f});o(g.settings||{...s,autoRepair:!!f}),e(),N(`Auto-repair ${f?"enabled":"disabled"}`,"success")}catch(g){N(g.message||"Could not update auto-repair","error")}finally{i(!1)}}},onToggleNotifications:async f=>{if(!r){i(!0);try{let g=await ad({notificationsEnabled:!!f});o(g.settings||{...s,notificationsEnabled:!!f}),e(),N(`Notifications ${f?"enabled":"disabled"}`,"success")}catch(g){N(g.message||"Could not update notifications","error")}finally{i(!1)}}},onRepair:async()=>{if(!c){l(!0);try{let f=await ha();if(!f.ok)throw new Error(f.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{e(),n()},800)}catch(f){N(f.message||"Could not run repair","error")}finally{l(!1)}}}}};var K1=({watchdogStatus:t=null,onRefreshStatuses:e=()=>{},restartSignal:n=0}={})=>{let s=t||{},o=j1({restartSignal:n,onRefreshStatuses:e}),r=z1(),i=U1({watchdogStatus:s,onRefreshStatuses:e,onRefreshIncidents:o.refreshEvents}),a=V1();return{currentWatchdogStatus:s,events:o.events,refreshEvents:o.refreshEvents,resources:r.resources,memoryExpanded:r.memoryExpanded,setMemoryExpanded:r.setMemoryExpanded,settings:i.settings,savingSettings:i.savingSettings,onToggleAutoRepair:i.onToggleAutoRepair,onToggleNotifications:i.onToggleNotifications,onRepair:i.onRepair,isRepairInProgress:i.isRepairInProgress,logs:a.logs,loadingLogs:a.loadingLogs,stickToBottom:a.stickToBottom,setStickToBottom:a.setStickToBottom,activeConsoleTab:a.activeConsoleTab,handleSelectConsoleTab:a.handleSelectConsoleTab,connectingTerminal:a.connectingTerminal,terminalConnected:a.terminalConnected,terminalEnded:a.terminalEnded,terminalStatusText:a.terminalStatusText,terminalUiSettling:a.terminalUiSettling,onRestartTerminalSession:a.onRestartTerminalSession,logsPanelHeightPx:a.logsPanelHeightPx,logsRef:a.logsRef,terminalPanelRef:a.terminalPanelRef,terminalHostRef:a.terminalHostRef,terminalInstanceRef:a.terminalInstanceRef}};var Ec=P.bind(T),LR=t=>t==null?"bg-gray-600":"bg-cyan-400",Zi=({label:t,percent:e,detail:n,segments:s=null,expanded:o=!1,onToggle:r=null})=>Ec` +`;var Un="logs",Cn="terminal",Lf="watchdogConsoleTab",Ef="watchdogLogsPanelHeightPx";var RR="/css/vendor/xterm.css",N1="/api/watchdog/terminal/ws",Rf=null,O1=()=>(Rf||(Rf=Promise.all([import("./chunks/xterm-DK3X7FZB.js"),import("./chunks/addon-fit-4LH2IIZ4.js")])),Rf),B1=()=>{if(typeof document>"u"||document.getElementById("ac-xterm-css"))return;let t=document.createElement("link");t.id="ac-xterm-css",t.rel="stylesheet",t.href=RR,document.head.appendChild(t)},Ji=({panel:t=null,fitAddon:e=null,minWidthPx:n=120,minHeightPx:s=80}={})=>{if(!t||!e)return!1;let o=Number(t.clientWidth||0),r=Number(t.clientHeight||0);return ot===Cn?Cn:Un,If=t=>{let e=Number(t),n=Number.isFinite(e)?Math.round(e):320;return Math.max(160,n)},F1=t=>{if(!t)return 0;let e=Number.parseFloat(window.getComputedStyle(t).height||"0");return Number.isFinite(e)?e:0},Kn=t=>t==null?"\u2014":t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(0)} KB`:t<1024*1024*1024?`${(t/(1024*1024)).toFixed(0)} MB`:`${(t/(1024*1024*1024)).toFixed(1)} GB`,H1=t=>{let e=String(t?.eventType||"").trim().toLowerCase(),n=String(t?.status||"").trim().toLowerCase();return n==="failed"?{dotClass:"bg-red-500/90",label:"Failed"}:n==="ok"&&e==="health_check"?{dotClass:"bg-green-500/90",label:"Healthy"}:n==="warn"||n==="warning"?{dotClass:"bg-yellow-400/90",label:"Warning"}:{dotClass:"bg-gray-500/70",label:"Unknown"}};var W1=({active:t=!1,panelRef:e=null,hostRef:n=null}={})=>{let[s,o]=y(!1),[r,i]=y(!1),[a,l]=y(!1),[c,d]=y(""),[u,p]=y(!1),[f,g]=y(""),[m,h]=y(0),b=ee(null),x=ee(null),v=ee(null),w=ee("");return L(()=>{w.current=String(f||"")},[f]),L(()=>{if(!t)return;let k=!1,A=null;(async()=>{try{o(!0),B1();let[{Terminal:j},{FitAddon:E}]=await O1();if(k)return;if(!b.current&&n?.current){let I=new j({cursorBlink:!0,fontFamily:"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",fontSize:12,lineHeight:1.2,letterSpacing:0,convertEol:!1,theme:{background:"rgba(0, 0, 0, 0)",foreground:"#d1d5db",cursor:"#67e8f9"}}),U=new E;I.loadAddon(U),I.open(n.current),U.fit(),I.attachCustomKeyEventHandler(W=>{if(W.type!=="keydown")return!0;let G=String(W.key||"").toLowerCase();return!W.metaKey||W.ctrlKey||W.altKey||W.shiftKey||G!=="k"?!0:(W.preventDefault(),I.clear(),!1)}),window.setTimeout(()=>{x.current?.fit()},120),I.focus(),I.onData(W=>{let G=v.current;!G||G.readyState!==1||G.send(JSON.stringify({type:"input",data:W}))}),b.current=I,x.current=U}let M=v.current;if(M&&M.readyState<=1){o(!1),p(!1),Ji({panel:e?.current,fitAddon:x.current}),b.current?.focus();return}let R=window.location.protocol==="https:"?"wss":"ws",B=new WebSocket(`${R}://${window.location.host}${N1}`);v.current=B,B.onopen=()=>{k||(o(!1),p(!1),i(!0),l(!1),d("Connected"),Ji({panel:e?.current,fitAddon:x.current}),b.current?.focus())},B.onmessage=I=>{let U=null;try{U=JSON.parse(String(I.data||""))}catch{return}let W=String(U?.type||"");if(W==="session"){let G=String(U?.session?.id||"");G&&g(G),d("Connected");return}if(W==="output"){b.current?.write(String(U?.data||""));return}W==="exit"&&(l(!0),i(!1),d("Session ended"))},B.onclose=()=>{k||(o(!1),p(!1),i(!1),a||d("Disconnected"))},B.onerror=()=>{k||(o(!1),p(!1),i(!1),d("Connection error"),N("Watchdog terminal connection failed","error"))}}catch{if(k)return;o(!1),p(!1),i(!1),d("Terminal failed to load"),N("Could not initialize terminal","error")}})();let D=()=>{A&&window.clearTimeout(A),A=window.setTimeout(()=>{Ji({panel:e?.current,fitAddon:x.current})},60)};return window.addEventListener("resize",D),()=>{k=!0,A&&window.clearTimeout(A),window.removeEventListener("resize",D)}},[t,a,m,e,n]),L(()=>()=>{let k=String(w.current||"");k&&ad(k).catch(()=>{});let A=v.current;A&&A.readyState<=1&&A.close(),v.current=null,x.current=null,b.current&&b.current.dispose(),b.current=null},[]),{connectingTerminal:s,terminalConnected:r,terminalEnded:a,terminalStatusText:c,terminalUiSettling:u,terminalInstanceRef:b,fitNow:()=>{Ji({panel:e?.current,fitAddon:x.current})},prepareForActivate:()=>{if(!!v.current&&v.current.readyState<=1&&r){p(!1),o(!1);return}p(!0),o(!0)},clearSettling:()=>{p(!1)},restartSession:()=>{let k=String(f||"");k&&ad(k).catch(()=>{});let A=v.current;A&&A.readyState<=1&&A.close(),v.current=null,b.current?.clear(),o(!0),p(!0),l(!1),i(!1),g(""),d("Connecting..."),h(O=>O+1)}}};var V1=()=>{let[t,e]=y(""),[n,s]=y(!0),[o,r]=y(!0),[i,a]=y(()=>{let h=Fe();return Ec(h?.[Lf])}),[l,c]=y(()=>{let h=Fe();return If(h?.[Ef])}),d=ee(null),u=ee(null),p=ee(null),f=W1({active:i===Cn,panelRef:u,hostRef:p});return L(()=>{let h=Fe();h[Lf]=Ec(i),St(h)},[i]),L(()=>{let h=!0,b=null,x=async()=>{try{let v=await fm(65536);if(!h)return;e(v||""),s(!1)}catch{if(!h)return;s(!1)}h&&(b=setTimeout(x,3e3))};return x(),()=>{h=!1,b&&clearTimeout(b)}},[]),L(()=>{let h=d.current;!h||!o||(h.scrollTop=h.scrollHeight)},[t,o]),L(()=>{let h=i===Un?d.current:u.current;if(!h||typeof ResizeObserver>"u")return()=>{};let b=null,x=new ResizeObserver(v=>{let w=v?.[0],$=If(F1(w?.target));c(S=>Math.abs(S-$)>=1?$:S),b&&window.clearTimeout(b),b=window.setTimeout(()=>{let S=Fe();S[Ef]=$,St(S)},120),i===Cn&&window.requestAnimationFrame(()=>{f.fitNow()})});return x.observe(h),()=>{x.disconnect(),b&&window.clearTimeout(b)}},[i]),{logs:t,loadingLogs:n,stickToBottom:o,setStickToBottom:r,activeConsoleTab:i,handleSelectConsoleTab:(h=Un)=>{let b=Ec(h);b===Cn?f.prepareForActivate():f.clearSettling(),a(b)},logsPanelHeightPx:l,logsRef:d,terminalPanelRef:u,terminalHostRef:p,onRestartTerminalSession:()=>{f.restartSession(),a(Cn)},...f}};var z1=({restartSignal:t=0,onRefreshStatuses:e=()=>{}}={})=>{let n=Re(()=>pm(20),15e3);return L(()=>{if(!t)return;e(),n.refresh();let s=setTimeout(()=>{e(),n.refresh()},1200),o=setTimeout(()=>{e(),n.refresh()},3500);return()=>{clearTimeout(s),clearTimeout(o)}},[t,e,n.refresh]),{events:n.data?.events||[],refreshEvents:n.refresh}};var j1=()=>{let t=Re(()=>hm(),5e3),[e,n]=y(!1);return{resources:t.data?.resources||null,memoryExpanded:e,setMemoryExpanded:n}};var U1=({watchdogStatus:t=null,onRefreshStatuses:e=()=>{},onRefreshIncidents:n=()=>{}}={})=>{let[s,o]=y({autoRepair:!1,notificationsEnabled:!0}),[r,i]=y(!1),[a,l]=y(!1),c=a||!!(t||{})?.operationInProgress;return L(()=>{let f=!0;return(async()=>{try{let m=await mm();if(!f)return;o(m.settings||{autoRepair:!1,notificationsEnabled:!0})}catch(m){if(!f)return;N(m.message||"Could not load watchdog settings","error")}})(),()=>{f=!1}},[]),{settings:s,savingSettings:r,isRepairInProgress:c,onToggleAutoRepair:async f=>{if(!r){i(!0);try{let g=await ld({autoRepair:!!f});o(g.settings||{...s,autoRepair:!!f}),e(),N(`Auto-repair ${f?"enabled":"disabled"}`,"success")}catch(g){N(g.message||"Could not update auto-repair","error")}finally{i(!1)}}},onToggleNotifications:async f=>{if(!r){i(!0);try{let g=await ld({notificationsEnabled:!!f});o(g.settings||{...s,notificationsEnabled:!!f}),e(),N(`Notifications ${f?"enabled":"disabled"}`,"success")}catch(g){N(g.message||"Could not update notifications","error")}finally{i(!1)}}},onRepair:async()=>{if(!c){l(!0);try{let f=await ha();if(!f.ok)throw new Error(f.error||"Repair failed");N("Repair triggered","success"),setTimeout(()=>{e(),n()},800)}catch(f){N(f.message||"Could not run repair","error")}finally{l(!1)}}}}};var K1=({watchdogStatus:t=null,onRefreshStatuses:e=()=>{},restartSignal:n=0}={})=>{let s=t||{},o=z1({restartSignal:n,onRefreshStatuses:e}),r=j1(),i=U1({watchdogStatus:s,onRefreshStatuses:e,onRefreshIncidents:o.refreshEvents}),a=V1();return{currentWatchdogStatus:s,events:o.events,refreshEvents:o.refreshEvents,resources:r.resources,memoryExpanded:r.memoryExpanded,setMemoryExpanded:r.setMemoryExpanded,settings:i.settings,savingSettings:i.savingSettings,onToggleAutoRepair:i.onToggleAutoRepair,onToggleNotifications:i.onToggleNotifications,onRepair:i.onRepair,isRepairInProgress:i.isRepairInProgress,logs:a.logs,loadingLogs:a.loadingLogs,stickToBottom:a.stickToBottom,setStickToBottom:a.setStickToBottom,activeConsoleTab:a.activeConsoleTab,handleSelectConsoleTab:a.handleSelectConsoleTab,connectingTerminal:a.connectingTerminal,terminalConnected:a.terminalConnected,terminalEnded:a.terminalEnded,terminalStatusText:a.terminalStatusText,terminalUiSettling:a.terminalUiSettling,onRestartTerminalSession:a.onRestartTerminalSession,logsPanelHeightPx:a.logsPanelHeightPx,logsRef:a.logsRef,terminalPanelRef:a.terminalPanelRef,terminalHostRef:a.terminalHostRef,terminalInstanceRef:a.terminalInstanceRef}};var Ic=P.bind(T),LR=t=>t==null?"bg-gray-600":"bg-cyan-400",Zi=({label:t,percent:e,detail:n,segments:s=null,expanded:o=!1,onToggle:r=null})=>Ic`
      - ${o&&s?s.map(i=>Ec` + ${o&&s?s.map(i=>Ic`
      - `):Ec` + `):Ic`
      ${n} - ${o&&s&&s.filter(i=>i.label).map(i=>Ec` + ${o&&s&&s.filter(i=>i.label).map(i=>Ic` @@ -9307,9 +9307,9 @@ ${t}
      -`;var If=P.bind(T),G1=({resources:t=null,memoryExpanded:e=!1,onSetMemoryExpanded:n=()=>{}})=>{if(!t)return null;let s=(()=>{let o=t.processes,r=t.memory?.totalBytes,i=t.memory?.usedBytes;if(!o||!r||!i)return null;let a=[],l=0;o.gateway?.rssBytes!=null&&(l+=o.gateway.rssBytes,a.push({percent:o.gateway.rssBytes/r*100,color:"#22d3ee",label:`Gateway ${Kn(o.gateway.rssBytes)}`})),o.alphaclaw?.rssBytes!=null&&(l+=o.alphaclaw.rssBytes,a.push({percent:o.alphaclaw.rssBytes/r*100,color:"#a78bfa",label:`AlphaClaw ${Kn(o.alphaclaw.rssBytes)}`}));let c=Math.max(0,i-l);return c>0&&a.push({percent:c/r*100,color:"#4b5563",label:`Other ${Kn(c)}`}),a.length?a:null})();return If` +`;var Df=P.bind(T),G1=({resources:t=null,memoryExpanded:e=!1,onSetMemoryExpanded:n=()=>{}})=>{if(!t)return null;let s=(()=>{let o=t.processes,r=t.memory?.totalBytes,i=t.memory?.usedBytes;if(!o||!r||!i)return null;let a=[],l=0;o.gateway?.rssBytes!=null&&(l+=o.gateway.rssBytes,a.push({percent:o.gateway.rssBytes/r*100,color:"#22d3ee",label:`Gateway ${Kn(o.gateway.rssBytes)}`})),o.alphaclaw?.rssBytes!=null&&(l+=o.alphaclaw.rssBytes,a.push({percent:o.alphaclaw.rssBytes/r*100,color:"#a78bfa",label:`AlphaClaw ${Kn(o.alphaclaw.rssBytes)}`}));let c=Math.max(0,i-l);return c>0&&a.push({percent:c/r*100,color:"#4b5563",label:`Other ${Kn(c)}`}),a.length?a:null})();return Df`
      - ${e?If` + ${e?Df` <${Zi} label="Memory" detail=${`${Kn(t.memory?.usedBytes)} / ${Kn(t.memory?.totalBytes)}`} @@ -9318,7 +9318,7 @@ ${t}n(!1)} segments=${s} /> - `:If` + `:Df`
      <${Zi} label="Memory" @@ -9450,7 +9450,7 @@ ${p?"Loading logs...":u||"No logs yet."}
      -`;var Df=P.bind(T),Y1=({events:t=[],onRefresh:e=()=>{}})=>Df` +`;var Nf=P.bind(T),Y1=({events:t=[],onRefresh:e=()=>{}})=>Nf`

      Recent incidents

      @@ -9459,8 +9459,8 @@ ${p?"Loading logs...":u||"No logs yet."}
      - ${t.length===0&&Df`

      No incidents recorded.

      `} - ${t.map(n=>{let s=H1(n);return Df` + ${t.length===0&&Nf`

      No incidents recorded.

      `} + ${t.map(n=>{let s=H1(n);return Nf`
      @@ -9492,7 +9492,7 @@ ${typeof n.details=="string"?n.details:JSON.stringify(n.details||{},null,2)} `;var DR=P.bind(T),X1=({gatewayStatus:t=null,openclawVersion:e=null,watchdogStatus:n=null,onRefreshStatuses:s=()=>{},restartingGateway:o=!1,onRestartGateway:r,restartSignal:i=0,openclawUpdateInProgress:a=!1,onOpenclawVersionActionComplete:l=()=>{},onOpenclawUpdate:c})=>{let d=K1({watchdogStatus:n,onRefreshStatuses:s,restartSignal:i});return DR`
      - <${bc} + <${xc} status=${t} openclawVersion=${e} restarting=${o} @@ -9543,7 +9543,7 @@ ${typeof n.details=="string"?n.details:JSON.stringify(n.details||{},null,2)}
      - `};var NR=P.bind(T),Nf=({statusData:t=null,watchdogStatus:e=null,onRefreshStatuses:n=()=>{},restartingGateway:s=!1,onRestartGateway:o=()=>{},restartSignal:r=0,openclawUpdateInProgress:i=!1,onOpenclawVersionActionComplete:a=()=>{},onOpenclawUpdate:l=()=>{}})=>NR` + `};var NR=P.bind(T),Of=({statusData:t=null,watchdogStatus:e=null,onRefreshStatuses:n=()=>{},restartingGateway:s=!1,onRestartGateway:o=()=>{},restartSignal:r=0,openclawUpdateInProgress:i=!1,onOpenclawVersionActionComplete:a=()=>{},onOpenclawUpdate:l=()=>{}})=>NR`
      <${X1} gatewayStatus=${t?.gateway||null} @@ -9558,7 +9558,7 @@ ${typeof n.details=="string"?n.details:JSON.stringify(n.details||{},null,2)}
      -`;var Of=P.bind(T),Q1=({visible:t,name:e,mode:n="webhook",onModeChange:s=()=>{},onNameChange:o=()=>{},canCreate:r=!1,creating:i=!1,onCreate:a=()=>{},onClose:l=()=>{}})=>{let{sessions:c,loading:d,error:u,destinationSessionKey:p,setDestinationSessionKey:f,selectedDestination:g}=Ds({enabled:t,resetKey:String(t)}),h=String(e||"").trim().toLowerCase()||"{name}",b=`/hooks/${h}`,x=n==="oauth"?`${window.location.origin}/oauth/{id}`:`${window.location.origin}${b}`;return t?Of` +`;var Bf=P.bind(T),Q1=({visible:t,name:e,mode:n="webhook",onModeChange:s=()=>{},onNameChange:o=()=>{},canCreate:r=!1,creating:i=!1,onCreate:a=()=>{},onClose:l=()=>{}})=>{let{sessions:c,loading:d,error:u,destinationSessionKey:p,setDestinationSessionKey:f,selectedDestination:g}=Ds({enabled:t,resetKey:String(t)}),h=String(e||"").trim().toLowerCase()||"{name}",b=`/hooks/${h}`,x=n==="oauth"?`${window.location.origin}/oauth/{id}`:`${window.location.origin}${b}`;return t?Bf` <${De} visible=${t} onClose=${l} @@ -9566,7 +9566,7 @@ ${typeof n.details=="string"?n.details:JSON.stringify(n.details||{},null,2)} <${Ne} title="Create Webhook" - actions=${Of` + actions=${Bf`
      - ${n==="oauth"?Of` + ${n==="oauth"?Bf`

      For OAuth providers that can't send auth headers. AlphaClaw @@ -9671,8 +9671,8 @@ ${typeof n.details=="string"?n.details:JSON.stringify(n.details||{},null,2)}

      - `:null};var Bf=/^[a-z0-9]+(?:-[a-z0-9]+)*$/,ek=["all","success","error"],tk=t=>Wo(t,{fallback:"\u2014"}),Ic=t=>Yn(t,{fallback:"\u2014"}),nk=t=>{let e=Number(t||0);return!Number.isFinite(e)||e<=0?"0B":e<1024?`${e}B`:e<1024*1024?`${(e/1024).toFixed(1)}KB`:`${(e/(1024*1024)).toFixed(1)}MB`},sk=t=>t==="red"?"bg-red-500":t==="yellow"?"bg-yellow-500":"bg-green-500",ok=t=>t==="success"?{dotClass:"bg-green-500/90",textClass:"text-status-success-muted/90"}:t==="error"?{dotClass:"bg-red-500/90",textClass:"text-status-error-muted"}:{dotClass:"bg-gray-500/70",textClass:"text-fg-muted"},Ff=(t="")=>String(t||"").trim().split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(" ")||"Main Agent",Os=t=>{if(typeof t=="string")try{let e=JSON.parse(t);return JSON.stringify(e,null,2)}catch{return t}try{return JSON.stringify(t||{},null,2)}catch{return String(t||"")}},rk=({hookName:t="",webhook:e=null,request:n=null})=>{let s=String(e?.path||"").trim()||(t?`/hooks/${t}`:"/hooks/unknown"),o=n?.gatewayStatus==null?"n/a":String(n.gatewayStatus);return["Investigate this failed webhook request and share findings before fixing anything.","Reply with your diagnosis first, including the likely root cause, any relevant risks, and what you would change if I approve a fix.","",`Webhook: ${s}`,`Request ID: ${String(n?.id||"unknown")}`,`Time: ${String(n?.createdAt||"unknown")}`,`Method: ${String(n?.method||"unknown")}`,`Source IP: ${String(n?.sourceIp||"unknown")}`,`Gateway status: ${o}`,`Transform path: ${String(e?.transformPath||"unknown")}`,`Payload truncated: ${n?.payloadTruncated?"yes":"no"}`,"","Headers:",Os(n?.headers),"","Payload:",Os(n?.payload),"","Gateway response:",Os(n?.gatewayBody)].join(` -`)};var ik=({selectedHookName:t="",effectiveAuthMode:e="headers",webhookUrl:n="",webhookUrlWithQueryToken:s="",bearerTokenValue:o="",refreshNonce:r=0})=>{let[i,a]=y("all"),[l,c]=y(()=>new Set),[d,u]=y(null),[p,f]=y(null),[g,m]=y(null),h=Re(async()=>t?await rg(t,{limit:25,offset:0,status:i}):{requests:[]},5e3,{enabled:!!t}),b=h.data?.requests||[];L(()=>{t&&h.refresh()},[r,h.refresh,t]);let x=q((A,O)=>{c(D=>{let z=new Set(D);return O?z.add(A):z.delete(A),z})},[]),v=q(A=>{a(A),c(new Set),setTimeout(()=>h.refresh(),0)},[h.refresh]),w=q(()=>{a("all"),c(new Set),m(null),f(null),u(null)},[]),$=q(async(A,O)=>{try{await navigator.clipboard.writeText(String(A||"")),N(`${O} copied`,"success")}catch{N(`Could not copy ${String(O||"value").toLowerCase()}`,"error")}},[]),S=F(()=>e==="query"?s:n,[e,n,s]),C=F(()=>{let A={"Content-Type":"application/json"};return e==="headers"&&(A.Authorization=o),A},[o,e]),_=q(async A=>{if(!(!A||d===A.id)){if(A.payloadTruncated){N("Cannot replay a truncated payload","warning");return}u(A.id);try{let O=await fetch(S,{method:"POST",headers:C,body:String(A.payload||"")}),D=await O.text(),z=null;try{z=D?JSON.parse(D):null}catch{z=null}let E=z?.ok===!1?z?.error||"Webhook rejected":O.ok?"":z?.error||D||`HTTP ${O.status}`;if(E){N(`Replay failed: ${E}`,"error");return}N("Request replayed","success"),setTimeout(()=>h.refresh(),0)}catch(O){N(O.message||"Could not replay request","error")}finally{u(null)}}},[d,C,S,h.refresh]),k=q(async A=>{if(!(!t||!A?.id||p===A.id))try{f(A.id);let O=await ig(t,A.id);m(O?.request||A)}catch(O){N(O.message||"Could not load webhook request details","error")}finally{f(null)}},[p,t]);return{state:{requests:b,statusFilter:i,expandedRows:l,replayingRequestId:d,debugLoadingRequestId:p,debugRequest:g},actions:{refreshRequests:h.refresh,handleRequestRowToggle:x,handleSetStatusFilter:v,handleReplayRequest:_,handleCopyRequestField:$,handleAskAgentToDebug:k,setDebugRequest:m,resetState:w}}};var ho=P.bind(T),ak=({selectedHookName:t="",effectiveAuthMode:e="headers",webhookUrl:n="",webhookUrlWithQueryToken:s="",bearerTokenValue:o="",selectedWebhook:r=null,refreshNonce:i=0})=>{let{state:a,actions:l}=ik({selectedHookName:t,effectiveAuthMode:e,webhookUrl:n,webhookUrlWithQueryToken:s,bearerTokenValue:o,refreshNonce:i}),{requests:c,statusFilter:d,expandedRows:u,replayingRequestId:p,debugLoadingRequestId:f,debugRequest:g}=a,m=F(()=>rk({hookName:t,webhook:r,request:g}),[g,t,r]);return ho` + `:null};var Ff=/^[a-z0-9]+(?:-[a-z0-9]+)*$/,ek=["all","success","error"],tk=t=>Wo(t,{fallback:"\u2014"}),Dc=t=>Yn(t,{fallback:"\u2014"}),nk=t=>{let e=Number(t||0);return!Number.isFinite(e)||e<=0?"0B":e<1024?`${e}B`:e<1024*1024?`${(e/1024).toFixed(1)}KB`:`${(e/(1024*1024)).toFixed(1)}MB`},sk=t=>t==="red"?"bg-red-500":t==="yellow"?"bg-yellow-500":"bg-green-500",ok=t=>t==="success"?{dotClass:"bg-green-500/90",textClass:"text-status-success-muted/90"}:t==="error"?{dotClass:"bg-red-500/90",textClass:"text-status-error-muted"}:{dotClass:"bg-gray-500/70",textClass:"text-fg-muted"},Hf=(t="")=>String(t||"").trim().split(/[-_\s]+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join(" ")||"Main Agent",Os=t=>{if(typeof t=="string")try{let e=JSON.parse(t);return JSON.stringify(e,null,2)}catch{return t}try{return JSON.stringify(t||{},null,2)}catch{return String(t||"")}},rk=({hookName:t="",webhook:e=null,request:n=null})=>{let s=String(e?.path||"").trim()||(t?`/hooks/${t}`:"/hooks/unknown"),o=n?.gatewayStatus==null?"n/a":String(n.gatewayStatus);return["Investigate this failed webhook request and share findings before fixing anything.","Reply with your diagnosis first, including the likely root cause, any relevant risks, and what you would change if I approve a fix.","",`Webhook: ${s}`,`Request ID: ${String(n?.id||"unknown")}`,`Time: ${String(n?.createdAt||"unknown")}`,`Method: ${String(n?.method||"unknown")}`,`Source IP: ${String(n?.sourceIp||"unknown")}`,`Gateway status: ${o}`,`Transform path: ${String(e?.transformPath||"unknown")}`,`Payload truncated: ${n?.payloadTruncated?"yes":"no"}`,"","Headers:",Os(n?.headers),"","Payload:",Os(n?.payload),"","Gateway response:",Os(n?.gatewayBody)].join(` +`)};var ik=({selectedHookName:t="",effectiveAuthMode:e="headers",webhookUrl:n="",webhookUrlWithQueryToken:s="",bearerTokenValue:o="",refreshNonce:r=0})=>{let[i,a]=y("all"),[l,c]=y(()=>new Set),[d,u]=y(null),[p,f]=y(null),[g,m]=y(null),h=Re(async()=>t?await ig(t,{limit:25,offset:0,status:i}):{requests:[]},5e3,{enabled:!!t}),b=h.data?.requests||[];L(()=>{t&&h.refresh()},[r,h.refresh,t]);let x=q((A,O)=>{c(D=>{let j=new Set(D);return O?j.add(A):j.delete(A),j})},[]),v=q(A=>{a(A),c(new Set),setTimeout(()=>h.refresh(),0)},[h.refresh]),w=q(()=>{a("all"),c(new Set),m(null),f(null),u(null)},[]),$=q(async(A,O)=>{try{await navigator.clipboard.writeText(String(A||"")),N(`${O} copied`,"success")}catch{N(`Could not copy ${String(O||"value").toLowerCase()}`,"error")}},[]),S=F(()=>e==="query"?s:n,[e,n,s]),C=F(()=>{let A={"Content-Type":"application/json"};return e==="headers"&&(A.Authorization=o),A},[o,e]),_=q(async A=>{if(!(!A||d===A.id)){if(A.payloadTruncated){N("Cannot replay a truncated payload","warning");return}u(A.id);try{let O=await fetch(S,{method:"POST",headers:C,body:String(A.payload||"")}),D=await O.text(),j=null;try{j=D?JSON.parse(D):null}catch{j=null}let E=j?.ok===!1?j?.error||"Webhook rejected":O.ok?"":j?.error||D||`HTTP ${O.status}`;if(E){N(`Replay failed: ${E}`,"error");return}N("Request replayed","success"),setTimeout(()=>h.refresh(),0)}catch(O){N(O.message||"Could not replay request","error")}finally{u(null)}}},[d,C,S,h.refresh]),k=q(async A=>{if(!(!t||!A?.id||p===A.id))try{f(A.id);let O=await ag(t,A.id);m(O?.request||A)}catch(O){N(O.message||"Could not load webhook request details","error")}finally{f(null)}},[p,t]);return{state:{requests:b,statusFilter:i,expandedRows:l,replayingRequestId:d,debugLoadingRequestId:p,debugRequest:g},actions:{refreshRequests:h.refresh,handleRequestRowToggle:x,handleSetStatusFilter:v,handleReplayRequest:_,handleCopyRequestField:$,handleAskAgentToDebug:k,setDebugRequest:m,resetState:w}}};var ho=P.bind(T),ak=({selectedHookName:t="",effectiveAuthMode:e="headers",webhookUrl:n="",webhookUrlWithQueryToken:s="",bearerTokenValue:o="",selectedWebhook:r=null,refreshNonce:i=0})=>{let{state:a,actions:l}=ik({selectedHookName:t,effectiveAuthMode:e,webhookUrl:n,webhookUrlWithQueryToken:s,bearerTokenValue:o,refreshNonce:i}),{requests:c,statusFilter:d,expandedRows:u,replayingRequestId:p,debugLoadingRequestId:f,debugRequest:g}=a,m=F(()=>rk({hookName:t,webhook:r,request:g}),[g,t,r]);return ho`

      Request history

      @@ -9703,7 +9703,7 @@ ${typeof n.details=="string"?n.details:JSON.stringify(n.details||{},null,2)}▸ - ${Ic(h.createdAt)} + ${Dc(h.createdAt)} @@ -9800,7 +9800,7 @@ ${Os(h.gatewayBody)} `} - <${mc} + <${gc} visible=${!!g} title="Ask agent to debug" messageLabel="Debug request" @@ -9813,7 +9813,7 @@ ${Os(h.gatewayBody)}{try{return await fa({message:b,sessionKey:h}),N("Debug request sent to agent","success"),!0}catch(x){return N(x.message||"Could not send debug request","error"),!1}}} />
      - `};var lk=(t=null)=>{let e=String(t?.channel||"").trim(),n=String(t?.to||"").trim();if(!e||!n)return null;let s=String(t?.agentId||"").trim();return{channel:e,to:n,...s?{agentId:s}:{}}},OR=(t=[],e=null)=>{let n=lk(e);if(!n)return"";let s=String(n?.agentId||"").trim(),o=t.find(r=>{let i=String(r?.replyChannel||"").trim(),a=String(r?.replyTo||"").trim(),l=nf(Je(r)),c=s?l===s:!0;return i===n.channel&&a===n.to&&c});return String(o?.key||"").trim()},BR=(t=null,e=null)=>!t&&!e?!0:!t||!e?!1:String(t.channel||"").trim()===String(e.channel||"").trim()&&String(t.to||"").trim()===String(e.to||"").trim()&&String(t.agentId||"").trim()===String(e.agentId||"").trim(),ck=({selectedHookName:t="",onBackToList:e=()=>{},onRestartRequired:n=()=>{},onTestWebhookSent:s=()=>{}})=>{let[o,r]=y("headers"),[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(!1),[g,m]=y(!1),[h,b]=y(!1),[x,v]=y(!1),w=F(()=>`/api/webhooks/${encodeURIComponent(String(t||""))}`,[t]),$=ct(w,async()=>t&&(await eg(t)).webhook||null,{enabled:!!t,maxAgeMs:15e3}),S=ct("/api/agents",Ao,{enabled:!0,maxAgeMs:3e4}),C=Array.isArray(S.data?.agents)?S.data.agents:[],_=F(()=>new Map(C.map(te=>[String(te?.id||"").trim(),String(te?.name||"").trim()||Ff(te?.id)])),[C]),k=$.data,A=!!t&&$.loading,O=$.error,D=!!k?.managed,z=String(k?.agentId||"main").trim()||"main",E=_.get(z)||Ff(z),M=String(k?.channel||"last").trim()||"last",R=F(()=>[t,k?.agentId,k?.channel,k?.to].map(te=>String(te||"").trim()).join("|"),[t,k?.agentId,k?.channel,k?.to]),{sessions:B,loading:I,error:U,destinationSessionKey:W,setDestinationSessionKey:G,selectedDestination:se}=Ds({enabled:!!t&&!D,resetKey:R}),K=k?.fullUrl||`.../hooks/${t}`,j=String(k?.oauthCallbackUrl||"").trim(),X=!!j,de=F(()=>{if(!X)return"";try{let te=new URL(j);return te.searchParams.has("code")||te.searchParams.set("code","TEST_AUTH_CODE"),te.searchParams.has("state")||te.searchParams.set("state","TEST_STATE"),te.searchParams.has("message")||te.searchParams.set("message","OAuth callback test"),te.toString()}catch{let te=j.includes("?")?"&":"?";return`${j}${te}code=TEST_AUTH_CODE&state=TEST_STATE&message=OAuth%20callback%20test`}},[X,j]),le=k?.queryStringUrl||`${K}${K.includes("?")?"&":"?"}token=`,ie=F(()=>{try{let te=new URL(le);return String(te.searchParams.get("token")||"").trim()}catch{return""}},[le]),ne=k?.authHeaderValue||(ie?`Authorization: Bearer ${ie}`:"Authorization: Bearer "),xe=ne.startsWith("Authorization: ")?ne.slice(15):ne,he=F(()=>String(t||"").trim().toLowerCase()==="gmail"?{payload:{account:"test@gmail.com",messages:[{id:"test-message-1",from:"alerts@example.com",to:["test@gmail.com"],subject:"Test Gmail webhook event",snippet:"This is a simulated Gmail message payload for webhook testing.",receivedAt:new Date().toISOString()}]}}:{source:"manual-test",message:`This is a test of the ${t||"webhook"} webhook.`},[t]),ue=JSON.stringify(he),me=`curl -X POST "${K}" -H "Content-Type: application/json" -H "${ne}" -d '${ue}'`,V=`curl -X POST "${le}" -H "Content-Type: application/json" -d '${ue}'`,J=`curl -X GET "${de}"`,oe=D?"headers":o,ce=X?J:oe==="query"?V:me,be=q(()=>{$.refresh({force:!0}),S.refresh({force:!0})},[S.refresh,$.refresh]);L(()=>{if(!t||D||!k)return;if(!Array.isArray(B)||B.length<=0){G("");return}let te=OR(B,k);G(te)},[B,t,k,D,G]);let Ve=F(()=>lk(k),[k]),et=F(()=>!BR(Ve,se),[Ve,se]),wt=q(async()=>{if(!(!t||D||x||!et)){v(!0);try{let te=await sg(t,{destination:se||null});te?.restartRequired&&n(!0),te?.syncWarning&&N(`Updated, but git-sync failed: ${te.syncWarning}`,"warning"),N("Webhook destination updated","success"),be()}catch(te){N(te.message||"Could not update webhook destination","error")}finally{v(!1)}}},[et,n,be,x,se,t,D]),Nt=q(async()=>{if(!(!t||h)){b(!0);try{let te=X?await fetch(de,{method:"GET"}):await fetch(oe==="query"?le:K,{method:"POST",headers:{"Content-Type":"application/json",...oe==="headers"?{Authorization:xe}:{}},body:ue});s();let Ee=await te.text(),ke=null;try{ke=Ee?JSON.parse(Ee):null}catch{ke=null}let H=ke?.ok===!1?ke?.error||"Webhook rejected":te.ok?"":ke?.error||Ee||`HTTP ${te.status}`;if(H){N(`Test webhook failed: ${H}`,"error");return}N("Test webhook sent","success")}catch(te){N(te.message||"Could not send test webhook","error")}finally{b(!1)}}},[xe,oe,X,de,s,t,h,ue,K,le]),Q=q(async()=>{if(!(!t||i)){a(!0);try{let te=await ng(t,{deleteTransformDir:d});te.restartRequired&&n(!0),e(),c(!1),u(!0),N("Webhook removed","success"),te.deletedTransformDir&&N("Transform directory deleted","success"),te.syncWarning&&N(`Deleted, but git-sync failed: ${te.syncWarning}`,"warning"),be()}catch(te){N(te.message||"Could not delete webhook","error")}finally{a(!1)}}},[d,i,e,n,be,t]),pe=q(async()=>{if(!(!t||p)){f(!0);try{await og(t),N("OAuth callback rotated","success"),m(!1),be()}catch(te){N(te.message||"Could not rotate OAuth callback","error")}finally{f(!1)}}},[be,p,t]);return{state:{authMode:o,selectedWebhook:k,isWebhookLoading:A,webhookLoadError:O,selectedWebhookManaged:D,selectedDeliveryAgentName:E,selectedDeliveryChannel:M,selectableSessions:B,loadingDestinationSessions:I,destinationLoadError:U,destinationSessionKey:W,destinationDirty:et,savingDestination:x,webhookUrl:K,oauthCallbackUrl:j,hasOauthCallback:X,webhookUrlWithQueryToken:le,authHeaderValue:ne,bearerTokenValue:xe,effectiveAuthMode:oe,activeCurlCommand:ce,deleting:i,showDeleteConfirm:l,deleteTransformDir:d,rotatingOauthCallback:p,showRotateOauthConfirm:g,sendingTestWebhook:h},actions:{refreshDetail:be,setAuthMode:r,setDestinationSessionKey:G,setShowDeleteConfirm:c,setDeleteTransformDir:u,setShowRotateOauthConfirm:m,handleSaveDestination:wt,handleDeleteConfirmed:Q,handleRotateOauthCallback:pe,handleSendTestWebhook:Nt}}};var ft=P.bind(T),dk=({selectedHookName:t="",onBackToList:e=()=>{},onRestartRequired:n=()=>{},onOpenFile:s=()=>{}})=>{let[o,r]=y(0),i=q(()=>{r(W=>W+1)},[]),{state:a,actions:l}=ck({selectedHookName:t,onBackToList:e,onRestartRequired:n,onTestWebhookSent:i}),{authMode:c,selectedWebhook:d,isWebhookLoading:u,webhookLoadError:p,selectedWebhookManaged:f,selectedDeliveryAgentName:g,selectedDeliveryChannel:m,selectableSessions:h,loadingDestinationSessions:b,destinationLoadError:x,destinationSessionKey:v,destinationDirty:w,savingDestination:$,webhookUrl:S,oauthCallbackUrl:C,hasOauthCallback:_,webhookUrlWithQueryToken:k,authHeaderValue:A,bearerTokenValue:O,effectiveAuthMode:D,activeCurlCommand:z,deleting:E,showDeleteConfirm:M,deleteTransformDir:R,sendingTestWebhook:B,rotatingOauthCallback:I,showRotateOauthConfirm:U}=a;return ft` + `};var lk=(t=null)=>{let e=String(t?.channel||"").trim(),n=String(t?.to||"").trim();if(!e||!n)return null;let s=String(t?.agentId||"").trim();return{channel:e,to:n,...s?{agentId:s}:{}}},OR=(t=[],e=null)=>{let n=lk(e);if(!n)return"";let s=String(n?.agentId||"").trim(),o=t.find(r=>{let i=String(r?.replyChannel||"").trim(),a=String(r?.replyTo||"").trim(),l=sf(Je(r)),c=s?l===s:!0;return i===n.channel&&a===n.to&&c});return String(o?.key||"").trim()},BR=(t=null,e=null)=>!t&&!e?!0:!t||!e?!1:String(t.channel||"").trim()===String(e.channel||"").trim()&&String(t.to||"").trim()===String(e.to||"").trim()&&String(t.agentId||"").trim()===String(e.agentId||"").trim(),ck=({selectedHookName:t="",onBackToList:e=()=>{},onRestartRequired:n=()=>{},onTestWebhookSent:s=()=>{}})=>{let[o,r]=y("headers"),[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!0),[p,f]=y(!1),[g,m]=y(!1),[h,b]=y(!1),[x,v]=y(!1),w=F(()=>`/api/webhooks/${encodeURIComponent(String(t||""))}`,[t]),$=ct(w,async()=>t&&(await tg(t)).webhook||null,{enabled:!!t,maxAgeMs:15e3}),S=ct("/api/agents",Ao,{enabled:!0,maxAgeMs:3e4}),C=Array.isArray(S.data?.agents)?S.data.agents:[],_=F(()=>new Map(C.map(te=>[String(te?.id||"").trim(),String(te?.name||"").trim()||Hf(te?.id)])),[C]),k=$.data,A=!!t&&$.loading,O=$.error,D=!!k?.managed,j=String(k?.agentId||"main").trim()||"main",E=_.get(j)||Hf(j),M=String(k?.channel||"last").trim()||"last",R=F(()=>[t,k?.agentId,k?.channel,k?.to].map(te=>String(te||"").trim()).join("|"),[t,k?.agentId,k?.channel,k?.to]),{sessions:B,loading:I,error:U,destinationSessionKey:W,setDestinationSessionKey:G,selectedDestination:se}=Ds({enabled:!!t&&!D,resetKey:R}),K=k?.fullUrl||`.../hooks/${t}`,z=String(k?.oauthCallbackUrl||"").trim(),X=!!z,de=F(()=>{if(!X)return"";try{let te=new URL(z);return te.searchParams.has("code")||te.searchParams.set("code","TEST_AUTH_CODE"),te.searchParams.has("state")||te.searchParams.set("state","TEST_STATE"),te.searchParams.has("message")||te.searchParams.set("message","OAuth callback test"),te.toString()}catch{let te=z.includes("?")?"&":"?";return`${z}${te}code=TEST_AUTH_CODE&state=TEST_STATE&message=OAuth%20callback%20test`}},[X,z]),le=k?.queryStringUrl||`${K}${K.includes("?")?"&":"?"}token=`,ie=F(()=>{try{let te=new URL(le);return String(te.searchParams.get("token")||"").trim()}catch{return""}},[le]),ne=k?.authHeaderValue||(ie?`Authorization: Bearer ${ie}`:"Authorization: Bearer "),xe=ne.startsWith("Authorization: ")?ne.slice(15):ne,he=F(()=>String(t||"").trim().toLowerCase()==="gmail"?{payload:{account:"test@gmail.com",messages:[{id:"test-message-1",from:"alerts@example.com",to:["test@gmail.com"],subject:"Test Gmail webhook event",snippet:"This is a simulated Gmail message payload for webhook testing.",receivedAt:new Date().toISOString()}]}}:{source:"manual-test",message:`This is a test of the ${t||"webhook"} webhook.`},[t]),ue=JSON.stringify(he),me=`curl -X POST "${K}" -H "Content-Type: application/json" -H "${ne}" -d '${ue}'`,V=`curl -X POST "${le}" -H "Content-Type: application/json" -d '${ue}'`,J=`curl -X GET "${de}"`,oe=D?"headers":o,ce=X?J:oe==="query"?V:me,be=q(()=>{$.refresh({force:!0}),S.refresh({force:!0})},[S.refresh,$.refresh]);L(()=>{if(!t||D||!k)return;if(!Array.isArray(B)||B.length<=0){G("");return}let te=OR(B,k);G(te)},[B,t,k,D,G]);let Ve=F(()=>lk(k),[k]),et=F(()=>!BR(Ve,se),[Ve,se]),wt=q(async()=>{if(!(!t||D||x||!et)){v(!0);try{let te=await og(t,{destination:se||null});te?.restartRequired&&n(!0),te?.syncWarning&&N(`Updated, but git-sync failed: ${te.syncWarning}`,"warning"),N("Webhook destination updated","success"),be()}catch(te){N(te.message||"Could not update webhook destination","error")}finally{v(!1)}}},[et,n,be,x,se,t,D]),Nt=q(async()=>{if(!(!t||h)){b(!0);try{let te=X?await fetch(de,{method:"GET"}):await fetch(oe==="query"?le:K,{method:"POST",headers:{"Content-Type":"application/json",...oe==="headers"?{Authorization:xe}:{}},body:ue});s();let Ee=await te.text(),ke=null;try{ke=Ee?JSON.parse(Ee):null}catch{ke=null}let H=ke?.ok===!1?ke?.error||"Webhook rejected":te.ok?"":ke?.error||Ee||`HTTP ${te.status}`;if(H){N(`Test webhook failed: ${H}`,"error");return}N("Test webhook sent","success")}catch(te){N(te.message||"Could not send test webhook","error")}finally{b(!1)}}},[xe,oe,X,de,s,t,h,ue,K,le]),Q=q(async()=>{if(!(!t||i)){a(!0);try{let te=await sg(t,{deleteTransformDir:d});te.restartRequired&&n(!0),e(),c(!1),u(!0),N("Webhook removed","success"),te.deletedTransformDir&&N("Transform directory deleted","success"),te.syncWarning&&N(`Deleted, but git-sync failed: ${te.syncWarning}`,"warning"),be()}catch(te){N(te.message||"Could not delete webhook","error")}finally{a(!1)}}},[d,i,e,n,be,t]),pe=q(async()=>{if(!(!t||p)){f(!0);try{await rg(t),N("OAuth callback rotated","success"),m(!1),be()}catch(te){N(te.message||"Could not rotate OAuth callback","error")}finally{f(!1)}}},[be,p,t]);return{state:{authMode:o,selectedWebhook:k,isWebhookLoading:A,webhookLoadError:O,selectedWebhookManaged:D,selectedDeliveryAgentName:E,selectedDeliveryChannel:M,selectableSessions:B,loadingDestinationSessions:I,destinationLoadError:U,destinationSessionKey:W,destinationDirty:et,savingDestination:x,webhookUrl:K,oauthCallbackUrl:z,hasOauthCallback:X,webhookUrlWithQueryToken:le,authHeaderValue:ne,bearerTokenValue:xe,effectiveAuthMode:oe,activeCurlCommand:ce,deleting:i,showDeleteConfirm:l,deleteTransformDir:d,rotatingOauthCallback:p,showRotateOauthConfirm:g,sendingTestWebhook:h},actions:{refreshDetail:be,setAuthMode:r,setDestinationSessionKey:G,setShowDeleteConfirm:c,setDeleteTransformDir:u,setShowRotateOauthConfirm:m,handleSaveDestination:wt,handleDeleteConfirmed:Q,handleRotateOauthCallback:pe,handleSendTestWebhook:Nt}}};var ft=P.bind(T),dk=({selectedHookName:t="",onBackToList:e=()=>{},onRestartRequired:n=()=>{},onOpenFile:s=()=>{}})=>{let[o,r]=y(0),i=q(()=>{r(W=>W+1)},[]),{state:a,actions:l}=ck({selectedHookName:t,onBackToList:e,onRestartRequired:n,onTestWebhookSent:i}),{authMode:c,selectedWebhook:d,isWebhookLoading:u,webhookLoadError:p,selectedWebhookManaged:f,selectedDeliveryAgentName:g,selectedDeliveryChannel:m,selectableSessions:h,loadingDestinationSessions:b,destinationLoadError:x,destinationSessionKey:v,destinationDirty:w,savingDestination:$,webhookUrl:S,oauthCallbackUrl:C,hasOauthCallback:_,webhookUrlWithQueryToken:k,authHeaderValue:A,bearerTokenValue:O,effectiveAuthMode:D,activeCurlCommand:j,deleting:E,showDeleteConfirm:M,deleteTransformDir:R,sendingTestWebhook:B,rotatingOauthCallback:I,showRotateOauthConfirm:U}=a;return ft`
      @@ -9976,13 +9976,13 @@ ${Os(h.gatewayBody)}
      @@ -10088,7 +10088,7 @@ ${Os(h.gatewayBody)}
      - `};var uk=({onSelectHook:t=()=>{}})=>{let e=Re(Qm,15e3),n=e.data?.webhooks||[],s=!e.data&&!e.error,o=q(r=>{t(r)},[t]);return{state:{webhooks:n,isListLoading:s},actions:{refreshList:e.refresh,handleSelectHook:o}}};var Bs=P.bind(T),pk=({onSelectHook:t=()=>{}})=>{let{state:e,actions:n}=uk({onSelectHook:t}),{webhooks:s,isListLoading:o}=e;return Bs` + `};var uk=({onSelectHook:t=()=>{}})=>{let e=Re(eg,15e3),n=e.data?.webhooks||[],s=!e.data&&!e.error,o=q(r=>{t(r)},[t]);return{state:{webhooks:n,isListLoading:s},actions:{refreshList:e.refresh,handleSelectHook:o}}};var Bs=P.bind(T),pk=({onSelectHook:t=()=>{}})=>{let{state:e,actions:n}=uk({onSelectHook:t}),{webhooks:s,isListLoading:o}=e;return Bs`
      ${o?Bs`

      Loading webhooks...

      `:null} ${!o&&s.length===0?Bs`

      @@ -10123,7 +10123,7 @@ ${Os(h.gatewayBody)} - ${Ic(r.lastReceived)} + ${Dc(r.lastReceived)} `:null}

      - `};var Xi=P.bind(T),fk=({selectedHookName:t="",onSelectHook:e=()=>{},onBackToList:n=()=>{},onRestartRequired:s=()=>{},onOpenFile:o=()=>{}})=>{let[r,i]=y(!1),[a,l]=y(""),[c,d]=y("webhook"),[u,p]=y(!1),f=F(()=>{let m=String(a||"").trim().toLowerCase();return Bf.test(m)},[a]),g=q(async(m=null,h="webhook")=>{let b=String(a||"").trim().toLowerCase();if(!Bf.test(b)){N("Name must be lowercase letters, numbers, and hyphens","error");return}if(!u){p(!0);try{let x=await tg(b,{destination:m,oauthCallback:h==="oauth"});i(!1),l(""),d("webhook"),e(b),x.restartRequired&&s(!0),h==="oauth"&&x?.webhook?.oauthCallbackUrl?N("Webhook + OAuth callback created","success"):N("Webhook created","success"),x.syncWarning&&N(`Created, but git-sync failed: ${x.syncWarning}`,"warning")}catch(x){N(x.message||"Could not create webhook","error")}finally{p(!1)}}},[u,a,s,e]);return Xi` + `};var Xi=P.bind(T),fk=({selectedHookName:t="",onSelectHook:e=()=>{},onBackToList:n=()=>{},onRestartRequired:s=()=>{},onOpenFile:o=()=>{}})=>{let[r,i]=y(!1),[a,l]=y(""),[c,d]=y("webhook"),[u,p]=y(!1),f=F(()=>{let m=String(a||"").trim().toLowerCase();return Ff.test(m)},[a]),g=q(async(m=null,h="webhook")=>{let b=String(a||"").trim().toLowerCase();if(!Ff.test(b)){N("Name must be lowercase letters, numbers, and hyphens","error");return}if(!u){p(!0);try{let x=await ng(b,{destination:m,oauthCallback:h==="oauth"});i(!1),l(""),d("webhook"),e(b),x.restartRequired&&s(!0),h==="oauth"&&x?.webhook?.oauthCallbackUrl?N("Webhook + OAuth callback created","success"):N("Webhook created","success"),x.syncWarning&&N(`Created, but git-sync failed: ${x.syncWarning}`,"warning")}catch(x){N(x.message||"Could not create webhook","error")}finally{p(!1)}}},[u,a,s,e]);return Xi`
      <${Ne} title="Webhooks" @@ -10211,7 +10211,7 @@ ${Os(h.gatewayBody)}{i(!1),d("webhook")}} />
      - `};var FR=P.bind(T),Dc=({hookName:t="",routeHistoryRef:e=null,getCurrentPath:n=()=>"",onSetLocation:s=()=>{},onRestartRequired:o=()=>{},onNavigateToBrowseFile:r=()=>{}})=>FR` + `};var FR=P.bind(T),Nc=({hookName:t="",routeHistoryRef:e=null,getCurrentPath:n=()=>"",onSetLocation:s=()=>{},onRestartRequired:o=()=>{},onNavigateToBrowseFile:r=()=>{}})=>FR`
      <${fk} selectedHookName=${t} @@ -10221,7 +10221,7 @@ ${Os(h.gatewayBody)}r(String(a||"").trim(),{view:"edit"})} />
      - `;var hk=()=>{let[t,e]=y([]),[n,s]=y(!0),[o,r]=y(!1),i=q(async()=>{s(!0);try{let u=await Ao();e(Array.isArray(u?.agents)?u.agents:[])}finally{s(!1)}},[]);L(()=>{i()},[i]);let a=q(async u=>{r(!0);try{let p=await Jm(u);return e(f=>[...f,p.agent]),p.agent}finally{r(!1)}},[]),l=q(async(u,p)=>{r(!0);try{let f=await Zm(u,p);return e(g=>g.map(m=>m.id===u?f.agent:m)),f.agent}finally{r(!1)}},[]),c=q(async u=>{r(!0);try{await Xm(u),e(p=>p.map(f=>({...f,default:f.id===u})))}finally{r(!1)}},[]),d=q(async(u,p={})=>{r(!0);try{await Ym(u,p),e(f=>f.filter(g=>g.id!==u))}finally{r(!1)}},[]);return{state:{agents:t,loading:n,saving:o},actions:{create:a,loadAgents:i,remove:d,setDefault:c,update:l}}};var HR=[{type:"file",path:"openclaw.json"},{type:"directory",path:"hooks/transforms"}],mk=t=>String(t||"").trim().replace(/^\/+|\/+$/g,""),WR=(t,e)=>{let n=mk(t);if(!n||!e||typeof e!="object")return!1;let s=String(e.type||"").toLowerCase(),o=mk(e.path);return o?s==="directory"?n===o||n.startsWith(`${o}/`):s==="file"?n===o:!1:!1},gk=t=>HR.some(e=>WR(t,e));var bk=({location:t=""}={})=>{let[n,s]=y(null),[o,r]=y(!1),[i,a]=y(null),[l,c]=y(null),[d,u]=y(!1),[p,f]=y(!1),[g,m]=y(!1),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(0),[S,C]=y(15e3),[_,k]=y(!1),[A,O]=y(!1),[D,z]=y(!1),[E,M]=y(null),[R,B]=y(null),[I,U]=y(null),W=Re(So,S,{enabled:n===!0&&!D&&_,cacheKey:"/api/status"}),G=Re(am,S,{enabled:n===!0&&!D&&_,cacheKey:"/api/watchdog/status"}),se=Re(ua,S,{enabled:n===!0&&!D&&_,cacheKey:"/api/doctor/status"}),K=E||W.data||null,j=R||G.data?.status||null,X=I||se.data?.status||null,de=g||h,le=q(()=>{W.refresh(),G.refresh(),se.refresh()},[se.refresh,W.refresh,G.refresh]);L(()=>{Om().then(V=>s(V.onboarded)).catch(()=>s(!1)),Dm().then(V=>r(!!V.authEnabled)).catch(()=>{})},[]),L(()=>{if(n!==!0)return k(!1),()=>{};let V=setTimeout(()=>{k(!0)},5e3);return()=>{clearTimeout(V)}},[n]),L(()=>{if(n!==!0)return;let V=!1,oe=(()=>{if(!V)try{return Wh({onOpen:()=>{V||z(!0)},onMessage:(ce={})=>{V||(ce.status&&typeof ce.status=="object"&&M(ce.status),ce.watchdogStatus&&typeof ce.watchdogStatus=="object"&&B(ce.watchdogStatus),ce.doctorStatus&&typeof ce.doctorStatus=="object"&&U(ce.doctorStatus))},onError:()=>{V||z(!1)}})}catch{return z(!1),null}})();return()=>{V=!0,z(!1),typeof oe=="function"&&oe()}},[n]),L(()=>{if(!n)return;let V=!0,J=async(ce=!1)=>{try{let be=await bm(ce);if(!V)return;a(be.currentVersion||null),c(be.latestVersion||null),u(!!be.hasUpdate)}catch{}};J(!0);let oe=setInterval(()=>J(!1),300*1e3);return()=>{V=!1,clearInterval(oe)}},[n]);let ie=q(async()=>{if(n)try{let V=await rm();m(!!V.restartRequired),v(!!V.restartInProgress)}catch{}},[n]);L(()=>{n&&ie()},[n,ie]),L(()=>{if(n!==!0)return;let V=t.startsWith("/general")||t.startsWith("/watchdog"),J=K?.gateway??null,oe=String(j?.health||"").toLowerCase(),ce=String(j?.lifecycle||"").toLowerCase(),be=oe==="unknown"||ce==="restarting"||ce==="stopped"||!!j?.operationInProgress,et=V&&(be||(!J||J!=="running"))?2e3:15e3;C(wt=>wt===et?wt:et)},[t,n,K?.gateway,j?.health,j?.lifecycle,j?.operationInProgress]),L(()=>{if(!n||!g&&!x)return;let V=setInterval(ie,2e3);return()=>clearInterval(V)},[n,g,x,ie]),L(()=>{let V=J=>{let oe=String(J?.detail?.path||"");gk(oe)&&b(!0)};return window.addEventListener("alphaclaw:browse-file-saved",V),()=>{window.removeEventListener("alphaclaw:browse-file-saved",V)}},[]),L(()=>{let V=()=>m(!0);return window.addEventListener("alphaclaw:restart-required",V),()=>{window.removeEventListener("alphaclaw:restart-required",V)}},[]);let ne=q(async()=>{if(!x){v(!0);try{let V=await om();if(!V?.ok)throw new Error(V?.error||"Gateway restart failed");m(!!V.restartRequired),b(!1),$(Date.now()),le(),N("Gateway restarted","success"),setTimeout(ie,800)}catch(V){N(V.message||"Restart failed","error"),setTimeout(ie,800)}finally{v(!1)}}},[ie,le,x]),xe=q(async()=>{if(A)return{ok:!1,error:"OpenClaw update already in progress"};O(!0);try{return await ma()}finally{O(!1),le(),setTimeout(le,1200),setTimeout(le,3500),setTimeout(ie,1200)}},[A,ie,le]),he=q(({type:V})=>{V==="update"&&(le(),setTimeout(le,1200))},[le]),ue=q(async()=>{if(!p){f(!0);try{let V=await ym();V.ok?(N("AlphaClaw updated \u2014 restarting...","success"),setTimeout(()=>window.location.reload(),5e3)):(N(V.error||"AlphaClaw update failed","error"),f(!1))}catch(V){N(V.message||"Could not update AlphaClaw","error"),f(!1)}}},[p]),me=q(async()=>{m(!1),b(!1);try{await im(),await ie()}catch(V){N(V.message||"Could not dismiss restart banner","error"),await ie()}},[ie]);return{state:{acHasUpdate:d,acLatest:l,acUpdating:p,acVersion:i,authEnabled:o,gatewayRestartSignal:w,isAnyRestartRequired:de,onboarded:n,openclawUpdateInProgress:A,restartingGateway:x,sharedDoctorStatus:X,sharedStatus:K,sharedWatchdogStatus:j},actions:{handleAcUpdate:ue,handleGatewayRestart:ne,handleOnboardingComplete:()=>s(!0),handleOpenclawUpdate:xe,handleOpenclawVersionActionComplete:he,refreshSharedStatuses:le,dismissRestartBanner:me,setRestartRequired:m}}};var VR=220,jR=180,zR=460,xk=t=>Math.max(jR,Math.min(zR,t)),yk=()=>{let t=ee(null),e=ee(null),[n,s]=y(!1),[o,r]=y(()=>{let h=Fe();return Number.isFinite(h.sidebarWidthPx)?xk(h.sidebarWidthPx):VR}),[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!1),p=q(h=>{e.current&&!e.current.contains(h.target)&&s(!1)},[]);L(()=>{if(n)return document.addEventListener("click",p,!0),()=>document.removeEventListener("click",p,!0)},[p,n]),L(()=>{if(!l)return;let h=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.body.style.overflow=h}},[l]),L(()=>{let h=Fe();h.sidebarWidthPx=o,St(h)},[o]);let f=q(h=>{let b=t.current;if(!b)return;let x=b.getBoundingClientRect(),v=xk(Math.round(h-x.left));r(v)},[]),g=q(h=>{h.preventDefault(),a(!0),f(h.clientX)},[f]);L(()=>{if(!i)return()=>{};let h=w=>f(w.clientX),b=()=>a(!1);window.addEventListener("pointermove",h),window.addEventListener("pointerup",b);let x=document.body.style.userSelect,v=document.body.style.cursor;return document.body.style.userSelect="none",document.body.style.cursor="col-resize",()=>{window.removeEventListener("pointermove",h),window.removeEventListener("pointerup",b),document.body.style.userSelect=x,document.body.style.cursor=v}},[i,f]);let m=q(h=>{let b=h.currentTarget.scrollTop>0;u(x=>x===b?x:b)},[]);return{refs:{appShellRef:t,menuRef:e},state:{isResizingSidebar:i,menuOpen:n,mobileSidebarOpen:l,mobileTopbarScrolled:d,sidebarWidthPx:o},actions:{closeMobileSidebar:()=>c(!1),handlePaneScroll:m,onSidebarResizerPointerDown:g,onToggleMenu:()=>s(h=>!h),setMenuOpen:s,setMobileSidebarOpen:c}}};var mo="general",vk=[{label:"Setup",items:[{id:"general",label:"General"}]},{label:"Monitoring",items:[{id:"cron",label:"Cron"},{id:"usage",label:"Usage"},{id:"doctor",label:"Doctor"},{id:"watchdog",label:"Watchdog"}]},{label:"Config",items:[{id:"models",label:"Models"},{id:"envars",label:"Envars"},{id:"webhooks",label:"Webhooks"},{id:"nodes",label:"Nodes"}]}],$k=({isBrowseRoute:t=!1,location:e=""}={})=>t?"browse":e.startsWith("/telegram")?"":e.startsWith("/models")?"models":e.startsWith("/agents")?"agents":e.startsWith("/providers")?"models":e.startsWith("/watchdog")?"watchdog":e.startsWith("/cron")?"cron":e.startsWith("/usage")?"usage":e.startsWith("/doctor")?"doctor":e.startsWith("/nodes")?"nodes":e.startsWith("/envars")?"envars":e.startsWith("/webhooks")?"webhooks":mo;var Nc=t=>String(t||"").replace(/^\/+|\/+$/g,""),Qi=(t,e={})=>{let n=String(e?.view||"edit"),s=String(t||"").split("/").filter(Boolean).map(a=>encodeURIComponent(a)).join("/"),o=s?`/browse/${s}`:"/browse",r=new URLSearchParams;n==="diff"&&s&&r.set("view","diff"),e.line&&r.set("line",String(e.line)),e.lineEnd&&r.set("lineEnd",String(e.lineEnd));let i=r.toString();return i?`${o}?${i}`:o},wk=({location:t="",browsePreviewPath:e=""}={})=>{let n=t.startsWith("/browse"),s=n?String(t||"").split("?")[0]:"",o=n&&String(t||"").includes("?")?String(t||"").split("?").slice(1).join("?"):"",r=n?s.replace(/^\/browse\/?/,"").split("/").filter(Boolean).map(u=>{try{return decodeURIComponent(u)}catch{return u}}).join("/"):"",i=e||r,a=n?new URLSearchParams(o):null,l=!e&&a?.get("view")==="diff"?"diff":"edit",c=Number.parseInt(a?.get("line")||"",10)||0,d=Number.parseInt(a?.get("lineEnd")||"",10)||0;return{activeBrowsePath:i,browseLineEndTarget:d,browseLineTarget:c,browseViewerMode:l,isBrowseRoute:n,selectedBrowsePath:r}};var Hf="browseLastPath",kk="lastMenuRoute",Sk=({location:t="",setLocation:e=()=>{},onCloseMobileSidebar:n=()=>{}}={})=>{let[s,o]=y(()=>t.startsWith("/browse")?"browse":"menu"),[r,i]=y(()=>{let k=Fe();return typeof k[Hf]=="string"?k[Hf]:""}),[a,l]=y(()=>{let A=Fe()[kk];return typeof A=="string"&&A.startsWith("/")&&!A.startsWith("/browse")&&!A.startsWith("/agents")?A:`/${mo}`}),[c,d]=y(""),u=ee([]),{activeBrowsePath:p,browseLineEndTarget:f,browseLineTarget:g,browseViewerMode:m,isBrowseRoute:h,selectedBrowsePath:b}=wk({location:t,browsePreviewPath:c}),x=$k({isBrowseRoute:h,location:t});L(()=>{o(k=>t.startsWith("/browse")?"browse":k==="browse"?"menu":k)},[t]),L(()=>{t.startsWith("/browse")||d("")},[t]),L(()=>{let k=u.current;k[k.length-1]!==t&&(k.push(t),k.length>100&&k.shift())},[t]),L(()=>{t.startsWith("/browse")||t.startsWith("/telegram")||l(k=>k===t?k:t)},[t]),L(()=>{h&&b&&i(k=>k===b?k:b)},[h,b]),L(()=>{let k=()=>{if(!h||m!=="diff")return;let A=String(b||"").trim();A&&e(Qi(A,{view:"edit"}))};return window.addEventListener("alphaclaw:browse-git-synced",k),()=>{window.removeEventListener("alphaclaw:browse-git-synced",k)}},[m,h,b,e]),L(()=>{let k=Fe();k[Hf]=r,k[kk]=a,St(k)},[r,a]);let v=q(k=>{e(`/${k}`),n()},[n,e]),w=q(k=>{let A=Nc(k);d(A)},[]),$=q((k,A={})=>{let O=Nc(k),D=!!A.directory||String(k||"").trim().endsWith("/"),z=D&&!!A.preservePreview,E=Nc(c||b||"");d(z&&E&&E!==O?E:"");let R=D?{...A,view:"edit"}:A;e(Qi(O,R)),n()},[c,n,b,e]),S=q(k=>{if(o(k),k==="menu"&&t.startsWith("/browse")){d(""),e(a||`/${mo}`);return}if(k==="browse"&&!t.startsWith("/browse")){e(Qi(r));return}},[r,a,t,e]),C=q(k=>{e(`/${k}`),n()},[n,e]),_=q(()=>{e(`/${mo}`),n()},[n,e]);return{state:{activeBrowsePath:p,browseLineEndTarget:f,browseLineTarget:g,browsePreviewPath:c,browseViewerMode:m,isBrowseRoute:h,routeHistoryRef:u,selectedBrowsePath:b,selectedNavId:x,sidebarTab:s},actions:{buildBrowseRoute:Qi,clearBrowsePreview:()=>d(""),exitSubScreen:_,handleBrowsePreviewFile:w,handleSelectNavItem:C,handleSelectSidebarTab:S,navigateToBrowseFile:$,navigateToSubScreen:v},constants:{kNavSections:vk}}};var Wf=()=>{let t=window.location.hash.replace(/^#/,"");return t?t.startsWith("/")?t:`/${t}`:`/${mo}`},Ck=()=>{let[t,e]=y(Wf);L(()=>{let s=()=>e(Wf());return window.addEventListener("hashchange",s),()=>window.removeEventListener("hashchange",s)},[]);let n=q(s=>{let o=s.startsWith("/")?s:`/${s}`,r=`#${o}`;if(window.location.hash!==r){window.location.hash=o;return}e(o)},[]);return[t,n]},Vf=Wf;var Ut=P.bind(T),_k="doctorWarningDismissedUntilMs",UR=10080*60*1e3,Oc="__alphaclawPendingCreateAgent",KR=()=>{let[t,e]=ca(),[n,s]=y(()=>{let C=Fe();return Number(C[_k]||0)}),{state:o,actions:r}=bk({location:t}),{refs:i,state:a,actions:l}=yk(),{state:c,actions:d,constants:u}=Sk({location:t,setLocation:e,onCloseMobileSidebar:l.closeMobileSidebar}),{state:p,actions:f}=hk(),g=t.startsWith("/agents"),m=t.startsWith("/cron"),h=t.startsWith("/envars"),b=t.startsWith("/models"),x=t.startsWith("/nodes"),v=(()=>{let C=t.match(/^\/agents\/([^/]+)/);return C?decodeURIComponent(C[1]):""})(),w=(()=>{let C=t.match(/^\/agents\/[^/]+\/([^/]+)/);return(C?C[1]:"")==="tools"?"tools":"overview"})(),$=(()=>{let C=t.match(/^\/cron\/([^/]+)/);return C?decodeURIComponent(C[1]):""})();L(()=>{g&&(window[Oc]||v||p.loading||p.agents.length===0||e(`/agents/${encodeURIComponent(p.agents[0].id)}`))},[g,v,p.loading,p.agents,e]),L(()=>{g&&window[Oc]&&(window[Oc]=!1,window.setTimeout(()=>{window.dispatchEvent(new Event("alphaclaw:create-agent"))},0))},[g]),L(()=>{let C=Fe();C[_k]=n,St(C)},[n]);let S=async()=>{l.setMenuOpen(!1),await Nm();try{window.localStorage.clear(),window.sessionStorage.clear()}catch{}window.location.href="/login.html"};return o.onboarded===null?Ut` + `;var hk=()=>{let[t,e]=y([]),[n,s]=y(!0),[o,r]=y(!1),i=q(async()=>{s(!0);try{let u=await Ao();e(Array.isArray(u?.agents)?u.agents:[])}finally{s(!1)}},[]);L(()=>{i()},[i]);let a=q(async u=>{r(!0);try{let p=await Zm(u);return e(f=>[...f,p.agent]),p.agent}finally{r(!1)}},[]),l=q(async(u,p)=>{r(!0);try{let f=await Ym(u,p);return e(g=>g.map(m=>m.id===u?f.agent:m)),f.agent}finally{r(!1)}},[]),c=q(async u=>{r(!0);try{await Qm(u),e(p=>p.map(f=>({...f,default:f.id===u})))}finally{r(!1)}},[]),d=q(async(u,p={})=>{r(!0);try{await Xm(u,p),e(f=>f.filter(g=>g.id!==u))}finally{r(!1)}},[]);return{state:{agents:t,loading:n,saving:o},actions:{create:a,loadAgents:i,remove:d,setDefault:c,update:l}}};var HR=[{type:"file",path:"openclaw.json"},{type:"directory",path:"hooks/transforms"}],mk=t=>String(t||"").trim().replace(/^\/+|\/+$/g,""),WR=(t,e)=>{let n=mk(t);if(!n||!e||typeof e!="object")return!1;let s=String(e.type||"").toLowerCase(),o=mk(e.path);return o?s==="directory"?n===o||n.startsWith(`${o}/`):s==="file"?n===o:!1:!1},gk=t=>HR.some(e=>WR(t,e));var bk=({location:t=""}={})=>{let[n,s]=y(null),[o,r]=y(!1),[i,a]=y(null),[l,c]=y(null),[d,u]=y(!1),[p,f]=y(!1),[g,m]=y(!1),[h,b]=y(!1),[x,v]=y(!1),[w,$]=y(0),[S,C]=y(15e3),[_,k]=y(!1),[A,O]=y(!1),[D,j]=y(!1),[E,M]=y(null),[R,B]=y(null),[I,U]=y(null),W=Re(So,S,{enabled:n===!0&&!D&&_,cacheKey:"/api/status"}),G=Re(lm,S,{enabled:n===!0&&!D&&_,cacheKey:"/api/watchdog/status"}),se=Re(ua,S,{enabled:n===!0&&!D&&_,cacheKey:"/api/doctor/status"}),K=E||W.data||null,z=R||G.data?.status||null,X=I||se.data?.status||null,de=g||h,le=q(()=>{W.refresh(),G.refresh(),se.refresh()},[se.refresh,W.refresh,G.refresh]);L(()=>{Bm().then(V=>s(V.onboarded)).catch(()=>s(!1)),Nm().then(V=>r(!!V.authEnabled)).catch(()=>{})},[]),L(()=>{if(n!==!0)return k(!1),()=>{};let V=setTimeout(()=>{k(!0)},5e3);return()=>{clearTimeout(V)}},[n]),L(()=>{if(n!==!0)return;let V=!1,oe=(()=>{if(!V)try{return Vh({onOpen:()=>{V||j(!0)},onMessage:(ce={})=>{V||(ce.status&&typeof ce.status=="object"&&M(ce.status),ce.watchdogStatus&&typeof ce.watchdogStatus=="object"&&B(ce.watchdogStatus),ce.doctorStatus&&typeof ce.doctorStatus=="object"&&U(ce.doctorStatus))},onError:()=>{V||j(!1)}})}catch{return j(!1),null}})();return()=>{V=!0,j(!1),typeof oe=="function"&&oe()}},[n]),L(()=>{if(!n)return;let V=!0,J=async(ce=!1)=>{try{let be=await xm(ce);if(!V)return;a(be.currentVersion||null),c(be.latestVersion||null),u(!!be.hasUpdate)}catch{}};J(!0);let oe=setInterval(()=>J(!1),300*1e3);return()=>{V=!1,clearInterval(oe)}},[n]);let ie=q(async()=>{if(n)try{let V=await im();m(!!V.restartRequired),v(!!V.restartInProgress)}catch{}},[n]);L(()=>{n&&ie()},[n,ie]),L(()=>{if(n!==!0)return;let V=t.startsWith("/general")||t.startsWith("/watchdog"),J=K?.gateway??null,oe=String(z?.health||"").toLowerCase(),ce=String(z?.lifecycle||"").toLowerCase(),be=oe==="unknown"||ce==="restarting"||ce==="stopped"||!!z?.operationInProgress,et=V&&(be||(!J||J!=="running"))?2e3:15e3;C(wt=>wt===et?wt:et)},[t,n,K?.gateway,z?.health,z?.lifecycle,z?.operationInProgress]),L(()=>{if(!n||!g&&!x)return;let V=setInterval(ie,2e3);return()=>clearInterval(V)},[n,g,x,ie]),L(()=>{let V=J=>{let oe=String(J?.detail?.path||"");gk(oe)&&b(!0)};return window.addEventListener("alphaclaw:browse-file-saved",V),()=>{window.removeEventListener("alphaclaw:browse-file-saved",V)}},[]),L(()=>{let V=()=>m(!0);return window.addEventListener("alphaclaw:restart-required",V),()=>{window.removeEventListener("alphaclaw:restart-required",V)}},[]);let ne=q(async()=>{if(!x){v(!0);try{let V=await rm();if(!V?.ok)throw new Error(V?.error||"Gateway restart failed");m(!!V.restartRequired),b(!1),$(Date.now()),le(),N("Gateway restarted","success"),setTimeout(ie,800)}catch(V){N(V.message||"Restart failed","error"),setTimeout(ie,800)}finally{v(!1)}}},[ie,le,x]),xe=q(async()=>{if(A)return{ok:!1,error:"OpenClaw update already in progress"};O(!0);try{return await ma()}finally{O(!1),le(),setTimeout(le,1200),setTimeout(le,3500),setTimeout(ie,1200)}},[A,ie,le]),he=q(({type:V})=>{V==="update"&&(le(),setTimeout(le,1200))},[le]),ue=q(async()=>{if(!p){f(!0);try{let V=await vm();V.ok?(N("AlphaClaw updated \u2014 restarting...","success"),setTimeout(()=>window.location.reload(),5e3)):(N(V.error||"AlphaClaw update failed","error"),f(!1))}catch(V){N(V.message||"Could not update AlphaClaw","error"),f(!1)}}},[p]),me=q(async()=>{m(!1),b(!1);try{await am(),await ie()}catch(V){N(V.message||"Could not dismiss restart banner","error"),await ie()}},[ie]);return{state:{acHasUpdate:d,acLatest:l,acUpdating:p,acVersion:i,authEnabled:o,gatewayRestartSignal:w,isAnyRestartRequired:de,onboarded:n,openclawUpdateInProgress:A,restartingGateway:x,sharedDoctorStatus:X,sharedStatus:K,sharedWatchdogStatus:z},actions:{handleAcUpdate:ue,handleGatewayRestart:ne,handleOnboardingComplete:()=>s(!0),handleOpenclawUpdate:xe,handleOpenclawVersionActionComplete:he,refreshSharedStatuses:le,dismissRestartBanner:me,setRestartRequired:m}}};var VR=220,zR=180,jR=460,xk=t=>Math.max(zR,Math.min(jR,t)),yk=()=>{let t=ee(null),e=ee(null),[n,s]=y(!1),[o,r]=y(()=>{let h=Fe();return Number.isFinite(h.sidebarWidthPx)?xk(h.sidebarWidthPx):VR}),[i,a]=y(!1),[l,c]=y(!1),[d,u]=y(!1),p=q(h=>{e.current&&!e.current.contains(h.target)&&s(!1)},[]);L(()=>{if(n)return document.addEventListener("click",p,!0),()=>document.removeEventListener("click",p,!0)},[p,n]),L(()=>{if(!l)return;let h=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.body.style.overflow=h}},[l]),L(()=>{let h=Fe();h.sidebarWidthPx=o,St(h)},[o]);let f=q(h=>{let b=t.current;if(!b)return;let x=b.getBoundingClientRect(),v=xk(Math.round(h-x.left));r(v)},[]),g=q(h=>{h.preventDefault(),a(!0),f(h.clientX)},[f]);L(()=>{if(!i)return()=>{};let h=w=>f(w.clientX),b=()=>a(!1);window.addEventListener("pointermove",h),window.addEventListener("pointerup",b);let x=document.body.style.userSelect,v=document.body.style.cursor;return document.body.style.userSelect="none",document.body.style.cursor="col-resize",()=>{window.removeEventListener("pointermove",h),window.removeEventListener("pointerup",b),document.body.style.userSelect=x,document.body.style.cursor=v}},[i,f]);let m=q(h=>{let b=h.currentTarget.scrollTop>0;u(x=>x===b?x:b)},[]);return{refs:{appShellRef:t,menuRef:e},state:{isResizingSidebar:i,menuOpen:n,mobileSidebarOpen:l,mobileTopbarScrolled:d,sidebarWidthPx:o},actions:{closeMobileSidebar:()=>c(!1),handlePaneScroll:m,onSidebarResizerPointerDown:g,onToggleMenu:()=>s(h=>!h),setMenuOpen:s,setMobileSidebarOpen:c}}};var mo="general",vk=[{label:"Setup",items:[{id:"general",label:"General"}]},{label:"Monitoring",items:[{id:"cron",label:"Cron"},{id:"usage",label:"Usage"},{id:"doctor",label:"Doctor"},{id:"watchdog",label:"Watchdog"}]},{label:"Config",items:[{id:"models",label:"Models"},{id:"envars",label:"Envars"},{id:"webhooks",label:"Webhooks"},{id:"nodes",label:"Nodes"}]}],$k=({isBrowseRoute:t=!1,location:e=""}={})=>t?"browse":e.startsWith("/telegram")?"":e.startsWith("/models")?"models":e.startsWith("/agents")?"agents":e.startsWith("/providers")?"models":e.startsWith("/watchdog")?"watchdog":e.startsWith("/cron")?"cron":e.startsWith("/usage")?"usage":e.startsWith("/doctor")?"doctor":e.startsWith("/nodes")?"nodes":e.startsWith("/envars")?"envars":e.startsWith("/webhooks")?"webhooks":mo;var Oc=t=>String(t||"").replace(/^\/+|\/+$/g,""),Qi=(t,e={})=>{let n=String(e?.view||"edit"),s=String(t||"").split("/").filter(Boolean).map(a=>encodeURIComponent(a)).join("/"),o=s?`/browse/${s}`:"/browse",r=new URLSearchParams;n==="diff"&&s&&r.set("view","diff"),e.line&&r.set("line",String(e.line)),e.lineEnd&&r.set("lineEnd",String(e.lineEnd));let i=r.toString();return i?`${o}?${i}`:o},wk=({location:t="",browsePreviewPath:e=""}={})=>{let n=t.startsWith("/browse"),s=n?String(t||"").split("?")[0]:"",o=n&&String(t||"").includes("?")?String(t||"").split("?").slice(1).join("?"):"",r=n?s.replace(/^\/browse\/?/,"").split("/").filter(Boolean).map(u=>{try{return decodeURIComponent(u)}catch{return u}}).join("/"):"",i=e||r,a=n?new URLSearchParams(o):null,l=!e&&a?.get("view")==="diff"?"diff":"edit",c=Number.parseInt(a?.get("line")||"",10)||0,d=Number.parseInt(a?.get("lineEnd")||"",10)||0;return{activeBrowsePath:i,browseLineEndTarget:d,browseLineTarget:c,browseViewerMode:l,isBrowseRoute:n,selectedBrowsePath:r}};var Wf="browseLastPath",kk="lastMenuRoute",Sk=({location:t="",setLocation:e=()=>{},onCloseMobileSidebar:n=()=>{}}={})=>{let[s,o]=y(()=>t.startsWith("/browse")?"browse":"menu"),[r,i]=y(()=>{let k=Fe();return typeof k[Wf]=="string"?k[Wf]:""}),[a,l]=y(()=>{let A=Fe()[kk];return typeof A=="string"&&A.startsWith("/")&&!A.startsWith("/browse")&&!A.startsWith("/agents")?A:`/${mo}`}),[c,d]=y(""),u=ee([]),{activeBrowsePath:p,browseLineEndTarget:f,browseLineTarget:g,browseViewerMode:m,isBrowseRoute:h,selectedBrowsePath:b}=wk({location:t,browsePreviewPath:c}),x=$k({isBrowseRoute:h,location:t});L(()=>{o(k=>t.startsWith("/browse")?"browse":k==="browse"?"menu":k)},[t]),L(()=>{t.startsWith("/browse")||d("")},[t]),L(()=>{let k=u.current;k[k.length-1]!==t&&(k.push(t),k.length>100&&k.shift())},[t]),L(()=>{t.startsWith("/browse")||t.startsWith("/telegram")||l(k=>k===t?k:t)},[t]),L(()=>{h&&b&&i(k=>k===b?k:b)},[h,b]),L(()=>{let k=()=>{if(!h||m!=="diff")return;let A=String(b||"").trim();A&&e(Qi(A,{view:"edit"}))};return window.addEventListener("alphaclaw:browse-git-synced",k),()=>{window.removeEventListener("alphaclaw:browse-git-synced",k)}},[m,h,b,e]),L(()=>{let k=Fe();k[Wf]=r,k[kk]=a,St(k)},[r,a]);let v=q(k=>{e(`/${k}`),n()},[n,e]),w=q(k=>{let A=Oc(k);d(A)},[]),$=q((k,A={})=>{let O=Oc(k),D=!!A.directory||String(k||"").trim().endsWith("/"),j=D&&!!A.preservePreview,E=Oc(c||b||"");d(j&&E&&E!==O?E:"");let R=D?{...A,view:"edit"}:A;e(Qi(O,R)),n()},[c,n,b,e]),S=q(k=>{if(o(k),k==="menu"&&t.startsWith("/browse")){d(""),e(a||`/${mo}`);return}if(k==="browse"&&!t.startsWith("/browse")){e(Qi(r));return}},[r,a,t,e]),C=q(k=>{e(`/${k}`),n()},[n,e]),_=q(()=>{e(`/${mo}`),n()},[n,e]);return{state:{activeBrowsePath:p,browseLineEndTarget:f,browseLineTarget:g,browsePreviewPath:c,browseViewerMode:m,isBrowseRoute:h,routeHistoryRef:u,selectedBrowsePath:b,selectedNavId:x,sidebarTab:s},actions:{buildBrowseRoute:Qi,clearBrowsePreview:()=>d(""),exitSubScreen:_,handleBrowsePreviewFile:w,handleSelectNavItem:C,handleSelectSidebarTab:S,navigateToBrowseFile:$,navigateToSubScreen:v},constants:{kNavSections:vk}}};var Vf=()=>{let t=window.location.hash.replace(/^#/,"");return t?t.startsWith("/")?t:`/${t}`:`/${mo}`},Ck=()=>{let[t,e]=y(Vf);L(()=>{let s=()=>e(Vf());return window.addEventListener("hashchange",s),()=>window.removeEventListener("hashchange",s)},[]);let n=q(s=>{let o=s.startsWith("/")?s:`/${s}`,r=`#${o}`;if(window.location.hash!==r){window.location.hash=o;return}e(o)},[]);return[t,n]},zf=Vf;var Ut=P.bind(T),_k="doctorWarningDismissedUntilMs",UR=10080*60*1e3,Bc="__alphaclawPendingCreateAgent",KR=()=>{let[t,e]=ca(),[n,s]=y(()=>{let C=Fe();return Number(C[_k]||0)}),{state:o,actions:r}=bk({location:t}),{refs:i,state:a,actions:l}=yk(),{state:c,actions:d,constants:u}=Sk({location:t,setLocation:e,onCloseMobileSidebar:l.closeMobileSidebar}),{state:p,actions:f}=hk(),g=t.startsWith("/agents"),m=t.startsWith("/cron"),h=t.startsWith("/envars"),b=t.startsWith("/models"),x=t.startsWith("/nodes"),v=(()=>{let C=t.match(/^\/agents\/([^/]+)/);return C?decodeURIComponent(C[1]):""})(),w=(()=>{let C=t.match(/^\/agents\/[^/]+\/([^/]+)/);return(C?C[1]:"")==="tools"?"tools":"overview"})(),$=(()=>{let C=t.match(/^\/cron\/([^/]+)/);return C?decodeURIComponent(C[1]):""})();L(()=>{g&&(window[Bc]||v||p.loading||p.agents.length===0||e(`/agents/${encodeURIComponent(p.agents[0].id)}`))},[g,v,p.loading,p.agents,e]),L(()=>{g&&window[Bc]&&(window[Bc]=!1,window.setTimeout(()=>{window.dispatchEvent(new Event("alphaclaw:create-agent"))},0))},[g]),L(()=>{let C=Fe();C[_k]=n,St(C)},[n]);let S=async()=>{l.setMenuOpen(!1),await Om();try{window.localStorage.clear(),window.sessionStorage.clear()}catch{}window.location.href="/login.html"};return o.onboarded===null?Ut`
      - <${Kb} + <${Gb} visible=${o.isAnyRestartRequired} restarting=${o.restartingGateway} onRestart=${r.handleGatewayRestart} @@ -10266,7 +10266,7 @@ ${Os(h.gatewayBody)}e(`/agents/${encodeURIComponent(C)}`)} - onAddAgent=${()=>{if(g){window.dispatchEvent(new Event("alphaclaw:create-agent"));return}window[Oc]=!0,e("/agents")}} + onAddAgent=${()=>{if(g){window.dispatchEvent(new Event("alphaclaw:create-agent"));return}window[Bc]=!0,e("/agents")}} />
      ${c.isBrowseRoute?Ut`
      - <${pu} + <${fu} activeBrowsePath=${c.activeBrowsePath} browseView=${c.browseViewerMode} lineTarget=${c.browseLineTarget} @@ -10322,7 +10322,7 @@ ${Os(h.gatewayBody)} - <${lu} + <${cu} agents=${p.agents} loading=${p.loading} saving=${p.saving} @@ -10338,7 +10338,7 @@ ${Os(h.gatewayBody)} - <${af} + <${lf} jobId=${$} onSetLocation=${e} /> @@ -10346,21 +10346,21 @@ ${Os(h.gatewayBody)} - <${pf} + <${ff} onRestartRequired=${r.setRestartRequired} />
      `:null} ${b?Ut`
      - <${mf} + <${gf} onRestartRequired=${r.setRestartRequired} />
      `:null} ${x?Ut`
      - <${gf} + <${bf} onRestartRequired=${r.setRestartRequired} />
      @@ -10371,9 +10371,9 @@ ${Os(h.gatewayBody)}
      - <${od}> + <${rd}> <${rt} path="/general"> - <${hf} + <${mf} statusData=${o.sharedStatus} watchdogData=${o.sharedWatchdogStatus} doctorStatusData=${o.sharedDoctorStatus} @@ -10393,11 +10393,11 @@ ${Os(h.gatewayBody)} <${rt} path="/doctor"> - <${uf} onNavigateToBrowseFile=${d.navigateToBrowseFile} /> + <${pf} onNavigateToBrowseFile=${d.navigateToBrowseFile} /> <${rt} path="/telegram/:accountId"> ${C=>Ut` - <${vf} + <${$f} accountId=${decodeURIComponent(C.accountId||"default")} onBack=${d.exitSubScreen} /> @@ -10410,7 +10410,7 @@ ${Os(h.gatewayBody)} <${rt} path="/watchdog"> - <${Nf} + <${Of} statusData=${o.sharedStatus} watchdogStatus=${o.sharedWatchdogStatus} onRefreshStatuses=${r.refreshSharedStatuses} @@ -10424,21 +10424,21 @@ ${Os(h.gatewayBody)} <${rt} path="/usage/:sessionId"> ${C=>Ut` - <${Rc} + <${Lc} sessionId=${decodeURIComponent(C.sessionId||"")} onSetLocation=${e} /> `} <${rt} path="/usage"> - <${Rc} onSetLocation=${e} /> + <${Lc} onSetLocation=${e} /> <${rt} path="/webhooks/:hookName"> ${C=>Ut` - <${Dc} + <${Nc} hookName=${decodeURIComponent(C.hookName||"")} routeHistoryRef=${c.routeHistoryRef} - getCurrentPath=${Vf} + getCurrentPath=${zf} onSetLocation=${e} onRestartRequired=${r.setRestartRequired} onNavigateToBrowseFile=${d.navigateToBrowseFile} @@ -10446,9 +10446,9 @@ ${Os(h.gatewayBody)} <${rt} path="/webhooks"> - <${Dc} + <${Nc} routeHistoryRef=${c.routeHistoryRef} - getCurrentPath=${Vf} + getCurrentPath=${zf} onSetLocation=${e} onRestartRequired=${r.setRestartRequired} onNavigateToBrowseFile=${d.navigateToBrowseFile} @@ -10457,7 +10457,7 @@ ${Os(h.gatewayBody)} <${Ki} to="/general" /> - +
      `} @@ -10496,17 +10496,20 @@ ${Os(h.gatewayBody)} - <${Ub} +
      + <${za} /> +
      + <${Kb} onComplete=${r.handleOnboardingComplete} acVersion=${o.acVersion} />
      <${Na} /> - `},Bc=document.getElementById("app");if(Bc){let t="__alphaclawSetupAppBootCount";window[t]=Number(window[t]||0)+1,wo(null,Bc),Bc.replaceChildren(),wo(Ut` + `},Fc=document.getElementById("app");if(Fc){let t="__alphaclawSetupAppBootCount";window[t]=Number(window[t]||0)+1,wo(null,Fc),Fc.replaceChildren(),wo(Ut` <${da} hook=${Ck}> <${KR} /> - `,Bc)} + `,Fc)} /*! Bundled license information: @kurkle/color/dist/color.esm.js: diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 96cc2934..6f936da2 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -9,6 +9,7 @@ import { } from "wouter-preact"; import { logout } from "./lib/api.js"; import { Welcome } from "./components/welcome/index.js"; +import { ThemeToggle } from "./components/theme-toggle.js"; import { ToastContainer } from "./components/toast.js"; import { GlobalRestartBanner } from "./components/global-restart-banner.js"; import { LoadingSpinner } from "./components/loading-spinner.js"; @@ -150,6 +151,9 @@ const App = () => { class="min-h-screen flex flex-col items-center pt-12 pb-8 px-4" style="position: relative; z-index: 1" > +
      + <${ThemeToggle} /> +
      <${Welcome} onComplete=${controllerActions.handleOnboardingComplete} acVersion=${controllerState.acVersion} diff --git a/lib/public/js/components/onboarding/welcome-header.js b/lib/public/js/components/onboarding/welcome-header.js index b8794b06..4d87db3e 100644 --- a/lib/public/js/components/onboarding/welcome-header.js +++ b/lib/public/js/components/onboarding/welcome-header.js @@ -34,7 +34,7 @@ export const WelcomeHeader = ({
      ${isPreStep ? "Choose your destiny" @@ -51,16 +51,16 @@ export const WelcomeHeader = ({ const isPairingComplete = idx < step || (isPairingStep && group.id === "pairing"); const bg = isPreStep - ? "rgba(82, 94, 122, 0.45)" + ? "var(--border-strong)" : isActive - ? "rgba(99, 235, 255, 0.9)" + ? "var(--accent)" : group.id === "pairing" ? isPairingComplete - ? "rgba(99, 235, 255, 0.55)" - : "rgba(82, 94, 122, 0.45)" + ? "var(--accent-dim)" + : "var(--border-strong)" : isComplete - ? "rgba(99, 235, 255, 0.55)" - : "rgba(82, 94, 122, 0.45)"; + ? "var(--accent-dim)" + : "var(--border-strong)"; return html`
      { if (error) { return html`
      -

      Setup failed

      +

      Setup failed

      Fix the values and try again.

      {
      - <${LoadingSpinner} className="h-8 w-8 text-white" /> -

      + <${LoadingSpinner} className="h-8 w-8 text-body" /> +

      Initializing OpenClaw...

      This could take 10-15 seconds

      diff --git a/lib/public/js/components/theme-toggle.js b/lib/public/js/components/theme-toggle.js index d09cde0f..7becdc6d 100644 --- a/lib/public/js/components/theme-toggle.js +++ b/lib/public/js/components/theme-toggle.js @@ -6,26 +6,27 @@ import { kThemeStorageKey } from "../lib/storage-keys.js"; const html = htm.bind(h); -const getTheme = () => { +/** Resolve the current effective theme. If no preference saved, follow OS. */ +const getEffectiveTheme = () => { try { const saved = localStorage.getItem(kThemeStorageKey); - if (saved) return saved; + if (saved === "dark" || saved === "light") return saved; } catch {} return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; }; -const setTheme = (theme) => { +const applyTheme = (theme) => { document.documentElement.dataset.theme = theme; try { localStorage.setItem(kThemeStorageKey, theme); } catch {} }; export const ThemeToggle = () => { - const [theme, setThemeState] = useState(getTheme); + const [theme, setTheme] = useState(getEffectiveTheme); const toggle = () => { const next = theme === "dark" ? "light" : "dark"; + applyTheme(next); setTheme(next); - setThemeState(next); }; return html` diff --git a/lib/public/login.html b/lib/public/login.html index 6d11fc1d..cbd5ed92 100644 --- a/lib/public/login.html +++ b/lib/public/login.html @@ -14,7 +14,7 @@ diff --git a/lib/public/setup.html b/lib/public/setup.html index 721f0a96..ce8341a9 100644 --- a/lib/public/setup.html +++ b/lib/public/setup.html @@ -19,7 +19,7 @@ // Apply saved theme before render to prevent flash. try { var t = localStorage.getItem("alphaclaw.ui.theme"); - if (!t) t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + if (t !== "dark" && t !== "light") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; document.documentElement.dataset.theme = t; } catch {} From b1094bbebe96c9e81021393776e67373fb6722e8 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 1 Apr 2026 20:23:20 -0700 Subject: [PATCH 351/417] feat(slack): add multi-account channel support (#49) --- .../agents-tab/create-channel-modal.js | 318 ++++++++++++++---- lib/public/js/components/modal-shell.js | 2 +- .../js/lib/channel-provider-availability.js | 3 +- lib/server.js | 7 +- lib/server/agents/channels.js | 5 +- lib/server/watchdog-notify.js | 227 ++++++++++--- .../channel-provider-availability.test.js | 34 ++ tests/server/agents-service.test.js | 216 +++++++++++- tests/server/watchdog-notify.test.js | 159 +++++++++ 9 files changed, 838 insertions(+), 133 deletions(-) create mode 100644 tests/frontend/channel-provider-availability.test.js create mode 100644 tests/server/watchdog-notify.test.js diff --git a/lib/public/js/components/agents-tab/create-channel-modal.js b/lib/public/js/components/agents-tab/create-channel-modal.js index 903bc70a..52e4e029 100644 --- a/lib/public/js/components/agents-tab/create-channel-modal.js +++ b/lib/public/js/components/agents-tab/create-channel-modal.js @@ -2,12 +2,15 @@ import { h } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import htm from "htm"; import { ActionButton } from "../action-button.js"; -import { CloseIcon } from "../icons.js"; +import { CloseIcon, FileCopyLineIcon } from "../icons.js"; import { ModalShell } from "../modal-shell.js"; import { PageHeader } from "../page-header.js"; import { SecretInput } from "../secret-input.js"; import { fetchChannelAccountToken } from "../../lib/api.js"; +import { copyTextToClipboard } from "../../lib/clipboard.js"; +import { isSingleAccountChannelProvider } from "../../lib/channel-provider-availability.js"; import { ALL_CHANNELS, getChannelMeta } from "../channels.js"; +import { showToast } from "../toast.js"; const html = htm.bind(h); @@ -25,15 +28,33 @@ const kSlackBotScopes = [ "channels:history", "channels:read", "chat:write", + "commands", + "emoji:read", + "files:read", + "files:write", + "groups:read", "groups:history", "im:history", "im:read", "im:write", "mpim:history", + "mpim:read", + "mpim:write", + "pins:read", + "pins:write", "reactions:read", "reactions:write", "users:read", ]; +const kSlackBotEvents = [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", +]; const kSlackInstructionsLink = "https://docs.openclaw.ai/channels/slack"; const slugifyChannelAccountId = (value) => @@ -50,7 +71,63 @@ const deriveChannelEnvKey = ({ provider, accountId }) => { if (!normalizedAccountId || normalizedAccountId === "default") return baseKey; return `${baseKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`; }; +const deriveChannelExtraEnvKey = ({ provider, accountId, index = 0 }) => { + const baseKeys = [kChannelExtraEnvKeys[String(provider || "").trim()]].filter( + Boolean, + ); + const baseKey = String(baseKeys[index] || "").trim(); + const normalizedAccountId = String(accountId || "").trim(); + if (!baseKey) return ""; + if (!normalizedAccountId || normalizedAccountId === "default") return baseKey; + return `${baseKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`; +}; const isMaskedTokenValue = (value) => /^\*+$/.test(String(value || "").trim()); +const buildSlackManifest = (appName = "AlphaClaw") => + JSON.stringify( + { + _metadata: { + major_version: 1, + }, + display_information: { + name: String(appName || "").trim() || "AlphaClaw", + description: "Slack connector for AlphaClaw", + }, + features: { + bot_user: { + display_name: String(appName || "").trim() || "AlphaClaw", + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + }, + oauth_config: { + scopes: { + bot: kSlackBotScopes, + }, + }, + settings: { + event_subscriptions: { + bot_events: kSlackBotEvents, + }, + org_deploy_enabled: false, + socket_mode_enabled: true, + is_hosted: false, + token_rotation_enabled: false, + }, + }, + null, + 2, + ); +const copyAndToast = async (value, label = "text") => { + const copied = await copyTextToClipboard(value); + if (copied) { + showToast("Copied to clipboard", "success"); + return; + } + showToast(`Could not copy ${label}`, "error"); +}; export const CreateChannelModal = ({ visible = false, @@ -152,9 +229,7 @@ export const CreateChannelModal = ({ } setName(providerLabel); }, [provider, providerHasAccounts, nameEditedManually, isEditMode]); - const isSingleAccountProvider = - String(provider || "").trim() === "discord" || - String(provider || "").trim() === "slack"; + const isSingleAccountProvider = isSingleAccountChannelProvider(provider); const needsAppToken = String(provider || "").trim() === "slack"; const accountId = useMemo(() => { @@ -170,6 +245,24 @@ export const CreateChannelModal = ({ () => deriveChannelEnvKey({ provider, accountId }), [provider, accountId], ); + const extraEnvKey = useMemo( + () => + deriveChannelExtraEnvKey({ + provider, + accountId, + }), + [provider, accountId], + ); + const slackManifestName = useMemo(() => { + const normalizedName = String(name || "").trim(); + if (!normalizedName) return "AlphaClaw"; + if (normalizedName.toLowerCase() === "slack") return "AlphaClaw"; + return normalizedName; + }, [name]); + const slackManifest = useMemo( + () => buildSlackManifest(slackManifestName), + [slackManifestName], + ); const accountExists = useMemo( () => @@ -269,7 +362,7 @@ export const CreateChannelModal = ({ <${ModalShell} visible=${visible} onClose=${onClose} - panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4" + panelClassName="bg-modal border border-border rounded-xl p-6 max-w-lg w-full max-h-[calc(100vh-2rem)] overflow-y-auto space-y-4" > <${PageHeader} title=${ @@ -360,7 +453,7 @@ export const CreateChannelModal = ({

      Saved behind the scenes as - ${kChannelExtraEnvKeys.slack} + ${extraEnvKey || kChannelExtraEnvKeys.slack} .

      @@ -371,60 +464,167 @@ export const CreateChannelModal = ({ ${ needsAppToken ? html` -
      - - Slack-specific instructions (step-by-step) - - -
      + + Manual setup instructions + +
      +
      +

      + Use this if you want to configure the Slack app by hand + instead of importing a manifest. +

      +
        +
      1. + In Slack app settings, turn on ${" "} + Socket Mode. +
      2. +
      3. + In ${" "} + App Home, enable + + Allow users to send Slash commands and messages from + the messages tab . +
      4. +
      5. + In ${" "} + Event Subscriptions, + toggle on + Subscribe to bot events + ${" "} and add + message.im. +
      6. +
      7. + In ${" "} + OAuth & Permissions, + add the bot scopes: + + ${kSlackBotScopes.join(", ")} + +
      8. +
      9. + In ${" "} + Basic Information, + create an App Token (xapp-...) with + connections:write. +
      10. +
      11. + Back in ${" "} + OAuth & Permissions, + install or reinstall the app, then copy the ${" "} + Bot User OAuth Token + ${" "} ( + xoxb-... + ). +
      12. +
      + + Open full Slack setup guide + +
      +
      +
      ` : null } diff --git a/lib/public/js/components/modal-shell.js b/lib/public/js/components/modal-shell.js index 129da2f0..c0108aba 100644 --- a/lib/public/js/components/modal-shell.js +++ b/lib/public/js/components/modal-shell.js @@ -29,7 +29,7 @@ export const ModalShell = ({ return createPortal( html`
      { if (closeOnOverlayClick && event.target === event.currentTarget) onClose?.(); }} diff --git a/lib/public/js/lib/channel-provider-availability.js b/lib/public/js/lib/channel-provider-availability.js index 531975b8..b4b9b9d8 100644 --- a/lib/public/js/lib/channel-provider-availability.js +++ b/lib/public/js/lib/channel-provider-availability.js @@ -1,4 +1,4 @@ -const kSingleAccountChannelProviders = new Set(["discord", "slack"]); +const kSingleAccountChannelProviders = new Set(["discord"]); const hasConfiguredAccounts = ({ configuredChannelMap, provider }) => { const channelEntry = configuredChannelMap instanceof Map @@ -20,4 +20,3 @@ export const isChannelProviderDisabledForAdd = ({ if (!isSingleAccountChannelProvider(provider)) return false; return hasConfiguredAccounts({ configuredChannelMap, provider }); }; - diff --git a/lib/server.js b/lib/server.js index c4ac1fbe..f5870c06 100644 --- a/lib/server.js +++ b/lib/server.js @@ -223,7 +223,12 @@ const webhookMiddleware = createWebhookMiddleware({ const telegramApi = createTelegramApi(() => process.env.TELEGRAM_BOT_TOKEN); const discordApi = createDiscordApi(() => process.env.DISCORD_BOT_TOKEN); const slackApi = createSlackApi(() => process.env.SLACK_BOT_TOKEN); -const watchdogNotifier = createWatchdogNotifier({ telegramApi, discordApi, slackApi }); +const watchdogNotifier = createWatchdogNotifier({ + telegramApi, + discordApi, + slackApi, + readEnvFile, +}); const watchdog = createWatchdog({ clawCmd, launchGatewayProcess, diff --git a/lib/server/agents/channels.js b/lib/server/agents/channels.js index 58334043..be3c2ee2 100644 --- a/lib/server/agents/channels.js +++ b/lib/server/agents/channels.js @@ -143,10 +143,7 @@ const createChannelsDomain = ({ `Channel account "${provider}/${accountId}" already exists`, ); } - if ( - (provider === "discord" || provider === "slack") && - Object.keys(existingAccounts).length > 0 - ) { + if (provider === "discord" && Object.keys(existingAccounts).length > 0) { throw new Error( `${kChannelLabels[provider] || "This provider"} supports a single channel account`, ); diff --git a/lib/server/watchdog-notify.js b/lib/server/watchdog-notify.js index 2e62d615..a3c452ae 100644 --- a/lib/server/watchdog-notify.js +++ b/lib/server/watchdog-notify.js @@ -1,35 +1,95 @@ const fs = require("fs"); const path = require("path"); const { OPENCLAW_DIR } = require("./constants"); +const { createSlackApi } = require("./slack-api"); -const getPairedIds = (channel) => { +const kSlackBotEnvKey = "SLACK_BOT_TOKEN"; + +const normalizeAccountId = (value) => + String(value || "").trim().toLowerCase() || "default"; + +const resolveCredentialPairingAccountId = ({ channel, fileName }) => { + const prefix = `${String(channel || "").trim().toLowerCase()}-`; + const suffix = "-allowFrom.json"; + const rawFileName = String(fileName || "").trim(); + if (!rawFileName.startsWith(prefix) || !rawFileName.endsWith(suffix)) { + return ""; + } + return normalizeAccountId(rawFileName.slice(prefix.length, -suffix.length)); +}; + +const deriveSlackBotEnvKey = (accountId = "default") => { + const normalizedAccountId = normalizeAccountId(accountId); + if (normalizedAccountId === "default") return kSlackBotEnvKey; + return `${kSlackBotEnvKey}_${normalizedAccountId.replace(/-/g, "_").toUpperCase()}`; +}; + +const getPairedTargetsByAccount = ({ + channel, + fsImpl = fs, + openclawDir = OPENCLAW_DIR, +}) => { const safeChannel = String(channel || "").trim().toLowerCase(); - if (!safeChannel) return []; - const credentialsDir = path.join(OPENCLAW_DIR, "credentials"); - if (!fs.existsSync(credentialsDir)) return []; - const ids = new Set(); + if (!safeChannel) return new Map(); + const credentialsDir = path.join(openclawDir, "credentials"); + if (!fsImpl.existsSync(credentialsDir)) return new Map(); + const idsByAccount = new Map(); try { - const files = fs + const files = fsImpl .readdirSync(credentialsDir) .filter( (fileName) => fileName.startsWith(`${safeChannel}-`) && fileName.endsWith("-allowFrom.json"), ); for (const fileName of files) { + const accountId = resolveCredentialPairingAccountId({ + channel: safeChannel, + fileName, + }); + if (!accountId) continue; const filePath = path.join(credentialsDir, fileName); - const raw = fs.readFileSync(filePath, "utf8"); + const raw = fsImpl.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw); const allowFrom = Array.isArray(parsed?.allowFrom) ? parsed.allowFrom : []; + const ids = + idsByAccount.get(accountId) instanceof Set + ? idsByAccount.get(accountId) + : new Set(); for (const id of allowFrom) { if (id == null) continue; const value = String(id).trim(); if (!value) continue; ids.add(value); } + idsByAccount.set(accountId, ids); } } catch (err) { console.error(`[watchdog] could not resolve ${safeChannel} allowFrom IDs: ${err.message}`); } + return new Map( + Array.from(idsByAccount.entries()).map(([accountId, ids]) => [ + accountId, + Array.from(ids), + ]), + ); +}; + +const getPairedIds = ({ + channel, + fsImpl = fs, + openclawDir = OPENCLAW_DIR, +}) => { + const ids = new Set(); + const idsByAccount = getPairedTargetsByAccount({ + channel, + fsImpl, + openclawDir, + }); + for (const accountIds of idsByAccount.values()) { + for (const id of accountIds) { + ids.add(id); + } + } return Array.from(ids); }; @@ -38,18 +98,30 @@ const formatDiscordMessage = (message) => /** * Track thread state for Slack notifications - * Key: userId, Value: { threadTs, lastEvent } + * Key: accountId:userId, Value: { threadTs, lastEvent } */ const slackThreads = new Map(); -const createWatchdogNotifier = ({ telegramApi, discordApi, slackApi }) => { +const createWatchdogNotifier = ({ + telegramApi, + discordApi, + slackApi, + readEnvFile = () => [], + createSlackApi: createSlackApiFactory = createSlackApi, + fsImpl = fs, + openclawDir = OPENCLAW_DIR, +}) => { const notify = async (message, opts = {}) => { const summary = { telegram: { sent: 0, failed: 0, skipped: false, targets: 0 }, discord: { sent: 0, failed: 0, skipped: false, targets: 0 }, slack: { sent: 0, failed: 0, skipped: false, targets: 0 }, }; - const telegramTargets = getPairedIds("telegram"); + const telegramTargets = getPairedIds({ + channel: "telegram", + fsImpl, + openclawDir, + }); summary.telegram.targets = telegramTargets.length; if (!telegramApi?.sendMessage || !process.env.TELEGRAM_BOT_TOKEN || telegramTargets.length === 0) { summary.telegram.skipped = true; @@ -67,7 +139,11 @@ const createWatchdogNotifier = ({ telegramApi, discordApi, slackApi }) => { } } - const discordTargets = getPairedIds("discord"); + const discordTargets = getPairedIds({ + channel: "discord", + fsImpl, + openclawDir, + }); summary.discord.targets = discordTargets.length; if (!discordApi?.sendDirectMessage || !process.env.DISCORD_BOT_TOKEN || discordTargets.length === 0) { summary.discord.skipped = true; @@ -85,61 +161,102 @@ const createWatchdogNotifier = ({ telegramApi, discordApi, slackApi }) => { } // Enhanced Slack notifications with threading and reactions - const slackTargets = getPairedIds("slack"); - summary.slack.targets = slackTargets.length; - if (!slackApi?.postMessage || !process.env.SLACK_BOT_TOKEN || slackTargets.length === 0) { + const slackTargetsByAccount = getPairedTargetsByAccount({ + channel: "slack", + fsImpl, + openclawDir, + }); + summary.slack.targets = Array.from(slackTargetsByAccount.values()).reduce( + (total, targets) => total + targets.length, + 0, + ); + if (summary.slack.targets === 0) { summary.slack.skipped = true; } else { const eventType = opts.eventType || "info"; // crash, recovery, health, info - - for (const userId of slackTargets) { - try { - let threadTs = null; - let shouldCreateNewThread = true; - - // Check if we have an active thread for this user - const existingThread = slackThreads.get(userId); - if (existingThread && existingThread.lastEvent === "crash" && eventType === "recovery") { - // Recovery message goes in the crash thread - threadTs = existingThread.threadTs; - shouldCreateNewThread = false; + const envVars = typeof readEnvFile === "function" ? readEnvFile() : []; + + const envMap = new Map( + (Array.isArray(envVars) ? envVars : []) + .map((entry) => [ + String(entry?.key || "").trim(), + String(entry?.value || "").trim(), + ]) + .filter(([key]) => key), + ); + + for (const [accountId, slackTargets] of slackTargetsByAccount.entries()) { + if (!slackTargets.length) continue; + const envKey = deriveSlackBotEnvKey(accountId); + const botToken = String(envMap.get(envKey) || process.env[envKey] || "").trim(); + if (!botToken) { + summary.slack.failed += slackTargets.length; + for (const userId of slackTargets) { + console.error( + `[watchdog] slack notification failed for ${accountId}/${userId}: missing ${envKey}`, + ); } + continue; + } - // Send message (in thread if continuing conversation) - const result = await slackApi.postMessage(userId, String(message || ""), { - thread_ts: threadTs, - mrkdwn: true, - }); + const accountSlackApi = + accountId === "default" && + slackApi?.postMessage && + botToken === String(process.env.SLACK_BOT_TOKEN || "").trim() + ? slackApi + : createSlackApiFactory(() => botToken); + + for (const userId of slackTargets) { + try { + let threadTs = null; + let shouldCreateNewThread = true; + const threadKey = `${accountId}:${userId}`; + + const existingThread = slackThreads.get(threadKey); + if (existingThread && existingThread.lastEvent === "crash" && eventType === "recovery") { + threadTs = existingThread.threadTs; + shouldCreateNewThread = false; + } - // Store thread for future related messages - if (shouldCreateNewThread && result.ts) { - slackThreads.set(userId, { - threadTs: result.ts, - lastEvent: eventType, + const result = await accountSlackApi.postMessage(userId, String(message || ""), { + thread_ts: threadTs, + mrkdwn: true, }); - } - // Add reactions based on event type - // Use result.channel (the actual conversation/DM ID) instead of userId - if (result.ts && result.channel && slackApi.addReaction) { - try { - if (eventType === "crash") { - await slackApi.addReaction(result.channel, result.ts, "x"); - } else if (eventType === "recovery") { - await slackApi.addReaction(result.channel, result.ts, "white_check_mark"); - } else if (eventType === "health") { - await slackApi.addReaction(result.channel, result.ts, "heart"); + if (shouldCreateNewThread && result.ts) { + slackThreads.set(threadKey, { + threadTs: result.ts, + lastEvent: eventType, + }); + } + + if (result.ts && result.channel && accountSlackApi.addReaction) { + try { + if (eventType === "crash") { + await accountSlackApi.addReaction(result.channel, result.ts, "x"); + } else if (eventType === "recovery") { + await accountSlackApi.addReaction( + result.channel, + result.ts, + "white_check_mark", + ); + } else if (eventType === "health") { + await accountSlackApi.addReaction(result.channel, result.ts, "heart"); + } + } catch (reactionErr) { + console.error( + `[watchdog] slack reaction failed for ${accountId}/${userId}: ${reactionErr.message}`, + ); } - } catch (reactionErr) { - // Reactions are nice-to-have, don't fail the whole notification - console.error(`[watchdog] slack reaction failed for ${userId}: ${reactionErr.message}`); } - } - summary.slack.sent += 1; - } catch (err) { - summary.slack.failed += 1; - console.error(`[watchdog] slack notification failed for ${userId}: ${err.message}`); + summary.slack.sent += 1; + } catch (err) { + summary.slack.failed += 1; + console.error( + `[watchdog] slack notification failed for ${accountId}/${userId}: ${err.message}`, + ); + } } } } diff --git a/tests/frontend/channel-provider-availability.test.js b/tests/frontend/channel-provider-availability.test.js new file mode 100644 index 00000000..ab4c6e74 --- /dev/null +++ b/tests/frontend/channel-provider-availability.test.js @@ -0,0 +1,34 @@ +const loadChannelProviderAvailabilityModule = async () => + import("../../lib/public/js/lib/channel-provider-availability.js"); + +describe("frontend/channel-provider-availability", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("allows multiple Slack accounts while keeping Discord single-account", async () => { + const { + isSingleAccountChannelProvider, + isChannelProviderDisabledForAdd, + } = await loadChannelProviderAvailabilityModule(); + const configuredChannelMap = new Map([ + ["slack", { accounts: [{ id: "default" }] }], + ["discord", { accounts: [{ id: "default" }] }], + ]); + + expect(isSingleAccountChannelProvider("slack")).toBe(false); + expect(isSingleAccountChannelProvider("discord")).toBe(true); + expect( + isChannelProviderDisabledForAdd({ + configuredChannelMap, + provider: "slack", + }), + ).toBe(false); + expect( + isChannelProviderDisabledForAdd({ + configuredChannelMap, + provider: "discord", + }), + ).toBe(true); + }); +}); diff --git a/tests/server/agents-service.test.js b/tests/server/agents-service.test.js index cf1c6c77..c23c8a54 100644 --- a/tests/server/agents-service.test.js +++ b/tests/server/agents-service.test.js @@ -1311,7 +1311,7 @@ describe("server/agents/service", () => { ).rejects.toThrow("Discord supports a single channel account"); }); - it("prevents creating multiple slack channel accounts", async () => { + it("creates an additional named slack channel account with suffixed env vars", async () => { const fsMock = buildFsMock({ initialConfig: { agents: { @@ -1332,6 +1332,8 @@ describe("server/agents/service", () => { }, }, }); + const writeEnvFile = vi.fn(); + const clawCmd = vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })); const service = createAgentsService({ fs: fsMock, OPENCLAW_DIR: "/tmp/openclaw", @@ -1339,21 +1341,71 @@ describe("server/agents/service", () => { { key: "SLACK_BOT_TOKEN", value: "xoxb-bot-token" }, { key: "SLACK_APP_TOKEN", value: "xapp-app-token" }, ]), - writeEnvFile: vi.fn(), + writeEnvFile, reloadEnv: vi.fn(), - clawCmd: vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })), + clawCmd, }); - await expect( - service.createChannelAccount({ - provider: "slack", - name: "Slack 2", - accountId: "alerts", - token: "xoxb-bot-token-2", - appToken: "xapp-app-token-2", + const result = await service.createChannelAccount({ + provider: "slack", + name: "Slack Alerts", + accountId: "alerts", + token: "xoxb-bot-token-2", + appToken: "xapp-app-token-2", + agentId: "main", + }); + + expect(result).toEqual({ + channel: "slack", + account: { + id: "alerts", + name: "Slack Alerts", + envKey: "SLACK_BOT_TOKEN_ALERTS", + }, + binding: { agentId: "main", + match: { channel: "slack", accountId: "alerts" }, + }, + }); + expect(writeEnvFile).toHaveBeenCalledWith([ + { key: "SLACK_BOT_TOKEN", value: "xoxb-bot-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-app-token" }, + { key: "SLACK_BOT_TOKEN_ALERTS", value: "xoxb-bot-token-2" }, + { key: "SLACK_APP_TOKEN_ALERTS", value: "xapp-app-token-2" }, + ]); + expect(clawCmd).toHaveBeenNthCalledWith( + 1, + "channels add --channel 'slack' --account 'alerts' --name 'Slack Alerts' --bot-token 'xoxb-bot-token-2' --app-token 'xapp-app-token-2'", + { quiet: true, timeoutMs: 30000 }, + ); + expect(clawCmd).toHaveBeenNthCalledWith( + 2, + "agents bind --agent 'main' --bind 'slack:alerts'", + { quiet: true, timeoutMs: 30000 }, + ); + expect(fsMock.readConfig()).toEqual( + expect.objectContaining({ + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + dmPolicy: "pairing", + }, + alerts: { + name: "Slack Alerts", + botToken: "${SLACK_BOT_TOKEN_ALERTS}", + appToken: "${SLACK_APP_TOKEN_ALERTS}", + dmPolicy: "pairing", + }, + }, + }, + }, }), - ).rejects.toThrow("Slack supports a single channel account"); + ); }); it("updates channel account name and bound agent", () => { @@ -1735,6 +1787,58 @@ describe("server/agents/service", () => { }); }); + it("loads named slack channel bot and app tokens by provider/account id", () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + name: "Slack", + }, + alerts: { + botToken: "${SLACK_BOT_TOKEN_ALERTS}", + appToken: "${SLACK_APP_TOKEN_ALERTS}", + name: "Slack Alerts", + }, + }, + }, + }, + }, + }); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile: vi.fn(() => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-token-123" }, + { key: "SLACK_APP_TOKEN", value: "xapp-token-123" }, + { key: "SLACK_BOT_TOKEN_ALERTS", value: "xoxb-alerts-token" }, + { key: "SLACK_APP_TOKEN_ALERTS", value: "xapp-alerts-token" }, + ]), + }); + + const result = service.getChannelAccountToken({ + provider: "slack", + accountId: "alerts", + }); + + expect(result).toEqual({ + provider: "slack", + accountId: "alerts", + envKey: "SLACK_BOT_TOKEN_ALERTS", + token: "xoxb-alerts-token", + appEnvKey: "SLACK_APP_TOKEN_ALERTS", + appToken: "xapp-alerts-token", + }); + }); + it("deletes channel accounts and removes their env entry", async () => { const fsMock = buildFsMock({ initialConfig: { @@ -2002,6 +2106,96 @@ describe("server/agents/service", () => { expect(reloadEnv).toHaveBeenCalled(); }); + it("deletes named slack channel env vars and keeps default slack tokens", async () => { + const fsMock = buildFsMock({ + initialConfig: { + agents: { + list: [{ id: "main", default: true }], + }, + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + name: "Slack", + }, + alerts: { + botToken: "${SLACK_BOT_TOKEN_ALERTS}", + appToken: "${SLACK_APP_TOKEN_ALERTS}", + name: "Slack Alerts", + }, + }, + }, + }, + bindings: [ + { + agentId: "main", + match: { channel: "slack", accountId: "default" }, + }, + { + agentId: "main", + match: { channel: "slack", accountId: "alerts" }, + }, + ], + }, + }); + const readEnvFile = vi.fn(() => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-token" }, + { key: "SLACK_BOT_TOKEN_ALERTS", value: "xoxb-alerts-token" }, + { key: "SLACK_APP_TOKEN_ALERTS", value: "xapp-alerts-token" }, + ]); + const writeEnvFile = vi.fn(); + const reloadEnv = vi.fn(); + const clawCmd = vi.fn(async () => ({ ok: true, stdout: "", stderr: "" })); + const service = createAgentsService({ + fs: fsMock, + OPENCLAW_DIR: "/tmp/openclaw", + readEnvFile, + writeEnvFile, + reloadEnv, + clawCmd, + }); + + const result = await service.deleteChannelAccount({ + provider: "slack", + accountId: "alerts", + }); + + expect(result).toEqual({ ok: true }); + expect(writeEnvFile).toHaveBeenCalledWith([ + { key: "SLACK_BOT_TOKEN", value: "xoxb-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-token" }, + ]); + expect(reloadEnv).toHaveBeenCalled(); + expect(fsMock.readConfig()).toEqual( + expect.objectContaining({ + channels: { + slack: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + botToken: "${SLACK_BOT_TOKEN}", + appToken: "${SLACK_APP_TOKEN}", + name: "Slack", + }, + }, + }, + }, + bindings: [ + { + agentId: "main", + match: { channel: "slack", accountId: "default" }, + }, + ], + }), + ); + }); + it("overwrites orphaned env var when channel is not in config", async () => { const fsMock = buildFsMock({ initialConfig: { diff --git a/tests/server/watchdog-notify.test.js b/tests/server/watchdog-notify.test.js new file mode 100644 index 00000000..d941f84a --- /dev/null +++ b/tests/server/watchdog-notify.test.js @@ -0,0 +1,159 @@ +const path = require("path"); + +const { createWatchdogNotifier } = require("../../lib/server/watchdog-notify"); + +const buildCredentialsFsMock = (entries = {}) => { + const credentialsDir = "/tmp/openclaw/credentials"; + const files = new Map( + Object.entries(entries).map(([fileName, allowFrom]) => [ + path.join(credentialsDir, fileName), + JSON.stringify({ allowFrom }), + ]), + ); + + return { + existsSync: vi.fn((targetPath) => { + const normalizedTargetPath = String(targetPath || ""); + return normalizedTargetPath === credentialsDir || files.has(normalizedTargetPath); + }), + readdirSync: vi.fn((targetPath) => { + if (String(targetPath || "") !== credentialsDir) return []; + return Array.from(files.keys()).map((filePath) => path.basename(filePath)); + }), + readFileSync: vi.fn((targetPath) => { + const normalizedTargetPath = String(targetPath || ""); + const value = files.get(normalizedTargetPath); + if (value === undefined) { + throw new Error(`Unexpected read: ${normalizedTargetPath}`); + } + return value; + }), + }; +}; + +const buildSlackApiFactory = () => { + const clientsByToken = new Map(); + const countersByToken = new Map(); + const createSlackApi = vi.fn((getToken) => { + const token = typeof getToken === "function" ? getToken() : getToken; + if (clientsByToken.has(token)) { + return clientsByToken.get(token); + } + const client = { + postMessage: vi.fn(async (_userId, _text, _opts = {}) => { + const nextCount = Number(countersByToken.get(token) || 0) + 1; + countersByToken.set(token, nextCount); + return { + ts: `${token}-ts-${nextCount}`, + channel: `dm-${token}`, + }; + }), + addReaction: vi.fn(async () => ({ ok: true })), + }; + clientsByToken.set(token, client); + return client; + }); + + return { + createSlackApi, + clientsByToken, + }; +}; + +describe("server/watchdog-notify", () => { + let consoleErrorSpy = null; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it("sends Slack watchdog notifications across default and named accounts with isolated threads", async () => { + const fsMock = buildCredentialsFsMock({ + "slack-default-allowFrom.json": ["U_SHARED_THREAD"], + "slack-alerts-allowFrom.json": ["U_SHARED_THREAD"], + }); + const { createSlackApi, clientsByToken } = buildSlackApiFactory(); + const notifier = createWatchdogNotifier({ + fsImpl: fsMock, + openclawDir: "/tmp/openclaw", + readEnvFile: () => [ + { key: "SLACK_BOT_TOKEN", value: "xoxb-default" }, + { key: "SLACK_BOT_TOKEN_ALERTS", value: "xoxb-alerts" }, + ], + createSlackApi, + }); + + const crashResult = await notifier.notify("Crash detected", { + eventType: "crash", + }); + const recoveryResult = await notifier.notify("Recovered", { + eventType: "recovery", + }); + + expect(crashResult.channels.slack).toEqual({ + sent: 2, + failed: 0, + skipped: false, + targets: 2, + }); + expect(recoveryResult.channels.slack).toEqual({ + sent: 2, + failed: 0, + skipped: false, + targets: 2, + }); + + const defaultClient = clientsByToken.get("xoxb-default"); + const alertsClient = clientsByToken.get("xoxb-alerts"); + expect(defaultClient.postMessage.mock.calls[0][2]).toEqual({ + thread_ts: null, + mrkdwn: true, + }); + expect(defaultClient.postMessage.mock.calls[1][2]).toEqual({ + thread_ts: "xoxb-default-ts-1", + mrkdwn: true, + }); + expect(alertsClient.postMessage.mock.calls[0][2]).toEqual({ + thread_ts: null, + mrkdwn: true, + }); + expect(alertsClient.postMessage.mock.calls[1][2]).toEqual({ + thread_ts: "xoxb-alerts-ts-1", + mrkdwn: true, + }); + }); + + it("reports partial Slack delivery failure when one account is missing a bot token", async () => { + const fsMock = buildCredentialsFsMock({ + "slack-default-allowFrom.json": ["U_DEFAULT_OK"], + "slack-alerts-allowFrom.json": ["U_ALERTS_MISSING"], + }); + const { createSlackApi, clientsByToken } = buildSlackApiFactory(); + const notifier = createWatchdogNotifier({ + fsImpl: fsMock, + openclawDir: "/tmp/openclaw", + readEnvFile: () => [{ key: "SLACK_BOT_TOKEN", value: "xoxb-default" }], + createSlackApi, + }); + + const result = await notifier.notify("Health check", { + eventType: "health", + }); + + expect(result.channels.slack).toEqual({ + sent: 1, + failed: 1, + skipped: false, + targets: 2, + }); + expect(createSlackApi).toHaveBeenCalledTimes(1); + expect(Array.from(clientsByToken.keys())).toEqual(["xoxb-default"]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[watchdog] slack notification failed for alerts/U_ALERTS_MISSING: missing SLACK_BOT_TOKEN_ALERTS", + ); + }); +}); From 9c5f312df7b0955cb604a4472c06d4066ba2663e Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 1 Apr 2026 22:17:57 -0700 Subject: [PATCH 352/417] Bump openclaw to 2026.4.1 and manage exec defaults on boot Seed safe exec-approval defaults (security: full, ask: off, askFallback: full) and openclaw.json exec defaults (security: full, strictInlineEval: false) during boot and onboarding so new and existing instances start with safe exec policies. Extracts read/write exec-approvals helpers into a shared module and updates the gateway WebSocket scope-clearing patch for 2026.4.1. --- lib/server.js | 8 + lib/server/exec-defaults-config.js | 172 ++++++ lib/server/init/server-lifecycle.js | 2 + lib/server/onboarding/index.js | 5 + lib/server/routes/nodes.js | 32 +- lib/server/startup.js | 8 + package-lock.json | 650 ++++++++++------------ package.json | 2 +- patches/openclaw+2026.3.28.patch | 13 - patches/openclaw+2026.4.1.patch | 13 + tests/server/exec-defaults-config.test.js | 117 ++++ tests/server/routes-onboarding.test.js | 6 + tests/server/startup.test.js | 5 + 13 files changed, 648 insertions(+), 385 deletions(-) create mode 100644 lib/server/exec-defaults-config.js delete mode 100644 patches/openclaw+2026.3.28.patch create mode 100644 patches/openclaw+2026.4.1.patch create mode 100644 tests/server/exec-defaults-config.test.js diff --git a/lib/server.js b/lib/server.js index f5870c06..84782a9a 100644 --- a/lib/server.js +++ b/lib/server.js @@ -138,6 +138,9 @@ const { const { ensureUsageTrackerPluginConfig, } = require("./server/usage-tracker-config"); +const { + ensureManagedExecDefaults, +} = require("./server/exec-defaults-config"); const { PORT, kTrustProxyHops, SETUP_API_PREFIXES } = constants; @@ -384,6 +387,11 @@ startServerLifecycle({ PORT, isOnboarded, runOnboardedBootSequence, + ensureManagedExecDefaults: () => + ensureManagedExecDefaults({ + fsModule: fs, + openclawDir: constants.OPENCLAW_DIR, + }), ensureUsageTrackerPluginConfig: () => ensureUsageTrackerPluginConfig({ fsModule: fs, diff --git a/lib/server/exec-defaults-config.js b/lib/server/exec-defaults-config.js new file mode 100644 index 00000000..d4cf3831 --- /dev/null +++ b/lib/server/exec-defaults-config.js @@ -0,0 +1,172 @@ +const fs = require("fs"); +const path = require("path"); +const { + readOpenclawConfig, + resolveOpenclawConfigPath, + writeOpenclawConfig, +} = require("./openclaw-config"); + +const kManagedExecApprovalsDefaults = Object.freeze({ + security: "full", + ask: "off", + askFallback: "full", +}); + +const kManagedOpenclawExecDefaults = Object.freeze({ + security: "full", + strictInlineEval: false, +}); + +const resolveExecApprovalsConfigPath = ({ openclawDir }) => + path.join(openclawDir, "exec-approvals.json"); + +const readExecApprovalsConfig = ({ + fsModule = fs, + openclawDir, + fallback = { version: 1 }, +} = {}) => { + const filePath = resolveExecApprovalsConfigPath({ openclawDir }); + try { + const parsed = JSON.parse(fsModule.readFileSync(filePath, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : fallback; + } catch { + return fallback; + } +}; + +const writeExecApprovalsConfig = ({ + fsModule = fs, + openclawDir, + file = {}, + spacing = 2, +} = {}) => { + const filePath = resolveExecApprovalsConfigPath({ openclawDir }); + fsModule.mkdirSync(path.dirname(filePath), { recursive: true }); + fsModule.writeFileSync(filePath, JSON.stringify(file, null, spacing) + "\n", "utf8"); + return filePath; +}; + +const hasOwn = (obj, key) => + !!obj && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key); + +const ensureManagedExecApprovalsDefaults = (rawFile = {}) => { + const file = + rawFile && typeof rawFile === "object" && !Array.isArray(rawFile) ? rawFile : {}; + const before = JSON.stringify(file); + if (!Number.isInteger(file.version)) file.version = 1; + if (!file.defaults || typeof file.defaults !== "object" || Array.isArray(file.defaults)) { + file.defaults = {}; + } + if (!file.agents || typeof file.agents !== "object" || Array.isArray(file.agents)) { + file.agents = {}; + } + const defaults = file.defaults; + if (!String(defaults.security || "").trim()) { + defaults.security = kManagedExecApprovalsDefaults.security; + } + if (!String(defaults.ask || "").trim()) { + defaults.ask = kManagedExecApprovalsDefaults.ask; + } + if (!String(defaults.askFallback || "").trim()) { + defaults.askFallback = kManagedExecApprovalsDefaults.askFallback; + } + return { + file, + changed: JSON.stringify(file) !== before, + }; +}; + +const ensureManagedOpenclawExecDefaults = (rawConfig = {}) => { + const config = + rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig) ? rawConfig : {}; + const before = JSON.stringify(config); + if (!config.tools || typeof config.tools !== "object" || Array.isArray(config.tools)) { + config.tools = {}; + } + if ( + !config.tools.exec || + typeof config.tools.exec !== "object" || + Array.isArray(config.tools.exec) + ) { + config.tools.exec = {}; + } + const exec = config.tools.exec; + if (!String(exec.security || "").trim()) { + exec.security = kManagedOpenclawExecDefaults.security; + } + if (!hasOwn(exec, "strictInlineEval")) { + exec.strictInlineEval = kManagedOpenclawExecDefaults.strictInlineEval; + } + return { + config, + changed: JSON.stringify(config) !== before, + }; +}; + +const ensureManagedExecDefaults = ({ fsModule = fs, openclawDir } = {}) => { + let openclawChanged = false; + let approvalsChanged = false; + + const openclawConfigPath = resolveOpenclawConfigPath({ openclawDir }); + const openclawExists = + typeof fsModule.existsSync === "function" ? fsModule.existsSync(openclawConfigPath) : null; + if (openclawExists !== false) { + const cfg = readOpenclawConfig({ + fsModule, + openclawDir, + fallback: openclawExists === true ? null : {}, + }); + if (cfg && typeof cfg === "object" && !Array.isArray(cfg)) { + const ensuredConfig = ensureManagedOpenclawExecDefaults(cfg); + if (ensuredConfig.changed) { + writeOpenclawConfig({ + fsModule, + openclawDir, + config: ensuredConfig.config, + spacing: 2, + }); + openclawChanged = true; + } + } + } + + const approvalsPath = resolveExecApprovalsConfigPath({ openclawDir }); + const approvalsExists = + typeof fsModule.existsSync === "function" ? fsModule.existsSync(approvalsPath) : null; + const approvals = readExecApprovalsConfig({ + fsModule, + openclawDir, + fallback: approvalsExists === true ? null : { version: 1 }, + }); + if (approvals && typeof approvals === "object" && !Array.isArray(approvals)) { + const ensuredApprovals = ensureManagedExecApprovalsDefaults(approvals); + if (ensuredApprovals.changed || approvalsExists === false) { + writeExecApprovalsConfig({ + fsModule, + openclawDir, + file: ensuredApprovals.file, + spacing: 2, + }); + approvalsChanged = true; + } + } + + return { + changed: openclawChanged || approvalsChanged, + openclawChanged, + approvalsChanged, + }; +}; + +module.exports = { + kManagedExecApprovalsDefaults, + kManagedOpenclawExecDefaults, + resolveExecApprovalsConfigPath, + readExecApprovalsConfig, + writeExecApprovalsConfig, + ensureManagedExecApprovalsDefaults, + ensureManagedOpenclawExecDefaults, + ensureManagedExecDefaults, +}; diff --git a/lib/server/init/server-lifecycle.js b/lib/server/init/server-lifecycle.js index b8276c0a..b86f33e8 100644 --- a/lib/server/init/server-lifecycle.js +++ b/lib/server/init/server-lifecycle.js @@ -3,6 +3,7 @@ const startServerLifecycle = ({ PORT, isOnboarded, runOnboardedBootSequence, + ensureManagedExecDefaults, ensureUsageTrackerPluginConfig, doSyncPromptFiles, reloadEnv, @@ -18,6 +19,7 @@ const startServerLifecycle = ({ console.log(`[alphaclaw] Express listening on :${PORT}`); if (isOnboarded()) { runOnboardedBootSequence({ + ensureManagedExecDefaults, ensureUsageTrackerPluginConfig, doSyncPromptFiles, reloadEnv, diff --git a/lib/server/onboarding/index.js b/lib/server/onboarding/index.js index 6c5637cc..8ad16a6d 100644 --- a/lib/server/onboarding/index.js +++ b/lib/server/onboarding/index.js @@ -25,6 +25,7 @@ const { } = require("./cron"); const { migrateManagedInternalFiles } = require("../internal-files-migration"); const { installGogCliSkill } = require("../gog-skill"); +const { ensureManagedExecDefaults } = require("../exec-defaults-config"); const kPlaceholderEnvValue = "placeholder"; const kEnvRefPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g; @@ -529,6 +530,10 @@ const createOnboardingService = ({ }); } authProfiles?.syncConfigAuthReferencesForAgent?.(); + ensureManagedExecDefaults({ + fsModule: fs, + openclawDir: OPENCLAW_DIR, + }); installGogCliSkill({ fs, openclawDir: OPENCLAW_DIR }); diff --git a/lib/server/routes/nodes.js b/lib/server/routes/nodes.js index 0ffc67a7..7c431835 100644 --- a/lib/server/routes/nodes.js +++ b/lib/server/routes/nodes.js @@ -1,7 +1,10 @@ -const path = require("path"); const crypto = require("crypto"); const { parseJsonObjectFromNoisyOutput } = require("../utils/json"); const { quoteShellArg } = require("../utils/shell"); +const { + readExecApprovalsConfig, + writeExecApprovalsConfig, +} = require("../exec-defaults-config"); const kAllowedExecHosts = new Set(["gateway", "node"]); const kAllowedExecSecurity = new Set(["deny", "allowlist", "full"]); @@ -81,23 +84,6 @@ const parseNodeBrowserStatus = (stdout) => { return decodedResult && typeof decodedResult === "object" ? decodedResult : null; }; -const readExecApprovalsFile = ({ fsModule, openclawDir }) => { - const filePath = path.join(openclawDir, "exec-approvals.json"); - try { - const raw = fsModule.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : { version: 1 }; - } catch { - return { version: 1 }; - } -}; - -const writeExecApprovalsFile = ({ fsModule, openclawDir, file }) => { - const filePath = path.join(openclawDir, "exec-approvals.json"); - fsModule.mkdirSync(path.dirname(filePath), { recursive: true }); - fsModule.writeFileSync(filePath, JSON.stringify(file, null, 2) + "\n", "utf8"); -}; - const ensureWildcardAgent = (file) => { const agents = file.agents && typeof file.agents === "object" ? file.agents : {}; const wildcard = @@ -372,7 +358,7 @@ const registerNodeRoutes = ({ app.get("/api/nodes/exec-approvals", (_req, res) => { const approvals = ensureWildcardAgent( - readExecApprovalsFile({ fsModule, openclawDir }), + readExecApprovalsConfig({ fsModule, openclawDir }), ); const allowlist = approvals?.agents?.["*"]?.allowlist || []; return res.json({ @@ -388,7 +374,7 @@ const registerNodeRoutes = ({ return res.status(400).json({ ok: false, error: "pattern is required" }); } const approvals = ensureWildcardAgent( - readExecApprovalsFile({ fsModule, openclawDir }), + readExecApprovalsConfig({ fsModule, openclawDir }), ); const allowlist = approvals.agents["*"].allowlist; const existing = allowlist.find( @@ -403,7 +389,7 @@ const registerNodeRoutes = ({ lastUsedAt: Date.now(), }; approvals.agents["*"].allowlist = [...allowlist, entry]; - writeExecApprovalsFile({ fsModule, openclawDir, file: approvals }); + writeExecApprovalsConfig({ fsModule, openclawDir, file: approvals }); return res.json({ ok: true, entry }); }); @@ -413,7 +399,7 @@ const registerNodeRoutes = ({ return res.status(400).json({ ok: false, error: "id is required" }); } const approvals = ensureWildcardAgent( - readExecApprovalsFile({ fsModule, openclawDir }), + readExecApprovalsConfig({ fsModule, openclawDir }), ); const allowlist = approvals.agents["*"].allowlist; const nextAllowlist = allowlist.filter((entry) => String(entry?.id || "") !== id); @@ -421,7 +407,7 @@ const registerNodeRoutes = ({ return res.status(404).json({ ok: false, error: "Allowlist entry not found" }); } approvals.agents["*"].allowlist = nextAllowlist; - writeExecApprovalsFile({ fsModule, openclawDir, file: approvals }); + writeExecApprovalsConfig({ fsModule, openclawDir, file: approvals }); return res.json({ ok: true }); }); }; diff --git a/lib/server/startup.js b/lib/server/startup.js index a0bb787b..86f22e44 100644 --- a/lib/server/startup.js +++ b/lib/server/startup.js @@ -1,4 +1,5 @@ const runOnboardedBootSequence = ({ + ensureManagedExecDefaults, ensureUsageTrackerPluginConfig, doSyncPromptFiles, reloadEnv, @@ -10,6 +11,13 @@ const runOnboardedBootSequence = ({ watchdog, gmailWatchService, }) => { + try { + ensureManagedExecDefaults(); + } catch (error) { + console.error( + `[alphaclaw] Failed to ensure managed exec defaults on boot: ${error.message}`, + ); + } try { ensureUsageTrackerPluginConfig(); } catch (error) { diff --git a/package-lock.json b/package-lock.json index 2d35100f..7332a52d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.3.28", + "openclaw": "2026.4.1", "patch-package": "^8.0.1", "ws": "^8.19.0" }, @@ -38,9 +38,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.0.tgz", - "integrity": "sha512-inBMYAEd9t4E+ULZK2os9kmLG5jbPvMLbPvY71XDDem1YteW/uDwkahg6OwsGR3tvvgVhYbRJ9mJCp2VXqG4xQ==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz", + "integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -228,82 +228,31 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1019.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1019.0.tgz", - "integrity": "sha512-GWe/mqO8CQBmco6M0f9n7DZsItmxTarwvo1zxuVLiqFb8ondXDU+Ry/TNn6FXWHm6YRLm8DUPwaSmcckdGyZNQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/credential-provider-node": "^3.972.27", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.26", - "@aws-sdk/region-config-resolver": "^3.972.10", - "@aws-sdk/token-providers": "3.1019.0", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.12", - "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.12", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-retry": "^4.4.44", - "@smithy/middleware-serde": "^4.2.15", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.0", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.43", - "@smithy/util-defaults-mode-node": "^4.2.47", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1019.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1019.0.tgz", - "integrity": "sha512-Wq1uMAZfySYofuwkFMaMM+k7epsGBRcJGE+ZosGB+8jC8Xs1lycbjSEFMt0Mo3z1qhkgEKGCQyjCbPTICMkkVw==", + "version": "3.1022.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1022.0.tgz", + "integrity": "sha512-gT8+ebNzmLjk07dPTVn0f4ZdEDSFYsyCX3rAxX2QGGOasKeeQQEBW4PxYqHGM6lJrcuFSc/ScSVKTRDxGZlFiA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/credential-provider-node": "^3.972.27", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/eventstream-handler-node": "^3.972.12", "@aws-sdk/middleware-eventstream": "^3.972.8", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/middleware-websocket": "^3.972.14", "@aws-sdk/region-config-resolver": "^3.972.10", - "@aws-sdk/token-providers": "3.1019.0", + "@aws-sdk/token-providers": "3.1022.0", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.12", + "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.12", + "@smithy/core": "^3.23.13", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", @@ -311,25 +260,25 @@ "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-retry": "^4.4.44", - "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.0", + "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.43", - "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", - "@smithy/util-stream": "^4.5.20", + "@smithy/util-retry": "^4.2.13", + "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -338,19 +287,19 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.25.tgz", - "integrity": "sha512-TNrx7eq6nKNOO62HWPqoBqPLXEkW6nLZQGwjL6lq1jZtigWYbK1NbCnT7mKDzbLMHZfuOECUt3n6CzxjUW9HWQ==", + "version": "3.973.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", + "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.16", - "@smithy/core": "^3.23.12", + "@smithy/core": "^3.23.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", @@ -362,12 +311,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.23.tgz", - "integrity": "sha512-EamaclJcCEaPHp6wiVknNMM2RlsPMjAHSsYSFLNENBM8Wz92QPc6cOn3dif6vPDQt0Oo4IEghDy3NMDCzY/IvA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", + "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", + "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", @@ -378,20 +327,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.25.tgz", - "integrity": "sha512-qPymamdPcLp6ugoVocG1y5r69ScNiRzb0hogX25/ij+Wz7c7WnsgjLTaz7+eB5BfRxeyUwuw5hgULMuwOGOpcw==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", + "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", + "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.0", + "@smithy/node-http-handler": "^4.5.1", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.20", + "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" }, "engines": { @@ -399,19 +348,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.26.tgz", - "integrity": "sha512-xKxEAMuP6GYx2y5GET+d3aGEroax3AgGfwBE65EQAUe090lzyJ/RzxPX9s8v7Z6qAk0XwfQl+LrmH05X7YvTeg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz", + "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/credential-provider-env": "^3.972.23", - "@aws-sdk/credential-provider-http": "^3.972.25", - "@aws-sdk/credential-provider-login": "^3.972.26", - "@aws-sdk/credential-provider-process": "^3.972.23", - "@aws-sdk/credential-provider-sso": "^3.972.26", - "@aws-sdk/credential-provider-web-identity": "^3.972.26", - "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-login": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", @@ -424,13 +373,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.26.tgz", - "integrity": "sha512-EFcM8RM3TUxnZOfMJo++3PnyxFu1fL/huzmn3Vh+8IWRgqZawUD3cRwwOr+/4bE9DpyHaLOWFAjY0lfK5X9ZkQ==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz", + "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", @@ -443,17 +392,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.27.tgz", - "integrity": "sha512-jXpxSolfFnPVj6GCTtx3xIdWNoDR7hYC/0SbetGZxOC9UnNmipHeX1k6spVstf7eWJrMhXNQEgXC0pD1r5tXIg==", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz", + "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.23", - "@aws-sdk/credential-provider-http": "^3.972.25", - "@aws-sdk/credential-provider-ini": "^3.972.26", - "@aws-sdk/credential-provider-process": "^3.972.23", - "@aws-sdk/credential-provider-sso": "^3.972.26", - "@aws-sdk/credential-provider-web-identity": "^3.972.26", + "@aws-sdk/credential-provider-env": "^3.972.24", + "@aws-sdk/credential-provider-http": "^3.972.26", + "@aws-sdk/credential-provider-ini": "^3.972.28", + "@aws-sdk/credential-provider-process": "^3.972.24", + "@aws-sdk/credential-provider-sso": "^3.972.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.28", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", @@ -466,12 +415,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.23.tgz", - "integrity": "sha512-IL/TFW59++b7MpHserjUblGrdP5UXy5Ekqqx1XQkERXBFJcZr74I7VaSrQT5dxdRMU16xGK4L0RQ5fQG1pMgnA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", + "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", + "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -483,14 +432,32 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.26.tgz", - "integrity": "sha512-c6ghvRb6gTlMznWhGxn/bpVCcp0HRaz4DobGVD9kI4vwHq186nU2xN/S7QGkm0lo0H2jQU8+dgpUFLxfTcwCOg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz", + "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", + "@aws-sdk/token-providers": "3.1021.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1021.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz", + "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/nested-clients": "^3.996.16", - "@aws-sdk/token-providers": "3.1019.0", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -502,13 +469,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.26.tgz", - "integrity": "sha512-cXcS3+XD3iwhoXkM44AmxjmbcKueoLCINr1e+IceMmCySda5ysNIfiGBGe9qn5EMiQ9Jd7pP0AGFtcd6OV3Lvg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz", + "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -595,18 +562,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.26.tgz", - "integrity": "sha512-AilFIh4rI/2hKyyGN6XrB0yN96W2o7e7wyrPWCM6QjZM1mcC/pVkW3IWWRvuBWMpVP8Fg+rMpbzeLQ6dTM4gig==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz", + "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", + "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", - "@smithy/core": "^3.23.12", + "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "tslib": "^2.6.2" }, "engines": { @@ -637,47 +604,47 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.16.tgz", - "integrity": "sha512-L7Qzoj/qQU1cL5GnYLQP5LbI+wlLCLoINvcykR3htKcQ4tzrPf2DOs72x933BM7oArYj1SKrkb2lGlsJHIic3g==", + "version": "3.996.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz", + "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.25", + "@aws-sdk/core": "^3.973.26", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.12", + "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.12", + "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.27", - "@smithy/middleware-retry": "^4.4.44", - "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-endpoint": "^4.4.28", + "@smithy/middleware-retry": "^4.4.46", + "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.0", + "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.43", - "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-defaults-mode-browser": "^4.3.44", + "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -702,13 +669,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1019.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1019.0.tgz", - "integrity": "sha512-OF+2RfRmUKyjzrRWlDcyju3RBsuqcrYDQ8TwrJg8efcOotMzuZN4U9mpVTIdATpmEc4lWNZBMSjPzrGm6JPnAQ==", + "version": "3.1022.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1022.0.tgz", + "integrity": "sha512-rC0+QQh5uo9Y0wtrvsVuGWi8njtf6h6FB94h5NClUoiNTuQiRG/+AjXiqhv1x/m8TnLrgYCHiFzykOdOb5Ea9w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.25", - "@aws-sdk/nested-clients": "^3.996.16", + "@aws-sdk/core": "^3.973.26", + "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -788,12 +755,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.12.tgz", - "integrity": "sha512-8phW0TS8ntENJgDcFewYT/Q8dOmarpvSxEjATu2GUBAutiHr++oEGCiBUwxslCMNvwW2cAPZNT53S/ym8zm/gg==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz", + "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.26", + "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", @@ -1386,9 +1353,9 @@ } }, "node_modules/@google/genai": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.47.0.tgz", - "integrity": "sha512-0VV7AaXm5rQu3oRHNZNEubRAOL2lv5u+YA72eWnDwcOx3B1jFRbvtgL4drRHlocRHOnludvr3xmbQGbR+/RQAQ==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz", + "integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -1449,9 +1416,9 @@ } }, "node_modules/@homebridge/ciao": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.5.tgz", - "integrity": "sha512-f7MAw7YuoEYgJEQ1VyRcLHGuVmCpmXi65GVR8CAtPWPqIZf/HFr4vHzVpOfQMpEQw9Pt5uh07guuLt5HE8ruog==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.6.tgz", + "integrity": "sha512-2F9N/15Q/GnoBXimr8PFg7fb1QrAQBvuZpaW2kseWOOy14Lzc3yZB1mT9N1Ju/4hlkboU33uHxtOxZkvkPoE/w==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1487,9 +1454,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -2336,21 +2303,21 @@ } }, "node_modules/@mariozechner/pi-agent-core": { - "version": "0.63.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.63.1.tgz", - "integrity": "sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==", + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.64.0.tgz", + "integrity": "sha512-IN/sIxWOD0v1OFVXHB605SGiZhO5XdEWG5dO8EAV08n3jz/p12o4OuYGvhGXmHhU28WXa/FGWC+FO5xiIih8Uw==", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.63.1" + "@mariozechner/pi-ai": "^0.64.0" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@mariozechner/pi-ai": { - "version": "0.63.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.63.1.tgz", - "integrity": "sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==", + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.64.0.tgz", + "integrity": "sha512-Z/Jnf+JSVDPLRcxJsa8XhYTJKIqKekNueaCpBLGQHgizL1F9RQ1Rur3rIfZpfXkt2cLu/AIPtOs223ueuoWaWg==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -2395,15 +2362,15 @@ } }, "node_modules/@mariozechner/pi-coding-agent": { - "version": "0.63.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.63.1.tgz", - "integrity": "sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==", + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.64.0.tgz", + "integrity": "sha512-Q4tcqSqFGQtOgCtRyIp1D80Nv2if13Q2pfbnrOlaT/mix90mLcZGML9jKVnT1jGSy5GMYudU1HsS7cx53kxb0g==", "license": "MIT", "dependencies": { "@mariozechner/jiti": "^2.6.2", - "@mariozechner/pi-agent-core": "^0.63.1", - "@mariozechner/pi-ai": "^0.63.1", - "@mariozechner/pi-tui": "^0.63.1", + "@mariozechner/pi-agent-core": "^0.64.0", + "@mariozechner/pi-ai": "^0.64.0", + "@mariozechner/pi-tui": "^0.64.0", "@silvia-odwyer/photon-node": "^0.3.4", "ajv": "^8.17.1", "chalk": "^5.5.0", @@ -2462,9 +2429,9 @@ } }, "node_modules/@mariozechner/pi-tui": { - "version": "0.63.1", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.63.1.tgz", - "integrity": "sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==", + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.64.0.tgz", + "integrity": "sha512-W1qLry9MAuN/V3YJmMv/BJa0VaYv721NkXPg/DGItdqWxuDc+1VdNbyAnRwxblNkIpXVUWL26x64BlyFXpxmkg==", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -2552,9 +2519,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", - "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -2892,9 +2859,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz", - "integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", "license": "MIT", "workspaces": [ "e2e/*" @@ -2907,23 +2874,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.95", - "@napi-rs/canvas-darwin-arm64": "0.1.95", - "@napi-rs/canvas-darwin-x64": "0.1.95", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.95", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.95", - "@napi-rs/canvas-linux-arm64-musl": "0.1.95", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.95", - "@napi-rs/canvas-linux-x64-gnu": "0.1.95", - "@napi-rs/canvas-linux-x64-musl": "0.1.95", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.95", - "@napi-rs/canvas-win32-x64-msvc": "0.1.95" + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.95.tgz", - "integrity": "sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", "cpu": [ "arm64" ], @@ -2941,9 +2908,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.95.tgz", - "integrity": "sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", "cpu": [ "arm64" ], @@ -2961,9 +2928,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.95.tgz", - "integrity": "sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", "cpu": [ "x64" ], @@ -2981,9 +2948,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.95.tgz", - "integrity": "sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", "cpu": [ "arm" ], @@ -3001,9 +2968,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.95.tgz", - "integrity": "sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", "cpu": [ "arm64" ], @@ -3021,9 +2988,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.95.tgz", - "integrity": "sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", "cpu": [ "arm64" ], @@ -3041,9 +3008,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.95.tgz", - "integrity": "sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", "cpu": [ "riscv64" ], @@ -3061,9 +3028,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.95.tgz", - "integrity": "sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", "cpu": [ "x64" ], @@ -3081,9 +3048,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.95.tgz", - "integrity": "sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", "cpu": [ "x64" ], @@ -3101,9 +3068,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.95.tgz", - "integrity": "sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", "cpu": [ "arm64" ], @@ -3121,9 +3088,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.95", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.95.tgz", - "integrity": "sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", "cpu": [ "x64" ], @@ -3622,24 +3589,11 @@ "license": "Apache-2.0" }, "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "license": "MIT" }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", - "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/config-resolver": { "version": "4.4.13", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", @@ -3658,9 +3612,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.12", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", - "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", + "version": "3.23.13", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", + "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.12", @@ -3669,7 +3623,7 @@ "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.20", + "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -3835,13 +3789,13 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.27", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.27.tgz", - "integrity": "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA==", + "version": "4.4.28", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", + "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/middleware-serde": "^4.2.15", + "@smithy/core": "^3.23.13", + "@smithy/middleware-serde": "^4.2.16", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", @@ -3854,18 +3808,18 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.44", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.44.tgz", - "integrity": "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA==", + "version": "4.4.46", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", + "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.12", + "@smithy/util-retry": "^4.2.13", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -3874,12 +3828,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", - "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", + "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.12", + "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" @@ -3917,12 +3871,11 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", - "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", + "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", @@ -4030,17 +3983,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.7", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.7.tgz", - "integrity": "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ==", + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", + "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.12", - "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/core": "^3.23.13", + "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.20", + "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" }, "engines": { @@ -4137,13 +4090,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.43", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.43.tgz", - "integrity": "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ==", + "version": "4.3.44", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", + "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, @@ -4152,16 +4105,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.47", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.47.tgz", - "integrity": "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ==", + "version": "4.2.48", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", + "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==", "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.7", + "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, @@ -4209,9 +4162,9 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", + "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.12", @@ -4223,13 +4176,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.20", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", - "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", + "version": "4.5.21", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", + "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.0", + "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", @@ -5960,9 +5913,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -7612,9 +7565,9 @@ "license": "Apache-2.0" }, "node_modules/matrix-js-sdk": { - "version": "41.2.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.2.0.tgz", - "integrity": "sha512-kVLDKm/bUlwlHoDKRemshs27LCnOnNpmsVKtbCOM5F5D/E1SkrKldou+vQ/lk4PyXTvu9/qglkd2m0pBwJ5opg==", + "version": "41.3.0-rc.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.3.0-rc.0.tgz", + "integrity": "sha512-HTGqU6ZWAB9Dl3U9wUQDbk0aq77a6JFVdATTRX3Yy9eLytcK3RSLI6bPwFBrKgV2qRz+gy7bfsqXVDWTXng7jA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", @@ -7763,12 +7716,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -8075,25 +8028,26 @@ } }, "node_modules/openclaw": { - "version": "2026.3.28", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.3.28.tgz", - "integrity": "sha512-n7ZS3zdimB2H/GfnylyG8xWXVrmlsSPHZdNEIEPe54Sl5XYuYD5yxilGYV0DWowgtsM5ysFEQMMMArdC/O77Jw==", + "version": "2026.4.1", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.4.1.tgz", + "integrity": "sha512-+1KF+Fp3WjILT0OqnoIYncQif0HmrVDUOzn7Ir1vS6nulYiVw0GKhcizWFWlCbR1wiDZjB6BTaoj7JRnWPd3LQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@agentclientprotocol/sdk": "0.17.0", + "@agentclientprotocol/sdk": "0.17.1", "@anthropic-ai/vertex-sdk": "^0.14.4", - "@aws-sdk/client-bedrock": "^3.1019.0", "@clack/prompts": "^1.1.0", - "@homebridge/ciao": "^1.3.5", + "@homebridge/ciao": "^1.3.6", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", - "@mariozechner/pi-agent-core": "0.63.1", - "@mariozechner/pi-ai": "0.63.1", - "@mariozechner/pi-coding-agent": "0.63.1", - "@mariozechner/pi-tui": "0.63.1", - "@modelcontextprotocol/sdk": "1.28.0", + "@mariozechner/pi-agent-core": "0.64.0", + "@mariozechner/pi-ai": "0.64.0", + "@mariozechner/pi-coding-agent": "0.64.0", + "@mariozechner/pi-tui": "0.64.0", + "@matrix-org/matrix-sdk-crypto-wasm": "18.0.0", + "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", - "@sinclair/typebox": "0.34.48", + "@sinclair/typebox": "0.34.49", "ajv": "^8.18.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -8112,14 +8066,14 @@ "linkedom": "^0.18.12", "long": "^5.3.2", "markdown-it": "^14.1.1", - "matrix-js-sdk": "41.2.0", + "matrix-js-sdk": "41.3.0-rc.0", "node-edge-tts": "^1.2.10", "osc-progress": "^0.3.0", - "pdfjs-dist": "^5.5.207", + "pdfjs-dist": "^5.6.205", "playwright-core": "1.58.2", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", - "sqlite-vec": "0.1.7", + "sqlite-vec": "0.1.9", "tar": "7.5.13", "tslog": "^4.10.2", "undici": "^7.24.6", @@ -8713,15 +8667,15 @@ "license": "MIT" }, "node_modules/pdfjs-dist": { - "version": "5.5.207", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", - "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", "license": "Apache-2.0", "engines": { "node": ">=20.19.0 || >=22.13.0 || >=24" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.95", + "@napi-rs/canvas": "^0.1.96", "node-readable-to-web-readable-stream": "^0.4.2" } }, @@ -9814,22 +9768,22 @@ } }, "node_modules/sqlite-vec": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.7.tgz", - "integrity": "sha512-1Sge9uRc3B6wDKR4J6sGFi/E2ai9SAU5FenDki3OmhdP/a49PO2Juy1U5yQnx2bZP5t+C3BYJTkG+KkDi3q9Xg==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", + "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", "license": "MIT OR Apache", "optionalDependencies": { - "sqlite-vec-darwin-arm64": "0.1.7", - "sqlite-vec-darwin-x64": "0.1.7", - "sqlite-vec-linux-arm64": "0.1.7", - "sqlite-vec-linux-x64": "0.1.7", - "sqlite-vec-windows-x64": "0.1.7" + "sqlite-vec-darwin-arm64": "0.1.9", + "sqlite-vec-darwin-x64": "0.1.9", + "sqlite-vec-linux-arm64": "0.1.9", + "sqlite-vec-linux-x64": "0.1.9", + "sqlite-vec-windows-x64": "0.1.9" } }, "node_modules/sqlite-vec-darwin-arm64": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.7.tgz", - "integrity": "sha512-dQ7u4GKPdOPi3IfZ44K7HHdYup2JssM6fuKR9zgqRzW137uFOQmRhbYChNu+ZfW+yhJutsPgfNRFsuWKmy627w==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz", + "integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==", "cpu": [ "arm64" ], @@ -9840,9 +9794,9 @@ ] }, "node_modules/sqlite-vec-darwin-x64": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.7.tgz", - "integrity": "sha512-MDoczft1BriQcGMEz+CqeSCkB0OsAf12ytZOapS6MaB7zgNzLLSLH6Sxe3yzcPWUyDuCWgK7WzyRIo8u1vAIVA==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz", + "integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==", "cpu": [ "x64" ], @@ -9853,9 +9807,9 @@ ] }, "node_modules/sqlite-vec-linux-arm64": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.7.tgz", - "integrity": "sha512-V429sYT/gwr9PgtT8rbjQd6ls7CFchFpiS45TKSf7rU7wxt9MBmCVorUcheD4kEZb4VeZ6PnFXXCqPMeaHkaUw==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz", + "integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==", "cpu": [ "arm64" ], @@ -9866,9 +9820,9 @@ ] }, "node_modules/sqlite-vec-linux-x64": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.7.tgz", - "integrity": "sha512-wZL+lXeW7y63DLv6FYU6Q4nv2lP5F94cWt7bJCWNiHmZ6NdKIgz/p0QlyuJA/51b8TyoDvsTdusLVlZz9cIh5A==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz", + "integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==", "cpu": [ "x64" ], @@ -9879,9 +9833,9 @@ ] }, "node_modules/sqlite-vec-windows-x64": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.7.tgz", - "integrity": "sha512-FEZMjMT03irJxwqMQg+A+4hHCiFslxISOAkQ0eYn2lP7GdpppkgYveaT5Xnw/2V+GLq2MXOJb0nDGFNethHSkg==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz", + "integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==", "cpu": [ "x64" ], @@ -10536,9 +10490,9 @@ } }, "node_modules/undici": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", - "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index fc51d3d2..0498e4fb 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.3.28", + "openclaw": "2026.4.1", "patch-package": "^8.0.1", "ws": "^8.19.0" }, diff --git a/patches/openclaw+2026.3.28.patch b/patches/openclaw+2026.3.28.patch deleted file mode 100644 index 82071783..00000000 --- a/patches/openclaw+2026.3.28.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js b/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js -index ca48b932..c12478c4 100644 ---- a/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js -+++ b/node_modules/openclaw/dist/gateway-cli-DlnlX7IW.js -@@ -25935,7 +25935,7 @@ function attachGatewayWsMessageHandler(params) { - close(1008, truncateCloseReason(authMessage)); - }; - const clearUnboundScopes = () => { -- if (scopes.length > 0) { -+ if (scopes.length > 0 && !sharedAuthOk) { - scopes = []; - connectParams.scopes = scopes; - } diff --git a/patches/openclaw+2026.4.1.patch b/patches/openclaw+2026.4.1.patch new file mode 100644 index 00000000..50e386e4 --- /dev/null +++ b/patches/openclaw+2026.4.1.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/openclaw/dist/gateway-cli-6Ksv5U_O.js b/node_modules/openclaw/dist/gateway-cli-6Ksv5U_O.js +index 4c742cee..bb87239b 100644 +--- a/node_modules/openclaw/dist/gateway-cli-6Ksv5U_O.js ++++ b/node_modules/openclaw/dist/gateway-cli-6Ksv5U_O.js +@@ -26669,7 +26669,7 @@ function attachGatewayWsMessageHandler(params) { + close(1008, truncateCloseReason(authMessage)); + }; + const clearUnboundScopes = () => { +- if (scopes.length > 0) { ++ if (scopes.length > 0 && !sharedAuthOk) { + scopes = []; + connectParams.scopes = scopes; + } diff --git a/tests/server/exec-defaults-config.test.js b/tests/server/exec-defaults-config.test.js new file mode 100644 index 00000000..1acb17e9 --- /dev/null +++ b/tests/server/exec-defaults-config.test.js @@ -0,0 +1,117 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + ensureManagedExecDefaults, +} = require("../../lib/server/exec-defaults-config"); + +const createTempOpenclawDir = () => + fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-exec-defaults-test-")); + +describe("server/exec-defaults-config", () => { + it("fills missing managed exec defaults for openclaw.json and exec-approvals.json", () => { + const openclawDir = createTempOpenclawDir(); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify( + { + tools: { + profile: "full", + }, + channels: { + telegram: { enabled: true }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir }); + + expect(result).toEqual({ + changed: true, + openclawChanged: true, + approvalsChanged: true, + }); + + const openclawConfig = JSON.parse( + fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf8"), + ); + expect(openclawConfig.tools).toEqual({ + profile: "full", + exec: { + security: "full", + strictInlineEval: false, + }, + }); + expect(openclawConfig.channels.telegram).toEqual({ enabled: true }); + + const approvals = JSON.parse( + fs.readFileSync(path.join(openclawDir, "exec-approvals.json"), "utf8"), + ); + expect(approvals).toEqual({ + version: 1, + defaults: { + security: "full", + ask: "off", + askFallback: "full", + }, + agents: {}, + }); + }); + + it("preserves existing exec settings when they are already configured", () => { + const openclawDir = createTempOpenclawDir(); + const openclawPath = path.join(openclawDir, "openclaw.json"); + const approvalsPath = path.join(openclawDir, "exec-approvals.json"); + const openclawContent = JSON.stringify( + { + tools: { + profile: "full", + exec: { + host: "node", + node: "mac-1", + security: "allowlist", + ask: "always", + strictInlineEval: true, + }, + }, + }, + null, + 2, + ); + const approvalsContent = + JSON.stringify( + { + version: 1, + defaults: { + security: "allowlist", + ask: "always", + askFallback: "deny", + }, + agents: { + main: { + security: "allowlist", + }, + }, + }, + null, + 2, + ) + "\n"; + fs.writeFileSync(openclawPath, openclawContent, "utf8"); + fs.writeFileSync(approvalsPath, approvalsContent, "utf8"); + + const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir }); + + expect(result).toEqual({ + changed: false, + openclawChanged: false, + approvalsChanged: false, + }); + expect(fs.readFileSync(openclawPath, "utf8")).toBe(openclawContent); + expect(fs.readFileSync(approvalsPath, "utf8")).toBe(approvalsContent); + }); +}); diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index a30420b6..09564e9c 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -643,9 +643,15 @@ describe("server/routes/onboarding", () => { expect(files.get("/tmp/openclaw/openclaw.json")).toContain( '"bootstrap-extra-files"', ); + expect(files.get("/tmp/openclaw/openclaw.json")).toContain( + '"strictInlineEval": false', + ); expect(files.get("/tmp/openclaw/openclaw.json")).not.toContain( '"transformsDir"', ); + expect(files.get("/tmp/openclaw/exec-approvals.json")).toContain( + '"askFallback": "full"', + ); expect( deps.shellCmd.mock.calls.some(([cmd]) => cmd.includes( diff --git a/tests/server/startup.test.js b/tests/server/startup.test.js index cc4f6ead..2c8cdfee 100644 --- a/tests/server/startup.test.js +++ b/tests/server/startup.test.js @@ -3,6 +3,9 @@ const { runOnboardedBootSequence } = require("../../lib/server/startup"); describe("server/startup", () => { it("syncs gateway proxy config with the resolved setup URL before startup", () => { const callOrder = []; + const ensureManagedExecDefaults = vi.fn(() => + callOrder.push("ensureManagedExecDefaults"), + ); const ensureUsageTrackerPluginConfig = vi.fn(() => callOrder.push("ensureUsageTrackerPluginConfig"), ); @@ -27,6 +30,7 @@ describe("server/startup", () => { }; runOnboardedBootSequence({ + ensureManagedExecDefaults, ensureUsageTrackerPluginConfig, doSyncPromptFiles, reloadEnv, @@ -41,6 +45,7 @@ describe("server/startup", () => { expect(ensureGatewayProxyConfig).toHaveBeenCalledWith("https://setup.example.com"); expect(callOrder).toEqual([ + "ensureManagedExecDefaults", "ensureUsageTrackerPluginConfig", "doSyncPromptFiles", "reloadEnv", From d1cb453f28e336793ad0b94e4b08cb8885844bd0 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 1 Apr 2026 22:18:02 -0700 Subject: [PATCH 353/417] 0.8.4-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7332a52d..a985a1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.3", + "version": "0.8.4-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.3", + "version": "0.8.4-beta.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0498e4fb..b5224748 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.3", + "version": "0.8.4-beta.0", "publishConfig": { "access": "public" }, From edc55abf0f989bae945c2ce89f8ecf70bafb982e Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 1 Apr 2026 22:36:51 -0700 Subject: [PATCH 354/417] Avoid widening existing exec config during 2026.4.1 backfill --- lib/server/exec-defaults-config.js | 49 ++++++-------- tests/server/exec-defaults-config.test.js | 80 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/lib/server/exec-defaults-config.js b/lib/server/exec-defaults-config.js index d4cf3831..038dfe26 100644 --- a/lib/server/exec-defaults-config.js +++ b/lib/server/exec-defaults-config.js @@ -55,22 +55,21 @@ const ensureManagedExecApprovalsDefaults = (rawFile = {}) => { const file = rawFile && typeof rawFile === "object" && !Array.isArray(rawFile) ? rawFile : {}; const before = JSON.stringify(file); - if (!Number.isInteger(file.version)) file.version = 1; - if (!file.defaults || typeof file.defaults !== "object" || Array.isArray(file.defaults)) { - file.defaults = {}; - } - if (!file.agents || typeof file.agents !== "object" || Array.isArray(file.agents)) { - file.agents = {}; - } - const defaults = file.defaults; - if (!String(defaults.security || "").trim()) { - defaults.security = kManagedExecApprovalsDefaults.security; - } - if (!String(defaults.ask || "").trim()) { - defaults.ask = kManagedExecApprovalsDefaults.ask; - } - if (!String(defaults.askFallback || "").trim()) { - defaults.askFallback = kManagedExecApprovalsDefaults.askFallback; + const defaults = + file.defaults && typeof file.defaults === "object" && !Array.isArray(file.defaults) + ? file.defaults + : null; + const hasNonEmptyDefaults = !!defaults && Object.keys(defaults).length > 0; + if (!hasNonEmptyDefaults) { + if (!Number.isInteger(file.version)) file.version = 1; + file.defaults = { + security: kManagedExecApprovalsDefaults.security, + ask: kManagedExecApprovalsDefaults.ask, + askFallback: kManagedExecApprovalsDefaults.askFallback, + }; + if (!file.agents || typeof file.agents !== "object" || Array.isArray(file.agents)) { + file.agents = {}; + } } return { file, @@ -85,19 +84,11 @@ const ensureManagedOpenclawExecDefaults = (rawConfig = {}) => { if (!config.tools || typeof config.tools !== "object" || Array.isArray(config.tools)) { config.tools = {}; } - if ( - !config.tools.exec || - typeof config.tools.exec !== "object" || - Array.isArray(config.tools.exec) - ) { - config.tools.exec = {}; - } - const exec = config.tools.exec; - if (!String(exec.security || "").trim()) { - exec.security = kManagedOpenclawExecDefaults.security; - } - if (!hasOwn(exec, "strictInlineEval")) { - exec.strictInlineEval = kManagedOpenclawExecDefaults.strictInlineEval; + if (!hasOwn(config.tools, "exec")) { + config.tools.exec = { + security: kManagedOpenclawExecDefaults.security, + strictInlineEval: kManagedOpenclawExecDefaults.strictInlineEval, + }; } return { config, diff --git a/tests/server/exec-defaults-config.test.js b/tests/server/exec-defaults-config.test.js index 1acb17e9..ad19ecb7 100644 --- a/tests/server/exec-defaults-config.test.js +++ b/tests/server/exec-defaults-config.test.js @@ -114,4 +114,84 @@ describe("server/exec-defaults-config", () => { expect(fs.readFileSync(openclawPath, "utf8")).toBe(openclawContent); expect(fs.readFileSync(approvalsPath, "utf8")).toBe(approvalsContent); }); + + it("does not add or change openclaw exec subkeys when tools.exec already exists", () => { + const openclawDir = createTempOpenclawDir(); + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify( + { + tools: { + profile: "full", + exec: { + host: "gateway", + ask: "off", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir }); + + expect(result).toEqual({ + changed: true, + openclawChanged: false, + approvalsChanged: true, + }); + + const openclawConfig = JSON.parse( + fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf8"), + ); + expect(openclawConfig.tools.exec).toEqual({ + host: "gateway", + ask: "off", + }); + }); + + it("does not add or change exec approvals defaults when defaults is a non-empty object", () => { + const openclawDir = createTempOpenclawDir(); + const openclawPath = path.join(openclawDir, "openclaw.json"); + const approvalsPath = path.join(openclawDir, "exec-approvals.json"); + const openclawContent = JSON.stringify( + { + tools: { + profile: "full", + exec: { + host: "gateway", + }, + }, + }, + null, + 2, + ); + const approvalsContent = + JSON.stringify( + { + socket: { + path: "/data/.openclaw/exec-approvals.sock", + token: "", + }, + defaults: { + ask: "always", + }, + }, + null, + 2, + ) + "\n"; + fs.writeFileSync(openclawPath, openclawContent, "utf8"); + fs.writeFileSync(approvalsPath, approvalsContent, "utf8"); + + const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir }); + + expect(result).toEqual({ + changed: false, + openclawChanged: false, + approvalsChanged: false, + }); + expect(fs.readFileSync(approvalsPath, "utf8")).toBe(approvalsContent); + }); }); From 874a01454e6303bbf3d9245a048f96b49dadae01 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Wed, 1 Apr 2026 22:40:40 -0700 Subject: [PATCH 355/417] 0.8.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a985a1b3..edaa7cb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.4-beta.0", + "version": "0.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.4-beta.0", + "version": "0.8.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b5224748..88033e0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.4-beta.0", + "version": "0.8.4", "publishConfig": { "access": "public" }, From 03417bc8a387120343cd8cd135c3f8fcbc7cd8cd Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 2 Apr 2026 10:05:10 -0700 Subject: [PATCH 356/417] Fix AlphaClaw self-update failures on Docker (EBUSY) Use the same temp-dir + cp-af install pattern as the OpenClaw updater so npm does not try to rename directories the running process holds open. Also fix node_modules path matching to require path separators and check devDependencies/optionalDependencies when locating the consumer project. --- lib/server/alphaclaw-version.js | 73 +++++++++++++++++++++----- tests/server/alphaclaw-version.test.js | 35 +++++++++--- 2 files changed, 89 insertions(+), 19 deletions(-) diff --git a/lib/server/alphaclaw-version.js b/lib/server/alphaclaw-version.js index a19a8803..796e7cec 100644 --- a/lib/server/alphaclaw-version.js +++ b/lib/server/alphaclaw-version.js @@ -1,5 +1,6 @@ const childProcess = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const https = require("https"); const http = require("http"); @@ -7,6 +8,7 @@ const { kLatestVersionCacheTtlMs, kAlphaclawRegistryUrl, kNpmPackageRoot, + kOpenclawUpdateCopyTimeoutMs, kRootDir, } = require("./constants"); @@ -114,7 +116,7 @@ const createAlphaclawVersionService = () => { const parent = path.dirname(dir); if ( path.basename(parent) === "node_modules" || - parent.includes("node_modules") + parent.includes(`${path.sep}node_modules${path.sep}`) ) { dir = parent; continue; @@ -123,7 +125,11 @@ const createAlphaclawVersionService = () => { if (fs.existsSync(pkgPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - if (pkg.dependencies?.["@chrysb/alphaclaw"]) { + if ( + pkg.dependencies?.["@chrysb/alphaclaw"] || + pkg.devDependencies?.["@chrysb/alphaclaw"] || + pkg.optionalDependencies?.["@chrysb/alphaclaw"] + ) { return parent; } } catch {} @@ -137,19 +143,39 @@ const createAlphaclawVersionService = () => { const installLatestAlphaclaw = () => new Promise((resolve, reject) => { const installDir = findInstallDir(); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-update-"), + ); + + const cleanup = () => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }; + + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ + private: true, + dependencies: { "@chrysb/alphaclaw": "latest" }, + }), + ); + + const npmEnv = { + ...process.env, + npm_config_update_notifier: "false", + npm_config_fund: "false", + npm_config_audit: "false", + }; + console.log( - `[alphaclaw] Running: npm install @chrysb/alphaclaw@latest (cwd: ${installDir})`, + `[alphaclaw] Running: npm install @chrysb/alphaclaw@latest in temp dir (target: ${installDir})`, ); childProcess.exec( - "npm install @chrysb/alphaclaw@latest --omit=dev --no-save --save=false --package-lock=false --prefer-online", + "npm install --omit=dev --prefer-online --package-lock=false", { - cwd: installDir, - env: { - ...process.env, - npm_config_update_notifier: "false", - npm_config_fund: "false", - npm_config_audit: "false", - }, + cwd: tmpDir, + env: npmEnv, timeout: 180000, }, (err, stdout, stderr) => { @@ -158,6 +184,7 @@ const createAlphaclawVersionService = () => { console.log( `[alphaclaw] alphaclaw install error: ${message.slice(0, 200)}`, ); + cleanup(); return reject( new Error( message || "Failed to install @chrysb/alphaclaw@latest", @@ -169,8 +196,28 @@ const createAlphaclawVersionService = () => { `[alphaclaw] alphaclaw install stdout: ${stdout.trim().slice(0, 300)}`, ); } - console.log("[alphaclaw] alphaclaw install completed"); - resolve({ stdout: stdout?.trim(), stderr: stderr?.trim() }); + + const src = path.join(tmpDir, "node_modules"); + const dest = path.join(installDir, "node_modules"); + childProcess.exec( + `cp -af "${src}/." "${dest}/"`, + { timeout: kOpenclawUpdateCopyTimeoutMs }, + (copyErr) => { + cleanup(); + if (copyErr) { + console.log( + `[alphaclaw] alphaclaw copy error: ${(copyErr.message || "").slice(0, 200)}`, + ); + return reject( + new Error( + `Failed to copy updated AlphaClaw files: ${copyErr.message}`, + ), + ); + } + console.log("[alphaclaw] alphaclaw install completed"); + resolve({ stdout: stdout?.trim(), stderr: stderr?.trim() }); + }, + ); }, ); }); diff --git a/tests/server/alphaclaw-version.test.js b/tests/server/alphaclaw-version.test.js index 45cffafc..5a789476 100644 --- a/tests/server/alphaclaw-version.test.js +++ b/tests/server/alphaclaw-version.test.js @@ -1,10 +1,15 @@ const childProcess = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const https = require("https"); const { EventEmitter } = require("events"); -const { kNpmPackageRoot, kRootDir } = require("../../lib/server/constants"); +const { + kNpmPackageRoot, + kOpenclawUpdateCopyTimeoutMs, + kRootDir, +} = require("../../lib/server/constants"); const modulePath = require.resolve("../../lib/server/alphaclaw-version"); const originalExec = childProcess.exec; const originalHttpsGet = https.get; @@ -72,9 +77,9 @@ describe("server/alphaclaw-version", () => { }); it("returns 409 while another update is in progress", async () => { - let installCallback = null; + const callbacks = []; const execMock = vi.fn().mockImplementation((cmd, opts, callback) => { - installCallback = callback; + callbacks.push(callback); }); const { createAlphaclawVersionService } = loadVersionModule({ execMock }); const service = createAlphaclawVersionService(); @@ -89,7 +94,11 @@ describe("server/alphaclaw-version", () => { error: "AlphaClaw update already in progress", }); - installCallback(null, "installed", ""); + callbacks[0](null, "installed", ""); + await new Promise((resolve) => { + setImmediate(resolve); + }); + callbacks[1](null, "", ""); await firstPromise; }); @@ -106,13 +115,27 @@ describe("server/alphaclaw-version", () => { expect(result.body.ok).toBe(true); expect(result.body.restarting).toBe(true); expect(result.body.previousVersion).toBeTruthy(); - expect(execMock).toHaveBeenCalledWith( - "npm install @chrysb/alphaclaw@latest --omit=dev --no-save --save=false --package-lock=false --prefer-online", + expect(execMock).toHaveBeenCalledTimes(2); + expect(execMock).toHaveBeenNthCalledWith( + 1, + "npm install --omit=dev --prefer-online --package-lock=false", expect.objectContaining({ + cwd: expect.stringContaining(path.join(os.tmpdir(), "alphaclaw-update-")), + env: expect.objectContaining({ + npm_config_update_notifier: "false", + npm_config_fund: "false", + npm_config_audit: "false", + }), timeout: 180000, }), expect.any(Function), ); + expect(execMock).toHaveBeenNthCalledWith( + 2, + expect.stringMatching(/^cp -af /), + expect.objectContaining({ timeout: kOpenclawUpdateCopyTimeoutMs }), + expect.any(Function), + ); }); it("returns 500 when npm install fails", async () => { From 3d85205fa3d7db667a55c4137aabe6679b4723db Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 2 Apr 2026 10:05:16 -0700 Subject: [PATCH 357/417] 0.8.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index edaa7cb0..d682370b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.4", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.4", + "version": "0.8.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 88033e0a..0baf35e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.4", + "version": "0.8.5", "publishConfig": { "access": "public" }, From 253aa3c765bbe6bdd749c12dcda9dd438539ab0b Mon Sep 17 00:00:00 2001 From: Keagan Stokoe Date: Thu, 2 Apr 2026 19:25:04 +0200 Subject: [PATCH 358/417] Address PR review: 3-state theme toggle, dark default, banner legibility - Rewrite theme toggle from simple dark/light button to hover dropdown with dark, light, and system options (using computer icon for system) - Default to dark mode for first-time visitors (not OS preference) - Add "system" preference that follows OS and listens for changes - Fix protected file banner legibility in light mode (amber, red, blue variants) - Update login.html and setup.html early scripts to handle "system" preference Co-Authored-By: Claude Opus 4.6 --- lib/public/css/explorer.css | 33 ++++++ lib/public/css/shell.css | 70 ++++++++++++ lib/public/js/components/theme-toggle.js | 136 +++++++++++++++++------ lib/public/login.html | 3 +- lib/public/setup.html | 3 +- 5 files changed, 211 insertions(+), 34 deletions(-) diff --git a/lib/public/css/explorer.css b/lib/public/css/explorer.css index 1d7fc59d..8e1bdadf 100644 --- a/lib/public/css/explorer.css +++ b/lib/public/css/explorer.css @@ -1895,3 +1895,36 @@ color: #0e7490; background: rgba(8, 145, 178, 0.1); } + +[data-theme="light"] .file-viewer-protected-banner { + background: rgba(234, 179, 8, 0.12); +} + +[data-theme="light"] .file-viewer-protected-banner-text { + color: #92400e; +} + +[data-theme="light"] .file-viewer-protected-banner-unlocked { + color: #a16207; +} + +[data-theme="light"] .file-viewer-protected-banner.is-locked { + background: rgba(220, 38, 38, 0.1); +} + +[data-theme="light"] .file-viewer-protected-banner.is-locked .file-viewer-protected-banner-text { + color: #b91c1c; +} + +[data-theme="light"] .file-viewer-protected-banner.is-locked .file-viewer-protected-banner-icon { + color: #dc2626; +} + +[data-theme="light"] .file-viewer-diff-banner { + background: rgba(59, 130, 246, 0.08); +} + +[data-theme="light"] .file-viewer-diff-banner .file-viewer-protected-banner-text, +[data-theme="light"] .file-viewer-diff-banner { + color: #1d4ed8; +} diff --git a/lib/public/css/shell.css b/lib/public/css/shell.css index 19e48027..76162a91 100644 --- a/lib/public/css/shell.css +++ b/lib/public/css/shell.css @@ -492,6 +492,76 @@ } } +/* ── Theme toggle dropdown ────────────────────── */ + +.theme-toggle-menu { + position: relative; + display: inline-flex; +} + +.theme-toggle-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.theme-toggle-trigger:hover { + color: var(--text-muted); + background: var(--bg-hover); +} + +.theme-toggle-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 120px; + padding: 4px; + background: var(--bg-content); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + z-index: 50; + display: flex; + flex-direction: column; +} + +[data-theme="light"] .theme-toggle-dropdown { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.theme-toggle-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.theme-toggle-option:hover { + background: var(--bg-hover); + color: var(--text); +} + +.theme-toggle-option.active { + color: var(--accent); +} + /* ── Light theme overrides ─────────────────────── */ [data-theme="light"] .app-sidebar { diff --git a/lib/public/js/components/theme-toggle.js b/lib/public/js/components/theme-toggle.js index 7becdc6d..9a376845 100644 --- a/lib/public/js/components/theme-toggle.js +++ b/lib/public/js/components/theme-toggle.js @@ -1,53 +1,125 @@ import { h } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import htm from "htm"; -import { MoonIcon, SunIcon } from "./icons.js"; +import { ComputerLineIcon, MoonIcon, SunIcon } from "./icons.js"; import { kThemeStorageKey } from "../lib/storage-keys.js"; const html = htm.bind(h); -/** Resolve the current effective theme. If no preference saved, follow OS. */ -const getEffectiveTheme = () => { +const kOptions = [ + { id: "dark", label: "Dark", Icon: MoonIcon }, + { id: "light", label: "Light", Icon: SunIcon }, + { id: "system", label: "System", Icon: ComputerLineIcon }, +]; + +/** Map a preference to the icon component shown on the trigger button. */ +const kPrefIcon = { dark: MoonIcon, light: SunIcon, system: ComputerLineIcon }; + +/** Resolve a preference string to an effective "dark" | "light" value. */ +const resolveEffective = (pref) => { + if (pref === "dark" || pref === "light") return pref; + try { + return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + } catch { + return "dark"; + } +}; + +/** Read the stored preference. Falls back to "dark" (not OS). */ +const readPreference = () => { try { const saved = localStorage.getItem(kThemeStorageKey); - if (saved === "dark" || saved === "light") return saved; + if (saved === "dark" || saved === "light" || saved === "system") return saved; } catch {} - return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + return "dark"; +}; + +const applyEffective = (effective) => { + document.documentElement.dataset.theme = effective; }; -const applyTheme = (theme) => { - document.documentElement.dataset.theme = theme; - try { localStorage.setItem(kThemeStorageKey, theme); } catch {} +const savePreference = (pref) => { + try { localStorage.setItem(kThemeStorageKey, pref); } catch {} }; export const ThemeToggle = () => { - const [theme, setTheme] = useState(getEffectiveTheme); + const [pref, setPref] = useState(readPreference); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const closeTimer = useRef(null); + + // Apply effective theme whenever preference changes (and listen for OS changes when "system"). + useEffect(() => { + applyEffective(resolveEffective(pref)); - const toggle = () => { - const next = theme === "dark" ? "light" : "dark"; - applyTheme(next); - setTheme(next); + if (pref !== "system") return; + + const mql = window.matchMedia("(prefers-color-scheme: light)"); + const onChange = () => applyEffective(resolveEffective("system")); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, [pref]); + + // Close dropdown on outside click. + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) setOpen(false); + }; + window.addEventListener("click", handler, true); + return () => window.removeEventListener("click", handler, true); + }, [open]); + + const select = (id) => { + setPref(id); + savePreference(id); + applyEffective(resolveEffective(id)); + setOpen(false); + }; + + const startHoverOpen = () => { + clearTimeout(closeTimer.current); + setOpen(true); + }; + + const startHoverClose = () => { + closeTimer.current = setTimeout(() => setOpen(false), 200); }; + const TriggerIcon = kPrefIcon[pref] || MoonIcon; + return html` - + + ${open && html` +
      + ${kOptions.map(({ id, label, Icon }) => html` + + `)} +
      + `} +
      `; }; diff --git a/lib/public/login.html b/lib/public/login.html index cbd5ed92..42e93488 100644 --- a/lib/public/login.html +++ b/lib/public/login.html @@ -14,7 +14,8 @@ diff --git a/lib/public/setup.html b/lib/public/setup.html index 17b9be17..3067f14f 100644 --- a/lib/public/setup.html +++ b/lib/public/setup.html @@ -20,7 +20,8 @@ // Apply saved theme before render to prevent flash. try { var t = localStorage.getItem("alphaclaw.ui.theme"); - if (t !== "dark" && t !== "light") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + if (t === "system") t = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; + else if (t !== "dark" && t !== "light") t = "dark"; document.documentElement.dataset.theme = t; } catch {} From bb9186091f9bf88ab15dfcdd0d3fdfae9a6e99e7 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Thu, 2 Apr 2026 14:43:42 -0700 Subject: [PATCH 359/417] Use recent webhook requests for list health --- lib/server/db/webhooks/index.js | 56 ++++++++++++++++++++++++---- lib/server/routes/webhooks.js | 13 ++++++- tests/server/routes-webhooks.test.js | 52 ++++++++++++++++++++++++++ tests/server/webhooks-db.test.js | 39 +++++++++++++++++++ 4 files changed, 151 insertions(+), 9 deletions(-) diff --git a/lib/server/db/webhooks/index.js b/lib/server/db/webhooks/index.js index b79d5d07..c7c99f2d 100644 --- a/lib/server/db/webhooks/index.js +++ b/lib/server/db/webhooks/index.js @@ -10,6 +10,7 @@ let pruneTimer = null; const kDefaultRequestLimit = 50; const kMaxRequestLimit = 200; const kPruneIntervalMs = 12 * 60 * 60 * 1000; +const kHealthSummaryWindow = 25; const ensureDb = () => { if (!db) throw new Error("Webhooks DB not initialized"); @@ -202,22 +203,61 @@ const getHookSummaries = () => { const database = ensureDb(); const rows = database .prepare(` + WITH ranked_requests AS ( + SELECT + hook_name, + created_at, + gateway_status, + ROW_NUMBER() OVER ( + PARTITION BY hook_name + ORDER BY created_at DESC, id DESC + ) AS row_num + FROM webhook_requests + ), + overall_counts AS ( + SELECT + hook_name, + MAX(created_at) AS last_received, + COUNT(*) AS total_count, + SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count + FROM webhook_requests + GROUP BY hook_name + ), + recent_counts AS ( + SELECT + hook_name, + COUNT(*) AS recent_total_count, + SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS recent_success_count, + SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS recent_error_count + FROM ranked_requests + WHERE row_num <= $health_window + GROUP BY hook_name + ) SELECT - hook_name, - MAX(created_at) AS last_received, - COUNT(*) AS total_count, - SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count - FROM webhook_requests - GROUP BY hook_name + overall_counts.hook_name, + overall_counts.last_received, + overall_counts.total_count, + overall_counts.success_count, + overall_counts.error_count, + COALESCE(recent_counts.recent_total_count, 0) AS recent_total_count, + COALESCE(recent_counts.recent_success_count, 0) AS recent_success_count, + COALESCE(recent_counts.recent_error_count, 0) AS recent_error_count + FROM overall_counts + LEFT JOIN recent_counts + ON recent_counts.hook_name = overall_counts.hook_name `) - .all(); + .all({ $health_window: kHealthSummaryWindow }); return rows.map((row) => ({ hookName: row.hook_name, lastReceived: row.last_received || null, totalCount: Number(row.total_count || 0), successCount: Number(row.success_count || 0), errorCount: Number(row.error_count || 0), + recentTotalCount: Number(row.recent_total_count || 0), + recentSuccessCount: Number(row.recent_success_count || 0), + recentErrorCount: Number(row.recent_error_count || 0), + healthWindowSize: kHealthSummaryWindow, })); }; diff --git a/lib/server/routes/webhooks.js b/lib/server/routes/webhooks.js index 4cf4a539..207b9361 100644 --- a/lib/server/routes/webhooks.js +++ b/lib/server/routes/webhooks.js @@ -29,13 +29,24 @@ const mergeWebhookAndSummary = ({ webhook, summary }) => { const totalCount = Number(summary?.totalCount || 0); const errorCount = Number(summary?.errorCount || 0); const successCount = Number(summary?.successCount || 0); + const recentTotalCount = Number(summary?.recentTotalCount || 0); + const recentErrorCount = Number(summary?.recentErrorCount || 0); + const recentSuccessCount = Number(summary?.recentSuccessCount || 0); + const healthWindowSize = Number(summary?.healthWindowSize || 0); return { ...webhook, lastReceived: summary?.lastReceived || null, totalCount, successCount, errorCount, - health: buildHealth({ totalCount, errorCount }), + recentTotalCount, + recentSuccessCount, + recentErrorCount, + healthWindowSize, + health: buildHealth({ + totalCount: recentTotalCount || totalCount, + errorCount: recentTotalCount > 0 ? recentErrorCount : errorCount, + }), }; }; diff --git a/tests/server/routes-webhooks.test.js b/tests/server/routes-webhooks.test.js index dfb819ce..ce0c1b2c 100644 --- a/tests/server/routes-webhooks.test.js +++ b/tests/server/routes-webhooks.test.js @@ -212,4 +212,56 @@ describe("server/routes/webhooks", () => { expect(response.body?.webhook?.to).toBe("new-session"); expect(response.body?.webhook?.agentId).toBe("alpha"); }); + + it("uses the recent request window for webhook health", async () => { + const openclawDir = "/tmp/openclaw"; + const configPath = path.join(openclawDir, "openclaw.json"); + const fs = createMemoryFs({ + [configPath]: JSON.stringify({ + agents: { + list: [{ id: "main", default: true }], + }, + }), + }); + createWebhook({ + fs, + constants: { OPENCLAW_DIR: openclawDir }, + name: "recent-health", + }); + const app = createApp({ + fs, + constants: { OPENCLAW_DIR: openclawDir }, + webhooksDb: { + getHookSummaries: () => [ + { + hookName: "recent-health", + lastReceived: "2026-04-02T12:00:00.000Z", + totalCount: 30, + successCount: 25, + errorCount: 5, + recentTotalCount: 25, + recentSuccessCount: 25, + recentErrorCount: 0, + healthWindowSize: 25, + }, + ], + getRequests: () => [], + getRequestById: () => null, + deleteRequestsByHook: () => 0, + createOauthCallback: () => null, + getOauthCallbackByHook: () => null, + rotateOauthCallback: () => null, + deleteOauthCallback: () => 0, + }, + }); + + const response = await request(app).get("/api/webhooks"); + + expect(response.status).toBe(200); + expect(response.body?.webhooks).toHaveLength(1); + expect(response.body?.webhooks?.[0]?.errorCount).toBe(5); + expect(response.body?.webhooks?.[0]?.recentErrorCount).toBe(0); + expect(response.body?.webhooks?.[0]?.healthWindowSize).toBe(25); + expect(response.body?.webhooks?.[0]?.health).toBe("green"); + }); }); diff --git a/tests/server/webhooks-db.test.js b/tests/server/webhooks-db.test.js index eefd1a03..46c3926a 100644 --- a/tests/server/webhooks-db.test.js +++ b/tests/server/webhooks-db.test.js @@ -51,4 +51,43 @@ describe("server/webhooks-db", () => { fs.rmSync(rootDir, { recursive: true, force: true }); }); + + it("tracks recent health counts separately from all-time totals", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "webhooks-db-health-")); + const { + initWebhooksDb, + insertRequest, + getHookSummaries, + } = loadWebhooksDb(); + + initWebhooksDb({ rootDir }); + + for (let index = 0; index < 30; index += 1) { + insertRequest({ + hookName: "recent-health", + method: "POST", + headers: {}, + payload: `{"index":${index}}`, + payloadTruncated: false, + payloadSize: 12, + sourceIp: "127.0.0.1", + gatewayStatus: index < 5 ? 500 : 200, + gatewayBody: "", + }); + } + + const summary = getHookSummaries().find( + (item) => item.hookName === "recent-health", + ); + + expect(summary).toBeTruthy(); + expect(summary.totalCount).toBe(30); + expect(summary.errorCount).toBe(5); + expect(summary.recentTotalCount).toBe(25); + expect(summary.recentSuccessCount).toBe(25); + expect(summary.recentErrorCount).toBe(0); + expect(summary.healthWindowSize).toBe(25); + + fs.rmSync(rootDir, { recursive: true, force: true }); + }); }); From 639a9d0e8d07a262f9d2aadc86629cf507002b77 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Fri, 3 Apr 2026 11:25:05 -0700 Subject: [PATCH 360/417] Fix #52: show onboarding validation on Next --- .../components/onboarding/welcome-config.js | 86 +++++++++-- .../onboarding/welcome-form-step.js | 6 +- lib/public/js/components/welcome/index.js | 2 - .../js/components/welcome/use-welcome.js | 137 +++++++++++++----- tests/frontend/welcome-config.test.js | 74 ++++++++++ 5 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 tests/frontend/welcome-config.test.js diff --git a/lib/public/js/components/onboarding/welcome-config.js b/lib/public/js/components/onboarding/welcome-config.js index 59ce6b10..5b2236cb 100644 --- a/lib/public/js/components/onboarding/welcome-config.js +++ b/lib/public/js/components/onboarding/welcome-config.js @@ -11,6 +11,8 @@ export const kGithubFlowImport = "import"; export const kGithubTargetRepoModeCreate = "create"; export const kGithubTargetRepoModeExistingEmpty = "existing-empty"; +const hasValue = (value) => !!String(value || "").trim(); + export const normalizeGithubRepoInput = (repoInput) => String(repoInput || "") .trim() @@ -25,6 +27,74 @@ export const isValidGithubRepoInput = (repoInput) => { return parts.length === 2 && !parts.some((part) => /\s/.test(part)); }; +const getGithubGroupError = (vals) => { + const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; + if (!hasValue(vals.GITHUB_TOKEN)) { + return "Enter a GitHub personal access token to continue."; + } + if (!hasValue(vals.GITHUB_WORKSPACE_REPO)) { + return 'Enter the target repo as "owner/repo".'; + } + if (!isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) { + return 'Target repo must be in "owner/repo" format.'; + } + if (githubFlow === kGithubFlowImport) { + if (!hasValue(vals._GITHUB_SOURCE_REPO)) { + return 'Enter the source repo as "owner/repo".'; + } + if (!isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO)) { + return 'Source repo must be in "owner/repo" format.'; + } + } + return ""; +}; + +const getAiGroupError = (vals, ctx = {}) => { + if (!hasValue(vals.MODEL_KEY) || !String(vals.MODEL_KEY).includes("/")) { + return "Choose a model to continue."; + } + if (ctx.selectedProvider === "openai-codex" && ctx.codexLoading) { + return "Checking Codex OAuth status. Try Next again in a moment."; + } + if (!ctx.hasAi) { + return ctx.selectedProvider === "openai-codex" + ? "Connect Codex OAuth to continue." + : "Add credentials for the selected model provider to continue."; + } + return ""; +}; + +const getChannelsGroupError = (vals) => { + const hasTelegram = hasValue(vals.TELEGRAM_BOT_TOKEN); + const hasDiscord = hasValue(vals.DISCORD_BOT_TOKEN); + const hasSlackBot = hasValue(vals.SLACK_BOT_TOKEN); + const hasSlackApp = hasValue(vals.SLACK_APP_TOKEN); + + if (hasSlackBot && !hasSlackApp) { + return "Add the Slack app token to continue with Slack."; + } + if (!hasSlackBot && hasSlackApp) { + return "Add the Slack bot token to continue with Slack."; + } + if (!hasTelegram && !hasDiscord && !(hasSlackBot && hasSlackApp)) { + return "Add at least one channel to continue."; + } + return ""; +}; + +export const getWelcomeGroupError = (groupId, vals, ctx = {}) => { + switch (groupId) { + case "github": + return getGithubGroupError(vals); + case "ai": + return getAiGroupError(vals, ctx); + case "channels": + return getChannelsGroupError(vals); + default: + return ""; + } +}; + export const kWelcomeGroups = [ { id: "github", @@ -64,21 +134,14 @@ export const kWelcomeGroups = [ placeholder: "ghp_... or github_pat_...", }, ], - validate: (vals) => { - const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh; - const hasTarget = isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO); - const hasSource = - githubFlow !== kGithubFlowImport || - isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO); - return !!(vals.GITHUB_TOKEN && hasTarget && hasSource); - }, + validate: (vals, ctx = {}) => !getWelcomeGroupError("github", vals, ctx), }, { id: "ai", title: "Primary Agent Model", description: "Choose your main model and authenticate its provider", fields: kAllAiAuthFields, - validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi), + validate: (vals, ctx = {}) => !getWelcomeGroupError("ai", vals, ctx), }, { id: "channels", @@ -152,7 +215,7 @@ export const kWelcomeGroups = [ placeholder: "xapp-...", }, ], - validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN || (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)), + validate: (vals, ctx = {}) => !getWelcomeGroupError("channels", vals, ctx), }, { id: "tools", @@ -175,3 +238,6 @@ export const kWelcomeGroups = [ validate: () => true, }, ]; + +export const findFirstInvalidWelcomeGroup = (vals, ctx = {}) => + kWelcomeGroups.find((group) => getWelcomeGroupError(group.id, vals, ctx)) || null; diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index 44e37b59..e41ab4f1 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -50,12 +50,10 @@ export const WelcomeFormStep = ({ error, step, totalGroups, - currentGroupValid, goBack, goNext, loading, githubStepLoading, - allValid, handleSubmit, }) => { const [showOptionalOpenai, setShowOptionalOpenai] = useState(false); @@ -300,7 +298,7 @@ export const WelcomeFormStep = ({

      ${codexAuthWaiting - ? "Complete login in the popup, then paste the full redirect URL from the address bar (starts with " + ? "Complete login in the new tab or window, then paste the full redirect URL from the address bar (starts with " : "Paste the full redirect URL from the address bar (starts with "} http://localhost:1455/auth/callback

      `} <${ActionButton} onClick=${goNext} - disabled=${!currentGroupValid} loading=${activeGroup.id === "github" && githubStepLoading} tone="primary" size="md" @@ -466,7 +463,6 @@ export const WelcomeFormStep = ({ : html`
      `} <${ActionButton} onClick=${handleSubmit} - disabled=${!allValid} loading=${loading} tone="primary" size="md" diff --git a/lib/public/js/components/welcome/index.js b/lib/public/js/components/welcome/index.js index 8121f65d..026f4348 100644 --- a/lib/public/js/components/welcome/index.js +++ b/lib/public/js/components/welcome/index.js @@ -102,12 +102,10 @@ export const Welcome = ({ onComplete, acVersion }) => { error=${state.formError} step=${state.step} totalGroups=${kWelcomeGroups.length} - currentGroupValid=${state.currentGroupValid} goBack=${actions.goBack} goNext=${actions.goNext} loading=${state.loading} githubStepLoading=${state.githubStepLoading} - allValid=${state.allValid} handleSubmit=${actions.handleSubmit} /> `} diff --git a/lib/public/js/components/welcome/use-welcome.js b/lib/public/js/components/welcome/use-welcome.js index bc6c724d..d071325b 100644 --- a/lib/public/js/components/welcome/use-welcome.js +++ b/lib/public/js/components/welcome/use-welcome.js @@ -6,6 +6,7 @@ import { applyImport, fetchModels, } from "../../lib/api.js"; +import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; import { getModelProvider, getAuthProviderFromModelProvider, @@ -13,8 +14,15 @@ import { getVisibleAiFieldKeys, kProviderAuthFields, } from "../../lib/model-config.js"; +import { + getInitialOnboardingModelKey, + getModelCatalogModels, + kModelCatalogCacheKey, +} from "../../lib/model-catalog.js"; import { kWelcomeGroups, + getWelcomeGroupError, + findFirstInvalidWelcomeGroup, isValidGithubRepoInput, kGithubFlowFresh, kGithubFlowImport, @@ -76,11 +84,18 @@ const normalizePlaceholderReview = (review) => { export const useWelcome = ({ onComplete }) => { const kSetupStepIndex = kWelcomeGroups.length; const kPairingStepIndex = kSetupStepIndex + 1; - const { vals, setVals, setValue, step, setStep, setupError, setSetupError } = - useWelcomeStorage({ - kSetupStepIndex, - kPairingStepIndex, - }); + const { + vals, + setVals, + setValue: setStoredValue, + step, + setStep, + setupError, + setSetupError, + } = useWelcomeStorage({ + kSetupStepIndex, + kPairingStepIndex, + }); const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); const [modelsError, setModelsError] = useState(null); @@ -110,6 +125,14 @@ export const useWelcome = ({ onComplete }) => { const [importScanResult, setImportScanResult] = useState(null); const [importScanning, setImportScanning] = useState(false); const [importError, setImportError] = useState(null); + const modelsFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, { + maxAgeMs: 30000, + }); + + const setValue = (key, value) => { + if (formError) setFormError(null); + setStoredValue(key, value); + }; const setImportStep = (nextStep) => { setImportStepState(nextStep); @@ -129,21 +152,54 @@ export const useWelcome = ({ onComplete }) => { }; useEffect(() => { - fetchModels() - .then((result) => { - const list = Array.isArray(result.models) ? result.models : []; - const featured = getFeaturedModels(list); - setModels(list); - if (!vals.MODEL_KEY && list.length > 0) { - const defaultModel = featured[0] || list[0]; - setVals((prev) => ({ ...prev, MODEL_KEY: defaultModel.key })); - } - }) - .catch(() => setModelsError("Failed to load models")) - .finally(() => setModelsLoading(false)); - }, []); + const list = getModelCatalogModels(modelsFetchState.data); + if (!modelsFetchState.data) return; + setModels(list); + setModelsError(list.length > 0 ? null : "No models found"); + const defaultModelKey = getInitialOnboardingModelKey({ + catalog: list, + currentModelKey: vals.MODEL_KEY, + }); + if (!vals.MODEL_KEY && defaultModelKey) { + setVals((prev) => ({ ...prev, MODEL_KEY: defaultModelKey })); + } + }, [modelsFetchState.data, setVals, vals.MODEL_KEY]); + + useEffect(() => { + const hasModels = getModelCatalogModels(modelsFetchState.data).length > 0; + setModelsLoading(modelsFetchState.loading && !hasModels); + }, [modelsFetchState.data, modelsFetchState.loading]); + + useEffect(() => { + if (!modelsFetchState.error) return; + setModelsError("Failed to load models"); + setModelsLoading(false); + }, [modelsFetchState.error]); + + const getValidationContext = (currentVals = {}) => { + const currentSelectedProvider = getModelProvider( + String(currentVals.MODEL_KEY || "").trim(), + ); + const currentSelectedAuthProvider = + getAuthProviderFromModelProvider(currentSelectedProvider); + const currentProviderAuthFields = + kProviderAuthFields[currentSelectedAuthProvider] || []; + const currentHasAi = + currentSelectedProvider === "openai-codex" + ? !!codexStatus.connected + : currentProviderAuthFields.some((field) => + !!String(currentVals[field.key] || "").trim(), + ); - const selectedProvider = getModelProvider(vals.MODEL_KEY); + return { + hasAi: currentHasAi, + selectedProvider: currentSelectedProvider, + codexLoading, + }; + }; + + const validationContext = getValidationContext(vals); + const { selectedProvider, hasAi } = validationContext; const placeholderReview = normalizePlaceholderReview( vals[kImportPlaceholderReviewKey], ); @@ -164,23 +220,10 @@ export const useWelcome = ({ onComplete }) => { const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length; const visibleAiFieldKeys = getVisibleAiFieldKeys(selectedProvider); - const selectedAuthProvider = getAuthProviderFromModelProvider(selectedProvider); - const selectedProviderAuthFields = kProviderAuthFields[selectedAuthProvider] || []; - const hasAi = - selectedProvider === "openai-codex" - ? !!codexStatus.connected - : selectedProviderAuthFields.some( - (field) => !!String(vals[field.key] || "").trim(), - ); - - const allValid = kWelcomeGroups.every((group) => group.validate(vals, { hasAi })); const isPreStep = step === -1; const isSetupStep = step === kSetupStepIndex; const isPairingStep = step === kPairingStepIndex; const activeGroup = step >= 0 && step < kSetupStepIndex ? kWelcomeGroups[step] : null; - const currentGroupValid = activeGroup - ? activeGroup.validate(vals, { hasAi }) - : false; const selectedPairingChannel = String( vals[kPairingChannelKey] || getPreferredPairingChannel(vals), ); @@ -202,7 +245,21 @@ export const useWelcome = ({ onComplete }) => { const handleSubmit = async () => { const { normalizedVals, didChange } = normalizeOnboardingVals(vals); if (didChange) setVals(normalizedVals); - if (!kWelcomeGroups.every((group) => group.validate(normalizedVals, { hasAi }))) { + const submitValidationContext = getValidationContext(normalizedVals); + const invalidGroup = findFirstInvalidWelcomeGroup( + normalizedVals, + submitValidationContext, + ); + if (invalidGroup) { + setFormError( + getWelcomeGroupError( + invalidGroup.id, + normalizedVals, + submitValidationContext, + ), + ); + setSetupError(null); + setStep(kWelcomeGroups.findIndex((group) => group.id === invalidGroup.id)); return; } if (loading) return; @@ -309,7 +366,17 @@ export const useWelcome = ({ onComplete }) => { const goNext = async () => { const { normalizedVals, didChange } = normalizeOnboardingVals(vals); if (didChange) setVals(normalizedVals); - if (!activeGroup || !activeGroup.validate(normalizedVals, { hasAi })) return; + if (!activeGroup) return; + const stepValidationContext = getValidationContext(normalizedVals); + const stepValidationError = getWelcomeGroupError( + activeGroup.id, + normalizedVals, + stepValidationContext, + ); + if (stepValidationError) { + setFormError(stepValidationError); + return; + } setFormError(null); if (activeGroup.id === "github") { const githubFlow = normalizedVals._GITHUB_FLOW || kGithubFlowFresh; @@ -545,12 +612,10 @@ export const useWelcome = ({ onComplete }) => { canToggleFullCatalog, visibleAiFieldKeys, hasAi, - allValid, isPreStep, isSetupStep, isPairingStep, activeGroup, - currentGroupValid, selectedPairingChannel, placeholderReview, isImportStep, diff --git a/tests/frontend/welcome-config.test.js b/tests/frontend/welcome-config.test.js new file mode 100644 index 00000000..8ce7b002 --- /dev/null +++ b/tests/frontend/welcome-config.test.js @@ -0,0 +1,74 @@ +const loadWelcomeConfig = async () => + import("../../lib/public/js/components/onboarding/welcome-config.js"); + +describe("frontend/welcome-config", () => { + it("reports a target repo format error for invalid GitHub input", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError("github", { + GITHUB_TOKEN: "ghp_123", + GITHUB_WORKSPACE_REPO: "owner-only", + }), + ).toBe('Target repo must be in "owner/repo" format.'); + }); + + it("requires a source repo when import mode is selected", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError("github", { + _GITHUB_FLOW: welcomeConfig.kGithubFlowImport, + GITHUB_TOKEN: "ghp_123", + GITHUB_WORKSPACE_REPO: "owner/target-repo", + _GITHUB_SOURCE_REPO: "", + }), + ).toBe('Enter the source repo as "owner/repo".'); + }); + + it("returns a Codex-specific auth message for the AI step", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError( + "ai", + { MODEL_KEY: "openai-codex/gpt-5.4" }, + { + selectedProvider: "openai-codex", + hasAi: false, + codexLoading: false, + }, + ), + ).toBe("Connect Codex OAuth to continue."); + }); + + it("requires both Slack tokens before the channels step can pass", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + expect( + welcomeConfig.getWelcomeGroupError("channels", { + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "", + }), + ).toBe("Add the Slack app token to continue with Slack."); + }); + + it("finds the first invalid step in welcome order", async () => { + const welcomeConfig = await loadWelcomeConfig(); + + const invalidGroup = welcomeConfig.findFirstInvalidWelcomeGroup( + { + GITHUB_TOKEN: "ghp_123", + GITHUB_WORKSPACE_REPO: "owner/target-repo", + MODEL_KEY: "openai-codex/gpt-5.4", + }, + { + selectedProvider: "openai-codex", + hasAi: false, + codexLoading: false, + }, + ); + + expect(invalidGroup?.id).toBe("ai"); + }); +}); From 305f41189996321628a9e950e4c14fcfa0dd0b39 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 4 Apr 2026 21:25:53 -0700 Subject: [PATCH 361/417] Cache model catalog responses --- .../js/components/models-tab/use-models.js | 83 +++++- lib/public/js/lib/model-catalog.js | 20 ++ lib/server/model-catalog-cache.js | 251 ++++++++++++++++++ lib/server/routes/models.js | 37 +-- tests/frontend/model-catalog.test.js | 51 ++++ tests/server/model-catalog-cache.test.js | 188 +++++++++++++ tests/server/routes-models.test.js | 51 +++- 7 files changed, 639 insertions(+), 42 deletions(-) create mode 100644 lib/public/js/lib/model-catalog.js create mode 100644 lib/server/model-catalog-cache.js create mode 100644 tests/frontend/model-catalog.test.js create mode 100644 tests/server/model-catalog-cache.test.js diff --git a/lib/public/js/components/models-tab/use-models.js b/lib/public/js/components/models-tab/use-models.js index 36634a5b..3afaafb5 100644 --- a/lib/public/js/components/models-tab/use-models.js +++ b/lib/public/js/components/models-tab/use-models.js @@ -8,16 +8,35 @@ import { } from "../../lib/api.js"; import { showToast } from "../toast.js"; import { useCachedFetch } from "../../hooks/use-cached-fetch.js"; +import { usePolling } from "../../hooks/usePolling.js"; +import { invalidateCache } from "../../lib/api-cache.js"; +import { + getModelCatalogModels, + isModelCatalogRefreshing, + kModelCatalogCacheKey, + kModelCatalogPollIntervalMs, +} from "../../lib/model-catalog.js"; let kModelsTabCache = null; const getCredentialValue = (value) => String(value?.key || value?.token || value?.access || "").trim(); +const kNoModelsFoundError = "No models found"; +const kModelSettingsLoadError = "Failed to load model settings"; export const useModels = (agentId) => { const isScoped = !!agentId; const normalizedAgentId = String(agentId || "").trim(); const useCache = !isScoped; const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []); + const [catalogStatus, setCatalogStatus] = useState( + () => + (useCache && kModelsTabCache?.catalogStatus) || { + source: "", + fetchedAt: null, + stale: false, + refreshing: false, + }, + ); const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || ""); const [configuredModels, setConfiguredModels] = useState( () => (useCache && kModelsTabCache?.configuredModels) || {}, @@ -48,7 +67,7 @@ export const useModels = (agentId) => { const modelsConfigCacheKey = normalizedAgentId ? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}` : "/api/models/config"; - const catalogFetchState = useCachedFetch("/api/models", fetchModels, { + const catalogFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, { maxAgeMs: 30000, }); const configFetchState = useCachedFetch( @@ -59,6 +78,41 @@ export const useModels = (agentId) => { const codexFetchState = useCachedFetch("/api/codex/status", fetchCodexStatus, { maxAgeMs: 15000, }); + const catalogPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, { + enabled: ready && isModelCatalogRefreshing(catalogStatus), + pauseWhenHidden: true, + cacheKey: kModelCatalogCacheKey, + }); + + const syncCatalogError = useCallback((catalogModels) => { + setError((current) => { + if (catalogModels.length > 0) { + return current === kNoModelsFoundError ? "" : current; + } + return current || kNoModelsFoundError; + }); + }, []); + + const applyCatalogResult = useCallback( + (catalogResult) => { + const catalogModels = getModelCatalogModels(catalogResult); + const nextCatalogStatus = { + source: String(catalogResult?.source || ""), + fetchedAt: Number(catalogResult?.fetchedAt || 0) || null, + stale: Boolean(catalogResult?.stale), + refreshing: Boolean(catalogResult?.refreshing), + }; + setCatalog(catalogModels); + setCatalogStatus(nextCatalogStatus); + updateCache({ + catalog: catalogModels, + catalogStatus: nextCatalogStatus, + }); + syncCatalogError(catalogModels); + return catalogModels; + }, + [syncCatalogError, updateCache], + ); const refresh = useCallback(async () => { if (!ready) setLoading(true); @@ -69,10 +123,7 @@ export const useModels = (agentId) => { configFetchState.refresh({ force: true }), codexFetchState.refresh({ force: true }), ]); - const catalogModels = Array.isArray(catalogResult.models) - ? catalogResult.models - : []; - setCatalog(catalogModels); + const catalogModels = applyCatalogResult(catalogResult); const p = configResult.primary || ""; const cm = configResult.configuredModels || {}; const ap = configResult.authProfiles || []; @@ -94,20 +145,31 @@ export const useModels = (agentId) => { authOrder: ao, codexStatus: codex || { connected: false }, }); - if (!catalogModels.length) setError("No models found"); } catch (err) { - setError("Failed to load model settings"); - showToast(`Failed to load model settings: ${err.message}`, "error"); + setError(kModelSettingsLoadError); + showToast(`${kModelSettingsLoadError}: ${err.message}`, "error"); } finally { setReady(true); setLoading(false); } - }, [catalogFetchState, codexFetchState, configFetchState, ready, updateCache, agentId, isScoped]); + }, [ + applyCatalogResult, + catalogFetchState, + codexFetchState, + configFetchState, + ready, + updateCache, + ]); useEffect(() => { refresh(); }, [agentId]); + useEffect(() => { + if (!catalogPoll.data) return; + applyCatalogResult(catalogPoll.data); + }, [applyCatalogResult, catalogPoll.data]); + const stableStringify = (obj) => JSON.stringify(Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {})); @@ -261,6 +323,7 @@ export const useModels = (agentId) => { if (result.syncWarning) { showToast(`Saved, but git-sync failed: ${result.syncWarning}`, "warning"); } + invalidateCache(kModelCatalogCacheKey); await refresh(); } catch (err) { showToast(err.message || "Failed to save changes", "error"); @@ -274,6 +337,8 @@ export const useModels = (agentId) => { profileEdits, orderEdits, authProfiles, + isScoped, + agentId, refresh, ]); diff --git a/lib/public/js/lib/model-catalog.js b/lib/public/js/lib/model-catalog.js new file mode 100644 index 00000000..45e9bc83 --- /dev/null +++ b/lib/public/js/lib/model-catalog.js @@ -0,0 +1,20 @@ +import { getFeaturedModels } from "./model-config.js"; + +export const kModelCatalogCacheKey = "/api/models"; +export const kModelCatalogPollIntervalMs = 3000; + +export const getModelCatalogModels = (payload) => + Array.isArray(payload?.models) ? payload.models : []; + +export const isModelCatalogRefreshing = (payload) => + Boolean(payload?.refreshing); + +export const getInitialOnboardingModelKey = ({ + catalog = [], + currentModelKey = "", +} = {}) => { + const normalizedCurrent = String(currentModelKey || "").trim(); + if (normalizedCurrent) return normalizedCurrent; + const featuredModels = getFeaturedModels(catalog); + return String(featuredModels[0]?.key || catalog[0]?.key || ""); +}; diff --git a/lib/server/model-catalog-cache.js b/lib/server/model-catalog-cache.js new file mode 100644 index 00000000..6f4f45d9 --- /dev/null +++ b/lib/server/model-catalog-cache.js @@ -0,0 +1,251 @@ +const fs = require("fs"); +const path = require("path"); +const { ALPHACLAW_DIR, kFallbackOnboardingModels } = require("./constants"); + +const kModelCatalogCacheVersion = 1; +const kModelCatalogRefreshBackoffMs = 30 * 1000; +const kDefaultCachePath = path.join(ALPHACLAW_DIR, "cache", "model-catalog.json"); + +const createResponse = ({ + source = "fallback", + fetchedAt = null, + stale = false, + refreshing = false, + models = [], +} = {}) => ({ + ok: true, + source, + fetchedAt, + stale, + refreshing, + models, +}); + +const normalizeCachedModels = ({ + models, + normalizeOnboardingModels = (items) => items, +} = {}) => + normalizeOnboardingModels( + (Array.isArray(models) ? models : []).map((model) => ({ + key: model?.key, + name: model?.label || model?.name || model?.key, + })), + ); + +const normalizeCacheEntry = ({ + raw, + normalizeOnboardingModels = (items) => items, +} = {}) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const fetchedAt = Number(raw.fetchedAt || 0); + const models = normalizeCachedModels({ + models: raw.models, + normalizeOnboardingModels, + }); + if (!Number.isFinite(fetchedAt) || fetchedAt <= 0 || models.length === 0) { + return null; + } + return { + version: kModelCatalogCacheVersion, + fetchedAt, + models, + }; +}; + +const createModelCatalogCache = ({ + fsModule = fs, + pathModule = path, + shellCmd, + gatewayEnv = () => ({}), + parseJsonFromNoisyOutput = () => ({}), + normalizeOnboardingModels = (items) => items, + fallbackModels = kFallbackOnboardingModels, + cachePath = kDefaultCachePath, + refreshBackoffMs = kModelCatalogRefreshBackoffMs, + now = () => Date.now(), + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + logger = console, +} = {}) => { + let cacheLoaded = false; + let memoryCache = null; + let cacheIsStale = false; + let refreshPromise = null; + let retryTimer = null; + let backoffUntilMs = 0; + + const clearRetryTimer = () => { + if (!retryTimer) return; + clearTimeoutFn(retryTimer); + retryTimer = null; + }; + + const isRefreshPending = () => !!refreshPromise || !!retryTimer; + + const setCacheEntry = (entry, { fresh = false } = {}) => { + memoryCache = entry; + cacheLoaded = true; + cacheIsStale = !fresh; + backoffUntilMs = 0; + clearRetryTimer(); + return memoryCache; + }; + + const readDiskCache = () => { + if (cacheLoaded) return memoryCache; + cacheLoaded = true; + try { + const raw = JSON.parse(fsModule.readFileSync(cachePath, "utf8")); + const entry = normalizeCacheEntry({ + raw, + normalizeOnboardingModels, + }); + if (!entry) return null; + memoryCache = entry; + cacheIsStale = true; + return memoryCache; + } catch { + memoryCache = null; + cacheIsStale = false; + return null; + } + }; + + const writeDiskCache = (entry) => { + fsModule.mkdirSync(pathModule.dirname(cachePath), { recursive: true }); + fsModule.writeFileSync( + cachePath, + `${JSON.stringify(entry, null, 2)}\n`, + "utf8", + ); + }; + + const loadFreshCatalog = async () => { + const output = await shellCmd("openclaw models list --all --json", { + env: gatewayEnv(), + timeout: 20000, + }); + const parsed = parseJsonFromNoisyOutput(output); + const models = normalizeOnboardingModels(parsed?.models || []); + if (models.length === 0) { + throw new Error("No models found"); + } + const entry = { + version: kModelCatalogCacheVersion, + fetchedAt: now(), + models, + }; + writeDiskCache(entry); + setCacheEntry(entry, { fresh: true }); + return entry; + }; + + const scheduleRetry = () => { + if (!memoryCache || retryTimer) return; + const delayMs = Math.max(backoffUntilMs - now(), 0); + retryTimer = setTimeoutFn(() => { + retryTimer = null; + if (!memoryCache || !cacheIsStale || refreshPromise) return; + void startBackgroundRefresh(); + }, delayMs); + if (typeof retryTimer?.unref === "function") retryTimer.unref(); + }; + + const handleRefreshFailure = (err) => { + if (memoryCache) { + cacheIsStale = true; + backoffUntilMs = now() + refreshBackoffMs; + scheduleRetry(); + logger.error?.( + `[models] Failed to refresh cached models: ${err.message || String(err)}`, + ); + return; + } + logger.error?.( + `[models] Failed to load dynamic models: ${err.message || String(err)}`, + ); + }; + + const startBackgroundRefresh = () => { + readDiskCache(); + if (!memoryCache) return null; + if (refreshPromise) return refreshPromise; + if (retryTimer) return null; + if (backoffUntilMs > now()) { + scheduleRetry(); + return null; + } + refreshPromise = Promise.resolve() + .then(() => loadFreshCatalog()) + .catch((err) => { + handleRefreshFailure(err); + return null; + }) + .finally(() => { + refreshPromise = null; + }); + return refreshPromise; + }; + + return { + async getCatalogResponse() { + readDiskCache(); + if (memoryCache && !cacheIsStale) { + return createResponse({ + source: "openclaw", + fetchedAt: memoryCache.fetchedAt, + stale: false, + refreshing: false, + models: memoryCache.models, + }); + } + if (memoryCache) { + startBackgroundRefresh(); + return createResponse({ + source: "cache", + fetchedAt: memoryCache.fetchedAt, + stale: true, + refreshing: isRefreshPending(), + models: memoryCache.models, + }); + } + try { + const freshEntry = await loadFreshCatalog(); + return createResponse({ + source: "openclaw", + fetchedAt: freshEntry.fetchedAt, + stale: false, + refreshing: false, + models: freshEntry.models, + }); + } catch (err) { + handleRefreshFailure(err); + return createResponse({ + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: fallbackModels, + }); + } + }, + + markStale() { + readDiskCache(); + if (!memoryCache) return; + cacheIsStale = true; + backoffUntilMs = 0; + clearRetryTimer(); + }, + }; +}; + +module.exports = { + createModelCatalogCache, + createResponse, + normalizeCachedModels, + normalizeCacheEntry, + kModelCatalogCacheVersion, + kModelCatalogRefreshBackoffMs, + kDefaultCachePath, +}; diff --git a/lib/server/routes/models.js b/lib/server/routes/models.js index 96b9d611..1cb69abf 100644 --- a/lib/server/routes/models.js +++ b/lib/server/routes/models.js @@ -1,4 +1,5 @@ const { kFallbackOnboardingModels } = require("../constants"); +const { createModelCatalogCache } = require("../model-catalog-cache"); const runModelsGitSync = async (shellCmd) => { if (typeof shellCmd !== "function") return null; @@ -22,6 +23,13 @@ const registerModelRoutes = ({ readEnvFile, writeEnvFile, reloadEnv, + modelCatalogCache = createModelCatalogCache({ + shellCmd, + gatewayEnv, + parseJsonFromNoisyOutput, + normalizeOnboardingModels, + fallbackModels: kFallbackOnboardingModels, + }), }) => { const upsertEnvVar = (items, key, value) => { const next = Array.isArray(items) ? [...items] : []; @@ -154,29 +162,8 @@ const registerModelRoutes = ({ // ── Existing CLI-backed catalog/status routes ── app.get("/api/models", async (req, res) => { - try { - const output = await shellCmd("openclaw models list --all --json", { - env: gatewayEnv(), - timeout: 20000, - }); - const parsed = parseJsonFromNoisyOutput(output); - const models = normalizeOnboardingModels(parsed?.models || []); - if (models.length > 0) { - return res.json({ ok: true, source: "openclaw", models }); - } - return res.json({ - ok: true, - source: "fallback", - models: kFallbackOnboardingModels, - }); - } catch (err) { - console.error("[models] Failed to load dynamic models:", err.message); - return res.json({ - ok: true, - source: "fallback", - models: kFallbackOnboardingModels, - }); - } + const response = await modelCatalogCache.getCatalogResponse(); + return res.json(response); }); app.get("/api/models/status", async (req, res) => { @@ -210,6 +197,7 @@ const registerModelRoutes = ({ env: gatewayEnv(), timeout: 30000, }); + modelCatalogCache.markStale(); res.json({ ok: true }); } catch (err) { res @@ -286,6 +274,7 @@ const registerModelRoutes = ({ authProfiles.syncConfigAuthReferencesForAgent(agentId); const syncWarning = await runModelsGitSync(shellCmd); + modelCatalogCache.markStale(); res.json({ ok: true, ...(syncWarning ? { syncWarning } : {}), @@ -338,6 +327,7 @@ const registerModelRoutes = ({ const agentId = req.query.agentId || undefined; authProfiles.upsertProfile(profileId, credential, agentId); syncEnvVarsForProfiles([{ id: profileId, ...credential }]); + modelCatalogCache.markStale(); res.json({ ok: true }); } catch (err) { res @@ -359,6 +349,7 @@ const registerModelRoutes = ({ try { const agentId = req.query.agentId || undefined; const removed = authProfiles.removeProfile(profileId, agentId); + modelCatalogCache.markStale(); res.json({ ok: true, removed }); } catch (err) { res diff --git a/tests/frontend/model-catalog.test.js b/tests/frontend/model-catalog.test.js new file mode 100644 index 00000000..26cf3f9b --- /dev/null +++ b/tests/frontend/model-catalog.test.js @@ -0,0 +1,51 @@ +describe("frontend/model-catalog", () => { + it("returns catalog models when the payload is valid", async () => { + const { getModelCatalogModels } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect( + getModelCatalogModels({ + models: [{ key: "openai/gpt-5.4", label: "GPT-5.4" }], + }), + ).toEqual([{ key: "openai/gpt-5.4", label: "GPT-5.4" }]); + expect(getModelCatalogModels(null)).toEqual([]); + }); + + it("preserves an existing onboarding selection", async () => { + const { getInitialOnboardingModelKey } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect( + getInitialOnboardingModelKey({ + catalog: [{ key: "openai-codex/gpt-5.4", label: "GPT-5.4" }], + currentModelKey: "anthropic/claude-opus-4-6", + }), + ).toBe("anthropic/claude-opus-4-6"); + }); + + it("picks the first featured onboarding model when nothing is selected", async () => { + const { getInitialOnboardingModelKey } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect( + getInitialOnboardingModelKey({ + catalog: [ + { key: "openai-codex/gpt-5.4", label: "GPT-5.4" }, + { key: "anthropic/claude-opus-4-6", label: "Opus 4.6" }, + ], + }), + ).toBe("anthropic/claude-opus-4-6"); + }); + + it("reports whether the catalog is still refreshing", async () => { + const { isModelCatalogRefreshing } = await import( + "../../lib/public/js/lib/model-catalog.js" + ); + + expect(isModelCatalogRefreshing({ refreshing: true })).toBe(true); + expect(isModelCatalogRefreshing({ refreshing: false })).toBe(false); + }); +}); diff --git a/tests/server/model-catalog-cache.test.js b/tests/server/model-catalog-cache.test.js new file mode 100644 index 00000000..d6003467 --- /dev/null +++ b/tests/server/model-catalog-cache.test.js @@ -0,0 +1,188 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + createModelCatalogCache, + kModelCatalogRefreshBackoffMs, +} = require("../../lib/server/model-catalog-cache"); +const { kFallbackOnboardingModels } = require("../../lib/server/constants"); + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +const normalizeModels = (models = []) => + (Array.isArray(models) ? models : []) + .filter((model) => model?.key) + .map((model) => ({ + key: model.key, + provider: String(model.key).split("/")[0] || "", + label: model.name || model.label || model.key, + })); + +const writeCacheFile = ({ cachePath, fetchedAt = 1000, models = [] }) => { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync( + cachePath, + `${JSON.stringify({ version: 1, fetchedAt, models }, null, 2)}\n`, + "utf8", + ); +}; + +describe("server/model-catalog-cache", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns cached models immediately and shares a single in-flight refresh", async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-model-catalog-cache-"), + ); + const cachePath = path.join(tempRoot, "cache", "model-catalog.json"); + writeCacheFile({ + cachePath, + fetchedAt: 111, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + + let resolveShell; + const shellCmd = vi.fn( + () => + new Promise((resolve) => { + resolveShell = resolve; + }), + ); + const parseJsonFromNoisyOutput = vi.fn(() => ({ + models: [{ key: "openai/gpt-fresh", name: "Fresh" }], + })); + const cache = createModelCatalogCache({ + cachePath, + shellCmd, + gatewayEnv: () => ({ OPENCLAW_GATEWAY_TOKEN: "token" }), + parseJsonFromNoisyOutput, + normalizeOnboardingModels: normalizeModels, + }); + + const first = await cache.getCatalogResponse(); + const second = await cache.getCatalogResponse(); + + expect(first).toEqual({ + ok: true, + source: "cache", + fetchedAt: 111, + stale: true, + refreshing: true, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + expect(second.source).toBe("cache"); + expect(second.refreshing).toBe(true); + expect(shellCmd).toHaveBeenCalledTimes(1); + + resolveShell("{}"); + await flushPromises(); + + const fresh = await cache.getCatalogResponse(); + expect(fresh).toEqual({ + ok: true, + source: "openclaw", + fetchedAt: expect.any(Number), + stale: false, + refreshing: false, + models: normalizeModels([{ key: "openai/gpt-fresh", name: "Fresh" }]), + }); + const written = JSON.parse(fs.readFileSync(cachePath, "utf8")); + expect(written.models).toEqual( + normalizeModels([{ key: "openai/gpt-fresh", name: "Fresh" }]), + ); + }); + + it("keeps serving cache after refresh failures and retries after backoff", async () => { + vi.useFakeTimers(); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-model-catalog-backoff-"), + ); + const cachePath = path.join(tempRoot, "cache", "model-catalog.json"); + writeCacheFile({ + cachePath, + fetchedAt: 222, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + + const shellCmd = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce("{}"); + const parseJsonFromNoisyOutput = vi.fn(() => ({ + models: [{ key: "openai/gpt-retried", name: "Retried" }], + })); + const cache = createModelCatalogCache({ + cachePath, + shellCmd, + parseJsonFromNoisyOutput, + normalizeOnboardingModels: normalizeModels, + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout, + }); + + const cached = await cache.getCatalogResponse(); + expect(cached.source).toBe("cache"); + expect(cached.refreshing).toBe(true); + expect(shellCmd).toHaveBeenCalledTimes(1); + + await flushPromises(); + + const afterFailure = await cache.getCatalogResponse(); + expect(afterFailure).toEqual({ + ok: true, + source: "cache", + fetchedAt: 222, + stale: true, + refreshing: true, + models: normalizeModels([{ key: "openai/gpt-cached", label: "Cached" }]), + }); + + await vi.advanceTimersByTimeAsync(kModelCatalogRefreshBackoffMs - 1); + expect(shellCmd).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await flushPromises(); + expect(shellCmd).toHaveBeenCalledTimes(2); + + const fresh = await cache.getCatalogResponse(); + expect(fresh).toEqual({ + ok: true, + source: "openclaw", + fetchedAt: expect.any(Number), + stale: false, + refreshing: false, + models: normalizeModels([{ key: "openai/gpt-retried", name: "Retried" }]), + }); + }); + + it("falls back when no cache exists and the CLI load fails", async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-model-catalog-fallback-"), + ); + const cachePath = path.join(tempRoot, "cache", "model-catalog.json"); + const shellCmd = vi.fn().mockRejectedValue(new Error("boom")); + const cache = createModelCatalogCache({ + cachePath, + shellCmd, + parseJsonFromNoisyOutput: vi.fn(() => ({})), + normalizeOnboardingModels: normalizeModels, + }); + + const response = await cache.getCatalogResponse(); + + expect(response).toEqual({ + ok: true, + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: kFallbackOnboardingModels, + }); + }); +}); diff --git a/tests/server/routes-models.test.js b/tests/server/routes-models.test.js index fc61bc56..ccf46d60 100644 --- a/tests/server/routes-models.test.js +++ b/tests/server/routes-models.test.js @@ -1,6 +1,10 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); const express = require("express"); const request = require("supertest"); +const { createModelCatalogCache } = require("../../lib/server/model-catalog-cache"); const { registerModelRoutes } = require("../../lib/server/routes/models"); const { kFallbackOnboardingModels } = require("../../lib/server/constants"); @@ -39,9 +43,20 @@ const createModelDeps = () => { const createApp = (deps) => { const app = express(); app.use(express.json()); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "alphaclaw-routes-models-"), + ); + const modelCatalogCache = createModelCatalogCache({ + cachePath: path.join(tempRoot, "cache", "model-catalog.json"), + shellCmd: deps.shellCmd, + gatewayEnv: deps.gatewayEnv, + parseJsonFromNoisyOutput: deps.parseJsonFromNoisyOutput, + normalizeOnboardingModels: deps.normalizeOnboardingModels, + }); registerModelRoutes({ app, ...deps, + modelCatalogCache, }); return app; }; @@ -61,11 +76,16 @@ describe("server/routes/models", () => { const res = await request(app).get("/api/models"); expect(res.status).toBe(200); - expect(res.body).toEqual({ - ok: true, - source: "openclaw", - models: [{ key: "openai/gpt-5.1-codex", provider: "openai", label: "GPT" }], - }); + expect(res.body).toEqual( + expect.objectContaining({ + ok: true, + source: "openclaw", + stale: false, + refreshing: false, + fetchedAt: expect.any(Number), + models: [{ key: "openai/gpt-5.1-codex", provider: "openai", label: "GPT" }], + }), + ); expect(deps.shellCmd).toHaveBeenCalledWith("openclaw models list --all --json", { env: { OPENCLAW_GATEWAY_TOKEN: "token" }, timeout: 20000, @@ -82,9 +102,14 @@ describe("server/routes/models", () => { const res = await request(app).get("/api/models"); expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.source).toBe("fallback"); - expect(res.body.models).toEqual(kFallbackOnboardingModels); + expect(res.body).toEqual({ + ok: true, + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: kFallbackOnboardingModels, + }); }); it("returns fallback models when command throws", async () => { @@ -95,8 +120,14 @@ describe("server/routes/models", () => { const res = await request(app).get("/api/models"); expect(res.status).toBe(200); - expect(res.body.ok).toBe(true); - expect(res.body.source).toBe("fallback"); + expect(res.body).toEqual({ + ok: true, + source: "fallback", + fetchedAt: null, + stale: false, + refreshing: false, + models: kFallbackOnboardingModels, + }); }); it("returns model status payload on GET /api/models/status", async () => { From cab795e2ae403e441213f0695b4d22d4328470f2 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 4 Apr 2026 21:55:04 -0700 Subject: [PATCH 362/417] Improve Codex OAuth reconnect flow --- .../models-tab/provider-auth-card.js | 109 ++++++++++-------- lib/public/js/components/models.js | 89 ++++++++------ .../onboarding/use-welcome-codex.js | 58 ++++++---- .../onboarding/welcome-form-step.js | 5 +- lib/public/js/components/providers.js | 95 ++++++++------- lib/public/js/lib/codex-oauth-window.js | 22 ++++ tests/frontend/codex-oauth-window.test.js | 54 +++++++++ 7 files changed, 277 insertions(+), 155 deletions(-) create mode 100644 lib/public/js/lib/codex-oauth-window.js create mode 100644 tests/frontend/codex-oauth-window.test.js diff --git a/lib/public/js/components/models-tab/provider-auth-card.js b/lib/public/js/components/models-tab/provider-auth-card.js index b85040f6..707a4fbf 100644 --- a/lib/public/js/components/models-tab/provider-auth-card.js +++ b/lib/public/js/components/models-tab/provider-auth-card.js @@ -5,6 +5,10 @@ import { Badge } from "../badge.js"; import { SecretInput } from "../secret-input.js"; import { ActionButton } from "../action-button.js"; import { exchangeCodexOAuth, disconnectCodex } from "../../lib/api.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../../lib/codex-oauth-window.js"; import { showToast } from "../toast.js"; import { kProviderAuthFields, @@ -108,6 +112,7 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { const [authWaiting, setAuthWaiting] = useState(false); const [manualInput, setManualInput] = useState(""); const [exchanging, setExchanging] = useState(false); + const exchangeInFlightRef = useRef(false); const popupPollRef = useRef(null); useEffect( @@ -117,6 +122,30 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { [], ); + const submitAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || exchangeInFlightRef.current) return; + exchangeInFlightRef.current = true; + setManualInput(normalizedInput); + setExchanging(true); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setManualInput(""); + showToast("Codex connected", "success"); + setAuthStarted(false); + setAuthWaiting(false); + await onRefreshCodex(); + } catch (err) { + setAuthWaiting(false); + showToast(err.message || "Codex OAuth exchange failed", "error"); + } finally { + exchangeInFlightRef.current = false; + setExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { @@ -124,6 +153,8 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { setAuthStarted(false); setAuthWaiting(false); await onRefreshCodex(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast( `Codex auth failed: ${e.data.message || "unknown error"}`, @@ -133,19 +164,14 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [onRefreshCodex]); + }, [onRefreshCodex, submitAuthInput]); const startAuth = () => { setAuthStarted(true); setAuthWaiting(true); - const popup = window.open( - "/auth/codex/start", - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (popupPollRef.current) clearInterval(popupPollRef.current); @@ -159,22 +185,7 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { }; const completeAuth = async () => { - if (!manualInput.trim() || exchanging) return; - setExchanging(true); - try { - const result = await exchangeCodexOAuth(manualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setManualInput(""); - showToast("Codex connected", "success"); - setAuthStarted(false); - setAuthWaiting(false); - await onRefreshCodex(); - } catch (err) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setExchanging(false); - } + await submitAuthInput(manualInput); }; const handleDisconnect = async () => { @@ -198,7 +209,23 @@ const CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => { ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}
      - ${codexStatus.connected + ${authStarted + ? html` +
      +

      + ${authWaiting + ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." + : "Paste the redirect URL from your browser to finish connecting."} +

      + +
      + ` + : codexStatus.connected ? html`
      ` - : !authStarted + : html` + + `} + ${authStarted ? html` - - ` - : html` -
      -

      - ${authWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

      - -
      - `} - ${!codexStatus.connected && authStarted - ? html`

      After login, copy the full redirect URL (starts with { const [savedModel, setSavedModel] = useState(() => kModelsTabCache?.savedModel || ""); const [modelDirty, setModelDirty] = useState(false); const [savedAiValues, setSavedAiValues] = useState(() => kModelsTabCache?.savedAiValues || {}); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { @@ -122,18 +127,43 @@ export const Models = () => { } }, []); + const submitCodexAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || codexExchangeInFlightRef.current) return; + codexExchangeInFlightRef.current = true; + setCodexManualInput(normalizedInput); + setCodexExchanging(true); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + showToast("Codex connected", "success"); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexConnection(); + } catch (err) { + setCodexAuthWaiting(false); + showToast(err.message || "Codex OAuth exchange failed", "error"); + } finally { + codexExchangeInFlightRef.current = false; + setCodexExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); await refreshCodexConnection(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitCodexAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast(`Codex auth failed: ${e.data.message || "unknown error"}`, "error"); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, []); + }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { @@ -194,10 +224,9 @@ export const Models = () => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const popup = window.open("/auth/codex/start", "codex-auth", "popup=yes,width=640,height=780"); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (codexPopupPollRef.current) { @@ -213,21 +242,7 @@ export const Models = () => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - showToast("Codex connected", "success"); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexConnection(); - } catch (err) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { @@ -301,7 +316,23 @@ export const Models = () => { ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}

      - ${codexStatus.connected + ${codexAuthStarted + ? html` +
      +

      + ${codexAuthWaiting + ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." + : "Paste the redirect URL from your browser to finish connecting."} +

      + +
      + ` + : codexStatus.connected ? html`
      ` - : !codexAuthStarted - ? html` + : html` - ` - : html` -
      -

      - ${codexAuthWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

      - -
      `} - ${!codexStatus.connected && codexAuthStarted + ${codexAuthStarted ? html`

      After login, copy the full redirect URL (starts with diff --git a/lib/public/js/components/onboarding/use-welcome-codex.js b/lib/public/js/components/onboarding/use-welcome-codex.js index c7b7b7dd..17fba712 100644 --- a/lib/public/js/components/onboarding/use-welcome-codex.js +++ b/lib/public/js/components/onboarding/use-welcome-codex.js @@ -4,6 +4,10 @@ import { exchangeCodexOAuth, fetchCodexStatus, } from "../../lib/api.js"; +import { + isCodexAuthCallbackMessage, + openCodexAuthWindow, +} from "../../lib/codex-oauth-window.js"; export const useWelcomeCodex = ({ setFormError } = {}) => { const [codexStatus, setCodexStatus] = useState({ connected: false }); @@ -12,6 +16,7 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { const [codexExchanging, setCodexExchanging] = useState(false); const [codexAuthStarted, setCodexAuthStarted] = useState(false); const [codexAuthWaiting, setCodexAuthWaiting] = useState(false); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refreshCodexStatus = async () => { @@ -33,10 +38,36 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { refreshCodexStatus(); }, []); + const submitCodexAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || codexExchangeInFlightRef.current) return; + codexExchangeInFlightRef.current = true; + setCodexManualInput(normalizedInput); + setCodexExchanging(true); + setFormError(null); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexStatus(); + } catch (err) { + setCodexAuthWaiting(false); + setFormError(err.message || "Codex OAuth exchange failed"); + } finally { + codexExchangeInFlightRef.current = false; + setCodexExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { await refreshCodexStatus(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitCodexAuthInput(e.data.input); } if (e.data?.codex === "error") { setFormError(`Codex auth failed: ${e.data.message || "unknown error"}`); @@ -44,7 +75,7 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [setFormError]); + }, [setFormError, submitCodexAuthInput]); useEffect( () => () => { @@ -60,15 +91,9 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const authUrl = "/auth/codex/start"; - const popup = window.open( - authUrl, - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = authUrl; return; } if (codexPopupPollRef.current) { @@ -84,22 +109,7 @@ export const useWelcomeCodex = ({ setFormError } = {}) => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - setFormError(null); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexStatus(); - } catch (err) { - setFormError(err.message || "Codex OAuth exchange failed"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index e41ab4f1..94e49e55 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -292,13 +292,12 @@ export const WelcomeFormStep = ({ /> `}

      - ${!codexStatus.connected && - codexAuthStarted && + ${codexAuthStarted && html`

      ${codexAuthWaiting - ? "Complete login in the new tab or window, then paste the full redirect URL from the address bar (starts with " + ? "Complete login in the popup. AlphaClaw should finish automatically, but if it doesn't, paste the full redirect URL from the address bar (starts with " : "Paste the full redirect URL from the address bar (starts with "} http://localhost:1455/auth/callback {} }) => { () => kProvidersTabCache?.savedAiValues || {}, ); const [showMoreProviders, setShowMoreProviders] = useState(false); + const codexExchangeInFlightRef = useRef(false); const codexPopupPollRef = useRef(null); const refresh = async () => { @@ -171,11 +176,37 @@ export const Providers = ({ onRestartRequired = () => {} }) => { [], ); + const submitCodexAuthInput = async (input) => { + const normalizedInput = String(input || "").trim(); + if (!normalizedInput || codexExchangeInFlightRef.current) return; + codexExchangeInFlightRef.current = true; + setCodexManualInput(normalizedInput); + setCodexExchanging(true); + try { + const result = await exchangeCodexOAuth(normalizedInput); + if (!result.ok) + throw new Error(result.error || "Codex OAuth exchange failed"); + setCodexManualInput(""); + showToast("Codex connected", "success"); + setCodexAuthStarted(false); + setCodexAuthWaiting(false); + await refreshCodexConnection(); + } catch (err) { + setCodexAuthWaiting(false); + showToast(err.message || "Codex OAuth exchange failed", "error"); + } finally { + codexExchangeInFlightRef.current = false; + setCodexExchanging(false); + } + }; + useEffect(() => { const onMessage = async (e) => { if (e.data?.codex === "success") { showToast("Codex connected", "success"); await refreshCodexConnection(); + } else if (isCodexAuthCallbackMessage(e.data)) { + await submitCodexAuthInput(e.data.input); } else if (e.data?.codex === "error") { showToast( `Codex auth failed: ${e.data.message || "unknown error"}`, @@ -185,7 +216,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, []); + }, [submitCodexAuthInput]); const setEnvValue = (key, value) => { setEnvVars((prev) => { @@ -296,14 +327,9 @@ export const Providers = ({ onRestartRequired = () => {} }) => { if (codexStatus.connected) return; setCodexAuthStarted(true); setCodexAuthWaiting(true); - const popup = window.open( - "/auth/codex/start", - "codex-auth", - "popup=yes,width=640,height=780", - ); + const popup = openCodexAuthWindow(); if (!popup || popup.closed) { setCodexAuthWaiting(false); - window.location.href = "/auth/codex/start"; return; } if (codexPopupPollRef.current) { @@ -319,22 +345,7 @@ export const Providers = ({ onRestartRequired = () => {} }) => { }; const completeCodexAuth = async () => { - if (!codexManualInput.trim() || codexExchanging) return; - setCodexExchanging(true); - try { - const result = await exchangeCodexOAuth(codexManualInput.trim()); - if (!result.ok) - throw new Error(result.error || "Codex OAuth exchange failed"); - setCodexManualInput(""); - showToast("Codex connected", "success"); - setCodexAuthStarted(false); - setCodexAuthWaiting(false); - await refreshCodexConnection(); - } catch (err) { - showToast(err.message || "Codex OAuth exchange failed", "error"); - } finally { - setCodexExchanging(false); - } + await submitCodexAuthInput(codexManualInput); }; const handleCodexDisconnect = async () => { @@ -385,7 +396,23 @@ export const Providers = ({ onRestartRequired = () => {} }) => { ? html`<${Badge} tone="success">Connected` : html`<${Badge} tone="warning">Not connected`}

      - ${codexStatus.connected + ${codexAuthStarted + ? html` +
      +

      + ${codexAuthWaiting + ? "Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't." + : "Paste the redirect URL from your browser to finish connecting."} +

      + +
      + ` + : codexStatus.connected ? html`
      ` - : !codexAuthStarted - ? html` + : html` - ` - : html` -
      -

      - ${codexAuthWaiting - ? "Complete login in the popup, then paste the redirect URL." - : "Paste the redirect URL from your browser to finish connecting."} -

      - -
      `} - ${!codexStatus.connected && codexAuthStarted + ${codexAuthStarted ? html`

      After login, copy the full redirect URL (starts with diff --git a/lib/public/js/lib/codex-oauth-window.js b/lib/public/js/lib/codex-oauth-window.js new file mode 100644 index 00000000..508b58a5 --- /dev/null +++ b/lib/public/js/lib/codex-oauth-window.js @@ -0,0 +1,22 @@ +const kCodexAuthStartPath = "/auth/codex/start"; +const kCodexAuthWindowName = "codex-auth"; +const kCodexAuthPopupFeatures = "popup=yes,width=640,height=780"; +const kCodexAuthCallbackMessageType = "callback-input"; + +export const openCodexAuthWindow = () => { + const popup = window.open( + kCodexAuthStartPath, + kCodexAuthWindowName, + kCodexAuthPopupFeatures, + ); + if (!popup || popup.closed) { + window.location.href = kCodexAuthStartPath; + return null; + } + return popup; +}; + +export const isCodexAuthCallbackMessage = (value) => + value?.codex === kCodexAuthCallbackMessageType && + typeof value.input === "string" && + value.input.trim().length > 0; diff --git a/tests/frontend/codex-oauth-window.test.js b/tests/frontend/codex-oauth-window.test.js new file mode 100644 index 00000000..6c355809 --- /dev/null +++ b/tests/frontend/codex-oauth-window.test.js @@ -0,0 +1,54 @@ +const loadCodexOauthWindow = async () => + import("../../lib/public/js/lib/codex-oauth-window.js"); + +describe("frontend/codex-oauth-window", () => { + beforeEach(() => { + vi.resetModules(); + global.window = { + open: vi.fn(), + location: { href: "http://localhost/" }, + }; + }); + + it("uses popup features when opening Codex auth", async () => { + global.window.open.mockReturnValue({ closed: false }); + const mod = await loadCodexOauthWindow(); + + const opened = mod.openCodexAuthWindow(); + + expect(global.window.open).toHaveBeenCalledWith( + "/auth/codex/start", + "codex-auth", + "popup=yes,width=640,height=780", + ); + expect(opened).toBeTruthy(); + }); + + it("falls back to navigating the current page when opening fails", async () => { + global.window.open.mockReturnValue(null); + const mod = await loadCodexOauthWindow(); + + const opened = mod.openCodexAuthWindow(); + + expect(opened).toBeNull(); + expect(global.window.location.href).toBe("/auth/codex/start"); + }); + + it("detects automatic localhost callback messages", async () => { + const mod = await loadCodexOauthWindow(); + + expect( + mod.isCodexAuthCallbackMessage({ + codex: "callback-input", + input: "http://localhost:1455/auth/callback?code=abc&state=def", + }), + ).toBe(true); + expect(mod.isCodexAuthCallbackMessage({ codex: "success" })).toBe(false); + expect( + mod.isCodexAuthCallbackMessage({ + codex: "callback-input", + input: " ", + }), + ).toBe(false); + }); +}); From 713fc9afc44a241cd9f17e5a84adab2b2b26e5a3 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 4 Apr 2026 22:09:49 -0700 Subject: [PATCH 363/417] Polish theme toggle menu and light-mode logo accent --- lib/public/css/theme.css | 15 ++++++++++++++- .../js/components/onboarding/welcome-header.js | 12 +++++------- lib/public/js/components/sidebar.js | 6 +++++- lib/public/js/components/theme-toggle.js | 12 ------------ 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/lib/public/css/theme.css b/lib/public/css/theme.css index 89545d66..73d76014 100644 --- a/lib/public/css/theme.css +++ b/lib/public/css/theme.css @@ -108,6 +108,20 @@ body { line-height: 1.6; } +.ac-logo-mark { + display: inline-block; + flex: 0 0 auto; + width: var(--ac-logo-width, 20px); + height: var(--ac-logo-height, 20px); + background: #00efff; + -webkit-mask: url("../img/logo.svg") center / contain no-repeat; + mask: url("../img/logo.svg") center / contain no-repeat; +} + +[data-theme="light"] .ac-logo-mark { + background: var(--accent); +} + /* Subtle grid texture overlay */ body::before { content: ''; @@ -989,4 +1003,3 @@ textarea:focus { [data-theme="light"] .text-purple-400 { color: #7e22ce !important; } - diff --git a/lib/public/js/components/onboarding/welcome-header.js b/lib/public/js/components/onboarding/welcome-header.js index 4d87db3e..6df96461 100644 --- a/lib/public/js/components/onboarding/welcome-header.js +++ b/lib/public/js/components/onboarding/welcome-header.js @@ -20,13 +20,11 @@ export const WelcomeHeader = ({ return html`

      - alphaclaw +

      Setup

      Let's get your agent running diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index a7af0147..fc4b6ebd 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -247,7 +247,11 @@ export const AppSidebar = ({ return html`