[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: chrysb\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [22]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: npm\n\n      - run: npm ci\n\n      - run: npm run build:ui\n\n      - run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\n.DS_Store\n.env\n.cursor/\ncoverage/\nworkspace/.openclaw/\n\n# Build artifacts (generated by npm run build:ui)\nlib/public/dist/\nlib/public/css/tailwind.generated.css\nlib/public/css/vendor/\n"
  },
  {
    "path": ".npmrc",
    "content": "@chrysb:registry=https://registry.npmjs.org/\n"
  },
  {
    "path": "AGENTS.md",
    "content": "## Project Overview\n\n### AlphaClaw Project Context\n\nAlphaClaw 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.\n\n### Understanding OpenClaw\n\nIf you need to understand the internals of OpenClaw, you can inspect the code at `~/Projects/openclaw/src`\n\n### Architecture At A Glance\n\n- `bin/alphaclaw.js`: CLI entrypoint and lifecycle command surface.\n- `lib/server`: Express server, authenticated setup APIs, watchdog APIs, channel integrations, and proxying to the OpenClaw gateway.\n- `lib/public`: Setup UI frontend (component-driven tabs and flows for providers, envars, watchdog, webhooks, and onboarding).\n- `lib/setup`: Prompt hardening templates and setup-related assets injected into agent/system behavior.\n\nRuntime model:\n\n1. AlphaClaw server starts and manages OpenClaw as a child process.\n2. Setup UI calls AlphaClaw APIs for configuration and operations.\n3. AlphaClaw proxies gateway traffic and handles watchdog monitoring/repair.\n\n### Key Technologies\n\n- Node.js 22.14+ runtime.\n- Express-based HTTP API server.\n- `http-proxy` for gateway proxy behavior.\n- OpenClaw CLI/gateway process orchestration.\n- Preact + `htm` frontend patterns for Setup UI components.\n- Vitest + Supertest for server and route testing.\n\n## Coding Conventions\n\n### Change Patterns\n\n- Keep edits targeted and production-safe; favor small, reviewable changes.\n- Preserve existing behavior unless the task explicitly requires behavior changes.\n- Follow existing UI conventions and shared components for consistency.\n- Reuse existing server route and state patterns before introducing new abstractions.\n- Update tests when behavior changes in routes, watchdog flows, or setup state.\n- Before running tests in a fresh checkout, run `npm install` so `vitest` (devDependency) is available for `npm test`.\n\n### Code Structure\n\n- 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.\n- 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.\n- 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.\n\n### Networking and Fetching\n\n- Prefer the shared cache primitives in `lib/public/js/lib/api-cache.js` for backend reads:\n  - `cachedFetch(...)` for imperative fetch paths.\n  - `getCached(...)` / `setCached(...)` / `invalidateCache(...)` for cache lifecycle.\n- For component-level read requests, prefer `useCachedFetch` from `lib/public/js/hooks/use-cached-fetch.js` over ad-hoc `useEffect(() => fetchX())` mount loads.\n- Treat the API URL (including query params) as the canonical cache key for GET-style payloads.\n- Keep cache in-memory for fast tab switches; do not add persistent storage caching unless explicitly required by product behavior.\n- Do not keep route panes mounted via `display:none` just to preserve data. Prefer conditional rendering + cache-backed remounts.\n- Use `usePolling` for recurring refreshes and always pass a stable `cacheKey` when poll results should hydrate remounts.\n- Keep `pauseWhenHidden` behavior enabled for polling unless a specific flow requires background polling while the browser tab is hidden.\n- Tune polling intervals conservatively; avoid 1-2s polling unless there is a clear real-time requirement.\n- For app-shell status streams, prefer SSE (`/api/events/status`) where available and keep polling as fallback behavior.\n- After write/mutation APIs (POST/PUT/DELETE), refresh or invalidate relevant cached keys so the UI does not show stale data.\n\n### OpenClaw Config Access\n\n- When reading `openclaw.json` in server code, use the shared helper in `lib/server/openclaw-config.js` (`readOpenclawConfig`) instead of ad-hoc `JSON.parse(fs.readFileSync(...))` blocks.\n\n### Where To Put Agent Guidance\n\n- **This file (`AGENTS.md`):** Project-level guidance for coding agents working on the AlphaClaw codebase — architecture, conventions, release flow, UI patterns, etc.\n- **`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.\n\n## Operations\n\n### Release Flow (Beta -> Production)\n\nUse this release flow when promoting tested beta builds to production:\n\n1. Ensure `main` is clean and synced, and tests pass.\n2. Publish beta iterations as needed:\n   - `npm version prerelease --preid=beta`\n   - `git push && git push --tags`\n   - `npm publish --tag beta`\n3. Immediately after each beta publish, update `~/Projects/openclaw-railway-template` on the `beta` branch to pin the exact beta version in `package.json` (for example `0.3.2-beta.4`), then commit and push that template change. Do not leave the beta template on `latest`, or Docker layer cache can reuse an older install.\n4. When ready for production, publish a stable release version (for example `0.3.2`):\n   - `npm version 0.3.2`\n   - `git push && git push --tags`\n   - `npm publish` (publishes to `latest`)\n   - Pin all deployment templates on `main` to that release: set `@chrysb/alphaclaw` in `~/Projects/openclaw-railway-template`, `~/Projects/openclaw-render-template`, and `~/Projects/openclaw-apex-template` to the released version (templates rely on AlphaClaw’s declared `openclaw` dependency — do not add `package.json` `overrides` for `openclaw` unless you have a one-off debug reason). Run `npm install` in each repo, confirm `npm ls openclaw` matches AlphaClaw’s `package.json` pin, commit `package.json` and `package-lock.json`, and push. Skipping a template leaves it stale relative to the others.\n5. Return templates to production channel:\n   - `@chrysb/alphaclaw: \"latest\"`\n6. Optionally keep beta branch/tag flows active for next release cycle.\n\n### Runtime Dependency Guardrails (Express 4 vs 5)\n\nAlphaClaw 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).\n\nKnown root cause pattern:\n\n- 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`.\n- This can hoist `express@5` to the app root, so `require(\"express\")` inside AlphaClaw resolves the wrong major version.\n\nPreferred fix/recovery:\n\n1. Ensure template `package.json` pins the intended `@chrysb/alphaclaw` version.\n2. Rebuild the `openclaw` container from scratch (no cache) and recreate it:\n   - `docker compose down`\n   - `docker compose build --no-cache openclaw`\n   - `docker compose up -d openclaw`\n3. Verify runtime resolution inside the container:\n   - `node -p \"require('express/package.json').version\"` should be `4.x`\n   - `npm ls express` should show `@chrysb/alphaclaw` on `express@4.x` (OpenClaw can still carry its own `express@5` subtree).\n\n### Telegram Notice Format (AlphaClaw)\n\nUse this format for any Telegram notices sent from AlphaClaw services (watchdog, system alerts, repair notices):\n\n1. Header line (Markdown): `🐺 *AlphaClaw Watchdog*`\n2. Headline line (simple, no `Status:` prefix):\n   - `🔴 Crash loop detected`\n   - `🔴 Crash loop detected, auto-repairing...`\n   - `🟡 Auto-repair started, awaiting health check`\n   - `🟢 Auto-repair complete, gateway healthy`\n   - `🟢 Gateway healthy again`\n   - `🔴 Auto-repair failed`\n3. Append a markdown link to the headline when URL is available:\n   - `... - [View logs](<full-url>/#/watchdog)`\n4. Optional context lines like `Trigger: ...`, `Attempt count: ...`\n5. For values with underscores or special characters (for example `crash_loop`), wrap the value in backticks:\n   - `Trigger: \\`crash_loop\\``\n6. Do not use HTML tags (`<b>`, `<a href>`) for Telegram watchdog notices.\n\n## UI Conventions\n\nUse these conventions for all UI work under `lib/public/js` and `lib/public/css`.\n\n### Setup UI bundle (esbuild)\n\n- 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).\n- **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.\n- **`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.\n\n### Component structure\n\n- Use arrow-function components and helpers.\n- Prefer shared components over one-off markup when a pattern already exists.\n- Keep constants in `kName` format (e.g. `kUiTabs`, `kGroupOrder`, `kNamePattern`).\n- Keep component-level helpers near the top of the file, before the main export.\n- Treat `index.js` as a presentational shell whenever possible: keep business logic in hooks and pass derived state/actions down as props.\n- Add reusable SVG icons to `lib/public/js/components/icons.js` and import them from there; avoid introducing one-off inline SVGs in feature files when a shared icon component can be used.\n\n### Rendering and composition\n\n- Use the `htm` + `preact` pattern:\n  - `const html = htm.bind(h);`\n  - return `html\\`...\\``\n- In `htm` templates, be explicit with inline spacing around styled inline tags (`<span>`, `<code>`, `<a>`): use ` ${\" \"}` where needed, and verify rendered copy so words never collapse (`eventsand`) or gain double spaces.\n- Prefer early return for hidden states (e.g. `if (!visible) return null;`).\n- Use `<PageHeader />` for tab/page headers that need a title and right-side actions.\n- Use card shells consistently: `bg-surface border border-border rounded-xl`.\n- 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.\n- For internal section dividers, use `border-t border-border` (avoid opacity variants) with comfortable vertical spacing around the divider.\n\n### Color and theme tokens\n\n- Prefer semantic Tailwind color utilities backed by theme tokens (`text-body`, `text-fg-muted`, `text-fg-dim`, `bg-field`, `bg-status-error-bg`, `border-status-warning-border`) instead of raw palette classes like `text-gray-300` or `bg-red-900/30`.\n- When a new reusable UI color role is needed, add the CSS variable in `lib/public/css/theme.css` and expose it through `tailwind.config.cjs` rather than introducing one-off hardcoded color classes in components.\n- Keep component refactors token-based so future theme changes stay centralized in the token layer instead of requiring per-component color rewrites.\n\n### Buttons\n\n- Primary actions: `ac-btn-cyan`\n- Secondary actions: `ac-btn-secondary`\n- Positive/success actions: `ac-btn-green`\n- Ghost/text actions: `ac-btn-ghost` (use for low-emphasis actions like \"Disconnect\" or \"Add provider\")\n- Destructive inline actions: `ac-btn-danger`\n- Use consistent disabled treatment: `opacity-50 cursor-not-allowed`.\n- Keep action sizing consistent (`text-xs px-3 py-1.5 rounded-lg` for compact controls unless there is a clear reason otherwise).\n- For `<PageHeader />` actions, use `ac-btn-cyan` (primary) or `ac-btn-secondary` (secondary) by default; avoid ghost/text-only styling for main header actions.\n- Prefer shared action components when available (`ActionButton`, `UpdateActionButton`, `ConfirmDialog`) before custom button logic.\n- In setup/onboarding auth flows (e.g. Codex OAuth), prefer `<ActionButton />` over raw `<button>` for consistency in tone, sizing, and loading behavior.\n- In setup wizard/multi-step modal footers, use `<ActionButton />` for Back/Next/Finish/Done actions (not raw `<button>`), so loading and tone behavior stays consistent.\n- In multi-step auth flows, keep the active \"finish\" action visually primary and demote the \"start/restart\" action to secondary once the flow has started.\n\n### Dialogs and modals\n\n- Use `<ConfirmDialog />` for destructive/confirmation flows.\n- Use `<ModalShell />` for non-confirm custom modals that need shared overlay and Escape handling.\n- Modal overlay convention:\n  - `fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50`\n- Modal panel convention:\n  - `bg-modal border border-border rounded-xl p-5 ...`\n- Support close-on-overlay click and Escape key for dialogs.\n\n### Inputs and forms\n\n- Reuse `<SecretInput />` for sensitive values and token/key inputs.\n- Reuse `<ToggleSwitch />` for boolean on/off controls instead of ad-hoc checkbox/switch markup.\n- Base input look should remain consistent:\n  - `bg-field border border-border rounded-lg ... focus:border-fg-muted`\n- Preserve monospace for technical values (`font-mono`) and codes/paths.\n- Prefer inline helper text under fields (`text-xs text-fg-muted` / `text-fg-dim`) for setup guidance.\n- For tip/help links in helper text, use the shared `ac-tip-link` class (token-backed via `--accent-link`) instead of per-file ad-hoc cyan classes.\n\n### Feedback and state\n\n- Use `showToast(...)` for user-visible operation outcomes.\n- Prefer semantic toast levels (`success`, `error`, `warning`, `info`) at callsites. Legacy color aliases are only for backwards compatibility.\n- Keep toast positioning relative to the active page container (not the viewport) when layout banners can shift content.\n- For hover help and icon labels, use the shared portal-backed tooltip components (`Tooltip`, `InfoTooltip`) instead of inline absolutely positioned popovers, so tooltips are not clipped by cards, rows, or scroll containers.\n- Keep loading/saving flags explicit in state (`saving`, `creating`, `restartingGateway`, etc.).\n- Reuse `<LoadingSpinner />` for loading indicators instead of inline spinner SVG markup.\n- Use `<Badge />` for compact status chips (e.g. connected/not connected) instead of one-off status span styling.\n- Use polling via `usePolling` for frequently refreshed backend-backed data.\n- For restart-required flows, render the standardized yellow restart banner style used in `providers`, `envars`, and `webhooks`.\n\n### Shared formatting utilities\n\n- Prefer shared formatter helpers in `lib/public/js/lib/format.js` for reusable value formatting (`formatX` style helpers such as date/time, currency, integers, and common duration formats).\n- Before adding a new formatter in a component, check `lib/public/js/lib/format.js` and reuse an existing helper when possible.\n- 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.\n- Avoid wrapper pass-through helpers that only rename a global formatter without adding feature-specific behavior.\n\n### Session key utilities\n\n- Keep shared session-key parsing/filtering helpers in `lib/public/js/lib/session-keys.js` (for example extracting `agentId`, destination-session matching checks, and destination payload derivation).\n- Before adding session-key logic in a hook/component, check `lib/public/js/lib/session-keys.js` first and reuse existing helpers.\n- When session-key behavior is reused across features, add/extend helpers in `lib/public/js/lib/session-keys.js` instead of duplicating regex/string parsing in feature files.\n\n### localStorage keys\n\n- 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.\n- Use the naming convention `alphaclaw.<area>.<purpose>` for new keys (e.g. `alphaclaw.doctor.lastSessionKey`).\n- 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.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to AlphaClaw\n\nThanks for your interest in contributing to AlphaClaw. This document covers how we work, what we value, and how to get your changes merged.\n\n## Vision\n\nAlphaClaw makes OpenClaw accessible: easy to deploy, easy to monitor, easy to repair, easy to keep running. Self-managed and open source, always.\n\nOne-click deployment templates come and go. The self-managed aspect is what makes this durable.\n\n### Guiding Principles\n\n- **UX over features.** Usability matters more than feature count. Every interaction should feel considered.\n- **Smart defaults.** AlphaClaw is opinionated. We bootstrap hooks, prompt hardening, and sensible configs so the out-of-box experience is good without manual tuning.\n- **Complement, don't replicate.** OpenClaw's Gateway dashboard is exhaustive. We surface the most common workflows and add net-new value, not duplicate switches.\n- **Always ejectable.** AlphaClaw is not a dependency. Remove it and your OpenClaw instance keeps running. Nothing proprietary, nothing to migrate.\n- **Reliability is a feature.** The watchdog, auto-repair, crash-loop recovery - these matter as much as any UI improvement.\n\n## What We're Looking For\n\n### Always welcome\n\n- Bug fixes\n- Reliability improvements (watchdog, crash recovery, gateway management)\n- Test coverage\n- Documentation fixes and clarifications\n\n### Welcome, but reviewed carefully\n\n- UX changes and small features\n- New integrations or wizard steps\n- Bootstrap prompt improvements\n\n### Proposal first\n\n- Large features or architectural changes\n- New paradigms (e.g., plugin system changes, new deployment targets)\n- Anything that changes the default experience significantly\n\nFor big changes, open an issue describing what you want to build, why, and your proposed approach. This saves everyone time.\n\n## Getting Started\n\n### Prerequisites\n\n- Node.js >= 22.14.0\n- Git\n\n### Setup\n\n```bash\ngit clone https://github.com/chrysb/alphaclaw.git\ncd alphaclaw\nnpm install\n```\n\n### Running Tests\n\n```bash\nnpm test              # Run all tests\nnpm run test:watch    # Watch mode\nnpm run test:coverage # With coverage report\n```\n\nAlphaClaw uses [Vitest](https://vitest.dev/) for testing.\n\n### Project Structure\n\n- `bin/` - CLI entrypoint (`alphaclaw.js`)\n- `lib/` - Core library (gateway manager, watchdog, setup UI, webhooks, etc.)\n- `tests/` - Test suites\n\n## Submitting Changes\n\n### Pull Request Process\n\n1. Fork the repo and create a branch from `main`.\n2. Make your changes. Write tests if applicable.\n3. Run `npm test` and make sure everything passes.\n4. Write a clear PR description: what changed, why, and how to test it.\n5. Sign off your commits (see DCO below).\n\n### Commit Messages\n\nKeep them clear and concise. Prefix with the area when it helps:\n\n```text\nwatchdog: recover from port conflict on restart\nsetup-ui: fix credential validation for Gemini provider\ndocs: clarify Railway deployment steps\n```\n\n### Code Style\n\n- Match the existing style. If something looks inconsistent, follow what the majority of the codebase does.\n- No unnecessary dependencies. AlphaClaw ships lean on purpose.\n\n## Developer Certificate of Origin (DCO)\n\nWe use the [DCO](https://developercertificate.org/) to certify that contributors have the right to submit their code under this project's MIT license.\n\nAdd a sign-off line to each commit:\n\n```text\nSigned-off-by: Your Name <your.email@example.com>\n```\n\nGit makes this easy:\n\n```bash\ngit commit -s -m \"your commit message\"\n```\n\nThe `-s` flag adds the sign-off automatically using your configured `user.name` and `user.email`.\n\n## Code of Conduct\n\nWe follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) (v2.1).\n\nThe short version: be respectful, be constructive, assume good intent. We're building something useful together.\n\n## Questions?\n\nOpen an issue or start a discussion on the repo. We're happy to help you find the right place to contribute.\n\n## License\n\nBy contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 chrysb\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <img width=\"771\" height=\"339\" alt=\"image\" src=\"https://github.com/user-attachments/assets/b96b45ab-52f2-4010-bfbe-c640e66b0f36\" />\n</p>\n<h1 align=\"center\">AlphaClaw</h1>\n<p align=\"center\">\n  <strong>The ultimate OpenClaw harness. Deploy in minutes. Stay running for months.</strong><br>\n  <strong>Observability. Reliability. Agent discipline. Zero SSH rescue missions.</strong>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/chrysb/alphaclaw/actions/workflows/ci.yml\"><img src=\"https://github.com/chrysb/alphaclaw/actions/workflows/ci.yml/badge.svg\" alt=\"CI\" /></a>\n  <a href=\"https://www.npmjs.com/package/@chrysb/alphaclaw\"><img src=\"https://img.shields.io/npm/v/@chrysb/alphaclaw\" alt=\"npm version\" /></a>\n  <a href=\"LICENSE\"><img src=\"https://img.shields.io/badge/license-MIT-blue.svg\" alt=\"License: MIT\" /></a>\n</p>\n\n<p align=\"center\">AlphaClaw wraps <a href=\"https://github.com/openclaw/openclaw\">OpenClaw</a> 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, Google Pub/Sub, Telegram Topics, Slack, Discord) so you can manage multiple agents from one UI instead of config files.</p>\n\n<p align=\"center\"><em>First deploy to first message in under five minutes.</em></p>\n\n<p align=\"center\">\n  <a href=\"https://railway.com/deploy/openclaw-fast-start?referralCode=jcFhp_&utm_medium=integration&utm_source=template&utm_campaign=generic\"><img height=\"40\" src=\"https://railway.com/button.svg\" alt=\"Deploy on Railway\" /></a>\n  <a href=\"https://render.com/deploy?repo=https://github.com/chrysb/openclaw-render-template\"><img height=\"40\" src=\"https://render.com/images/deploy-to-render-button.svg\" alt=\"Deploy to Render\" /></a>\n  <a href=\"https://updates.alphaclaw.md/desktop/prod/alphaclaw-mac-latest.dmg\"><img height=\"40\" src=\"https://img.shields.io/badge/Download%20for%20macOS-000000?style=for-the-badge&logo=apple&logoColor=white\" alt=\"Download for macOS\" /></a>\n</p>\n\n> **Platform:** AlphaClaw currently targets Docker/Linux deployments. macOS local development is not yet supported.\n\n## Features\n\n- **Setup UI:** Password-protected web dashboard for onboarding, configuration, and day-to-day management.\n- **Guided Onboarding:** Step-by-step setup wizard — model selection, provider credentials, GitHub repo, channel pairing.\n- **Multi-Agent Management:** Sidebar-driven agent navigation with create, rename, and delete flows. Per-agent overview cards, channel bindings, and URL-driven agent selection.\n- **Gateway Manager:** Spawns, monitors, restarts, and proxies the OpenClaw gateway as a managed child process.\n- **Watchdog:** Crash detection, crash-loop recovery, auto-repair (`openclaw doctor --fix`), Telegram/Discord/Slack notifications, and a live interactive terminal for monitoring gateway output directly from the browser.\n- **Channel Orchestration:** Telegram, Discord, and Slack bot pairing with per-agent channel bindings, credential sync, and a guided wizard for splitting Telegram into multi-threaded topic groups as your usage grows.\n- **Google Workspace:** OAuth integration for Gmail, Calendar, Drive, Docs, Sheets, Tasks, Contacts, and Meet, plus guided Gmail watch setup with Google Pub/Sub topic, subscription, and push endpoint handling.\n- **Cron Jobs:** Dedicated cron tab with job management, an interactive rolling calendar, run-history drilldowns, trend analytics, and per-run usage breakdowns.\n- **Nodes:** Guided local-node setup for VPS deployments with per-node browser attach checks, reconnect commands, and routing/pairing controls.\n- **Webhooks:** Named webhook endpoints with per-hook transform modules, request logging, payload inspection, editable delivery destinations, and OAuth callback support for third-party auth flows.\n- **File Explorer:** Browser-based workspace explorer with file visibility, inline edits, diff view, and Git-aware sync for quick fixes without SSH.\n- **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.\n- **Git Sync:** Automatic hourly commits of your OpenClaw workspace to GitHub with configurable cron schedule. Combined with prompt hardening, every agent action is version-controlled and auditable.\n- **Version Management:** In-place updates for both AlphaClaw and OpenClaw with in-app release notes, changelog review, and one-click apply.\n- **Codex OAuth:** Built-in PKCE flow for OpenAI Codex CLI model access.\n\n## Why AlphaClaw\n\n- **Zero to production in one deploy:** Railway/Render templates ship a complete stack — no manual gateway setup.\n- **Self-healing:** Watchdog detects crashes, enters repair mode, relaunches the gateway, and notifies you.\n- **Everything in the browser:** No SSH, no config files to hand-edit, no CLI required after first deploy.\n- **Stays out of the way:** AlphaClaw manages infrastructure; OpenClaw handles the AI.\n\n## No Lock-in. Eject Anytime.\n\nAlphaClaw simply wraps OpenClaw, it's not a dependency. Remove AlphaClaw and your agent keeps running. Nothing proprietary, nothing to migrate.\n\n## Quick Start\n\n### Deploy (recommended)\n\n[![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)\n[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/chrysb/openclaw-render-template)\n\nSet `SETUP_PASSWORD` at deploy time and visit your deployment URL. The welcome wizard handles the rest.\n\n> **Railway users:** after deploying, upgrade to the **Hobby plan** and redeploy to ensure your service has at least **8 GB of RAM**. The Trial plan's memory limit can cause out-of-memory crashes during normal operation.\n\n### Local / Docker\n\n```bash\nnpm install @chrysb/alphaclaw\nnpx alphaclaw start\n```\n\nOr with Docker:\n\n```dockerfile\nFROM node:22-slim\nRUN apt-get update && apt-get install -y git curl procps cron tini && rm -rf /var/lib/apt/lists/*\nWORKDIR /app\nCOPY package.json ./\nRUN npm install --omit=dev\nENV PATH=\"/app/node_modules/.bin:$PATH\"\nENV ALPHACLAW_ROOT_DIR=/data\nEXPOSE 3000\nENTRYPOINT [\"/usr/bin/tini\", \"--\"]\nCMD [\"alphaclaw\", \"start\"]\n```\n\n## Setup UI\n\n| Tab           | What it manages                                                                                                          |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------ |\n| **General**   | Gateway status, channel health, pending pairings, Google Workspace, repo sync schedule, OpenClaw dashboard               |\n| **Browse**    | File explorer for workspace visibility, inline edits, diff review, and Git-backed sync                                   |\n| **Usage**     | Token summaries, per-session and per-agent cost and token breakdown with source/agent dimension comparisons              |\n| **Cron**      | Cron job management, interactive rolling calendar, run-history drilldowns, trend analytics, and per-run usage breakdowns |\n| **Nodes**     | Guided local-node setup for VPS deployments, per-node browser attach, reconnect commands, and routing/pairing controls   |\n| **Watchdog**  | Health monitoring, crash-loop status, auto-repair toggle, notifications, event log, live log tail, interactive terminal  |\n| **Providers** | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram) and model selection                 |\n| **Envars**    | Environment variables — view, edit, add — with gateway restart prompts                                                   |\n| **Webhooks**  | Webhook endpoints, transform modules, request history, payload inspection, OAuth callbacks, Gmail watch delivery flows   |\n\n## CLI\n\n| Command                                                    | Description                                   |\n| ---------------------------------------------------------- | --------------------------------------------- |\n| `alphaclaw start`                                          | Start the server (Setup UI + gateway manager) |\n| `alphaclaw git-sync -m \"message\"`                          | Commit and push the OpenClaw workspace        |\n| `alphaclaw telegram topic add --thread <id> --name <text>` | Register a Telegram topic mapping             |\n| `alphaclaw version`                                        | Print version                                 |\n| `alphaclaw help`                                           | Show help                                     |\n\n## Architecture\n\n```mermaid\ngraph TD\n    subgraph AlphaClaw\n        UI[\"Setup UI<br/><small>Preact + htm + Wouter</small>\"]\n        WD[\"Watchdog<br/><small>Crash recovery · Notifications</small>\"]\n        WH[\"Webhooks<br/><small>Transforms · Request logging</small>\"]\n        UI --> API\n        WD --> API\n        WH --> API\n        API[\"Express Server<br/><small>JSON APIs · Auth · Proxy</small>\"]\n    end\n\n    API -- \"proxy\" --> GW[\"OpenClaw Gateway<br/><small>Child process · 127.0.0.1:18789</small>\"]\n    GW --> DATA[\"ALPHACLAW_ROOT_DIR<br/><small>.openclaw/ · .env · logs · SQLite</small>\"]\n```\n\n## Watchdog\n\nThe built-in watchdog monitors gateway health and recovers from failures automatically.\n\n| Capability               | Details                                                                |\n| ------------------------ | ---------------------------------------------------------------------- |\n| **Health checks**        | Periodic `openclaw health` with configurable interval                  |\n| **Crash detection**      | Listens for gateway exit events                                        |\n| **Crash-loop detection** | Threshold-based (default: 3 crashes in 300s)                           |\n| **Auto-repair**          | Runs `openclaw doctor --fix --yes`, relaunches gateway                 |\n| **Notifications**        | Telegram, Discord, and Slack alerts for crashes, repairs, and recovery |\n| **Event log**            | SQLite-backed incident history with API and UI access                  |\n\n## Environment Variables\n\n| Variable                          | Required | Description                                        |\n| --------------------------------- | -------- | -------------------------------------------------- |\n| `SETUP_PASSWORD`                  | Yes      | Password for the Setup UI                          |\n| `OPENCLAW_GATEWAY_TOKEN`          | Auto     | Gateway auth token (auto-generated if unset)       |\n| `GITHUB_TOKEN`                    | Yes      | GitHub PAT for workspace repo                      |\n| `GITHUB_WORKSPACE_REPO`           | Yes      | GitHub repo for workspace sync (e.g. `owner/repo`) |\n| `TELEGRAM_BOT_TOKEN`              | Optional | Telegram bot token                                 |\n| `DISCORD_BOT_TOKEN`               | Optional | Discord bot token                                  |\n| `SLACK_BOT_TOKEN`                 | Optional | Slack bot token (Socket Mode)                      |\n| `WATCHDOG_AUTO_REPAIR`            | Optional | Enable auto-repair on crash (`true`/`false`)       |\n| `WATCHDOG_NOTIFICATIONS_DISABLED` | Optional | Disable watchdog notifications (`true`/`false`)    |\n| `PORT`                            | Optional | Server port (default `3000`)                       |\n| `ALPHACLAW_ROOT_DIR`              | Optional | Data directory (default `/data`)                   |\n| `TRUST_PROXY_HOPS`                | Optional | Trust proxy hop count for correct client IP        |\n\n## Security Notes\n\nAlphaClaw is a convenience wrapper — it intentionally trades some of OpenClaw's default hardening for ease of setup. You should understand what's different:\n\n| Area                    | What AlphaClaw does                                                                                                                   | Trade-off                                                                                              |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |\n| **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.                            |\n| **One-click pairing**   | Channel pairings (Telegram/Discord/Slack) can be approved from the Setup UI instead of the CLI.                                       | No terminal access required, but anyone with the setup password can approve pairings.                  |\n| **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.                                        |\n| **Query-string tokens** | Webhook URLs support `?token=<WEBHOOK_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. |\n| **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.  |\n\nIf you need OpenClaw's full security posture (manual pairing codes, no query-string tokens, no auto-approval), use OpenClaw directly without AlphaClaw.\n\n## Development\n\n```bash\nnpm install\nnpm run build:ui        # Generate Setup UI bundle, Tailwind CSS, and vendor CSS (required for local runs from a git checkout)\nnpm test                # Full suite (440 tests)\nnpm run test:watchdog   # Watchdog-focused suite (14 tests)\nnpm run test:watch      # Watch mode\nnpm run test:coverage   # Coverage report\n```\n\n**Requirements:** Node.js ≥ 22.14.0\n\n## License\n\nMIT\n"
  },
  {
    "path": "bin/alphaclaw.js",
    "content": "#!/usr/bin/env node\n\"use strict\";\n\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { execSync } = require(\"child_process\");\nconst {\n  normalizeGitSyncFilePath,\n  validateGitSyncFilePath,\n  resolveRealGitPath,\n  shouldRefreshHourlyGitSyncScript,\n} = require(\"../lib/cli/git-runtime\");\nconst {\n  ensureMainUpstream,\n  restoreMissingOpenclawConfigFromRemote,\n} = require(\"../lib/cli/openclaw-config-restore\");\nconst { buildSecretReplacements } = require(\"../lib/server/helpers\");\nconst {\n  migrateManagedInternalFiles,\n} = require(\"../lib/server/internal-files-migration\");\n\nconst kUsageTrackerPluginPath = path.resolve(\n  __dirname,\n  \"..\",\n  \"lib\",\n  \"plugin\",\n  \"usage-tracker\",\n);\n\n// ---------------------------------------------------------------------------\n// Parse CLI flags\n// ---------------------------------------------------------------------------\n\nconst args = process.argv.slice(2);\n\nconst flagValue = (argv, ...flags) => {\n  for (const flag of flags) {\n    const idx = argv.indexOf(flag);\n    if (idx !== -1 && idx + 1 < argv.length) {\n      return argv[idx + 1];\n    }\n  }\n  return undefined;\n};\n\nconst kGlobalValueFlags = new Set([\"--root-dir\", \"--port\"]);\nconst splitGlobalAndCommandArgs = (argv) => {\n  const globalArgs = [];\n  let index = 0;\n  while (index < argv.length) {\n    const token = argv[index];\n    if (!token.startsWith(\"-\")) break;\n    globalArgs.push(token);\n    if (kGlobalValueFlags.has(token) && index + 1 < argv.length) {\n      globalArgs.push(argv[index + 1]);\n      index += 2;\n      continue;\n    }\n    index += 1;\n  }\n  return {\n    globalArgs,\n    commandArgs: argv.slice(index),\n  };\n};\n\nconst { globalArgs, commandArgs } = splitGlobalAndCommandArgs(args);\nconst command = commandArgs[0];\nconst commandScope = commandArgs[1];\nconst commandAction = commandArgs[2];\n\nconst pkg = JSON.parse(\n  fs.readFileSync(path.join(__dirname, \"..\", \"package.json\"), \"utf8\"),\n);\n\nif (\n  args.includes(\"--version\") ||\n  args.includes(\"-v\") ||\n  command === \"version\"\n) {\n  console.log(pkg.version);\n  process.exit(0);\n}\n\nif (!command || command === \"help\" || args.includes(\"--help\")) {\n  console.log(`\nalphaclaw v${pkg.version}\n\nUsage: alphaclaw <command> [options]\n\nCommands:\n  start     Start the AlphaClaw server (Setup UI + gateway manager)\n  git-sync  Commit and push /data/.openclaw safely using GITHUB_TOKEN\n  telegram topic add  Add/update Telegram topic mapping by thread ID\n  version   Print version\n\nGlobal options:\n--version, -v       Print version\n--help              Show this help message\n\nstart options:\n--root-dir <path>   Persistent data directory (default: ~/.alphaclaw)\n--port <number>     Server port (default: 3000)\n\ngit-sync options:\n  --message, -m <text> Commit message\n  --file, -f <path>    Optional file path in .openclaw to sync only one file\n\ntelegram topic add options:\n  --thread <id>       Telegram thread ID\n  --name <text>       Topic name\n  --system <text>     Optional system instructions\n  --agent <id>        Optional agent ID for per-topic routing\n  --group <id>        Optional group ID override (auto-resolves when one group exists)\n\nExamples:\n  alphaclaw git-sync --message \"sync workspace\"\n  alphaclaw git-sync --message \"update config\" --file \"workspace/app/config.json\"\n  alphaclaw telegram topic add --thread 12 --name \"Testing\"\n  alphaclaw telegram topic add --thread 12 --name \"Testing\" --system \"Handle QA requests\"\n  alphaclaw telegram topic add --thread 12 --name \"Ops\" --agent ops\n`);\n  process.exit(0);\n}\n\nconst quoteArg = (value) => `'${String(value || \"\").replace(/'/g, \"'\\\"'\\\"'\")}'`;\nconst resolveGithubRepoPath = (value) =>\n  String(value || \"\")\n    .trim()\n    .replace(/^git@github\\.com:/, \"\")\n    .replace(/^https:\\/\\/github\\.com\\//, \"\")\n    .replace(/\\.git$/, \"\");\n\n// ---------------------------------------------------------------------------\n// 1. Resolve root directory (before requiring any lib/ modules)\n// ---------------------------------------------------------------------------\n\nconst rootDir =\n  flagValue(args, \"--root-dir\") ||\n  process.env.ALPHACLAW_ROOT_DIR ||\n  path.join(os.homedir(), \".alphaclaw\");\n\nprocess.env.ALPHACLAW_ROOT_DIR = rootDir;\n\nconst portFlag = flagValue(args, \"--port\");\nif (portFlag) {\n  process.env.PORT = portFlag;\n}\n\n// ---------------------------------------------------------------------------\n// 2. Create directory structure\n// ---------------------------------------------------------------------------\n\nconst openclawDir = path.join(rootDir, \".openclaw\");\nfs.mkdirSync(openclawDir, { recursive: true });\nconst { hourlyGitSyncPath } = migrateManagedInternalFiles({\n  fs,\n  openclawDir,\n});\nconsole.log(`[alphaclaw] Root directory: ${rootDir}`);\n\n// Check for pending update marker (written by the update endpoint before restart).\n// In environments where the container filesystem is ephemeral (Railway, etc.),\n// the npm install from the update endpoint is lost on restart. This re-runs it\n// from the fresh container using the persistent volume marker.\nconst pendingUpdateMarker = path.join(rootDir, \".alphaclaw-update-pending\");\nif (fs.existsSync(pendingUpdateMarker)) {\n  console.log(\n    \"[alphaclaw] Pending update detected, installing @chrysb/alphaclaw@latest...\",\n  );\n  const alphaPkgRoot = path.resolve(__dirname, \"..\");\n  const nmIndex = alphaPkgRoot.lastIndexOf(\n    `${path.sep}node_modules${path.sep}`,\n  );\n  const installDir =\n    nmIndex >= 0 ? alphaPkgRoot.slice(0, nmIndex) : alphaPkgRoot;\n  try {\n    execSync(\n      \"npm install @chrysb/alphaclaw@latest --omit=dev --prefer-online\",\n      {\n        cwd: installDir,\n        stdio: \"inherit\",\n        timeout: 180000,\n      },\n    );\n    fs.unlinkSync(pendingUpdateMarker);\n    console.log(\"[alphaclaw] Update applied successfully\");\n  } catch (e) {\n    console.log(`[alphaclaw] Update install failed: ${e.message}`);\n    fs.unlinkSync(pendingUpdateMarker);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// 3. Symlink ~/.openclaw -> <root>/.openclaw\n// ---------------------------------------------------------------------------\n\nconst homeOpenclawLink = path.join(os.homedir(), \".openclaw\");\ntry {\n  if (!fs.existsSync(homeOpenclawLink)) {\n    fs.symlinkSync(openclawDir, homeOpenclawLink);\n    console.log(`[alphaclaw] Symlinked ${homeOpenclawLink} -> ${openclawDir}`);\n  }\n} catch (e) {\n  console.log(`[alphaclaw] Symlink skipped: ${e.message}`);\n}\n\n// ---------------------------------------------------------------------------\n// 4. Ensure <rootDir>/.env exists (seed from template if missing)\n// ---------------------------------------------------------------------------\n\nconst envFilePath = path.join(rootDir, \".env\");\nconst setupDir = path.join(__dirname, \"..\", \"lib\", \"setup\");\nconst templatePath = path.join(setupDir, \"env.template\");\n\ntry {\n  if (!fs.existsSync(envFilePath) && fs.existsSync(templatePath)) {\n    fs.copyFileSync(templatePath, envFilePath);\n    console.log(`[alphaclaw] Created env at ${envFilePath}`);\n  }\n} catch (e) {\n  console.log(`[alphaclaw] .env setup skipped: ${e.message}`);\n}\n\n// ---------------------------------------------------------------------------\n// 5. Load .env into process.env\n// ---------------------------------------------------------------------------\n\nif (fs.existsSync(envFilePath)) {\n  const content = fs.readFileSync(envFilePath, \"utf8\");\n  for (const line of content.split(\"\\n\")) {\n    const trimmed = line.trim();\n    if (!trimmed || trimmed.startsWith(\"#\")) continue;\n    const eqIdx = trimmed.indexOf(\"=\");\n    if (eqIdx === -1) continue;\n    const key = trimmed.slice(0, eqIdx);\n    const value = trimmed.slice(eqIdx + 1);\n    if (value) process.env[key] = value;\n  }\n  console.log(\"[alphaclaw] Loaded .env\");\n}\n\nconst runGitSync = () => {\n  const githubToken = String(process.env.GITHUB_TOKEN || \"\").trim();\n  const githubRepo = resolveGithubRepoPath(\n    process.env.GITHUB_WORKSPACE_REPO || \"\",\n  );\n  const commitMessage = String(\n    flagValue(commandArgs, \"--message\", \"-m\") || \"\",\n  ).trim();\n  const requestedFilePath = String(\n    flagValue(commandArgs, \"--file\", \"-f\") || \"\",\n  ).trim();\n  const normalizedFilePath = normalizeGitSyncFilePath(requestedFilePath);\n  if (!commitMessage) {\n    console.error(\"[alphaclaw] Missing --message for git-sync\");\n    return 1;\n  }\n  if (normalizedFilePath) {\n    const pathValidation = validateGitSyncFilePath(normalizedFilePath);\n    if (!pathValidation.ok) {\n      console.error(pathValidation.error);\n      return 1;\n    }\n  }\n  if (!githubToken) {\n    console.error(\"[alphaclaw] Missing GITHUB_TOKEN for git-sync\");\n    return 1;\n  }\n  if (!githubRepo) {\n    console.error(\"[alphaclaw] Missing GITHUB_WORKSPACE_REPO for git-sync\");\n    return 1;\n  }\n  if (!fs.existsSync(path.join(openclawDir, \".git\"))) {\n    console.error(\"[alphaclaw] No git repository at /data/.openclaw\");\n    return 1;\n  }\n\n  const realGitPath = resolveRealGitPath({\n    shimPath: \"/usr/local/bin/git\",\n  });\n  if (!realGitPath) {\n    console.error(\n      \"[alphaclaw] Missing git binary for git-sync; install git in the runtime image\",\n    );\n    return 1;\n  }\n\n  const originUrl = `https://github.com/${githubRepo}.git`;\n  let branch = \"main\";\n  try {\n    branch =\n      String(\n        execSync(\"git symbolic-ref --short HEAD\", {\n          cwd: openclawDir,\n          encoding: \"utf8\",\n          stdio: [\"ignore\", \"pipe\", \"ignore\"],\n        }),\n      ).trim() || \"main\";\n  } catch {}\n  const askPassPath = path.join(\n    os.tmpdir(),\n    `alphaclaw-git-askpass-${process.pid}.sh`,\n  );\n  const runGit = (gitCommand, { withAuth = false } = {}) => {\n    const cmd = withAuth\n      ? `GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=${quoteArg(askPassPath)} ${quoteArg(realGitPath)} ${gitCommand}`\n      : `${quoteArg(realGitPath)} ${gitCommand}`;\n    return execSync(cmd, {\n      cwd: openclawDir,\n      stdio: \"pipe\",\n      encoding: \"utf8\",\n      env: {\n        ...process.env,\n        GITHUB_TOKEN: githubToken,\n      },\n    });\n  };\n\n  try {\n    fs.writeFileSync(\n      askPassPath,\n      [\n        \"#!/usr/bin/env sh\",\n        'case \"$1\" in',\n        '  *Username*) echo \"x-access-token\" ;;',\n        '  *Password*) echo \"${GITHUB_TOKEN:-}\" ;;',\n        '  *) echo \"\" ;;',\n        \"esac\",\n        \"\",\n      ].join(\"\\n\"),\n      { mode: 0o700 },\n    );\n\n    runGit(`remote set-url origin ${quoteArg(originUrl)}`);\n    runGit(`config user.name ${quoteArg(\"AlphaClaw Agent\")}`);\n    runGit(`config user.email ${quoteArg(\"agent@alphaclaw.md\")}`);\n    try {\n      runGit(`ls-remote --exit-code --heads origin ${quoteArg(branch)}`, {\n        withAuth: true,\n      });\n      runGit(`pull --rebase --autostash origin ${quoteArg(branch)}`, {\n        withAuth: true,\n      });\n    } catch {\n      console.log(\n        `[alphaclaw] Remote branch \"${branch}\" not found, skipping pull`,\n      );\n    }\n    if (normalizedFilePath) {\n      runGit(`add -A -- ${quoteArg(normalizedFilePath)}`);\n    } else {\n      runGit(\"add -A\");\n    }\n    try {\n      runGit(\"diff --cached --quiet\");\n      console.log(\"[alphaclaw] No changes to commit\");\n      return 0;\n    } catch {}\n    if (normalizedFilePath) {\n      runGit(\n        `commit -m ${quoteArg(commitMessage)} -- ${quoteArg(normalizedFilePath)}`,\n      );\n    } else {\n      runGit(`commit -m ${quoteArg(commitMessage)}`);\n    }\n    runGit(`push origin ${quoteArg(branch)}`, { withAuth: true });\n    const hash = String(runGit(\"rev-parse --short HEAD\")).trim();\n    console.log(`[alphaclaw] Git sync complete (${hash})`);\n    console.log(\n      `[alphaclaw] Commit URL: https://github.com/${githubRepo}/commit/${hash}`,\n    );\n    return 0;\n  } catch (e) {\n    const details = String(e.stderr || e.stdout || e.message || \"\").trim();\n    console.error(`[alphaclaw] git-sync failed: ${details.slice(0, 400)}`);\n    return 1;\n  } finally {\n    try {\n      fs.rmSync(askPassPath, { force: true });\n    } catch {}\n  }\n};\n\nif (command === \"git-sync\") {\n  process.exit(runGitSync());\n}\n\nconst runTelegramTopicAdd = () => {\n  const topicName = String(flagValue(commandArgs, \"--name\") || \"\").trim();\n  const threadId = String(flagValue(commandArgs, \"--thread\") || \"\").trim();\n  const systemInstructions = String(\n    flagValue(commandArgs, \"--system\") || \"\",\n  ).trim();\n  const agentId = String(flagValue(commandArgs, \"--agent\") || \"\").trim();\n  const requestedGroupId = String(\n    flagValue(commandArgs, \"--group\") || \"\",\n  ).trim();\n  if (!threadId) {\n    console.error(\"[alphaclaw] Missing --thread for telegram topic add\");\n    return 1;\n  }\n  if (!topicName) {\n    console.error(\"[alphaclaw] Missing --name for telegram topic add\");\n    return 1;\n  }\n\n  const configPath = path.join(openclawDir, \"openclaw.json\");\n  if (!fs.existsSync(configPath)) {\n    console.error(\"[alphaclaw] Missing openclaw.json. Run setup first.\");\n    return 1;\n  }\n\n  try {\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const configuredGroups = Object.keys(cfg.channels?.telegram?.groups || {});\n    let groupId = requestedGroupId;\n    if (!groupId) {\n      if (configuredGroups.length === 1) {\n        [groupId] = configuredGroups;\n      } else if (configuredGroups.length === 0) {\n        console.error(\n          \"[alphaclaw] No Telegram group configured. Configure Telegram workspace first.\",\n        );\n        return 1;\n      } else {\n        console.error(\n          \"[alphaclaw] Multiple Telegram groups detected. Provide --group <groupId>.\",\n        );\n        return 1;\n      }\n    }\n\n    const topicRegistry = require(\"../lib/server/topic-registry\");\n    const {\n      syncConfigForTelegram,\n    } = require(\"../lib/server/telegram-workspace\");\n    const {\n      syncBootstrapPromptFiles,\n    } = require(\"../lib/server/onboarding/workspace\");\n    topicRegistry.updateTopic(groupId, threadId, {\n      name: topicName,\n      ...(systemInstructions ? { systemInstructions } : {}),\n      ...(agentId ? { agentId } : {}),\n    });\n\n    const requireMention =\n      !!cfg.channels?.telegram?.groups?.[groupId]?.requireMention;\n    const syncResult = syncConfigForTelegram({\n      fs,\n      openclawDir,\n      topicRegistry,\n      groupId,\n      requireMention,\n      resolvedUserId: \"\",\n    });\n    syncBootstrapPromptFiles({\n      fs,\n      workspaceDir: path.join(openclawDir, \"workspace\"),\n    });\n\n    const agentSuffix = agentId ? ` agent=${agentId}` : \"\";\n    console.log(\n      `[alphaclaw] Topic mapped: group=${groupId} thread=${threadId} name=${topicName}${agentSuffix}`,\n    );\n    console.log(\n      `[alphaclaw] Concurrency updated: agent=${syncResult.maxConcurrent} subagents=${syncResult.subagentMaxConcurrent} topics=${syncResult.totalTopics}`,\n    );\n    return 0;\n  } catch (e) {\n    console.error(`[alphaclaw] telegram topic add failed: ${e.message}`);\n    return 1;\n  }\n};\n\nif (\n  command === \"telegram\" &&\n  commandScope === \"topic\" &&\n  commandAction === \"add\"\n) {\n  process.exit(runTelegramTopicAdd());\n}\n\nconst kPort = String(process.env.PORT || \"3000\").trim();\nif (kPort === \"18789\") {\n  console.error(\n    [\n      \"[alphaclaw] Fatal config error: AlphaClaw cannot be started on port 18789.\",\n      \"[alphaclaw] Port 18789 is reserved for the OpenClaw gateway.\",\n    ].join(\"\\n\"),\n  );\n  process.exit(1);\n}\n\nconst kSetupPassword = String(process.env.SETUP_PASSWORD || \"\").trim();\nif (!kSetupPassword) {\n  console.error(\n    [\n      \"[alphaclaw] Fatal config error: SETUP_PASSWORD is missing or empty.\",\n      \"[alphaclaw] Set SETUP_PASSWORD in your deployment environment variables and restart.\",\n      \"[alphaclaw] Examples:\",\n      \"[alphaclaw] - Render: Dashboard -> Environment -> Add SETUP_PASSWORD\",\n      \"[alphaclaw] - Railway: Project -> Variables -> Add SETUP_PASSWORD\",\n    ].join(\"\\n\"),\n  );\n  process.exit(1);\n}\n\n// ---------------------------------------------------------------------------\n// 7. Set OPENCLAW_HOME globally so all child processes inherit it\n// ---------------------------------------------------------------------------\n\nprocess.env.OPENCLAW_HOME = rootDir;\nprocess.env.HOME = rootDir;\nprocess.env.OPENCLAW_CONFIG_PATH = path.join(openclawDir, \"openclaw.json\");\nprocess.env.OPENCLAW_STATE_DIR = openclawDir;\nprocess.env.GOG_KEYRING_PASSWORD =\n  process.env.GOG_KEYRING_PASSWORD || \"alphaclaw\";\n\n// ---------------------------------------------------------------------------\n// 8. Install gog (Google Workspace CLI) if not present\n// ---------------------------------------------------------------------------\n\nprocess.env.XDG_CONFIG_HOME = openclawDir;\n\nconst ensureGogCliCompatConfigPath = () => {\n  const configDir = path.join(rootDir, \".config\");\n  const compatPath = path.join(configDir, \"gogcli\");\n  const managedPath = path.join(openclawDir, \"gogcli\");\n\n  try {\n    fs.mkdirSync(configDir, { recursive: true });\n    if (!fs.existsSync(compatPath)) {\n      fs.symlinkSync(managedPath, compatPath, \"dir\");\n      console.log(\n        `[alphaclaw] Linked gogcli config path ${compatPath} -> ${managedPath}`,\n      );\n      return;\n    }\n\n    const stat = fs.lstatSync(compatPath);\n    if (!stat.isSymbolicLink()) return;\n    const linkTarget = fs.readlinkSync(compatPath);\n    const resolvedTarget = path.resolve(configDir, linkTarget);\n    if (resolvedTarget !== managedPath) {\n      console.log(\n        `[alphaclaw] gogcli config path already exists at ${compatPath}; leaving existing symlink in place`,\n      );\n    }\n  } catch (error) {\n    console.log(\n      `[alphaclaw] gogcli config path compatibility setup skipped: ${error.message}`,\n    );\n  }\n};\n\nensureGogCliCompatConfigPath();\n\nconst gogInstalled = (() => {\n  try {\n    execSync(\"command -v gog\", { stdio: \"ignore\" });\n    return true;\n  } catch {\n    return false;\n  }\n})();\n\nif (!gogInstalled) {\n  console.log(\"[alphaclaw] Installing gog CLI...\");\n  try {\n    const gogVersion = process.env.GOG_VERSION || \"0.11.0\";\n    const platform = os.platform() === \"darwin\" ? \"darwin\" : \"linux\";\n    const arch = os.arch() === \"arm64\" ? \"arm64\" : \"amd64\";\n    const tarball = `gogcli_${gogVersion}_${platform}_${arch}.tar.gz`;\n    const url = `https://github.com/steipete/gogcli/releases/download/v${gogVersion}/${tarball}`;\n    execSync(\n      `curl -fsSL \"${url}\" -o /tmp/gog.tar.gz && tar -xzf /tmp/gog.tar.gz -C /tmp/ && mv /tmp/gog /usr/local/bin/gog && chmod +x /usr/local/bin/gog && rm -f /tmp/gog.tar.gz`,\n      { stdio: \"inherit\" },\n    );\n    console.log(\"[alphaclaw] gog CLI installed\");\n  } catch (e) {\n    console.log(`[alphaclaw] gog install skipped: ${e.message}`);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// 9. Install/reconcile system cron entry\n// ---------------------------------------------------------------------------\n\nconst packagedHourlyGitSyncPath = path.join(setupDir, \"hourly-git-sync.sh\");\n\ntry {\n  if (fs.existsSync(packagedHourlyGitSyncPath)) {\n    const packagedSyncScript = fs.readFileSync(\n      packagedHourlyGitSyncPath,\n      \"utf8\",\n    );\n    const installedSyncScript = fs.existsSync(hourlyGitSyncPath)\n      ? fs.readFileSync(hourlyGitSyncPath, \"utf8\")\n      : \"\";\n    if (\n      shouldRefreshHourlyGitSyncScript({\n        packagedSyncScript,\n        installedSyncScript,\n      })\n    ) {\n      fs.writeFileSync(hourlyGitSyncPath, packagedSyncScript, { mode: 0o755 });\n      console.log(\"[alphaclaw] Refreshed hourly git sync script\");\n    }\n  }\n} catch (e) {\n  console.log(\n    `[alphaclaw] Hourly git sync script refresh skipped: ${e.message}`,\n  );\n}\n\nif (fs.existsSync(hourlyGitSyncPath)) {\n  try {\n    const syncCronConfig = path.join(openclawDir, \"cron\", \"system-sync.json\");\n    let cronEnabled = true;\n    let cronSchedule = \"0 * * * *\";\n\n    if (fs.existsSync(syncCronConfig)) {\n      try {\n        const cfg = JSON.parse(fs.readFileSync(syncCronConfig, \"utf8\"));\n        cronEnabled = cfg.enabled !== false;\n        const schedule = String(cfg.schedule || \"\").trim();\n        if (/^(\\S+\\s+){4}\\S+$/.test(schedule)) cronSchedule = schedule;\n      } catch {}\n    }\n\n    const cronFilePath = \"/etc/cron.d/openclaw-hourly-sync\";\n    if (cronEnabled) {\n      const cronContent = [\n        \"SHELL=/bin/bash\",\n        \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\n        `${cronSchedule} root bash \"${hourlyGitSyncPath}\" >> /var/log/openclaw-hourly-sync.log 2>&1`,\n        \"\",\n      ].join(\"\\n\");\n      fs.writeFileSync(cronFilePath, cronContent, { mode: 0o644 });\n      console.log(\"[alphaclaw] System cron entry installed\");\n    } else {\n      try {\n        fs.unlinkSync(cronFilePath);\n      } catch {}\n      console.log(\"[alphaclaw] System cron entry disabled\");\n    }\n  } catch (e) {\n    console.log(`[alphaclaw] Cron setup skipped: ${e.message}`);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// 9. Start cron daemon if available\n// ---------------------------------------------------------------------------\n\ntry {\n  execSync(\"command -v cron\", { stdio: \"ignore\" });\n  try {\n    execSync(\"pgrep -x cron\", { stdio: \"ignore\" });\n  } catch {\n    execSync(\"cron\", { stdio: \"ignore\" });\n  }\n  console.log(\"[alphaclaw] Cron daemon running\");\n} catch {}\n\n// ---------------------------------------------------------------------------\n// 10. Reconcile channels if already onboarded\n// ---------------------------------------------------------------------------\n\nconst configPath = path.join(openclawDir, \"openclaw.json\");\nconst githubRepo = process.env.GITHUB_WORKSPACE_REPO;\n\nif (fs.existsSync(path.join(openclawDir, \".git\"))) {\n  if (githubRepo) {\n    const repoUrl = githubRepo\n      .replace(/^git@github\\.com:/, \"\")\n      .replace(/^https:\\/\\/github\\.com\\//, \"\")\n      .replace(/\\.git$/, \"\");\n    const remoteUrl = `https://github.com/${repoUrl}.git`;\n    try {\n      execSync(`git remote set-url origin \"${remoteUrl}\"`, {\n        cwd: openclawDir,\n        stdio: \"ignore\",\n      });\n      console.log(\"[alphaclaw] Repo ready\");\n    } catch {}\n  }\n\n  // Migration path: scrub persisted PATs from existing GitHub origin URLs.\n  try {\n    const existingOrigin = execSync(\"git remote get-url origin\", {\n      cwd: openclawDir,\n      stdio: [\"ignore\", \"pipe\", \"ignore\"],\n      encoding: \"utf8\",\n    }).trim();\n    const match = existingOrigin.match(/^https:\\/\\/[^/@]+@github\\.com\\/(.+)$/i);\n    if (match?.[1]) {\n      const cleanedPath = String(match[1]).replace(/\\.git$/i, \"\");\n      const cleanedOrigin = `https://github.com/${cleanedPath}.git`;\n      execSync(`git remote set-url origin \"${cleanedOrigin}\"`, {\n        cwd: openclawDir,\n        stdio: \"ignore\",\n      });\n      console.log(\"[alphaclaw] Scrubbed tokenized GitHub remote URL\");\n    }\n  } catch {}\n\n  restoreMissingOpenclawConfigFromRemote({\n    openclawDir,\n    configPath,\n    env: process.env,\n  });\n  if (\n    ensureMainUpstream({\n      openclawDir,\n      gitEnv: process.env,\n    })\n  ) {\n    console.log(\"[alphaclaw] Set main upstream to origin/main\");\n  }\n}\n\nif (fs.existsSync(configPath)) {\n  console.log(\"[alphaclaw] Config exists, reconciling channels...\");\n\n  try {\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    if (!cfg.channels) cfg.channels = {};\n    if (!cfg.plugins) cfg.plugins = {};\n    if (!cfg.plugins.load) cfg.plugins.load = {};\n    if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = [];\n    if (!cfg.plugins.entries) cfg.plugins.entries = {};\n    let changed = false;\n\n    if (process.env.TELEGRAM_BOT_TOKEN && !cfg.channels.telegram) {\n      cfg.channels.telegram = {\n        enabled: true,\n        botToken: process.env.TELEGRAM_BOT_TOKEN,\n        dmPolicy: \"pairing\",\n        groupPolicy: \"allowlist\",\n      };\n      cfg.plugins.entries.telegram = { enabled: true };\n      console.log(\"[alphaclaw] Telegram added\");\n      changed = true;\n    }\n\n    if (process.env.DISCORD_BOT_TOKEN && !cfg.channels.discord) {\n      cfg.channels.discord = {\n        enabled: true,\n        token: process.env.DISCORD_BOT_TOKEN,\n        dmPolicy: \"pairing\",\n        groupPolicy: \"allowlist\",\n      };\n      cfg.plugins.entries.discord = { enabled: true };\n      console.log(\"[alphaclaw] Discord added\");\n      changed = true;\n    }\n    if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) {\n      cfg.plugins.load.paths.push(kUsageTrackerPluginPath);\n      changed = true;\n    }\n    if (cfg.plugins.entries[\"usage-tracker\"]?.enabled !== true) {\n      cfg.plugins.entries[\"usage-tracker\"] = { enabled: true };\n      changed = true;\n    }\n\n    if (changed) {\n      let content = JSON.stringify(cfg, null, 2);\n      const replacements = buildSecretReplacements(process.env);\n      for (const [secret, envRef] of replacements) {\n        if (secret) {\n          // Only replace the secret if it is an exact match for a JSON string value\n          // This ensures we do not replace substrings inside other strings\n          const secretJson = JSON.stringify(secret);\n          content = content.replace(\n            new RegExp(\n              secretJson.replace(/[-\\/\\\\^$*+?.()|[\\]{}]/g, \"\\\\$&\"),\n              \"g\",\n            ),\n            JSON.stringify(envRef),\n          );\n        }\n      }\n      fs.writeFileSync(configPath, content);\n      console.log(\"[alphaclaw] Config updated and sanitized\");\n    }\n  } catch (e) {\n    console.error(`[alphaclaw] Channel reconciliation error: ${e.message}`);\n  }\n} else {\n  console.log(\n    \"[alphaclaw] No config yet -- onboarding will run from the Setup UI\",\n  );\n}\n\n// ---------------------------------------------------------------------------\n// 12. Install systemctl shim if in Docker (no real systemd)\n// ---------------------------------------------------------------------------\n\ntry {\n  execSync(\"command -v systemctl\", { stdio: \"ignore\" });\n} catch {\n  const shimSrc = path.join(__dirname, \"..\", \"lib\", \"scripts\", \"systemctl\");\n  const shimDest = \"/usr/local/bin/systemctl\";\n  try {\n    fs.copyFileSync(shimSrc, shimDest);\n    fs.chmodSync(shimDest, 0o755);\n    console.log(\"[alphaclaw] systemctl shim installed\");\n  } catch (e) {\n    console.log(`[alphaclaw] systemctl shim skipped: ${e.message}`);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// 13. Install git auth shim\n// ---------------------------------------------------------------------------\n\ntry {\n  const gitAskPassSrc = path.join(__dirname, \"..\", \"lib\", \"scripts\", \"git-askpass\");\n  const gitAskPassDest = \"/tmp/alphaclaw-git-askpass.sh\";\n  const gitShimTemplatePath = path.join(__dirname, \"..\", \"lib\", \"scripts\", \"git\");\n  const gitShimDest = \"/usr/local/bin/git\";\n\n  if (fs.existsSync(gitAskPassSrc)) {\n    fs.copyFileSync(gitAskPassSrc, gitAskPassDest);\n    fs.chmodSync(gitAskPassDest, 0o755);\n  }\n\n  if (fs.existsSync(gitShimTemplatePath)) {\n    const realGitPath =\n      resolveRealGitPath({\n        shimPath: gitShimDest,\n      }) || \"/usr/bin/git\";\n\n    const gitShimTemplate = fs.readFileSync(gitShimTemplatePath, \"utf8\");\n    const gitShimContent = gitShimTemplate\n      .replace(\"@@REAL_GIT@@\", realGitPath)\n      .replace(\"@@OPENCLAW_REPO_ROOT@@\", openclawDir);\n    fs.writeFileSync(gitShimDest, gitShimContent, { mode: 0o755 });\n    console.log(\"[alphaclaw] git auth shim installed\");\n  }\n} catch (e) {\n  console.log(`[alphaclaw] git auth shim skipped: ${e.message}`);\n}\n\n// ---------------------------------------------------------------------------\n// 14. Start Express server\n// ---------------------------------------------------------------------------\n\nconsole.log(\"[alphaclaw] Setup complete -- starting server\");\nrequire(\"../lib/server.js\");\n"
  },
  {
    "path": "lib/cli/git-runtime.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst normalizeGitSyncFilePath = (requestedFilePath) => {\n  const rawPath = String(requestedFilePath || \"\").trim();\n  if (!rawPath) return \"\";\n  return rawPath.replace(/\\\\/g, \"/\").replace(/^\\.\\/+/, \"\");\n};\n\nconst validateGitSyncFilePath = (normalizedFilePath) => {\n  if (!normalizedFilePath) return { ok: true };\n  if (\n    normalizedFilePath.startsWith(\"/\") ||\n    normalizedFilePath.startsWith(\"../\") ||\n    normalizedFilePath.includes(\"/../\")\n  ) {\n    return {\n      ok: false,\n      error: \"[alphaclaw] --file must stay within /data/.openclaw\",\n    };\n  }\n  return { ok: true };\n};\n\nconst listGitCandidates = ({ execSyncImpl = execSync } = {}) => {\n  try {\n    return String(\n      execSyncImpl(\"which -a git\", {\n        stdio: [\"ignore\", \"pipe\", \"ignore\"],\n        encoding: \"utf8\",\n      }),\n    )\n      .split(\"\\n\")\n      .map((candidate) => candidate.trim())\n      .filter(Boolean);\n  } catch {\n    return [];\n  }\n};\n\nconst canExecute = ({ fsModule = fs, candidatePath = \"\" } = {}) => {\n  const normalizedCandidatePath = String(candidatePath || \"\").trim();\n  if (!normalizedCandidatePath) return false;\n  try {\n    fsModule.accessSync(normalizedCandidatePath, fsModule.constants.X_OK);\n    return true;\n  } catch {\n    return false;\n  }\n};\n\nconst resolveRealGitPath = ({\n  execSyncImpl = execSync,\n  fsModule = fs,\n  shimPath = \"\",\n  hintedPath = \"\",\n} = {}) => {\n  const normalizedShimPath = String(shimPath || \"\").trim()\n    ? path.resolve(String(shimPath || \"\").trim())\n    : \"\";\n  const candidates = [\n    String(process.env.ALPHACLAW_REAL_GIT || \"\").trim(),\n    String(hintedPath || \"\").trim(),\n    \"/usr/bin/git\",\n    \"/bin/git\",\n    \"/usr/libexec/git-core/git\",\n    \"/usr/local/bin/git.real\",\n  ];\n\n  for (const candidatePath of [...candidates, ...listGitCandidates({ execSyncImpl })]) {\n    const normalizedCandidatePath = String(candidatePath || \"\").trim();\n    if (!normalizedCandidatePath) continue;\n    const resolvedCandidatePath = path.resolve(normalizedCandidatePath);\n    if (normalizedShimPath && resolvedCandidatePath === normalizedShimPath) continue;\n    if (!canExecute({ fsModule, candidatePath: resolvedCandidatePath })) continue;\n    return resolvedCandidatePath;\n  }\n\n  return \"\";\n};\n\nconst shouldRefreshHourlyGitSyncScript = ({\n  packagedSyncScript = \"\",\n  installedSyncScript = \"\",\n} = {}) => {\n  const nextPackagedSyncScript = String(packagedSyncScript || \"\");\n  if (!nextPackagedSyncScript.trim()) return false;\n  return nextPackagedSyncScript !== String(installedSyncScript || \"\");\n};\n\nmodule.exports = {\n  normalizeGitSyncFilePath,\n  validateGitSyncFilePath,\n  resolveRealGitPath,\n  shouldRefreshHourlyGitSyncScript,\n};\n"
  },
  {
    "path": "lib/cli/git-sync.js",
    "content": "const normalizeGitSyncFilePath = (requestedFilePath) => {\n  const rawPath = String(requestedFilePath || \"\").trim();\n  if (!rawPath) return \"\";\n  return rawPath.replace(/\\\\/g, \"/\").replace(/^\\.\\/+/, \"\");\n};\n\nconst validateGitSyncFilePath = (normalizedFilePath) => {\n  if (!normalizedFilePath) return { ok: true };\n  if (\n    normalizedFilePath.startsWith(\"/\") ||\n    normalizedFilePath.startsWith(\"../\") ||\n    normalizedFilePath.includes(\"/../\")\n  ) {\n    return {\n      ok: false,\n      error: \"[alphaclaw] --file must stay within /data/.openclaw\",\n    };\n  }\n  return { ok: true };\n};\n\nmodule.exports = {\n  normalizeGitSyncFilePath,\n  validateGitSyncFilePath,\n};\n"
  },
  {
    "path": "lib/cli/openclaw-config-restore.js",
    "content": "\"use strict\";\n\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { execSync } = require(\"child_process\");\n\nconst kOpenclawConfigFile = \"openclaw.json\";\n\nconst quoteArg = (value) => `'${String(value || \"\").replace(/'/g, \"'\\\"'\\\"'\")}'`;\n\nconst resolveCurrentBranch = ({ execSyncImpl, openclawDir }) => {\n  try {\n    return (\n      String(\n        execSyncImpl(\"git symbolic-ref --short HEAD\", {\n          cwd: openclawDir,\n          stdio: [\"ignore\", \"pipe\", \"ignore\"],\n          encoding: \"utf8\",\n        }),\n      ).trim() || \"main\"\n    );\n  } catch {\n    return \"main\";\n  }\n};\n\nconst createGitEnv = ({ fsModule, osModule, env, processId }) => {\n  const githubToken = String(env.GITHUB_TOKEN || \"\").trim();\n  const gitEnv = { ...env };\n  if (!githubToken) {\n    return { gitEnv, askPassPath: \"\" };\n  }\n\n  const askPassPath = path.join(\n    osModule.tmpdir(),\n    `alphaclaw-boot-git-askpass-${processId}.sh`,\n  );\n  fsModule.writeFileSync(\n    askPassPath,\n    [\n      \"#!/usr/bin/env sh\",\n      'case \"$1\" in',\n      '  *Username*) echo \"x-access-token\" ;;',\n      '  *Password*) echo \"${GITHUB_TOKEN:-}\" ;;',\n      '  *) echo \"\" ;;',\n      \"esac\",\n      \"\",\n    ].join(\"\\n\"),\n    { mode: 0o700 },\n  );\n  gitEnv.GITHUB_TOKEN = githubToken;\n  gitEnv.GIT_TERMINAL_PROMPT = \"0\";\n  gitEnv.GIT_ASKPASS = askPassPath;\n  return { gitEnv, askPassPath };\n};\n\nconst restoreMissingOpenclawConfigFromRemote = ({\n  fsModule = fs,\n  osModule = os,\n  execSyncImpl = execSync,\n  env = process.env,\n  logger = console,\n  processId = process.pid,\n  openclawDir,\n  configPath = path.join(openclawDir || \"\", kOpenclawConfigFile),\n} = {}) => {\n  if (!openclawDir) {\n    throw new Error(\"openclawDir is required\");\n  }\n\n  if (fsModule.existsSync(configPath)) {\n    logger.log(\n      \"[alphaclaw] Remote config restore skipped: local openclaw.json already exists\",\n    );\n    return { restored: false, skipped: true, reason: \"exists\" };\n  }\n\n  const branch = resolveCurrentBranch({ execSyncImpl, openclawDir });\n  const { gitEnv, askPassPath } = createGitEnv({\n    fsModule,\n    osModule,\n    env,\n    processId,\n  });\n\n  try {\n    execSyncImpl(\n      `git ls-remote --exit-code --heads origin ${quoteArg(branch)}`,\n      {\n        cwd: openclawDir,\n        stdio: \"ignore\",\n        env: gitEnv,\n      },\n    );\n    execSyncImpl(`git fetch --quiet origin ${quoteArg(branch)}`, {\n      cwd: openclawDir,\n      stdio: \"ignore\",\n      env: gitEnv,\n    });\n    const remoteConfig = String(\n      execSyncImpl(`git show ${quoteArg(`origin/${branch}:openclaw.json`)}`, {\n        cwd: openclawDir,\n        stdio: [\"ignore\", \"pipe\", \"ignore\"],\n        encoding: \"utf8\",\n        env: gitEnv,\n      }),\n    );\n    if (!remoteConfig.trim()) {\n      logger.log(\"[alphaclaw] Remote config restore skipped: remote config empty\");\n      return { restored: false, skipped: true, reason: \"empty_remote\", branch };\n    }\n    fsModule.writeFileSync(configPath, remoteConfig);\n    logger.log(`[alphaclaw] Restored missing openclaw.json from origin/${branch}`);\n    return { restored: true, skipped: false, reason: \"missing\", branch };\n  } catch (e) {\n    logger.log(\n      `[alphaclaw] Remote config restore skipped: ${String(e.message || \"\").slice(0, 200)}`,\n    );\n    return {\n      restored: false,\n      skipped: true,\n      reason: \"error\",\n      branch,\n      error: e,\n    };\n  } finally {\n    if (askPassPath) {\n      try {\n        fsModule.rmSync(askPassPath, { force: true });\n      } catch {}\n    }\n  }\n};\n\nconst ensureMainUpstream = ({ execSyncImpl = execSync, openclawDir, gitEnv }) => {\n  try {\n    execSyncImpl(\"git show-ref --verify --quiet refs/heads/main\", {\n      cwd: openclawDir,\n      stdio: \"ignore\",\n    });\n    try {\n      execSyncImpl(\"git rev-parse --abbrev-ref --symbolic-full-name main@{upstream}\", {\n        cwd: openclawDir,\n        stdio: \"ignore\",\n      });\n    } catch {\n      execSyncImpl(\"git branch --set-upstream-to=origin/main main\", {\n        cwd: openclawDir,\n        stdio: \"ignore\",\n        env: gitEnv,\n      });\n      return true;\n    }\n  } catch {}\n  return false;\n};\n\nmodule.exports = {\n  ensureMainUpstream,\n  restoreMissingOpenclawConfigFromRemote,\n};\n"
  },
  {
    "path": "lib/plugin/usage-tracker/index.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { DatabaseSync } = require(\"node:sqlite\");\n\nconst kPluginId = \"usage-tracker\";\nconst kFallbackRootDir = path.join(os.homedir(), \".alphaclaw\");\n\nconst coerceCount = (value) => {\n  const parsed = Number.parseInt(String(value ?? 0), 10);\n  return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;\n};\n\nconst resolveRootDir = () =>\n  process.env.ALPHACLAW_ROOT_DIR ||\n  process.env.OPENCLAW_HOME ||\n  process.env.OPENCLAW_ROOT_DIR ||\n  kFallbackRootDir;\n\nconst safeAlterTable = (database, sql) => {\n  try {\n    database.exec(sql);\n  } catch (err) {\n    const message = String(err?.message || \"\").toLowerCase();\n    if (!message.includes(\"duplicate column name\")) throw err;\n  }\n};\n\nconst ensureSchema = (database) => {\n  database.exec(\"PRAGMA journal_mode=WAL;\");\n  database.exec(\"PRAGMA synchronous=NORMAL;\");\n  database.exec(\"PRAGMA busy_timeout=5000;\");\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS usage_events (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      timestamp INTEGER NOT NULL,\n      session_id TEXT,\n      session_key TEXT,\n      run_id TEXT,\n      provider TEXT NOT NULL,\n      model TEXT NOT NULL,\n      input_tokens INTEGER NOT NULL DEFAULT 0,\n      output_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n      total_tokens INTEGER NOT NULL DEFAULT 0\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_usage_events_ts\n    ON usage_events(timestamp DESC);\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_usage_events_session\n    ON usage_events(session_id);\n  `);\n  safeAlterTable(\n    database,\n    \"ALTER TABLE usage_events ADD COLUMN session_key TEXT;\",\n  );\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_usage_events_session_key\n    ON usage_events(session_key);\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS usage_daily (\n      date TEXT NOT NULL,\n      model TEXT NOT NULL,\n      provider TEXT,\n      input_tokens INTEGER NOT NULL DEFAULT 0,\n      output_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n      total_tokens INTEGER NOT NULL DEFAULT 0,\n      turn_count INTEGER NOT NULL DEFAULT 0,\n      PRIMARY KEY (date, model)\n    );\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS tool_events (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      timestamp INTEGER NOT NULL,\n      session_id TEXT,\n      session_key TEXT,\n      tool_name TEXT NOT NULL,\n      success INTEGER NOT NULL DEFAULT 1,\n      duration_ms INTEGER\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_tool_events_session\n    ON tool_events(session_id);\n  `);\n  safeAlterTable(\n    database,\n    \"ALTER TABLE tool_events ADD COLUMN session_key TEXT;\",\n  );\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_tool_events_session_key\n    ON tool_events(session_key);\n  `);\n};\n\nconst createPlugin = () => {\n  let database = null;\n  let dbPath = \"\";\n  let insertUsageEventStmt = null;\n  let upsertUsageDailyStmt = null;\n  let insertToolEventStmt = null;\n\n  const getDatabase = () => {\n    if (database) return database;\n    const rootDir = resolveRootDir();\n    const dbDir = path.join(rootDir, \"db\");\n    fs.mkdirSync(dbDir, { recursive: true });\n    dbPath = path.join(dbDir, \"usage.db\");\n    database = new DatabaseSync(dbPath);\n    ensureSchema(database);\n    insertUsageEventStmt = database.prepare(`\n      INSERT INTO usage_events (\n        timestamp,\n        session_id,\n        session_key,\n        run_id,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $run_id,\n        $provider,\n        $model,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens\n      )\n    `);\n    upsertUsageDailyStmt = database.prepare(`\n      INSERT INTO usage_daily (\n        date,\n        model,\n        provider,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens,\n        turn_count\n      ) VALUES (\n        $date,\n        $model,\n        $provider,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens,\n        1\n      )\n      ON CONFLICT(date, model) DO UPDATE SET\n        provider = COALESCE(excluded.provider, usage_daily.provider),\n        input_tokens = usage_daily.input_tokens + excluded.input_tokens,\n        output_tokens = usage_daily.output_tokens + excluded.output_tokens,\n        cache_read_tokens = usage_daily.cache_read_tokens + excluded.cache_read_tokens,\n        cache_write_tokens = usage_daily.cache_write_tokens + excluded.cache_write_tokens,\n        total_tokens = usage_daily.total_tokens + excluded.total_tokens,\n        turn_count = usage_daily.turn_count + 1\n    `);\n    insertToolEventStmt = database.prepare(`\n      INSERT INTO tool_events (\n        timestamp,\n        session_id,\n        session_key,\n        tool_name,\n        success,\n        duration_ms\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $tool_name,\n        $success,\n        $duration_ms\n      )\n    `);\n    return database;\n  };\n\n  const writeUsageEvent = (event, ctx, logger) => {\n    const usage = event?.usage ?? {};\n    const timestamp = Date.now();\n    const date = new Date(timestamp).toISOString().slice(0, 10);\n    const inputTokens = coerceCount(usage.input);\n    const outputTokens = coerceCount(usage.output);\n    const cacheReadTokens = coerceCount(usage.cacheRead);\n    const cacheWriteTokens = coerceCount(usage.cacheWrite);\n    const fallbackTotal = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;\n    const totalTokens = coerceCount(usage.total) || fallbackTotal;\n    if (totalTokens <= 0) return;\n    getDatabase();\n    insertUsageEventStmt.run({\n      $timestamp: timestamp,\n      $session_id: String(event?.sessionId || ctx?.sessionId || \"\"),\n      $session_key: String(ctx?.sessionKey || \"\"),\n      $run_id: String(event?.runId || \"\"),\n      $provider: String(event?.provider || \"unknown\"),\n      $model: String(event?.model || \"unknown\"),\n      $input_tokens: inputTokens,\n      $output_tokens: outputTokens,\n      $cache_read_tokens: cacheReadTokens,\n      $cache_write_tokens: cacheWriteTokens,\n      $total_tokens: totalTokens,\n    });\n    upsertUsageDailyStmt.run({\n      $date: date,\n      $model: String(event?.model || \"unknown\"),\n      $provider: String(event?.provider || \"unknown\"),\n      $input_tokens: inputTokens,\n      $output_tokens: outputTokens,\n      $cache_read_tokens: cacheReadTokens,\n      $cache_write_tokens: cacheWriteTokens,\n      $total_tokens: totalTokens,\n    });\n    if (logger?.debug) {\n      logger.debug(\n        `[${kPluginId}] usage event recorded model=${String(event?.model || \"unknown\")} total=${totalTokens}`,\n      );\n    }\n  };\n\n  const deriveToolSuccess = (event) => {\n    const message = event?.message;\n    if (!message || typeof message !== \"object\") {\n      return event?.error ? 0 : 1;\n    }\n    if (message?.isError === true) return 0;\n    if (message?.ok === false) return 0;\n    if (typeof message?.error === \"string\" && message.error.trim()) return 0;\n    return 1;\n  };\n\n  const writeToolEvent = (event, ctx) => {\n    const toolName = String(event?.toolName || \"\").trim();\n    if (!toolName) return;\n    const sessionKey = String(ctx?.sessionKey || \"\").trim();\n    const sessionId = String(ctx?.sessionId || \"\").trim();\n    if (!sessionKey && !sessionId) return;\n    getDatabase();\n    insertToolEventStmt.run({\n      $timestamp: Date.now(),\n      $session_id: sessionId,\n      $session_key: sessionKey,\n      $tool_name: toolName,\n      $success: deriveToolSuccess(event),\n      $duration_ms: coerceCount(event?.durationMs) || null,\n    });\n  };\n\n  return {\n    id: kPluginId,\n    name: \"AlphaClaw Usage Tracker\",\n    description: \"Captures LLM and tool usage into SQLite for Usage UI\",\n    register: (api) => {\n      const logger = api?.logger;\n      try {\n        getDatabase();\n        logger?.info?.(`[${kPluginId}] initialized db=${dbPath}`);\n      } catch (err) {\n        logger?.error?.(`[${kPluginId}] failed to initialize database: ${err?.message || err}`);\n        return;\n      }\n      api.on(\"llm_output\", (event, ctx) => {\n        try {\n          writeUsageEvent(event, ctx, logger);\n        } catch (err) {\n          logger?.error?.(`[${kPluginId}] llm_output write error: ${err?.message || err}`);\n        }\n      });\n      api.on(\"tool_result_persist\", (event, ctx) => {\n        try {\n          writeToolEvent(\n            {\n              ...event,\n              toolName: String(event?.toolName || ctx?.toolName || \"\"),\n              durationMs: event?.durationMs,\n            },\n            ctx,\n          );\n        } catch (err) {\n          logger?.error?.(`[${kPluginId}] tool_result_persist write error: ${err?.message || err}`);\n        }\n        return {};\n      });\n    },\n  };\n};\n\nconst plugin = createPlugin();\nmodule.exports = plugin;\nmodule.exports.default = plugin;\n"
  },
  {
    "path": "lib/plugin/usage-tracker/openclaw.plugin.json",
    "content": "{\n  \"id\": \"usage-tracker\",\n  \"configSchema\": {\n    \"type\": \"object\",\n    \"additionalProperties\": false,\n    \"properties\": {}\n  }\n}\n"
  },
  {
    "path": "lib/public/css/agents.css",
    "content": "/* ── Agents detail layout ────────────────────── */\n\n.app-content-pane.agents-pane {\n  overflow: hidden;\n  padding-left: 0;\n  padding-right: 0;\n  padding-bottom: 0;\n}\n\n/* ── Detail panel ────────────────────────────── */\n\n.agents-detail-panel {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.agents-detail-header-area {\n  padding: 8px 32px 16px;\n}\n\n.agents-detail-header-area-inner {\n  max-width: 42rem;\n  width: 100%;\n  margin: 0 auto;\n}\n\n.agents-detail-header {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 12px;\n}\n\n.agents-detail-body {\n  flex: 1;\n  min-height: 0;\n  overflow-y: auto;\n  overflow-x: hidden;\n  padding: 0 32px;\n}\n\n.agents-detail-content {\n  max-width: 42rem;\n  width: 100%;\n  margin: 0 auto;\n  padding: 0 0 24px;\n}\n\n@media (max-width: 768px) {\n  .agents-detail-header-area {\n    padding: 16px 14px 0;\n  }\n\n  .agents-detail-body {\n    padding: 0 14px;\n  }\n}\n\n.agents-detail-header-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--text);\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* ── Sub-tabs ────────────────────────────────── */\n\n.agents-sub-tabs {\n  display: flex;\n  align-items: center;\n  gap: 2px;\n  padding-top: 12px;\n  border-bottom: 1px solid var(--border);\n}\n\n.agents-sub-tab {\n  padding: 8px 14px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--text-muted);\n  background: transparent;\n  border: none;\n  border-bottom: 2px solid transparent;\n  cursor: pointer;\n  transition: color 0.12s, border-color 0.12s;\n  font-family: inherit;\n  margin-bottom: -1px;\n}\n\n.agents-sub-tab:hover {\n  color: var(--text);\n}\n\n.agents-sub-tab.active {\n  color: var(--accent);\n  border-bottom-color: var(--accent);\n}\n\n/* ── Empty state ─────────────────────────────── */\n\n.agents-empty-state {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  color: var(--text-dim);\n  gap: 12px;\n  padding: 32px;\n  text-align: center;\n}\n"
  },
  {
    "path": "lib/public/css/chat.css",
    "content": "/* ── Chat route ──────────────────────────────── */\n\n.app-content-pane.chat-pane {\n  padding: 0;\n}\n\n.chat-route-shell {\n  display: flex;\n  flex-direction: column;\n  min-height: 100%;\n  height: 100%;\n}\n\n.chat-route-header {\n  padding: 16px 24px 10px;\n  border-bottom: 1px solid var(--border);\n}\n\n.chat-route-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--text);\n}\n\n.chat-route-subtitle {\n  margin-top: 4px;\n  font-size: 12px;\n  color: var(--text-muted);\n}\n\n.chat-route-warning {\n  margin-top: 8px;\n  font-size: 12px;\n  color: #fca5a5;\n}\n\n.chat-thread {\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow-y: auto;\n  padding: 16px 24px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.chat-empty-state {\n  color: var(--text-dim);\n  font-size: 12px;\n  text-align: center;\n  margin-top: 24px;\n}\n\n.chat-bubble {\n  max-width: 86%;\n  border-radius: 10px;\n  border: 1px solid var(--panel-border-contrast);\n  background: rgba(255, 255, 255, 0.02);\n  padding: 12px 14px;\n}\n\n.chat-bubble.is-user {\n  align-self: flex-end;\n  background: rgba(99, 235, 255, 0.06);\n  border-color: rgba(99, 235, 255, 0.24);\n}\n\n.chat-bubble.is-assistant {\n  align-self: flex-start;\n}\n\n.chat-bubble-meta {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 10px;\n  margin-bottom: 8px;\n  font-size: 11px;\n  color: var(--text-muted);\n}\n\n.chat-bubble-content {\n  margin: 0;\n  font-family: inherit;\n  font-size: 12px;\n  color: var(--text);\n  line-height: 1.6;\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n\n.chat-bubble-markdown > :first-child {\n  margin-top: 0;\n}\n\n.chat-bubble-markdown > :last-child {\n  margin-bottom: 0;\n}\n\n.chat-bubble-markdown {\n  white-space: normal;\n}\n\n.chat-bubble-markdown p,\n.chat-bubble-markdown ul,\n.chat-bubble-markdown ol,\n.chat-bubble-markdown pre,\n.chat-bubble-markdown blockquote {\n  margin: 8px 0;\n}\n\n.chat-bubble-markdown ul,\n.chat-bubble-markdown ol {\n  padding-left: 18px;\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n.chat-bubble-markdown ul {\n  list-style: disc;\n}\n\n.chat-bubble-markdown ol {\n  list-style: decimal;\n}\n\n.chat-bubble-markdown li > p {\n  margin: 0;\n}\n\n.chat-bubble-markdown li + li {\n  margin-top: 4px;\n}\n\n.chat-bubble-markdown > * + * {\n  margin-top: 8px;\n}\n\n.chat-bubble-markdown h1,\n.chat-bubble-markdown h2,\n.chat-bubble-markdown h3,\n.chat-bubble-markdown h4 {\n  margin: 0;\n  line-height: 1.25;\n}\n\n.chat-bubble-markdown h1 {\n  font-size: 16px;\n}\n\n.chat-bubble-markdown h2 {\n  font-size: 14px;\n}\n\n.chat-bubble-markdown h3,\n.chat-bubble-markdown h4 {\n  font-size: 13px;\n}\n\n.chat-bubble-markdown h1 + *,\n.chat-bubble-markdown h2 + *,\n.chat-bubble-markdown h3 + *,\n.chat-bubble-markdown h4 + * {\n  margin-top: 10px;\n}\n\n.chat-bubble-markdown code {\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n    \"Courier New\", monospace;\n  font-size: 11px;\n  background: rgba(255, 255, 255, 0.06);\n  padding: 1px 4px;\n  border-radius: 4px;\n}\n\n.chat-bubble-markdown pre code {\n  display: block;\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  padding: 8px;\n}\n\n.chat-bubble-json {\n  white-space: pre-wrap;\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\",\n    \"Courier New\", monospace;\n  font-size: 11px;\n  line-height: 1.6;\n  background: rgba(255, 255, 255, 0.04);\n  border: 1px solid var(--panel-border-contrast);\n  border-radius: 8px;\n  padding: 10px;\n}\n\n.chat-message-json {\n  margin-top: 10px;\n  border-top: 1px dashed var(--border);\n  padding-top: 8px;\n}\n\n.chat-message-json summary {\n  cursor: pointer;\n  color: var(--text-muted);\n  font-size: 11px;\n}\n\n.chat-message-json pre {\n  margin: 8px 0 0;\n  font-size: 11px;\n  line-height: 1.45;\n  color: var(--text-muted);\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n\n.chat-tool-call-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  margin-top: 6px;\n}\n\n.chat-tool-call-row {\n  border: 1px dashed var(--border);\n  border-radius: 8px;\n  padding: 6px 8px;\n}\n\n.chat-tool-call-row summary {\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.chat-tool-call-name {\n  color: var(--text);\n  font-size: 12px;\n}\n\n.chat-tool-call-status {\n  color: var(--text-muted);\n  font-size: 10px;\n  text-transform: lowercase;\n}\n\n.chat-tool-inline {\n  font-weight: 600;\n}\n\n.chat-tool-inline-message {\n  margin: 0;\n}\n\n.chat-tool-inline-message summary {\n  cursor: pointer;\n  list-style: none;\n  color: var(--text);\n  display: flex;\n  align-items: center;\n  justify-content: flex-start;\n  gap: 10px;\n  font-size: 12px;\n  line-height: 1.6;\n}\n\n.chat-tool-inline-message summary::-webkit-details-marker {\n  display: none;\n}\n\n.chat-tool-inline-message summary::before {\n  content: \"▸\";\n  color: var(--text-muted);\n  font-size: 12px;\n  line-height: 1;\n  margin-right: 2px;\n}\n\n.chat-tool-inline-message[open] summary::before {\n  content: \"▾\";\n}\n\n.chat-tool-inline-icon {\n  font-size: 12px;\n  line-height: 1;\n}\n\n.chat-tool-inline-title {\n  font-weight: 600;\n}\n\n.chat-tool-inline-time {\n  margin-left: auto;\n  font-size: 11px;\n  color: var(--text-muted);\n}\n\n.chat-tool-inline-body {\n  margin-top: 8px;\n}\n\n.chat-tool-inline-label {\n  margin-top: 8px;\n  margin-bottom: 4px;\n  font-size: 11px;\n  color: var(--text-muted);\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n}\n\n.chat-tool-inline-body pre {\n  margin: 0;\n  font-size: 11px;\n  line-height: 1.45;\n  color: var(--text-muted);\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n\n.chat-typing-indicator {\n  min-width: 0;\n  align-self: flex-start;\n}\n\n.chat-typing-dots {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  padding: 2px 0 1px;\n}\n\n.chat-typing-dots span {\n  width: 6px;\n  height: 6px;\n  border-radius: 999px;\n  background: var(--text-muted);\n  opacity: 0.45;\n  animation: chatTypingPulse 1.2s infinite ease-in-out;\n}\n\n.chat-typing-dots span:nth-child(2) {\n  animation-delay: 0.15s;\n}\n\n.chat-typing-dots span:nth-child(3) {\n  animation-delay: 0.3s;\n}\n\n@keyframes chatTypingPulse {\n  0%,\n  80%,\n  100% {\n    transform: translateY(0);\n    opacity: 0.35;\n  }\n  40% {\n    transform: translateY(-2px);\n    opacity: 0.9;\n  }\n}\n\n.chat-composer {\n  border-top: 1px solid var(--border);\n  padding: 12px 24px 16px;\n  display: flex;\n  align-items: flex-end;\n  gap: 10px;\n}\n\n.chat-composer-input {\n  flex: 1;\n  min-height: calc(12px * 1.4 + 20px);\n  max-height: calc(12px * 1.4 * 5 + 20px);\n  border-radius: 10px;\n  resize: none;\n  overflow-x: hidden;\n  overflow-y: auto;\n  font-family: inherit;\n  font-size: 12px;\n  line-height: 1.4;\n  padding: 10px;\n  box-sizing: border-box;\n}\n\n.chat-composer-send {\n  white-space: nowrap;\n  padding: 8px 14px;\n  border-radius: 9px;\n  font-family: inherit;\n  font-size: 12px;\n}\n\n.chat-composer-actions {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.chat-composer-stop {\n  white-space: nowrap;\n  padding: 8px 12px;\n  border-radius: 9px;\n  font-family: inherit;\n  font-size: 12px;\n}\n\n.chat-raw-debug {\n  margin-top: 8px;\n  border-top: 1px dashed var(--border);\n  padding-top: 8px;\n}\n\n.chat-raw-debug summary {\n  cursor: pointer;\n  color: var(--text-muted);\n  font-size: 11px;\n}\n\n.chat-raw-debug pre {\n  margin: 8px 0 0;\n  font-size: 11px;\n  color: var(--text-muted);\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n"
  },
  {
    "path": "lib/public/css/cron.css",
    "content": ".app-content-pane.cron-pane {\n  padding: 24px 32px 12px;\n  overflow: hidden;\n}\n\n.cron-tab-shell {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.cron-tab-header {\n  padding: 0 0 16px;\n}\n\n.cron-tab-header-content {\n  width: 100%;\n  max-width: 672px;\n  margin-left: auto;\n  margin-right: auto;\n  box-sizing: border-box;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.cron-tab-main {\n  flex: 1;\n  min-height: 0;\n  overflow-y: auto;\n  overflow-x: hidden;\n}\n\n.cron-tab-main-content {\n  width: 100%;\n  max-width: 672px;\n  margin-left: auto;\n  margin-right: auto;\n  padding: 0 0 24px;\n  min-height: 100%;\n}\n\n.cron-tab-main-content .cron-detail-content {\n  padding-top: 8px;\n}\n\n@media (max-width: 768px) {\n  .app-content-pane.cron-pane {\n    padding: 0 14px 12px;\n  }\n\n  .cron-tab-header {\n    padding: 0 0 12px;\n  }\n}\n\n.cron-tab-selector-shell {\n  position: relative;\n  width: min(100%, 320px);\n}\n\n.cron-tab-selector-toggle {\n  width: 100%;\n  border: 1px solid var(--border);\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.015);\n  color: var(--text);\n  text-align: left;\n  font: inherit;\n  padding: 8px 10px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.cron-tab-selector-toggle:hover {\n  border-color: rgba(148, 163, 184, 0.45);\n}\n\n.cron-tab-selector-toggle.is-open {\n  border-color: rgba(99, 235, 255, 0.55);\n  background: rgba(99, 235, 255, 0.08);\n}\n\n.cron-tab-selector-title {\n  font-size: 14px;\n  font-weight: 700;\n  color: var(--text);\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.cron-tab-selector-caret {\n  color: var(--text-dim);\n  transition: transform 0.12s ease;\n}\n\n.cron-tab-selector-toggle.is-open .cron-tab-selector-caret {\n  transform: rotate(180deg);\n}\n\n.cron-tab-selector-dropdown {\n  position: absolute;\n  top: calc(100% + 8px);\n  left: 0;\n  width: min(100vw - 40px, 420px);\n  max-height: min(70vh, 620px);\n  border: 1px solid var(--border);\n  border-radius: 12px;\n  background: var(--bg-sidebar);\n  box-shadow: 0 14px 32px rgba(0, 0, 0, 0.45);\n  overflow: hidden;\n  z-index: 8;\n}\n\n.cron-tab-selector-dropdown .cron-list-panel-inner {\n  padding: 0 10px 10px;\n  max-height: min(70vh, 620px);\n  overflow-y: auto;\n}\n\n.cron-list-sticky-search {\n  position: sticky;\n  top: 0;\n  z-index: 2;\n  padding: 10px 0 8px;\n  background: var(--bg-sidebar);\n}\n\n.cron-list-search-input {\n  width: 100%;\n  height: 30px;\n  border-radius: 8px;\n  border: 1px solid var(--border);\n  background: rgba(255, 255, 255, 0.02);\n  color: var(--text);\n  font-size: 12px;\n  padding: 0 9px;\n  outline: none;\n  font-family: inherit;\n}\n\n.cron-list-search-input::placeholder {\n  color: var(--text-dim);\n}\n\n.cron-list-search-input:focus {\n  border-color: rgba(99, 235, 255, 0.45);\n  box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18);\n}\n\n.cron-list-separator {\n  border-top: 1px solid var(--border);\n  margin: 8px 2px;\n}\n\n.cron-list-items {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.cron-list-group {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.cron-list-group + .cron-list-group {\n  margin-top: 2px;\n}\n\n.cron-list-group-header {\n  position: sticky;\n  top: 48px;\n  z-index: 1;\n  font-size: 10px;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--text-dim);\n  background: var(--bg-sidebar);\n  padding: 4px 2px;\n}\n\n.cron-list-group-items {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.cron-list-item {\n  width: 100%;\n  border: 1px solid var(--border);\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.015);\n  color: var(--text-muted);\n  text-align: left;\n  font: inherit;\n  padding: 8px 10px;\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.cron-list-item:hover {\n  border-color: rgba(148, 163, 184, 0.45);\n  color: var(--text);\n}\n\n.cron-list-item.is-selected {\n  border-color: rgba(99, 235, 255, 0.55);\n  background: rgba(99, 235, 255, 0.08);\n  color: var(--text);\n}\n\n.cron-list-all {\n  margin-bottom: 8px;\n}\n\n.cron-list-item-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.cron-list-status-inline {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  flex: 0 0 auto;\n}\n\n.cron-list-last-run {\n  font-size: 11px;\n  color: var(--text-muted);\n  line-height: 1;\n}\n\n.cron-list-item-title {\n  font-size: 12px;\n  font-weight: 700;\n  color: var(--text);\n}\n\n.cron-list-item-subtitle {\n  font-size: 11px;\n  color: var(--text-muted);\n}\n\n.cron-list-health-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 999px;\n  flex: 0 0 auto;\n}\n\n.cron-detail-panel {\n  height: auto;\n  min-width: 0;\n  min-height: 100%;\n  overflow: visible;\n}\n\n.cron-detail-scroll {\n  height: auto;\n  overflow: visible;\n}\n\n.cron-detail-content {\n  padding: 16px 0 0;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  min-height: 100%;\n  width: 100%;\n  max-width: 672px;\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.cron-prompt-editor-shell {\n  height: 280px;\n  min-height: 180px;\n  border: 1px solid var(--border);\n  border-radius: 10px;\n  overflow: hidden;\n  resize: vertical;\n  background: rgba(255, 255, 255, 0.01);\n}\n\n.cron-prompt-editor-shell .file-viewer-editor-line-num-col {\n  width: 44px;\n  padding: 16px 10px 112px 0;\n}\n\n.cron-calendar-repeating-strip {\n  border: 1px solid var(--border);\n  border-radius: 10px;\n  padding: 8px;\n  background: rgba(255, 255, 255, 0.015);\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.cron-calendar-repeating-list {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.cron-calendar-repeating-pill {\n  min-width: 220px;\n  max-width: 100%;\n  border: 1px solid transparent;\n  border-radius: 8px;\n  padding: 6px 8px;\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.cron-calendar-legend {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  flex-wrap: wrap;\n}\n\n.cron-calendar-title {\n  color: var(--card-label-bright);\n}\n\n.cron-calendar-legend-label {\n  font-size: 11px;\n  color: var(--text-dim);\n  margin-right: 2px;\n}\n\n.cron-calendar-legend-pill {\n  font-size: 10px;\n  line-height: 1;\n  border-radius: 999px;\n  padding: 4px 7px;\n}\n\n.cron-calendar-grid-wrap {\n  border: 1px solid var(--border);\n  border-radius: 10px;\n  overflow: auto;\n  background: var(--bg-surface);\n}\n\n.cron-calendar-grid-header,\n.cron-calendar-grid-row {\n  display: grid;\n  grid-template-columns: 72px repeat(7, minmax(80px, 1fr));\n}\n\n.cron-calendar-day-header {\n  font-size: 11px;\n  color: var(--text-muted);\n  padding: 8px;\n  border-left: 1px solid var(--border);\n  border-bottom: 1px solid var(--border);\n  background: rgba(0, 0, 0, 0.12);\n  position: sticky;\n  top: 0;\n  z-index: 1;\n}\n\n.cron-calendar-day-header.is-today {\n  background: rgba(99, 235, 255, 0.08);\n}\n\n.cron-calendar-hour-cell {\n  font-size: 11px;\n  color: var(--text-dim);\n  padding: 8px;\n  border-bottom: 1px solid var(--border);\n  border-right: 1px solid var(--border);\n  background: rgba(0, 0, 0, 0.15);\n  position: sticky;\n  left: 0;\n  z-index: 2;\n}\n\n.cron-calendar-grid-header .cron-calendar-hour-cell {\n  top: 0;\n  z-index: 3;\n}\n\n.cron-calendar-grid-corner {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.cron-calendar-grid-wrap {\n  position: relative;\n}\n\n.cron-calendar-lightbox-panel {\n  width: min(96vw, 1200px);\n  max-height: 88vh;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  padding: 16px;\n  border: 1px solid var(--border);\n  border-radius: 12px;\n  background: var(--bg-sidebar);\n}\n\n.cron-calendar-lightbox-close {\n  width: 26px;\n  height: 26px;\n  border-radius: 8px;\n  border: 1px solid var(--border);\n  background: rgba(255, 255, 255, 0.03);\n  color: var(--text-dim);\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.cron-calendar-lightbox-close:hover {\n  color: var(--text);\n  border-color: rgba(148, 163, 184, 0.5);\n}\n\n.cron-calendar-lightbox-body {\n  min-height: 0;\n  overflow: auto;\n}\n\n.cron-calendar-grid-row .cron-calendar-hour-cell {\n  box-shadow: 1px 0 0 var(--border);\n}\n\n.cron-calendar-grid-cell {\n  min-height: 44px;\n  border-left: 1px solid var(--border);\n  border-bottom: 1px solid var(--border);\n  padding: 5px;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.cron-calendar-grid-cell.is-today {\n  background: rgba(99, 235, 255, 0.04);\n}\n\n.cron-calendar-now-indicator {\n  position: absolute;\n  left: 5px;\n  right: 5px;\n  height: 2px;\n  border-radius: 999px;\n  background: rgba(248, 113, 113, 0.95);\n  box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.18), 0 0 8px rgba(239, 68, 68, 0.55);\n  pointer-events: none;\n  z-index: 1;\n}\n\n.cron-calendar-now-indicator-dot {\n  position: absolute;\n  left: -3px;\n  top: 50%;\n  width: 6px;\n  height: 6px;\n  border-radius: 999px;\n  background: rgba(248, 113, 113, 0.98);\n  transform: translateY(-50%);\n}\n\n.cron-calendar-slot-chip {\n  font-size: 11px;\n  line-height: 1.2;\n  border: 1px solid transparent;\n  border-radius: 7px;\n  padding: 4px 6px;\n  display: inline-flex;\n  align-items: center;\n  min-height: 20px;\n  width: 100%;\n  max-width: 100%;\n  overflow: hidden;\n  position: relative;\n  z-index: 2;\n}\n\n.cron-calendar-slot-overflow {\n  font-size: 10px;\n  color: var(--text-dim);\n  padding-left: 2px;\n}\n\n.cron-calendar-slot-tier-low {\n  background: rgba(34, 211, 238, 0.14);\n  border-color: rgba(34, 211, 238, 0.38);\n  color: #c8f6ff;\n}\n\n.cron-calendar-slot-tier-unknown {\n  background: rgba(148, 163, 184, 0.08);\n  border-color: rgba(148, 163, 184, 0.24);\n  color: #c9d2df;\n}\n\n.cron-calendar-slot-tier-medium {\n  background: rgba(59, 130, 246, 0.14);\n  border-color: rgba(59, 130, 246, 0.38);\n  color: #d6e6ff;\n}\n\n.cron-calendar-slot-tier-high {\n  background: rgba(251, 191, 36, 0.14);\n  border-color: rgba(251, 191, 36, 0.38);\n  color: #ffecc1;\n}\n\n.cron-calendar-slot-tier-very-high {\n  background: rgba(239, 68, 68, 0.14);\n  border-color: rgba(239, 68, 68, 0.38);\n  color: #ffd6d6;\n}\n\n.cron-calendar-slot-tier-disabled {\n  background: rgba(148, 163, 184, 0.08);\n  border-color: rgba(148, 163, 184, 0.28);\n  color: #98a5b8;\n}\n\n.cron-calendar-slot-upcoming {\n  opacity: 1;\n}\n\n.cron-calendar-slot-past {\n  opacity: 0.75;\n}\n\n.cron-calendar-slot-ok {\n  box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.38);\n}\n\n.cron-calendar-slot-error {\n  box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.45);\n}\n\n.cron-calendar-slot-skipped {\n  box-shadow: inset 0 0 0 1px rgba(250, 204, 21, 0.45);\n}\n\n.cron-calendar-compact-list {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.cron-calendar-compact-row {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 10px;\n  border: 1px solid rgba(148, 163, 184, 0.32);\n  border-radius: 8px;\n  padding: 7px 9px;\n  text-align: left;\n  font-size: 11px;\n  line-height: 1.2;\n}\n\n.cron-calendar-compact-main {\n  min-width: 0;\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.cron-calendar-compact-time {\n  font-size: 10px;\n  font-family: var(--font-mono, monospace);\n  opacity: 0.72;\n  white-space: nowrap;\n}\n\n.cron-calendar-compact-name {\n  color: var(--text);\n}\n\n.cron-calendar-compact-estimate {\n  font-size: 10px;\n  color: var(--text);\n  font-weight: 500;\n  white-space: nowrap;\n  text-align: right;\n}\n\n.cron-runs-trend-bars {\n  display: grid;\n  grid-template-columns: repeat(var(--cron-runs-trend-columns, 7), minmax(0, 1fr));\n  gap: 4px;\n  align-items: end;\n}\n\n.cron-runs-trend-col {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 4px;\n  width: 100%;\n  cursor: pointer;\n  transition: opacity 140ms ease, transform 140ms ease;\n}\n\n.cron-runs-trend-col.is-dimmed {\n  opacity: 0.35;\n}\n\n.cron-runs-trend-col.is-selected .cron-runs-trend-track {\n  border-color: rgba(148, 163, 184, 0.55);\n  box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.2);\n}\n\n.cron-runs-trend-col:focus-visible {\n  outline: none;\n}\n\n.cron-runs-trend-col:focus-visible .cron-runs-trend-track {\n  border-color: rgba(148, 163, 184, 0.65);\n  box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.24);\n}\n\n.cron-runs-trend-track {\n  width: 100%;\n  height: 120px;\n  border: 1px solid var(--border);\n  border-radius: 6px;\n  background: rgba(0, 0, 0, 0.16);\n  overflow: hidden;\n  display: flex;\n  align-items: flex-end;\n}\n\n.cron-runs-trend-bar {\n  width: 100%;\n  min-height: 2px;\n  display: flex;\n  flex-direction: column;\n  justify-content: flex-end;\n}\n\n.cron-runs-trend-segment-ok {\n  background: rgba(34, 255, 170, 0.82);\n  box-shadow: inset 0 0 0 1px rgba(34, 255, 170, 0.28), 0 0 8px rgba(34, 255, 170, 0.24);\n}\n\n.cron-runs-trend-segment-error {\n  background: rgba(255, 74, 138, 0.84);\n  box-shadow: inset 0 0 0 1px rgba(255, 74, 138, 0.32), 0 0 8px rgba(255, 74, 138, 0.24);\n}\n\n.cron-runs-trend-segment-skipped {\n  background: rgba(255, 214, 64, 0.82);\n  box-shadow: inset 0 0 0 1px rgba(255, 214, 64, 0.28), 0 0 8px rgba(255, 214, 64, 0.22);\n}\n\n.cron-runs-trend-label {\n  font-size: 10px;\n  color: var(--text-dim);\n  min-height: 12px;\n}\n\n.cron-runs-trend-legend {\n  margin-top: 2px;\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n.cron-runs-trend-legend-item {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  font-size: 10px;\n  color: var(--text-dim);\n}\n\n.cron-runs-trend-legend-dot {\n  width: 7px;\n  height: 7px;\n  border-radius: 999px;\n}\n\n.cron-runs-trend-legend-dot.is-ok {\n  background: rgba(34, 255, 170, 0.98);\n  box-shadow: 0 0 8px rgba(34, 255, 170, 0.6);\n}\n\n.cron-runs-trend-legend-dot.is-error {\n  background: rgba(255, 74, 138, 0.98);\n  box-shadow: 0 0 8px rgba(255, 74, 138, 0.58);\n}\n\n.cron-runs-trend-legend-dot.is-skipped {\n  background: rgba(255, 214, 64, 0.98);\n  box-shadow: 0 0 8px rgba(255, 214, 64, 0.5);\n}\n"
  },
  {
    "path": "lib/public/css/explorer.css",
    "content": "/* ── Browse/Explorer mode ─────────────────────── */\n\n.sidebar-tabs {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 0px 12px 6px;\n  background: transparent;\n}\n\n.sidebar-tab {\n  width: 30px;\n  height: 30px;\n  padding: 0;\n  color: var(--text-muted);\n  border: 0;\n  border-radius: 6px;\n  background: transparent;\n  cursor: pointer;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  transition: color 0.12s, background 0.12s, box-shadow 0.12s, transform 0.12s;\n}\n\n.sidebar-tab:hover {\n  color: #a9eefb;\n}\n\n.sidebar-tab.active {\n  color: #b9f5ff;\n  background: rgba(99, 235, 255, 0.05);\n  /* box-shadow: 0 0 8px rgba(99, 235, 255, 0.16); */\n}\n\n.sidebar-tab-icon {\n  width: 17px;\n  height: 17px;\n  display: block;\n  opacity: 0.92;\n}\n\n.sidebar-tab:hover .sidebar-tab-icon,\n.sidebar-tab.active .sidebar-tab-icon {\n  opacity: 1;\n}\n\n.sidebar-browse-layout {\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  min-height: 0;\n}\n\n.sidebar-browse-panel {\n  display: flex;\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow: hidden;\n}\n\n.sidebar-browse-resizer {\n  height: 6px;\n  cursor: row-resize;\n  position: relative;\n}\n\n.sidebar-browse-resizer::before {\n  content: \"\";\n  position: absolute;\n  left: 0;\n  right: 0;\n  top: 2px;\n  height: 2px;\n  background: transparent;\n  transition: background 0.12s;\n}\n\n.sidebar-browse-resizer:hover::before,\n.sidebar-browse-resizer.is-resizing::before {\n  background: rgba(99, 235, 255, 0.55);\n}\n\n.sidebar-browse-bottom {\n  flex: 0 0 auto;\n  min-height: 0;\n  overflow: hidden;\n  padding-top: 0;\n}\n\n.sidebar-browse-bottom-inner {\n  min-height: 120px;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n/* ── Sidebar agents list ─────────────────────── */\n\n.sidebar-agents-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  padding: 12px 16px 4px;\n}\n\n.sidebar-agents-label {\n  padding: 0;\n}\n\n.sidebar-agents-add-button {\n  border: none;\n  background: transparent;\n  color: var(--text-muted);\n  font: inherit;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  line-height: 1;\n  padding: 6px;\n  margin: -6px;\n  cursor: pointer;\n  opacity: 0.9;\n  transition: color 0.1s, opacity 0.1s;\n}\n\n.sidebar-agents-add-icon {\n  width: 16px;\n  height: 16px;\n  display: block;\n}\n\n.sidebar-agents-add-button:hover {\n  color: var(--text);\n  opacity: 1;\n}\n\n.sidebar-agents-list {\n  flex: 0 0 auto;\n  overflow: visible;\n  padding: 2px 0 0;\n}\n\n.sidebar-agent-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 16px 6px 16px;\n  color: var(--text-muted);\n  font-size: 13px;\n  line-height: 1.4;\n  cursor: pointer;\n  transition: background 0.1s, color 0.1s;\n  position: relative;\n  user-select: none;\n  border: none;\n  background: transparent;\n  width: 100%;\n  text-align: left;\n  font: inherit;\n}\n\n.sidebar-agent-item:hover {\n  background: var(--bg-hover);\n  color: var(--text);\n}\n\n.sidebar-agent-item.active {\n  background: var(--bg-active);\n  color: var(--accent);\n}\n\n.sidebar-agent-item.active::before {\n  content: \"\";\n  position: absolute;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  width: 2px;\n  background: var(--accent);\n}\n\n.sidebar-agent-icon {\n  width: 14px;\n  height: 14px;\n  flex: 0 0 auto;\n}\n\n.sidebar-agent-emoji {\n  flex: 0 0 auto;\n  min-width: 14px;\n  max-width: 1.5em;\n  line-height: 1;\n  font-size: 14px;\n  text-align: center;\n  overflow: hidden;\n}\n\n.sidebar-agent-name {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.sidebar-agents-add {\n  margin: 0;\n  padding: 16px 14px 8px 20px;\n  border-top: 1px solid var(--border);\n  margin-top: 8px;\n}\n\n/* ── Sidebar chat sessions ───────────────────── */\n\n.sidebar-chat-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 12px 16px 4px;\n}\n\n.sidebar-chat-label {\n  padding: 0;\n}\n\n.sidebar-chat-sessions-list {\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow-y: auto;\n  padding: 2px 0 8px;\n}\n\n.sidebar-chat-agent-group {\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n}\n\n.sidebar-chat-agent-toggle {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  width: 100%;\n  margin: 0;\n  padding: 6px 12px 4px 10px;\n  border: none;\n  background: transparent;\n  color: var(--text-muted);\n  font-size: 11px;\n  font-weight: 600;\n  letter-spacing: 0.02em;\n  text-transform: uppercase;\n  text-align: left;\n  cursor: pointer;\n  font-family: inherit;\n  user-select: none;\n}\n\n.sidebar-chat-agent-toggle:hover {\n  color: var(--text);\n}\n\n.sidebar-chat-agent-chevron {\n  display: inline-flex;\n  flex-shrink: 0;\n  transition: transform 0.15s ease;\n  color: var(--text-dim);\n}\n\n.sidebar-chat-agent-chevron.is-collapsed {\n  transform: rotate(-90deg);\n}\n\n.sidebar-chat-agent-chevron-icon {\n  width: 12px;\n  height: 12px;\n  display: block;\n}\n\n.sidebar-chat-agent-label {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.sidebar-chat-agent-sessions {\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  padding: 0 0 4px;\n}\n\n.sidebar-chat-session-channel-icon {\n  width: 12px;\n  height: 12px;\n  flex-shrink: 0;\n  border-radius: 2px;\n  object-fit: contain;\n  opacity: 0.92;\n}\n\n.sidebar-chat-session-item {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 16px 6px 24px;\n  color: var(--text-muted);\n  font-size: 13px;\n  line-height: 1.4;\n  cursor: pointer;\n  transition: background 0.1s, color 0.1s;\n  position: relative;\n  user-select: none;\n  border: none;\n  background: transparent;\n  width: 100%;\n  text-align: left;\n  font-family: inherit;\n}\n\n.sidebar-chat-session-item:hover {\n  background: var(--bg-hover);\n  color: var(--text);\n}\n\n.sidebar-chat-session-item.active {\n  background: var(--bg-active);\n  color: var(--accent);\n}\n\n.sidebar-chat-session-item.active::before {\n  content: \"\";\n  position: absolute;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  width: 2px;\n  background: var(--accent);\n}\n\n.sidebar-chat-session-name {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.sidebar-chat-empty {\n  padding: 10px 16px 10px 24px;\n  color: var(--text-dim);\n  font-size: 12px;\n}\n\n.file-tree-wrap {\n  width: 100%;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n  flex: 1;\n  padding: 6px 0 0;\n}\n\n.file-tree-scroll {\n  flex: 1;\n  min-height: 0;\n  overflow-y: auto;\n  padding-bottom: 8px;\n}\n\n.file-tree-wrap-loading {\n  min-height: 100%;\n  display: flex;\n}\n\n.file-tree-search {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 0 8px 6px;\n}\n\n.file-tree-search-actions {\n  display: inline-flex;\n  align-items: center;\n  gap: 2px;\n  flex-shrink: 0;\n}\n\n.file-tree-search-input {\n  width: 100%;\n  height: 28px;\n  border-radius: 7px;\n  border: 1px solid var(--border);\n  background: rgba(255, 255, 255, 0.02);\n  color: var(--text);\n  font-size: 12px;\n  padding: 0 9px;\n  outline: none;\n  font-family: inherit;\n}\n\n.file-tree-search-input::placeholder {\n  color: var(--text-dim);\n}\n\n.file-tree-search-input:focus {\n  border-color: rgba(99, 235, 255, 0.45);\n  box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18);\n}\n\n.file-tree-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n\n.file-tree-scroll::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.file-tree-scroll::-webkit-scrollbar-thumb {\n  background: var(--border);\n  border-radius: 3px;\n}\n\n.file-tree {\n  list-style: none;\n}\n\n.tree-item {\n  position: relative;\n}\n\n.tree-item > a {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 2px 10px 2px 18px;\n  color: var(--text-muted);\n  text-decoration: none;\n  font-size: 13px;\n  font-weight: 400;\n  transition: background 0.1s, color 0.1s;\n  cursor: pointer;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  user-select: none;\n}\n\n.tree-item > a:hover {\n  background: var(--bg-hover);\n  color: var(--text);\n}\n\n.tree-item > a.active {\n  background: var(--bg-active);\n  color: var(--accent);\n}\n\n.tree-item > a.soft-active:not(.active) {\n  background: rgba(99, 235, 255, 0.06);\n  color: var(--text);\n}\n\n.tree-item > a.active::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  width: 2px;\n  background: var(--accent);\n}\n\n.tree-folder {\n  padding: 2px 10px 2px 12px;\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  color: var(--text);\n  font-weight: 400;\n  cursor: pointer;\n  user-select: none;\n  white-space: nowrap;\n  overflow: hidden;\n}\n\n.tree-folder:hover {\n  background: var(--bg-hover);\n}\n\n.tree-folder.active {\n  background: var(--bg-active);\n  color: var(--accent);\n}\n\n.tree-folder.active .arrow {\n  color: var(--accent);\n}\n\n.tree-folder-toggle {\n  border: 0;\n  margin: 0;\n  padding: 0;\n  background: transparent;\n  color: inherit;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  flex: 0 0 auto;\n}\n\n.arrow {\n  font-size: 10px;\n  transition: transform 0.15s;\n  color: var(--text-dim);\n  flex-shrink: 0;\n}\n\n.tree-folder.collapsed .arrow {\n  transform: rotate(-90deg);\n}\n\n.file-icon {\n  flex-shrink: 0;\n  width: 15px;\n  height: 15px;\n  display: block;\n  color: var(--text-dim);\n}\n\n.file-icon-md {\n  color: var(--accent);\n}\n\n.file-icon-js {\n  color: #f4d03f;\n}\n\n.file-icon-json {\n  color: #9b7bff;\n}\n\n.file-icon-css {\n  color: #7ec8ff;\n}\n\n.file-icon-html {\n  color: #ff9d57;\n}\n\n.file-icon-image {\n  color: #ff7ac6;\n}\n\n.file-icon-audio {\n  color: #f5a6ff;\n}\n\n.file-icon-shell {\n  color: #71f8a7;\n}\n\n.file-icon-db {\n  color: #67b3ff;\n}\n\n.file-icon-generic {\n  color: var(--text-muted);\n}\n\n.tree-label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.tree-draft-dot {\n  flex: 0 0 auto;\n  margin-left: auto;\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background: #2de2ff;\n  box-shadow: 0 0 6px rgba(45, 226, 255, 0.75);\n}\n\n.tree-lock-icon {\n  flex: 0 0 auto;\n  margin-left: auto;\n  width: 11px;\n  height: 11px;\n  color: var(--text-dim);\n}\n\n.tree-folder-action {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 22px;\n  height: 22px;\n  padding: 0;\n  border: 0;\n  border-radius: 4px;\n  background: transparent;\n  color: var(--text-muted);\n  cursor: pointer;\n  transition: color 0.1s, background 0.1s;\n}\n\n.tree-folder-action:hover {\n  color: var(--text);\n  background: rgba(255, 255, 255, 0.08);\n}\n\n.tree-folder-action-icon {\n  width: 16px;\n  height: 16px;\n  display: block;\n}\n\n.tree-create-row {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 2px 10px 2px 18px;\n}\n\n.tree-create-input {\n  flex: 1;\n  min-width: 0;\n  height: 22px;\n  border-radius: 4px;\n  border: 1px solid rgba(99, 235, 255, 0.45);\n  background: rgba(0, 0, 0, 0.3);\n  color: var(--text);\n  font-size: 12px;\n  font-family: inherit;\n  padding: 0 6px;\n  outline: none;\n}\n\n.tree-create-input::placeholder {\n  color: var(--text-dim);\n}\n\n.tree-create-input:focus {\n  box-shadow: 0 0 0 1px rgba(99, 235, 255, 0.18);\n}\n\n.tree-create-icon {\n  flex-shrink: 0;\n  width: 15px;\n  height: 15px;\n  display: block;\n  color: var(--text-dim);\n}\n\n.tree-context-menu {\n  position: fixed;\n  z-index: 100;\n  min-width: 160px;\n  padding: 4px 0;\n  background: var(--bg-sidebar);\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);\n}\n\n.tree-context-menu-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n  padding: 6px 12px;\n  border: 0;\n  background: transparent;\n  color: var(--text-muted);\n  font-family: inherit;\n  font-size: 12px;\n  cursor: pointer;\n  text-align: left;\n  transition: background 0.1s, color 0.1s;\n}\n\n.tree-context-menu-item:hover {\n  background: var(--bg-hover);\n  color: var(--text);\n}\n\n.tree-context-menu-item.is-disabled {\n  color: var(--text-dim);\n  cursor: default;\n  pointer-events: none;\n}\n\n.tree-context-menu-item.is-danger {\n  color: #f87171;\n}\n\n.tree-context-menu-item.is-danger:hover {\n  background: rgba(239, 68, 68, 0.1);\n  color: #fca5a5;\n}\n\n.tree-context-menu-icon {\n  width: 14px;\n  height: 14px;\n  flex-shrink: 0;\n}\n\n.tree-context-menu-sep {\n  height: 1px;\n  margin: 4px 8px;\n  background: var(--border);\n}\n\n.tree-item.is-dragging {\n  opacity: 0.4;\n}\n\n.tree-folder.is-drop-target {\n  outline: 1px dashed var(--accent);\n  outline-offset: -1px;\n  background: rgba(99, 235, 255, 0.06);\n}\n\n.tree-children {\n  list-style: none;\n}\n\n.tree-children.hidden {\n  display: none;\n}\n\n.file-tree-state {\n  padding: 10px 14px;\n  font-size: 12px;\n  color: var(--text-muted);\n}\n\n.file-tree-state-loading {\n  width: 100%;\n  flex: 1 1 auto;\n  min-height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.file-tree-state-error {\n  color: #f87171;\n}\n\n.file-viewer {\n  width: 100%;\n  min-height: 100%;\n  height: calc(100vh - 24px);\n  display: flex;\n  flex-direction: column;\n  background: transparent;\n}\n\n.file-viewer-tabbar {\n  position: sticky;\n  top: 0;\n  z-index: 10;\n  display: flex;\n  align-items: center;\n  background: var(--bg-sidebar);\n  border-bottom: 1px solid var(--border);\n  height: 40px;\n}\n\n.file-viewer-protected-banner {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-wrap: wrap;\n  gap: 10px;\n  min-height: 36px;\n  padding: 4px 0;\n  height: 42px;\n  background: rgba(234, 179, 8, 0.08);\n}\n\n.file-viewer-protected-banner.is-locked {\n  background: rgba(220, 38, 38, 0.16);\n}\n\n.file-viewer-protected-banner-icon {\n  width: 14px;\n  height: 14px;\n  color: #fca5a5;\n  flex-shrink: 0;\n}\n\n.file-viewer-protected-banner.is-locked .file-viewer-protected-banner-text {\n  color: #fecaca;\n}\n\n.file-viewer-protected-banner-text {\n  font-size: 12px;\n  color: #f7cc5e;\n  text-align: center;\n}\n\n.file-viewer-protected-banner-unlocked {\n  font-size: 11px;\n  color: #fde68a;\n  opacity: 0.95;\n  letter-spacing: 0.01em;\n}\n\n.file-viewer-diff-banner {\n  background: rgba(59, 130, 246, 0.12);\n}\n\n.file-viewer-diff-banner .file-viewer-protected-banner-text,\n.file-viewer-diff-banner {\n  color: #bfdbfe;\n}\n\n.file-viewer-tabbar-spacer {\n  flex: 1;\n}\n\n.file-viewer-preview-pill {\n  margin-right: 8px;\n  font-size: 11px;\n  color: var(--text-muted);\n  border: 1px solid var(--border);\n  border-radius: 999px;\n  padding: 3px 8px;\n  line-height: 1;\n}\n\n.file-viewer-tab {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  height: 40px;\n  padding: 0 16px;\n  font-size: 12px;\n  line-height: 1;\n  color: var(--text-muted);\n  border-right: 1px solid var(--border);\n  white-space: nowrap;\n}\n\n.file-viewer-dirty-dot {\n  width: 7px;\n  height: 7px;\n  border-radius: 999px;\n  background: var(--accent);\n  box-shadow: 0 0 10px rgba(99, 235, 255, 0.55);\n  margin-left: 2px;\n  flex-shrink: 0;\n}\n\n.file-viewer-tab.active {\n  color: var(--text);\n  background: var(--bg);\n  border-bottom: 1px solid var(--accent);\n  margin-bottom: -1px;\n}\n\n.file-viewer-breadcrumb {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.file-viewer-breadcrumb-item {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  color: var(--text-muted);\n}\n\n.file-viewer-breadcrumb-item .is-current {\n  color: var(--text);\n}\n\n.file-viewer-sep {\n  color: var(--text-dim);\n}\n\n.frontmatter-box {\n  margin-top: 16px;\n  margin-bottom: 4px;\n  margin-right: 20px;\n  margin-left: 36px;\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  overflow: hidden;\n  background: rgba(99, 235, 255, 0.025);\n}\n\n.frontmatter-title {\n  width: 100%;\n  border: 0;\n  text-align: left;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  cursor: pointer;\n  font-size: 11px;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--text-muted);\n  background: rgba(13, 17, 23, 0.55);\n  border-bottom: 1px solid rgba(255, 255, 255, 0.04);\n  padding: 4px 10px;\n}\n\n.frontmatter-title:hover {\n  background: rgba(13, 17, 23, 0.75);\n}\n\n.frontmatter-chevron {\n  width: 12px;\n  height: 12px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--accent);\n  transition: transform 0.15s ease;\n}\n\n.frontmatter-chevron.open {\n  transform: rotate(90deg);\n}\n\n.frontmatter-chevron svg {\n  width: 12px;\n  height: 12px;\n  display: block;\n}\n\n.frontmatter-chevron path {\n  fill: none;\n  stroke: currentColor;\n  stroke-width: 2;\n  stroke-linecap: round;\n  stroke-linejoin: round;\n}\n\n.frontmatter-grid {\n  display: grid;\n}\n\n.frontmatter-row {\n  display: grid;\n  grid-template-columns: 160px 1fr;\n  border-top: 1px solid rgba(255, 255, 255, 0.05);\n}\n\n.frontmatter-row:first-child {\n  border-top: 0;\n}\n\n.frontmatter-key {\n  padding: 6px 10px;\n  color: var(--keyword);\n  border-right: 1px solid rgba(255, 255, 255, 0.05);\n  word-break: break-word;\n  opacity: 0.85;\n}\n\n.frontmatter-value {\n  padding: 6px 10px;\n  color: var(--string);\n  white-space: pre-wrap;\n  word-break: break-word;\n  opacity: 0.88;\n}\n\n.frontmatter-value-pre {\n  margin: 0;\n  font-family: inherit;\n  font-size: 12px;\n}\n\n.file-viewer-save-action {\n  margin-right: 10px;\n}\n\n.file-viewer-save-icon {\n  width: 13px;\n  height: 13px;\n  flex-shrink: 0;\n}\n\n.file-viewer-icon-action {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  margin-right: 10px;\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  background: rgba(255, 255, 255, 0.02);\n  color: var(--text-muted);\n  transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;\n}\n\n.file-viewer-icon-action:hover {\n  border-color: rgba(148, 163, 184, 0.45);\n  color: var(--text);\n  background: rgba(148, 163, 184, 0.08);\n}\n\n.file-viewer-icon-action.is-disabled {\n  opacity: 0.45;\n  cursor: not-allowed;\n}\n\n.file-viewer-icon-action-icon {\n  width: 14px;\n  height: 14px;\n  flex-shrink: 0;\n}\n\n.file-viewer-view-toggle {\n  display: flex;\n  align-items: center;\n  margin-right: 10px;\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  overflow: hidden;\n  background: rgba(255, 255, 255, 0.02);\n  height: 28px;\n}\n\n.file-viewer-view-toggle-button {\n  border: 0;\n  background: transparent;\n  color: var(--text-muted);\n  font-family: inherit;\n  font-size: 12px;\n  text-transform: lowercase;\n  letter-spacing: 0.03em;\n  height: 100%;\n  line-height: 1;\n  padding: 0 10px;\n  cursor: pointer;\n}\n\n.file-viewer-view-toggle-button:hover {\n  color: var(--text);\n  background: rgba(255, 255, 255, 0.03);\n}\n\n.file-viewer-view-toggle-button.active {\n  color: var(--accent);\n  background: var(--bg-active);\n}\n\n.file-viewer-editor-shell {\n  width: 100%;\n  min-height: 0;\n  height: 100%;\n  flex: 1;\n  display: flex;\n  align-items: stretch;\n}\n\n.file-viewer-diff-shell {\n  width: 100%;\n  min-height: 0;\n  height: 100%;\n  overflow: auto;\n  padding: 8px 0;\n}\n\n.file-viewer-diff-pre {\n  margin: 0;\n  padding: 0 12px 12px;\n  font-family: inherit;\n  font-size: 12px;\n  line-height: 1.45;\n  color: var(--text-muted);\n}\n\n.file-viewer-diff-line {\n  white-space: pre-wrap;\n  word-break: break-word;\n  padding: 1px 8px;\n  border-radius: 4px;\n}\n\n.file-viewer-diff-line.is-added {\n  color: #86efac;\n  background: rgba(34, 197, 94, 0.1);\n}\n\n.file-viewer-diff-line.is-removed {\n  color: #fca5a5;\n  background: rgba(239, 68, 68, 0.1);\n}\n\n.file-viewer-diff-line.is-hunk {\n  color: #93c5fd;\n}\n\n.file-viewer-diff-line.is-header {\n  color: var(--text-dim);\n}\n\n.file-viewer-editor-line-num-col {\n  width: 56px;\n  flex-shrink: 0;\n  overflow: hidden;\n  padding: 16px 16px 112px 0;\n  text-align: right;\n}\n\n.file-viewer-editor-line-num {\n  min-height: 22px;\n  line-height: 22px;\n  color: var(--text-dim);\n  font-size: 12px;\n  user-select: none;\n  display: flex;\n  justify-content: flex-end;\n  align-items: flex-start;\n}\n\n.line-highlight-flash {\n  color: #4ade80 !important;\n}\n\n.file-viewer-editor {\n  width: 100%;\n  min-height: 0;\n  height: 100%;\n  flex: 1;\n  border: 0;\n  outline: none;\n  resize: none;\n  overflow-y: auto;\n  background: transparent;\n  color: var(--text);\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  line-height: 22px;\n  padding: 16px 20px 112px 0;\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n\n.file-viewer-editor-stack {\n  position: relative;\n  flex: 1;\n  min-height: 0;\n}\n\n.file-viewer-editor-highlight {\n  position: absolute;\n  inset: 0;\n  overflow: hidden;\n  pointer-events: none;\n  background: transparent;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  line-height: 22px;\n  color: var(--text);\n  padding: 16px 20px 112px 0;\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n\n.file-viewer-editor-highlight-line {\n  min-height: 22px;\n}\n\n.file-viewer-editor-highlight-line-content {\n  white-space: pre-wrap;\n  overflow-wrap: anywhere;\n  word-break: break-word;\n}\n\n.file-viewer-editor-overlay {\n  position: absolute;\n  inset: 0;\n  color: transparent;\n  caret-color: var(--text);\n  -webkit-text-fill-color: transparent;\n}\n\n.file-viewer-editor-overlay::selection {\n  background: rgba(99, 235, 255, 0.25);\n}\n\n.file-viewer-preview {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 20px 112px 36px;\n  line-height: 1.75;\n  background: transparent;\n}\n\n.file-viewer-pane-hidden {\n  display: none;\n}\n\n.file-viewer-preview h1,\n.file-viewer-preview h2,\n.file-viewer-preview h3,\n.file-viewer-preview h4,\n.file-viewer-preview h5,\n.file-viewer-preview h6 {\n  color: var(--accent);\n  margin: 18px 0 10px;\n  font-weight: 600;\n}\n\n.file-viewer-preview h1 {\n  font-size: 2em;\n}\n\n.file-viewer-preview h2 {\n  font-size: 1.5em;\n}\n\n.file-viewer-preview h3 {\n  font-size: 1.25em;\n}\n\n.file-viewer-preview h4 {\n  font-size: 1.1em;\n}\n\n.file-viewer-preview h5 {\n  font-size: 1em;\n}\n\n.file-viewer-preview h6 {\n  font-size: 0.9em;\n}\n\n.file-viewer-preview p,\n.file-viewer-preview ul,\n.file-viewer-preview ol,\n.file-viewer-preview blockquote,\n.file-viewer-preview pre,\n.file-viewer-preview table {\n  margin: 10px 0;\n}\n\n.file-viewer-preview ul,\n.file-viewer-preview ol {\n  padding-left: 20px;\n}\n\n.file-viewer-preview ul {\n  list-style: disc;\n}\n\n.file-viewer-preview ol {\n  list-style: decimal;\n}\n\n.file-viewer-preview a {\n  color: var(--accent);\n}\n\n.file-viewer-preview code {\n  color: var(--string);\n  background: rgba(255, 255, 255, 0.05);\n  border: 1px solid var(--border);\n  border-radius: 4px;\n  padding: 1px 6px;\n}\n\n.file-viewer-preview pre {\n  background: rgba(255, 255, 255, 0.04);\n  border: 1px solid var(--border);\n  border-radius: 6px;\n  padding: 12px;\n  overflow-x: auto;\n}\n\n.file-viewer-preview pre code {\n  background: transparent;\n  border: 0;\n  padding: 0;\n}\n\n.file-viewer-preview blockquote {\n  border-left: 2px solid var(--accent-dim);\n  padding-left: 12px;\n  color: var(--comment);\n}\n\n.file-viewer-preview table {\n  width: 100%;\n  border-collapse: collapse;\n}\n\n.file-viewer-preview th,\n.file-viewer-preview td {\n  border: 1px solid var(--border);\n  padding: 6px 8px;\n  text-align: left;\n}\n\n.file-viewer-preview th {\n  color: var(--text);\n  background: rgba(255, 255, 255, 0.04);\n}\n\n.release-notes-preview {\n  padding: 8px 12px 24px 14px;\n}\n\n.release-notes-preview > :first-child {\n  margin-top: 0;\n}\n\n.release-notes-preview > :last-child {\n  margin-bottom: 0;\n}\n\n.file-viewer-editor-highlight-line-content .hl-comment {\n  color: var(--comment);\n  font-style: italic;\n}\n\n.file-viewer-editor-highlight-line-content .hl-heading {\n  color: var(--accent);\n  font-weight: 700;\n}\n\n.file-viewer-editor-highlight-line-content .hl-string {\n  color: var(--string);\n}\n\n.file-viewer-editor-highlight-line-content .hl-bullet {\n  color: var(--orange);\n}\n\n.file-viewer-editor-highlight-line-content .hl-bold {\n  color: var(--text);\n  font-weight: 700;\n}\n\n.file-viewer-editor-highlight-line-content .hl-link {\n  color: var(--accent);\n  text-decoration: underline;\n  text-decoration-style: dotted;\n}\n\n.file-viewer-editor-highlight-line-content .hl-meta {\n  color: var(--text-dim);\n}\n\n.file-viewer-editor-highlight-line-content .hl-key {\n  color: var(--keyword);\n}\n\n.file-viewer-editor-highlight-line-content .hl-keyword {\n  color: var(--keyword);\n}\n\n.file-viewer-editor-highlight-line-content .hl-tag {\n  color: var(--accent);\n}\n\n.file-viewer-editor-highlight-line-content .hl-attr {\n  color: var(--keyword);\n}\n\n.file-viewer-editor-highlight-line-content .hl-entity {\n  color: var(--number);\n}\n\n.file-viewer-editor-highlight-line-content .hl-number {\n  color: var(--number);\n}\n\n.file-viewer-editor-highlight-line-content .hl-boolean,\n.file-viewer-editor-highlight-line-content .hl-null {\n  color: var(--orange);\n}\n\n.file-viewer-editor-highlight-line-content .hl-punc {\n  color: #5f6674;\n}\n\n.file-viewer-state {\n  padding: 20px;\n  color: var(--text-muted);\n  font-size: 12px;\n}\n\n.file-viewer-loading-shell {\n  flex: 1 1 auto;\n  min-height: 140px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--text-muted);\n}\n\n.file-viewer-image-shell {\n  flex: 1 1 auto;\n  min-height: 0;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 18px;\n  overflow: auto;\n}\n\n.file-viewer-image {\n  max-width: 100%;\n  max-height: 100%;\n  width: auto;\n  height: auto;\n  border-radius: 8px;\n  border: 1px solid var(--border);\n  background: rgba(255, 255, 255, 0.02);\n  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);\n}\n\n.file-viewer-audio-shell {\n  flex: 1 1 auto;\n  min-height: 0;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 18px;\n  overflow: auto;\n}\n\n.file-viewer-audio-player {\n  width: min(640px, 100%);\n}\n\n.file-viewer-sqlite-shell {\n  flex: 1 1 auto;\n  min-height: 0;\n  height: 100%;\n  overflow: auto;\n  padding: 14px 16px 22px;\n}\n\n.file-viewer-sqlite-header {\n  font-size: 12px;\n  color: var(--text-dim);\n  margin-bottom: 10px;\n}\n\n.file-viewer-sqlite-footer {\n  margin-top: 10px;\n  text-align: center;\n  font-size: 12px;\n  color: var(--text-dim);\n}\n\n.file-viewer-sqlite-list {\n  flex: 0 0 240px;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.file-viewer-sqlite-layout {\n  display: flex;\n  gap: 12px;\n  align-items: stretch;\n  min-height: 0;\n}\n\n.file-viewer-sqlite-card {\n  width: 100%;\n  text-align: left;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  padding: 7px 10px;\n  background: rgba(255, 255, 255, 0.02);\n  font: inherit;\n}\n\n.file-viewer-sqlite-card:hover {\n  background: rgba(255, 255, 255, 0.04);\n}\n\n.file-viewer-sqlite-card.is-active {\n  border-color: rgba(99, 235, 255, 0.45);\n  background: rgba(99, 235, 255, 0.08);\n}\n\n.file-viewer-sqlite-title {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 10px;\n  font-size: 12px;\n  color: var(--text);\n  margin-bottom: 0;\n}\n\n.file-viewer-sqlite-type {\n  font-size: 10px;\n  letter-spacing: 0.06em;\n  text-transform: uppercase;\n  color: var(--text-dim);\n}\n\n.file-viewer-sqlite-table-shell {\n  flex: 1;\n  min-width: 0;\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  padding: 10px;\n  background: rgba(255, 255, 255, 0.015);\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.file-viewer-sqlite-table-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 10px;\n}\n\n.file-viewer-sqlite-table-name {\n  font-size: 12px;\n  color: var(--text);\n  font-weight: 600;\n}\n\n.file-viewer-sqlite-table-nav {\n  display: inline-flex;\n  gap: 6px;\n}\n\n.file-viewer-sqlite-table-meta {\n  margin-top: 4px;\n  margin-bottom: 8px;\n  font-size: 11px;\n  color: var(--text-dim);\n}\n\n.file-viewer-sqlite-table-wrap {\n  min-height: 0;\n  overflow: auto;\n}\n\n.file-viewer-sqlite-table {\n  width: 100%;\n  border-collapse: collapse;\n  table-layout: fixed;\n}\n\n.file-viewer-sqlite-table th,\n.file-viewer-sqlite-table td {\n  border: 1px solid var(--border);\n  padding: 6px 8px;\n  font-size: 11px;\n  color: var(--text-muted);\n  text-align: left;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.file-viewer-sqlite-table th {\n  color: var(--text);\n  background: rgba(255, 255, 255, 0.04);\n}\n\n.file-viewer-sqlite-table-empty {\n  color: var(--text-dim);\n}\n\n.file-viewer-state-error {\n  color: #f87171;\n}\n\n.file-viewer-empty {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  height: calc(100vh - 120px);\n  color: var(--text-dim);\n  text-align: center;\n  padding: 48px;\n  gap: 10px;\n}\n\n.file-viewer-empty-mark {\n  font-size: 26px;\n  font-weight: 500;\n  letter-spacing: 0.08em;\n  color: var(--accent);\n  text-shadow:\n    0 0 12px rgba(99, 235, 255, 0.38),\n    0 0 22px rgba(99, 235, 255, 0.2);\n}\n\n.file-viewer-empty-title {\n  font-size: 13px;\n  font-weight: 400;\n  color: var(--text);\n  letter-spacing: 0.01em;\n}\n\n.sidebar-git-panel {\n  padding: 0;\n  margin: 0;\n  font-size: 11px;\n  display: flex;\n  flex-direction: column;\n  gap: 0;\n  flex: 1 1 auto;\n  min-height: 0;\n}\n\n.sidebar-git-loading {\n  min-height: 58px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.sidebar-git-panel-error {\n  color: #f87171;\n}\n\n.sidebar-git-bar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  flex-shrink: 0;\n  min-height: 28px;\n  padding: 0 12px;\n  border-top: 1px solid var(--border);\n  background: rgba(255, 255, 255, 0.018);\n}\n\n.sidebar-git-bar:first-child {\n  border-bottom: 1px double var(--border);\n}\n\n.sidebar-git-bar-secondary {\n  margin-top: 0;\n  border-top: 0;\n  border-bottom: 0;\n  background: rgba(255, 255, 255, 0.012);\n}\n\n.sidebar-git-bar-main {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  min-width: 0;\n}\n\n.sidebar-git-link {\n  color: inherit;\n  text-decoration: none;\n}\n\n.sidebar-git-link:hover .sidebar-git-repo-name {\n  color: var(--accent);\n}\n\n.sidebar-git-bar-icon {\n  width: 14px;\n  height: 14px;\n  color: var(--text-muted);\n  flex-shrink: 0;\n}\n\n.sidebar-git-repo-name {\n  color: var(--text);\n  font-size: 11px;\n  letter-spacing: 0.03em;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.sidebar-git-branch {\n  color: var(--text-muted);\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.sidebar-git-sync-status {\n  font-size: 12px;\n  font-weight: 600;\n  line-height: 1;\n}\n\n.sidebar-git-sync-status.is-up-to-date {\n  color: #71f8a7;\n}\n\n.sidebar-git-sync-status.is-ahead,\n.sidebar-git-sync-status.is-diverged {\n  color: #f3a86a;\n}\n\n.sidebar-git-sync-status.is-behind {\n  color: #93c5fd;\n}\n\n.sidebar-git-sync-status.is-no-upstream,\n.sidebar-git-sync-status.is-upstream-gone {\n  color: var(--text-dim);\n}\n\n.sidebar-git-meta {\n  color: var(--text-muted);\n}\n\n.sidebar-git-changes-label {\n  padding: 7px 10px 4px;\n  font-size: 10px;\n  letter-spacing: 0.06em;\n  text-transform: uppercase;\n  color: var(--text-dim);\n}\n\n.sidebar-git-changes-list {\n  list-style: none;\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  padding: 0 6px;\n}\n\n.sidebar-git-change-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  min-height: 22px;\n  padding: 2px 6px;\n  border-radius: 6px;\n  line-height: 1.25;\n}\n\n.sidebar-git-change-row.is-clickable {\n  cursor: pointer;\n}\n\n.sidebar-git-change-row.is-clickable:hover {\n  background: rgba(255, 255, 255, 0.04);\n}\n\n.sidebar-git-change-path {\n  min-width: 0;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  color: var(--text-muted);\n  font-weight: 400;\n}\n\n.sidebar-git-change-meta {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  flex-shrink: 0;\n  font-size: 10px;\n}\n\n.sidebar-git-change-plus {\n  color: #71f8a7;\n}\n\n.sidebar-git-change-minus {\n  color: #f3a86a;\n}\n\n.sidebar-git-change-status {\n  font-size: 10px;\n  letter-spacing: 0.06em;\n}\n\n.sidebar-git-change-row.is-untracked .sidebar-git-change-status {\n  color: #71f8a7;\n}\n\n.sidebar-git-change-row.is-modified .sidebar-git-change-status {\n  color: #63ebff;\n}\n\n.sidebar-git-change-row.is-deleted .sidebar-git-change-status {\n  color: #f87171;\n}\n\n.sidebar-git-change-row.is-deleted .sidebar-git-change-path {\n  text-decoration: line-through;\n  text-decoration-thickness: 1px;\n}\n\n.sidebar-git-scroll {\n  overflow-y: auto;\n  min-height: 0;\n  flex: 1 1 auto;\n}\n\n.sidebar-git-actions {\n  padding: 8px 10px 6px;\n}\n\n.sidebar-git-sync-button {\n  width: 100%;\n}\n\n.sidebar-git-list {\n  list-style: none;\n  display: flex;\n  flex-direction: column;\n  gap: 3px;\n  padding: 6px 10px 0;\n}\n\n.sidebar-git-list li {\n  display: flex;\n  align-items: baseline;\n  flex: 0 0 auto;\n  gap: 6px;\n  color: var(--text-muted);\n  line-height: 1.4;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.sidebar-git-commit-link {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  flex: 0 0 auto;\n  color: inherit;\n  text-decoration: none;\n  min-width: 0;\n  line-height: 1.4;\n}\n\n.sidebar-git-commit-link:hover {\n  color: var(--text);\n}\n\n.sidebar-git-commit-link:hover .sidebar-git-hash {\n  color: var(--accent);\n}\n\n.sidebar-git-hash {\n  color: var(--accent);\n  flex-shrink: 0;\n}\n\n@media (max-width: 768px) {\n  .sidebar-browse-resizer {\n    display: none;\n  }\n\n}\n\n/* ── Light theme overrides ─────────────────────── */\n\n[data-theme=\"light\"] .sidebar-tab {\n  color: var(--text-muted);\n}\n\n[data-theme=\"light\"] .sidebar-tab:hover {\n  color: var(--text-bright);\n}\n\n[data-theme=\"light\"] .sidebar-tab.active {\n  color: #0e7490;\n  background: rgba(8, 145, 178, 0.1);\n}\n\n[data-theme=\"light\"] .file-viewer-protected-banner {\n  background: rgba(234, 179, 8, 0.12);\n}\n\n[data-theme=\"light\"] .file-viewer-protected-banner-text {\n  color: #92400e;\n}\n\n[data-theme=\"light\"] .file-viewer-protected-banner-unlocked {\n  color: #a16207;\n}\n\n[data-theme=\"light\"] .file-viewer-protected-banner.is-locked {\n  background: rgba(220, 38, 38, 0.1);\n}\n\n[data-theme=\"light\"] .file-viewer-protected-banner.is-locked .file-viewer-protected-banner-text {\n  color: #b91c1c;\n}\n\n[data-theme=\"light\"] .file-viewer-protected-banner.is-locked .file-viewer-protected-banner-icon {\n  color: #dc2626;\n}\n\n[data-theme=\"light\"] .file-viewer-diff-banner {\n  background: rgba(59, 130, 246, 0.08);\n}\n\n[data-theme=\"light\"] .file-viewer-diff-banner .file-viewer-protected-banner-text,\n[data-theme=\"light\"] .file-viewer-diff-banner {\n  color: #1d4ed8;\n}\n"
  },
  {
    "path": "lib/public/css/shell.css",
    "content": "/* ── App shell grid ─────────────────────────────── */\n\n.app-shell {\n  --sidebar-width: 220px;\n  display: grid;\n  grid-template-columns: var(--sidebar-width) 0px minmax(0, 1fr);\n  grid-template-rows: auto 1fr 24px;\n  height: 100vh;\n  position: relative;\n  z-index: 1;\n}\n\n.global-restart-banner {\n  position: fixed;\n  left: 50%;\n  bottom: 52px;\n  transform: translateX(-50%);\n  width: auto;\n  max-width: calc(100vw - 32px);\n  z-index: 40;\n  pointer-events: none;\n}\n\n.global-restart-banner__content {\n  border: 1px solid rgba(234, 179, 8, 0.35);\n  background: rgba(43, 32, 6, 0.95);\n  box-shadow: 0 18px 46px rgba(0, 0, 0, 0.42);\n  border-radius: 14px;\n  padding: 10px 14px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  pointer-events: auto;\n}\n\n.global-restart-banner__text {\n  font-size: 12px;\n  color: #fde68a;\n  line-height: 1.4;\n}\n\n.global-restart-banner__button {\n  flex-shrink: 0;\n}\n\n.global-restart-banner__actions {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  flex-shrink: 0;\n}\n\n.global-restart-banner__dismiss {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  padding: 2px;\n  color: #fde68a;\n  opacity: 0.85;\n}\n\n.global-restart-banner__dismiss:hover {\n  opacity: 1;\n}\n\n.app-content {\n  grid-column: 3;\n  grid-row: 2;\n  overflow: hidden;\n  position: relative;\n  z-index: 1;\n}\n\n.app-content-pane {\n  position: absolute;\n  inset: 0;\n  overflow-y: auto;\n  padding: 24px 32px;\n}\n\n.app-content-pane.browse-pane {\n  padding: 0;\n}\n\n/* ── Fixed-header pane shell ────────────────────── */\n\n.app-content-pane.ac-fixed-header-pane {\n  overflow: hidden;\n  padding-left: 0;\n  padding-right: 0;\n  padding-bottom: 0;\n}\n\n.ac-pane-shell {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.ac-pane-header {\n  padding: 16px 32px 16px;\n}\n\n.ac-pane-header-content {\n  width: 100%;\n  max-width: 672px;\n  margin-left: auto;\n  margin-right: auto;\n}\n\n.ac-pane-body {\n  flex: 1;\n  min-height: 0;\n  overflow-y: auto;\n  overflow-x: hidden;\n  padding: 0 32px;\n}\n\n.ac-pane-body-content {\n  width: 100%;\n  max-width: 672px;\n  margin-left: auto;\n  margin-right: auto;\n  padding-bottom: 24px;\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n@media (max-width: 768px) {\n  .ac-pane-header {\n    padding: 16px 14px 12px;\n  }\n\n  .ac-pane-body {\n    padding: 0 14px;\n  }\n}\n\n/* ── Sidebar ───────────────────────────────────── */\n\n.app-sidebar {\n  grid-column: 1;\n  grid-row: 2;\n  background:\n    linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.006) 100%),\n    linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.16) 100%),\n    var(--bg-sidebar);\n  border-right: 1px solid var(--border);\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n}\n\n.sidebar-resizer {\n  grid-column: 2;\n  grid-row: 2;\n  cursor: col-resize;\n  position: relative;\n  width: 6px;\n  margin-left: -3px;\n  z-index: 4;\n}\n\n.sidebar-resizer::before {\n  content: \"\";\n  position: absolute;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  width: 6px;\n  background: transparent;\n}\n\n.sidebar-resizer::after {\n  content: \"\";\n  position: absolute;\n  left: 2px;\n  top: 0;\n  bottom: 0;\n  width: 2px;\n  background: transparent;\n  transition: background 0.12s;\n}\n\n.sidebar-resizer:hover::after,\n.sidebar-resizer.is-resizing::after {\n  background: rgba(99, 235, 255, 0.55);\n}\n\n.sidebar-brand {\n  padding: 16px;\n  font-size: 14px;\n  letter-spacing: 0.03em;\n  color: var(--text-muted);\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.sidebar-label {\n  padding: 12px 16px 6px;\n  font-size: 11px;\n  font-weight: 700;\n  color: var(--text-dim);\n  text-transform: uppercase;\n  letter-spacing: 0.1em;\n  user-select: none;\n}\n\n.sidebar-nav { list-style: none; }\n\n.sidebar-nav a {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 16px 6px 16px;\n  color: var(--text-muted);\n  text-decoration: none;\n  font-size: 13px;\n  cursor: pointer;\n  transition: background 0.1s, color 0.1s;\n  position: relative;\n  user-select: none;\n}\n\n.sidebar-nav a:hover { background: var(--bg-hover); color: var(--text); }\n.sidebar-nav a.active { background: var(--bg-active); color: var(--accent); }\n\n.sidebar-nav a.active::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  width: 2px;\n  background: var(--accent);\n}\n\n.sidebar-nav-icon {\n  width: 14px;\n  height: 14px;\n  flex: 0 0 auto;\n}\n\n/* ── Sidebar footer (update banner) ────────────── */\n\n.sidebar-footer {\n  margin-top: auto;\n  font-size: 11px;\n  color: var(--text-dim);\n}\n\n.sidebar-footer:empty { display: none; }\n\n.sidebar-footer:not(:empty) {\n  padding: 12px 16px;\n  border-top: 1px solid var(--border);\n}\n\n.sidebar-update-btn {\n  width: 100%;\n  font-size: 11px;\n  padding: 5px 10px;\n  border-radius: 6px;\n  border: 1px solid rgba(227, 179, 65, 0.2);\n  color: #e3b341;\n  background: rgba(227, 179, 65, 0.08);\n  cursor: pointer;\n  font-family: inherit;\n  white-space: nowrap;\n  text-align: center;\n  transition: background 0.15s, border-color 0.15s;\n}\n\n.sidebar-update-btn:hover { background: rgba(227, 179, 65, 0.14); border-color: rgba(227, 179, 65, 0.35); }\n.sidebar-update-btn:disabled { opacity: 0.5; cursor: not-allowed; }\n\n/* ── Brand dropdown menu ───────────────────────── */\n\n.brand-menu {\n  position: relative;\n  margin-left: auto;\n}\n\n.brand-menu-trigger {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 24px;\n  height: 24px;\n  border: none;\n  border-radius: 6px;\n  background: transparent;\n  color: var(--text-dim);\n  cursor: pointer;\n  transition: background 0.1s, color 0.1s;\n}\n.brand-menu-trigger:hover { background: var(--bg-hover); color: var(--text-muted); }\n\n.brand-dropdown {\n  position: absolute;\n  top: calc(100% + 4px);\n  right: 0;\n  min-width: max-content;\n  width: max-content;\n  max-width: min(280px, calc(100vw - 24px));\n  background: var(--bg-sidebar);\n  border: 1px solid var(--border-strong);\n  border-radius: 8px;\n  padding: 4px;\n  z-index: 50;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n}\n\n.brand-dropdown a,\n.brand-dropdown-item {\n  display: block;\n  width: 100%;\n  padding: 6px 10px;\n  font: inherit;\n  font-size: 12px;\n  color: var(--text-muted);\n  text-decoration: none;\n  border-radius: 5px;\n  transition: background 0.1s, color 0.1s;\n  background: transparent;\n  border: none;\n  text-align: left;\n  white-space: nowrap;\n  cursor: pointer;\n}\n.brand-dropdown a:hover,\n.brand-dropdown-item:hover { background: var(--bg-hover); color: var(--text); }\n\n/* ── Statusbar ─────────────────────────────────── */\n\n.app-statusbar {\n  grid-row: 3;\n  grid-column: 1 / -1;\n  background: var(--bg-sidebar);\n  border-top: 1px solid var(--border-strong);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 12px;\n  font-size: 11px;\n  color: var(--text-dim);\n}\n\n.app-statusbar a { color: var(--text-muted); text-decoration: none; }\n.app-statusbar a:hover { color: var(--accent); }\n\n.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 16px; margin-left: 2px; }\n\n.mobile-topbar {\n  display: none;\n}\n\n.mobile-topbar-menu {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  border: 1px solid var(--border-strong);\n  border-radius: 8px;\n  background: var(--bg-hover);\n  color: var(--text);\n  cursor: pointer;\n}\n.mobile-topbar-menu:hover { background: var(--bg-active); }\n\n.mobile-topbar-title {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  text-align: center;\n  font-size: 14px;\n  letter-spacing: 0.03em;\n  color: var(--text-muted);\n}\n\n.mobile-sidebar-overlay {\n  display: none;\n}\n\n/* ── Responsive ────────────────────────────────── */\n\n@media (max-width: 768px) {\n  .app-shell {\n    --sidebar-width: 0px !important;\n    grid-template-columns: 1fr;\n    grid-template-rows: auto 1fr 24px;\n  }\n  .global-restart-banner {\n    max-width: calc(100vw - 20px);\n    bottom: 44px;\n  }\n  .global-restart-banner__content {\n    align-items: stretch;\n    flex-direction: column;\n    gap: 8px;\n  }\n  .global-restart-banner__text {\n    text-align: left;\n  }\n  .global-restart-banner__button {\n    position: static;\n    transform: none;\n  }\n  .global-restart-banner__actions {\n    width: 100%;\n    justify-content: flex-end;\n  }\n  .app-content {\n    grid-column: 1;\n    grid-row: 2;\n  }\n  .app-content-pane {\n    padding: 0 14px 12px;\n    top: 52px;\n  }\n\n  .sidebar-resizer {\n    display: none;\n  }\n\n  .mobile-topbar {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    position: absolute;\n    left: 0;\n    right: 0;\n    top: 0;\n    z-index: 15;\n    background: var(--panel-bg-contrast);\n    border: 0;\n    border-bottom: 1px solid var(--panel-border-contrast);\n    border-radius: 0;\n    min-height: 52px;\n    padding: 8px 14px;\n    margin: 0;\n  }\n\n  .mobile-topbar.is-scrolled {\n    background: var(--bg-content);\n  }\n\n  .mobile-topbar-menu {\n    position: absolute;\n    left: 14px;\n    top: 50%;\n    transform: translateY(-50%);\n  }\n\n  .app-sidebar {\n    display: flex;\n    position: fixed;\n    top: 0;\n    left: 0;\n    bottom: 24px;\n    width: min(260px, 82vw);\n    z-index: 30;\n    transform: translateX(-100%);\n    transition: transform 0.18s ease;\n    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);\n  }\n\n  .app-sidebar.mobile-open {\n    transform: translateX(0);\n  }\n\n  .mobile-sidebar-overlay {\n    display: block;\n    position: fixed;\n    inset: 0 0 24px 0;\n    background: rgba(0, 0, 0, 0.45);\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.18s ease;\n    z-index: 20;\n  }\n\n  .mobile-sidebar-overlay.active {\n    opacity: 1;\n    pointer-events: auto;\n  }\n}\n\n/* ── Theme toggle dropdown ────────────────────── */\n\n.theme-toggle-menu {\n  position: relative;\n  display: inline-flex;\n}\n\n.theme-toggle-trigger {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 24px;\n  height: 24px;\n  border-radius: 6px;\n  border: none;\n  background: transparent;\n  color: var(--text-dim);\n  cursor: pointer;\n  transition: color 0.15s, background 0.15s;\n}\n\n.theme-toggle-trigger:hover {\n  color: var(--text-muted);\n  background: var(--bg-hover);\n}\n\n.theme-toggle-dropdown {\n  position: absolute;\n  top: calc(100% + 4px);\n  right: 0;\n  min-width: 120px;\n  padding: 4px;\n  background: var(--bg-content);\n  border: 1px solid var(--border);\n  border-radius: 8px;\n  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);\n  z-index: 50;\n  display: flex;\n  flex-direction: column;\n}\n\n[data-theme=\"light\"] .theme-toggle-dropdown {\n  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);\n}\n\n.theme-toggle-option {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n  padding: 6px 10px;\n  border: none;\n  border-radius: 5px;\n  background: transparent;\n  color: var(--text-muted);\n  font-size: 12px;\n  font-family: inherit;\n  cursor: pointer;\n  transition: color 0.15s, background 0.15s;\n}\n\n.theme-toggle-option:hover {\n  background: var(--bg-hover);\n  color: var(--text);\n}\n\n.theme-toggle-option.active {\n  color: var(--accent);\n}\n\n/* ── Light theme overrides ─────────────────────── */\n\n[data-theme=\"light\"] .app-sidebar {\n  background:\n    linear-gradient(180deg, rgba(0, 0, 0, 0.02) 0%, rgba(0, 0, 0, 0.04) 100%),\n    var(--bg-sidebar);\n  border-right-color: rgba(0, 0, 0, 0.1);\n}\n\n[data-theme=\"light\"] .sidebar-brand {\n  color: var(--text);\n}\n\n[data-theme=\"light\"] .sidebar-label {\n  color: var(--text-muted);\n}\n\n[data-theme=\"light\"] .sidebar-nav a {\n  color: var(--text);\n}\n\n[data-theme=\"light\"] .sidebar-nav a:hover {\n  background: rgba(0, 0, 0, 0.06);\n  color: var(--text-bright);\n}\n\n[data-theme=\"light\"] .sidebar-nav a.active {\n  background: rgba(8, 145, 178, 0.1);\n  color: #0e7490;\n}\n\n[data-theme=\"light\"] .sidebar-nav a.active::before {\n  background: #0e7490;\n}\n\n[data-theme=\"light\"] .brand-dropdown {\n  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);\n}\n\n[data-theme=\"light\"] .global-restart-banner__content {\n  background: rgba(254, 243, 199, 0.97);\n  border: 1px solid rgba(202, 138, 4, 0.5);\n  box-shadow: 0 18px 46px rgba(0, 0, 0, 0.12);\n}\n\n[data-theme=\"light\"] .global-restart-banner__text {\n  color: #78350f;\n}\n\n[data-theme=\"light\"] .global-restart-banner__dismiss {\n  color: #78350f;\n}\n\n[data-theme=\"light\"] .global-restart-banner__dismiss:hover {\n  color: #451a03;\n}\n\n[data-theme=\"light\"] .sidebar-update-btn {\n  border-color: rgba(202, 138, 4, 0.3);\n  color: #a16207;\n  background: rgba(202, 138, 4, 0.06);\n}\n\n[data-theme=\"light\"] .sidebar-update-btn:hover {\n  background: rgba(202, 138, 4, 0.1);\n  border-color: rgba(202, 138, 4, 0.4);\n}\n\n[data-theme=\"light\"] .sidebar-resizer:hover::after,\n[data-theme=\"light\"] .sidebar-resizer.is-resizing::after {\n  background: rgba(8, 145, 178, 0.55);\n}\n\n@media (max-width: 768px) {\n  [data-theme=\"light\"] .app-sidebar {\n    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15);\n  }\n}\n"
  },
  {
    "path": "lib/public/css/tailwind.input.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "lib/public/css/theme.css",
    "content": ":root {\n  --bg: #0d121b;\n  --bg-sidebar: #0f141f;\n  --bg-content: #0f1521;\n  --bg-hover: rgba(99, 235, 255, 0.05);\n  --bg-active: rgba(99, 235, 255, 0.08);\n  --border: rgba(255, 255, 255, 0.06);\n  --border-strong: rgba(255, 255, 255, 0.11);\n  --text: #c9d1d9;\n  --text-muted: #6e7681;\n  --text-dim: #424854;\n  --card-label-bright: #dbe7f6;\n  --accent: #63ebff;\n  --accent-dim: rgba(99, 235, 255, 0.4);\n  --accent-link: rgba(99, 235, 255, 0.6);\n  --orange: #d98a58;\n  --comment: #6a737d;\n  --keyword: #ff7b72;\n  --string: #a5d6ff;\n  --number: #79c0ff;\n  --panel-bg-contrast: rgba(255, 255, 255, 0.028);\n  --panel-border-contrast: rgba(255, 255, 255, 0.11);\n  --field-bg-contrast: rgba(0, 0, 0, 0.3);\n  --field-border-contrast: rgba(255, 255, 255, 0.13);\n\n  /* ── Semantic theme tokens ────────────────────── */\n  --text-bright: #f3f4f6;\n  --overlay: rgba(0, 0, 0, 0.7);\n\n  /* Status: error */\n  --status-error: #fca5a5;\n  --status-error-muted: #f87171;\n  --status-error-bg: rgba(127, 29, 29, 0.95);\n  --status-error-border: rgba(185, 28, 28, 0.8);\n\n  /* Status: warning */\n  --status-warning: #fde047;\n  --status-warning-muted: #facc15;\n  --status-warning-bg: rgba(66, 32, 6, 0.95);\n  --status-warning-border: rgba(161, 98, 7, 0.8);\n\n  /* Status: success */\n  --status-success: #86efac;\n  --status-success-muted: #22c55e;\n  --status-success-bg: rgba(5, 46, 22, 0.5);\n  --status-success-border: rgba(21, 128, 61, 0.8);\n\n  /* Status: info */\n  --status-info: #a5f3fc;\n  --status-info-muted: #06b6d4;\n  --status-info-bg: rgba(8, 51, 68, 0.95);\n  --status-info-border: rgba(14, 116, 144, 0.8);\n}\n\n/* ── Light theme ─────────────────────────────────── */\n[data-theme=\"light\"] {\n  --bg: #f8f9fb;\n  --bg-sidebar: #f0f2f5;\n  --bg-content: #ffffff;\n  --bg-hover: rgba(0, 0, 0, 0.04);\n  --bg-active: rgba(8, 145, 178, 0.08);\n  --border: rgba(0, 0, 0, 0.08);\n  --border-strong: rgba(0, 0, 0, 0.15);\n  --text: #1f2937;\n  --text-muted: #6b7280;\n  --text-dim: #9ca3af;\n  --text-bright: #111827;\n  --card-label-bright: #1f2937;\n  --accent: #0891b2;\n  --accent-dim: rgba(8, 145, 178, 0.3);\n  --accent-link: #0e7490;\n  --orange: #c2410c;\n  --comment: #9ca3af;\n  --keyword: #dc2626;\n  --string: #2563eb;\n  --number: #0284c7;\n  --panel-bg-contrast: rgba(0, 0, 0, 0.02);\n  --panel-border-contrast: rgba(0, 0, 0, 0.1);\n  --field-bg-contrast: rgba(0, 0, 0, 0.04);\n  --field-border-contrast: rgba(0, 0, 0, 0.15);\n  --overlay: rgba(0, 0, 0, 0.5);\n\n  --status-error: #dc2626;\n  --status-error-muted: #ef4444;\n  --status-error-bg: rgba(254, 226, 226, 0.95);\n  --status-error-border: rgba(252, 165, 165, 0.8);\n  --status-warning: #a16207;\n  --status-warning-muted: #854d0e;\n  --status-warning-bg: rgba(254, 249, 195, 0.95);\n  --status-warning-border: rgba(202, 138, 4, 0.5);\n  --status-success: #16a34a;\n  --status-success-muted: #22c55e;\n  --status-success-bg: rgba(220, 252, 231, 0.95);\n  --status-success-border: rgba(134, 239, 172, 0.8);\n  --status-info: #0891b2;\n  --status-info-muted: #06b6d4;\n  --status-info-bg: rgba(207, 250, 254, 0.95);\n  --status-info-border: rgba(103, 232, 249, 0.8);\n}\n\nhtml, body { height: 100%; }\n\nbody {\n  background: var(--bg);\n  color: var(--text);\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  line-height: 1.6;\n}\n\n.ac-logo-mark {\n  display: inline-block;\n  flex: 0 0 auto;\n  width: var(--ac-logo-width, 20px);\n  height: var(--ac-logo-height, 20px);\n  background: #00efff;\n  -webkit-mask: url(\"../img/logo.svg\") center / contain no-repeat;\n  mask: url(\"../img/logo.svg\") center / contain no-repeat;\n}\n\n[data-theme=\"light\"] .ac-logo-mark {\n  background: var(--accent);\n}\n\n/* Subtle grid texture overlay */\nbody::before {\n  content: '';\n  position: fixed;\n  inset: 0;\n  background-image:\n    linear-gradient(rgba(255, 255, 255, 0.024) 1px, transparent 1px),\n    linear-gradient(90deg, rgba(255, 255, 255, 0.024) 1px, transparent 1px);\n  background-size: 48px 48px;\n  pointer-events: none;\n  z-index: 0;\n}\n\n/* Standardised card / section label. */\n.card-label {\n  font-weight: 600;\n  letter-spacing: 0.04em;\n  color: var(--text-muted);\n}\n\n.card-label-bright {\n  color: var(--card-label-bright);\n}\n\n.ac-small-heading {\n  font-size: 11px;\n  font-weight: 500;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--text-muted);\n}\n\n/* Shared collapsible history rows (incidents, webhook requests). */\n.ac-history-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.ac-history-list.ac-history-list-tight {\n  gap: 0;\n}\n\n.ac-history-item {\n  border: 1px solid var(--panel-border-contrast);\n  border-radius: 10px;\n  background: rgba(0, 0, 0, 0.12);\n}\n\n.ac-history-item.ac-history-item-flat {\n  border: 0;\n  border-bottom: 1px solid var(--panel-border-contrast);\n  border-radius: 0;\n  background: transparent;\n}\n\n.snippet-collapse-fade {\n  background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.75) 70%);\n}\n\n/* Shared inset panel for \"surface on surface\" layouts. */\n.ac-surface-inset {\n  border: 1px solid var(--panel-border-contrast);\n  border-radius: 10px;\n  background: rgba(0, 0, 0, 0.12);\n}\n\n.ac-history-summary {\n  cursor: pointer;\n  list-style: none;\n  padding: 8px 10px;\n  color: #d1d5db;\n}\n\n.ac-history-summary::-webkit-details-marker {\n  display: none;\n}\n\n.ac-history-summary-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.ac-history-toggle {\n  color: var(--text-muted);\n  transition: transform 0.15s ease, color 0.15s ease;\n}\n\n.ac-history-item[open] > .ac-history-summary .ac-history-toggle {\n  transform: rotate(90deg);\n  color: #d1d5db;\n}\n\n.ac-history-body {\n  margin: 4px 10px 10px;\n  padding-top: 8px;\n  border-top: 1px solid var(--panel-border-contrast);\n}\n\n/* Unified panel treatment across tabs/pages. */\n.bg-surface {\n  background: var(--panel-bg-contrast) !important;\n  border-color: var(--panel-border-contrast) !important;\n}\n\n/* Solid background for modals so page content doesn't bleed through. */\n.bg-modal {\n  background: var(--bg) !important;\n  border-color: var(--panel-border-contrast) !important;\n}\n\n.border-border {\n  border-color: var(--panel-border-contrast) !important;\n}\n\n.ac-tip-link {\n  color: var(--accent-link);\n  text-decoration: underline;\n  text-underline-offset: 2px;\n}\n\n.ac-tip-link:hover {\n  color: var(--accent);\n}\n\n/* Universal field contrast treatment (all tabs/pages). */\ninput:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]),\nselect,\ntextarea {\n  background: var(--field-bg-contrast);\n  border-color: var(--field-border-contrast);\n}\n\ninput:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]):focus,\nselect:focus,\ntextarea:focus {\n  border-color: rgba(255, 255, 255, 0.28);\n}\n\n::placeholder { color: var(--text-dim) !important; opacity: 1 !important; }\n::-webkit-input-placeholder { color: var(--text-dim) !important; }\n::-moz-placeholder { color: var(--text-dim) !important; }\n\n::-webkit-scrollbar { width: 6px; }\n::-webkit-scrollbar-track { background: transparent; }\n::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 3px; }\n\n.scrollbar-hidden {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n}\n\n.scrollbar-hidden::-webkit-scrollbar {\n  display: none;\n}\n\n/* Google scope picker toggle buttons */\n.scope-btn { background: rgba(255,255,255,0.03); color: var(--text-muted); border: 1px solid var(--border); transition: all 0.15s; }\n.scope-btn:hover { border-color: var(--text-dim); color: var(--text); }\n.scope-btn-read.active {\n  background: rgba(255, 255, 255, 0.03);\n  color: #f3f4f6;\n  border-color: rgba(255, 255, 255, 0.35);\n}\n.scope-btn-write.active {\n  background: rgba(255, 255, 255, 0.03);\n  color: #f3f4f6;\n  border-color: rgba(255, 255, 255, 0.35);\n}\n\n/* Reusable cyan action buttons */\n.ac-btn-cyan {\n  border: 1px solid var(--accent-dim);\n  background: linear-gradient(\n    180deg,\n    rgba(99, 235, 255, 0.14) 0%,\n    rgba(99, 235, 255, 0.08) 100%\n  );\n  color: var(--accent);\n  box-shadow: inset 0 0 0 1px rgba(99, 235, 255, 0.12);\n  transition:\n    border-color 0.15s ease,\n    background 0.15s ease,\n    color 0.15s ease,\n    box-shadow 0.15s ease,\n    transform 0.15s ease;\n}\n\n.ac-btn-cyan:hover:not(:disabled) {\n  border-color: rgba(99, 235, 255, 0.7);\n  background: linear-gradient(\n    180deg,\n    rgba(99, 235, 255, 0.24) 0%,\n    rgba(99, 235, 255, 0.12) 100%\n  );\n  color: #b9f8ff;\n  box-shadow:\n    inset 0 0 0 1px rgba(99, 235, 255, 0.2),\n    0 0 12px rgba(99, 235, 255, 0.14);\n}\n\n.ac-btn-cyan:active:not(:disabled) {\n  transform: translateY(1px);\n}\n\n.ac-btn-cyan:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.ac-btn-cyan-ghost {\n  border: 1px solid var(--accent-dim);\n  color: rgba(99, 235, 255, 0.75);\n  background: rgba(99, 235, 255, 0.04);\n  transition:\n    border-color 0.15s ease,\n    color 0.15s ease,\n    background 0.15s ease;\n}\n\n.ac-btn-cyan-ghost:hover {\n  border-color: rgba(99, 235, 255, 0.5);\n  color: #a4f3ff;\n  background: rgba(99, 235, 255, 0.08);\n}\n\n.ac-path-card {\n  border: 1px solid var(--panel-border-contrast);\n  background: rgba(0, 0, 0, 0.2);\n  transition:\n    border-color 0.15s ease,\n    background 0.15s ease,\n    box-shadow 0.15s ease,\n    transform 0.15s ease;\n}\n\n.ac-path-card:hover {\n  border-color: rgba(99, 235, 255, 0.5);\n  background: rgba(99, 235, 255, 0.04);\n  box-shadow:\n    inset 0 0 0 1px rgba(99, 235, 255, 0.1),\n    0 0 12px rgba(99, 235, 255, 0.08);\n}\n\n.ac-path-card:hover .ac-path-icon {\n  color: var(--accent);\n  border-color: rgba(99, 235, 255, 0.3);\n  background: rgba(99, 235, 255, 0.1);\n}\n\n.ac-path-card:hover .ac-path-title {\n  color: #b9f8ff;\n}\n\n.ac-path-card:hover .ac-path-desc {\n  color: #94a3b8;\n}\n\n.ac-path-icon {\n  transition:\n    color 0.15s ease,\n    border-color 0.15s ease,\n    background 0.15s ease;\n}\n\n.ac-btn-secondary {\n  border: 1px solid var(--panel-border-contrast);\n  color: #d1d5db;\n  background: rgba(255, 255, 255, 0.03);\n  transition:\n    border-color 0.15s ease,\n    color 0.15s ease,\n    background 0.15s ease,\n    transform 0.15s ease;\n}\n\n.ac-btn-secondary:hover:not(:disabled) {\n  border-color: rgba(255, 255, 255, 0.35);\n  color: #f3f4f6;\n  background: rgba(255, 255, 255, 0.06);\n}\n\n.ac-btn-secondary:active:not(:disabled) {\n  transform: translateY(1px);\n}\n\n.ac-btn-secondary:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.ac-btn-ghost {\n  border: none;\n  color: var(--text-muted);\n  background: transparent;\n  transition: color 0.15s ease;\n}\n\n.ac-btn-ghost:hover:not(:disabled) {\n  color: #f3f4f6;\n}\n\n.ac-btn-ghost:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.ac-btn-danger {\n  border: 1px solid rgba(239, 68, 68, 0.45);\n  background: rgba(239, 68, 68, 0.08);\n  color: #fca5a5;\n  transition:\n    border-color 0.15s ease,\n    color 0.15s ease,\n    background 0.15s ease,\n    transform 0.15s ease;\n}\n\n.ac-btn-danger:hover:not(:disabled) {\n  border-color: rgba(239, 68, 68, 0.7);\n  background: rgba(239, 68, 68, 0.14);\n  color: #fecaca;\n}\n\n.ac-btn-danger:active:not(:disabled) {\n  transform: translateY(1px);\n}\n\n.ac-btn-danger:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.ac-toggle {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  cursor: pointer;\n  user-select: none;\n}\n\n.ac-toggle-input {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  opacity: 0;\n  pointer-events: none;\n}\n\n.ac-toggle-track {\n  position: relative;\n  width: 34px;\n  height: 20px;\n  border-radius: 999px;\n  border: 1px solid var(--panel-border-contrast);\n  background: rgba(0, 0, 0, 0.35);\n  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);\n  transition:\n    border-color 0.18s ease,\n    background-color 0.18s ease,\n    box-shadow 0.18s ease;\n}\n\n.ac-toggle-thumb {\n  position: absolute;\n  top: 2px;\n  left: 2px;\n  width: 14px;\n  height: 14px;\n  border-radius: 999px;\n  background: #94a3b8;\n  box-shadow: 0 0 0 1px rgba(15, 21, 33, 0.3);\n  transition:\n    transform 0.18s ease,\n    background-color 0.18s ease,\n    box-shadow 0.18s ease;\n}\n\n.ac-toggle-label {\n  font-size: 12px;\n  color: #d1d5db;\n}\n\n.ac-toggle-input:checked + .ac-toggle-track {\n  border-color: rgba(99, 235, 255, 0.8);\n  background: rgba(99, 235, 255, 0.16);\n  box-shadow:\n    inset 0 0 0 1px rgba(99, 235, 255, 0.2),\n    0 0 10px rgba(99, 235, 255, 0.2);\n}\n\n.ac-toggle-input:checked + .ac-toggle-track .ac-toggle-thumb {\n  transform: translateX(14px);\n  background: #b6f6ff;\n  box-shadow:\n    0 0 0 1px rgba(15, 21, 33, 0.25),\n    0 0 10px rgba(99, 235, 255, 0.45);\n}\n\n.ac-toggle-input:focus-visible + .ac-toggle-track {\n  outline: 2px solid rgba(99, 235, 255, 0.55);\n  outline-offset: 2px;\n}\n\n.ac-toggle-input:disabled + .ac-toggle-track {\n  opacity: 0.55;\n  box-shadow: none;\n}\n\n.ac-toggle-input:disabled + .ac-toggle-track + .ac-toggle-label {\n  opacity: 0.6;\n}\n\n@keyframes acStatusDotPulse {\n  0%,\n  100% {\n    transform: scale(1);\n    box-shadow:\n      0 0 0 0 rgba(34, 197, 94, 0.14),\n      0 0 5px rgba(34, 197, 94, 0.18);\n  }\n  50% {\n    transform: scale(1.08);\n    box-shadow:\n      0 0 0 3px rgba(34, 197, 94, 0.08),\n      0 0 9px rgba(34, 197, 94, 0.32);\n  }\n}\n\n@keyframes acStatusDotPulseInfo {\n  0%,\n  100% {\n    transform: scale(1);\n    box-shadow:\n      0 0 0 0 rgba(34, 211, 238, 0.16),\n      0 0 5px rgba(34, 211, 238, 0.22);\n  }\n  50% {\n    transform: scale(1.08);\n    box-shadow:\n      0 0 0 3px rgba(34, 211, 238, 0.1),\n      0 0 9px rgba(34, 211, 238, 0.34);\n  }\n}\n\n@keyframes acStepPillPulse {\n  0%,\n  100% {\n    box-shadow:\n      0 0 0 0 rgba(99, 235, 255, 0.14),\n      0 0 5px rgba(99, 235, 255, 0.18);\n  }\n  50% {\n    box-shadow:\n      0 0 0 3px rgba(99, 235, 255, 0.08),\n      0 0 9px rgba(99, 235, 255, 0.32);\n  }\n}\n\n.ac-step-pill-pulse {\n  animation: acStepPillPulse 2.6s ease-in-out infinite;\n}\n\n.ac-status-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 999px;\n}\n\n.ac-status-dot--healthy {\n  background: #22c55e;\n  animation: acStatusDotPulse 2.6s ease-in-out infinite;\n}\n\n.ac-status-dot--info {\n  background: #22d3ee;\n  animation: acStatusDotPulseInfo 2.6s ease-in-out infinite;\n}\n\n.ac-status-dot--healthy-offset {\n  animation-delay: 0.95s;\n}\n\n.ac-btn-green {\n  border: 1px solid rgba(34, 197, 94, 0.45);\n  background: linear-gradient(\n    180deg,\n    rgba(34, 197, 94, 0.2) 0%,\n    rgba(34, 197, 94, 0.12) 100%\n  );\n  color: #86efac;\n  box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.12);\n  transition:\n    border-color 0.15s ease,\n    background 0.15s ease,\n    color 0.15s ease,\n    box-shadow 0.15s ease,\n    transform 0.15s ease;\n}\n\n.ac-btn-green:hover:not(:disabled) {\n  border-color: rgba(34, 197, 94, 0.7);\n  background: linear-gradient(\n    180deg,\n    rgba(34, 197, 94, 0.28) 0%,\n    rgba(34, 197, 94, 0.16) 100%\n  );\n  color: #bbf7d0;\n  box-shadow:\n    inset 0 0 0 1px rgba(34, 197, 94, 0.2),\n    0 0 12px rgba(34, 197, 94, 0.12);\n}\n\n.ac-btn-green:active:not(:disabled) {\n  transform: translateY(1px);\n}\n\n@keyframes acSpinnerOrbit {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n.ac-spinner {\n  animation: acSpinnerOrbit 1s cubic-bezier(0.3, 0.2, 0.7, 0.8) infinite;\n  transform-origin: center;\n  will-change: transform;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ac-spinner {\n    animation-duration: 1.8s;\n    animation-timing-function: linear;\n  }\n}\n\n/* Reusable segmented control (pill toggle). */\n.ac-segmented-control {\n  display: inline-flex;\n  align-items: center;\n  border: 1px solid var(--panel-border-contrast);\n  border-radius: 8px;\n  overflow: hidden;\n  background: rgba(255, 255, 255, 0.02);\n  height: 28px;\n}\n\n.ac-segmented-control-full {\n  display: flex;\n  width: 100%;\n}\n\n.ac-segmented-control-button {\n  border: 0;\n  background: transparent;\n  color: var(--text-muted);\n  font-family: inherit;\n  font-size: 12px;\n  letter-spacing: 0.03em;\n  height: 100%;\n  line-height: 1;\n  padding: 0 10px;\n  cursor: pointer;\n  transition: color 0.12s, background 0.12s;\n}\n\n.ac-segmented-control-full .ac-segmented-control-button {\n  flex: 1 1 0%;\n}\n\n.ac-segmented-control > .inline-flex {\n  align-self: stretch;\n  display: flex;\n}\n\n.ac-segmented-control-full > .inline-flex {\n  flex: 1 1 0%;\n}\n\n.ac-segmented-control-lg {\n  height: 36px;\n  border-radius: 12px;\n}\n\n.ac-segmented-control-lg .ac-segmented-control-button {\n  font-size: 14px;\n  padding: 0 16px;\n}\n\n.ac-segmented-control-button:hover {\n  color: var(--text);\n  background: rgba(255, 255, 255, 0.03);\n}\n\n.ac-segmented-control-button.active {\n  color: var(--accent);\n  background: var(--bg-active);\n}\n\n.ac-segmented-control-dark {\n  background: rgba(0, 0, 0, 0.25);\n}\n\n/* ── PopActions: animated action group ───────── */\n\n.ac-pop-actions {\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n  flex-shrink: 0;\n}\n\n.ac-pop-actions-hidden {\n  visibility: hidden;\n  pointer-events: none;\n}\n\n.ac-pop-actions-in > * {\n  animation: acPopIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;\n}\n\n.ac-pop-actions-in > *:nth-child(2) {\n  animation-delay: 0.06s;\n}\n\n.ac-pop-actions-in .ac-btn-cyan {\n  animation: acPopIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) 0.06s both;\n}\n\n.ac-pop-actions-out > * {\n  animation: acPopOut 0.18s ease-in both;\n}\n\n.ac-pop-actions-out > *:first-child {\n  animation-delay: 0.03s;\n}\n\n.ac-pop-actions > button:active:not(:disabled) {\n  transform: translateY(1px) scale(0.98);\n}\n\n@keyframes acPopIn {\n  from { opacity: 0; transform: scale(0.85); }\n  to   { opacity: 1; transform: scale(1); }\n}\n\n@keyframes acPopOut {\n  from { opacity: 1; transform: scale(1); }\n  to   { opacity: 0; transform: scale(0.85); }\n}\n\n.watchdog-logs-panel {\n  min-height: 160px;\n  max-height: 80vh;\n  resize: vertical;\n}\n\n.watchdog-terminal-host {\n  position: relative;\n}\n\n.watchdog-terminal-host .xterm {\n  height: 100%;\n  letter-spacing: 0;\n  font-kerning: none;\n}\n\n.watchdog-terminal-host .xterm-viewport {\n  overflow-y: auto !important;\n}\n\n/* ── Light theme overrides for hardcoded dark patterns ── */\n\n[data-theme=\"light\"] body::before {\n  background-image:\n    linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),\n    linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);\n}\n\n[data-theme=\"light\"] .ac-history-item {\n  background: rgba(0, 0, 0, 0.02);\n}\n\n[data-theme=\"light\"] .ac-history-summary {\n  color: var(--text);\n}\n\n[data-theme=\"light\"] .ac-history-item[open] > .ac-history-summary .ac-history-toggle {\n  color: var(--text);\n}\n\n[data-theme=\"light\"] .ac-surface-inset {\n  background: rgba(0, 0, 0, 0.02);\n}\n\n[data-theme=\"light\"] .snippet-collapse-fade {\n  background: linear-gradient(to bottom, transparent 0%, rgba(255, 255, 255, 0.85) 70%);\n}\n\n[data-theme=\"light\"] input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"range\"]):focus,\n[data-theme=\"light\"] select:focus,\n[data-theme=\"light\"] textarea:focus {\n  border-color: rgba(0, 0, 0, 0.35);\n}\n\n[data-theme=\"light\"] ::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.12);\n}\n\n[data-theme=\"light\"] .scope-btn { background: rgba(0, 0, 0, 0.03); }\n[data-theme=\"light\"] .scope-btn-read.active,\n[data-theme=\"light\"] .scope-btn-write.active {\n  background: rgba(0, 0, 0, 0.03);\n  color: var(--text-bright);\n  border-color: rgba(0, 0, 0, 0.35);\n}\n\n[data-theme=\"light\"] .ac-btn-cyan {\n  border: 1px solid var(--accent-dim);\n  background: linear-gradient(180deg, rgba(8, 145, 178, 0.1) 0%, rgba(8, 145, 178, 0.05) 100%);\n  color: var(--accent);\n  box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.08);\n}\n\n[data-theme=\"light\"] .ac-btn-cyan:hover:not(:disabled) {\n  border-color: rgba(8, 145, 178, 0.6);\n  background: linear-gradient(180deg, rgba(8, 145, 178, 0.16) 0%, rgba(8, 145, 178, 0.08) 100%);\n  color: #065666;\n  box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.15), 0 0 12px rgba(8, 145, 178, 0.1);\n}\n\n[data-theme=\"light\"] .ac-btn-cyan-ghost {\n  border: 1px solid var(--accent-dim);\n  color: var(--accent);\n  background: rgba(8, 145, 178, 0.04);\n}\n\n[data-theme=\"light\"] .ac-btn-cyan-ghost:hover {\n  border-color: rgba(8, 145, 178, 0.5);\n  color: #065666;\n  background: rgba(8, 145, 178, 0.08);\n}\n\n[data-theme=\"light\"] .ac-btn-secondary {\n  color: var(--text);\n  background: rgba(0, 0, 0, 0.02);\n}\n\n[data-theme=\"light\"] .ac-btn-secondary:hover:not(:disabled) {\n  border-color: rgba(0, 0, 0, 0.25);\n  color: var(--text-bright);\n  background: rgba(0, 0, 0, 0.04);\n}\n\n[data-theme=\"light\"] .ac-btn-ghost:hover:not(:disabled) {\n  color: var(--text-bright);\n}\n\n[data-theme=\"light\"] .ac-btn-danger {\n  border: 1px solid rgba(220, 38, 38, 0.3);\n  background: rgba(220, 38, 38, 0.06);\n  color: #dc2626;\n}\n\n[data-theme=\"light\"] .ac-btn-danger:hover:not(:disabled) {\n  border-color: rgba(220, 38, 38, 0.5);\n  background: rgba(220, 38, 38, 0.1);\n  color: #b91c1c;\n}\n\n[data-theme=\"light\"] .ac-btn-green {\n  border: 1px solid rgba(22, 163, 74, 0.3);\n  background: linear-gradient(180deg, rgba(22, 163, 74, 0.1) 0%, rgba(22, 163, 74, 0.05) 100%);\n  color: #16a34a;\n  box-shadow: inset 0 0 0 1px rgba(22, 163, 74, 0.08);\n}\n\n[data-theme=\"light\"] .ac-btn-green:hover:not(:disabled) {\n  border-color: rgba(22, 163, 74, 0.5);\n  background: linear-gradient(180deg, rgba(22, 163, 74, 0.16) 0%, rgba(22, 163, 74, 0.08) 100%);\n  color: #15803d;\n  box-shadow: inset 0 0 0 1px rgba(22, 163, 74, 0.15), 0 0 12px rgba(22, 163, 74, 0.08);\n}\n\n[data-theme=\"light\"] .ac-toggle-track {\n  background: rgba(0, 0, 0, 0.1);\n}\n\n[data-theme=\"light\"] .ac-toggle-thumb {\n  background: #9ca3af;\n  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);\n}\n\n[data-theme=\"light\"] .ac-toggle-input:checked + .ac-toggle-track {\n  border-color: rgba(8, 145, 178, 0.6);\n  background: rgba(8, 145, 178, 0.12);\n  box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.15);\n}\n\n[data-theme=\"light\"] .ac-toggle-input:checked + .ac-toggle-track .ac-toggle-thumb {\n  background: #0891b2;\n  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);\n}\n\n[data-theme=\"light\"] .ac-toggle-label {\n  color: var(--text);\n}\n\n[data-theme=\"light\"] .ac-path-card {\n  background: rgba(0, 0, 0, 0.02);\n}\n\n[data-theme=\"light\"] .ac-path-card:hover {\n  border-color: rgba(8, 145, 178, 0.4);\n  background: rgba(8, 145, 178, 0.04);\n  box-shadow: inset 0 0 0 1px rgba(8, 145, 178, 0.08), 0 0 12px rgba(8, 145, 178, 0.06);\n}\n\n[data-theme=\"light\"] .ac-path-card:hover .ac-path-title {\n  color: #065666;\n}\n\n[data-theme=\"light\"] .ac-path-card:hover .ac-path-desc {\n  color: var(--text-muted);\n}\n\n[data-theme=\"light\"] .ac-segmented-control {\n  background: rgba(0, 0, 0, 0.02);\n  border-color: rgba(0, 0, 0, 0.12);\n}\n\n[data-theme=\"light\"] .ac-segmented-control-button:hover {\n  background: rgba(0, 0, 0, 0.04);\n}\n\n[data-theme=\"light\"] .ac-segmented-control-button.active {\n  background: rgba(8, 145, 178, 0.12);\n  color: #0e7490;\n}\n\n[data-theme=\"light\"] .ac-segmented-control-dark {\n  background: rgba(0, 0, 0, 0.04);\n}\n\n/* Modal and link overrides for light mode */\n[data-theme=\"light\"] .bg-modal {\n  background: #ffffff;\n}\n\n[data-theme=\"light\"] a[style*=\"color: rgba(99, 235, 255\"] {\n  color: #0e7490 !important;\n}\n\n[data-theme=\"light\"] a[style*=\"color: rgba(99, 235, 255\"]:hover {\n  color: var(--text-bright) !important;\n}\n\n[data-theme=\"light\"] .text-cyan-400 {\n  color: #0e7490 !important;\n}\n\n[data-theme=\"light\"] .text-cyan-300 {\n  color: #0e7490 !important;\n}\n\n[data-theme=\"light\"] .text-blue-400 {\n  color: #1d4ed8 !important;\n}\n\n[data-theme=\"light\"] .text-indigo-300 {\n  color: #4338ca !important;\n}\n\n[data-theme=\"light\"] .text-purple-400 {\n  color: #7e22ce !important;\n}\n"
  },
  {
    "path": "lib/public/js/app.js",
    "content": "import { h, render } from \"preact\";\nimport { useState, useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  Router,\n  Route,\n  Switch,\n  useLocation,\n} from \"wouter-preact\";\nimport { logout } from \"./lib/api.js\";\nimport { Welcome } from \"./components/welcome/index.js\";\nimport { ThemeToggle } from \"./components/theme-toggle.js\";\nimport { ToastContainer } from \"./components/toast.js\";\nimport { GlobalRestartBanner } from \"./components/global-restart-banner.js\";\nimport { LoadingSpinner } from \"./components/loading-spinner.js\";\nimport { AppSidebar } from \"./components/sidebar.js\";\nimport {\n  AgentsRoute,\n  BrowseRoute,\n  ChatRoute,\n  CronRoute,\n  DoctorRoute,\n  EnvarsRoute,\n  GeneralRoute,\n  ModelsRoute,\n  NodesRoute,\n  RouteRedirect,\n  TelegramRoute,\n  UsageRoute,\n  WatchdogRoute,\n  WebhooksRoute,\n} from \"./components/routes/index.js\";\nimport { useAgents } from \"./components/agents-tab/use-agents.js\";\nimport { useAppShellController } from \"./hooks/use-app-shell-controller.js\";\nimport { useAppShellUi } from \"./hooks/use-app-shell-ui.js\";\nimport { useBrowseNavigation } from \"./hooks/use-browse-navigation.js\";\nimport { useAgentSessions } from \"./hooks/useAgentSessions.js\";\nimport {\n  getHashRouterPath,\n  useHashLocation,\n} from \"./hooks/use-hash-location.js\";\nimport { readUiSettings, writeUiSettings } from \"./lib/ui-settings.js\";\n\nconst html = htm.bind(h);\nconst kDoctorWarningDismissedUntilUiSettingKey =\n  \"doctorWarningDismissedUntilMs\";\nconst kOneWeekMs = 7 * 24 * 60 * 60 * 1000;\nconst kPendingCreateAgentWindowFlag = \"__alphaclawPendingCreateAgent\";\n\nconst App = () => {\n  const [location, setLocation] = useLocation();\n  const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] =\n    useState(() => {\n      const settings = readUiSettings();\n      return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0);\n    });\n\n  const { state: controllerState, actions: controllerActions } =\n    useAppShellController({\n      location,\n    });\n  const {\n    refs: shellRefs,\n    state: shellState,\n    actions: shellActions,\n  } = useAppShellUi();\n  const {\n    state: browseState,\n    actions: browseActions,\n    constants: browseConstants,\n  } = useBrowseNavigation({\n    location,\n    setLocation,\n    onCloseMobileSidebar: shellActions.closeMobileSidebar,\n  });\n\n  const {\n    state: agentsState,\n    actions: agentsActions,\n  } = useAgents();\n\n  const isAgentsRoute = location.startsWith(\"/agents\");\n  const isChatRoute = location.startsWith(\"/chat\");\n  const isCronRoute = location.startsWith(\"/cron\");\n  const isEnvarsRoute = location.startsWith(\"/envars\");\n  const isModelsRoute = location.startsWith(\"/models\");\n  const isNodesRoute = location.startsWith(\"/nodes\");\n  const selectedAgentId = (() => {\n    const match = location.match(/^\\/agents\\/([^/]+)/);\n    return match ? decodeURIComponent(match[1]) : \"\";\n  })();\n  const agentDetailTab = (() => {\n    const match = location.match(/^\\/agents\\/[^/]+\\/([^/]+)/);\n    const tab = match ? match[1] : \"\";\n    return tab === \"tools\" ? \"tools\" : \"overview\";\n  })();\n  const selectedCronJobId = (() => {\n    const match = location.match(/^\\/cron\\/([^/]+)/);\n    return match ? decodeURIComponent(match[1]) : \"\";\n  })();\n  const {\n    sessions: chatSessions,\n    selectedSessionKey: selectedChatSessionKey,\n    setSelectedSessionKey: setSelectedChatSessionKey,\n  } = useAgentSessions({\n    enabled: controllerState.onboarded === true,\n  });\n  const footerVersion = (() => {\n    const openclawVersion = String(\n      controllerState.acCurrentOpenclawVersion || \"\",\n    ).trim();\n    const alphaclawVersion = String(controllerState.acVersion || \"\").trim();\n    if (openclawVersion && alphaclawVersion) {\n      return `OpenClaw ${openclawVersion} / AlphaClaw ${alphaclawVersion}`;\n    }\n    if (openclawVersion) {\n      return `OpenClaw ${openclawVersion}`;\n    }\n    if (alphaclawVersion) {\n      return `AlphaClaw ${alphaclawVersion}`;\n    }\n    return null;\n  })();\n\n  useEffect(() => {\n    if (!isAgentsRoute) return;\n    if (window[kPendingCreateAgentWindowFlag]) return;\n    if (selectedAgentId) return;\n    if (agentsState.loading || agentsState.agents.length === 0) return;\n    setLocation(`/agents/${encodeURIComponent(agentsState.agents[0].id)}`);\n  }, [isAgentsRoute, selectedAgentId, agentsState.loading, agentsState.agents, setLocation]);\n\n  useEffect(() => {\n    if (!isAgentsRoute) return;\n    if (!window[kPendingCreateAgentWindowFlag]) return;\n    window[kPendingCreateAgentWindowFlag] = false;\n    window.setTimeout(() => {\n      window.dispatchEvent(new Event(\"alphaclaw:create-agent\"));\n    }, 0);\n  }, [isAgentsRoute]);\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings[kDoctorWarningDismissedUntilUiSettingKey] =\n      doctorWarningDismissedUntilMs;\n    writeUiSettings(settings);\n  }, [doctorWarningDismissedUntilMs]);\n\n  const handleSidebarLogout = async () => {\n    shellActions.setMenuOpen(false);\n    await logout();\n    try {\n      window.localStorage.clear();\n      window.sessionStorage.clear();\n    } catch {}\n    window.location.href = \"/login.html\";\n  };\n\n  if (controllerState.onboarded === null) {\n    return html`\n      <div\n        class=\"min-h-screen flex items-center justify-center\"\n        style=\"position: relative; z-index: 1\"\n      >\n        <${LoadingSpinner}\n          className=\"h-6 w-6\"\n          style=\"color: var(--text-muted)\"\n        />\n      </div>\n      <${ToastContainer} />\n    `;\n  }\n\n  if (!controllerState.onboarded) {\n    return html`\n      <div\n        class=\"min-h-screen flex flex-col items-center pt-12 pb-8 px-4\"\n        style=\"position: relative; z-index: 1\"\n      >\n        <div style=\"position: fixed; top: 16px; right: 16px; z-index: 50;\">\n          <${ThemeToggle} />\n        </div>\n        <${Welcome}\n          onComplete=${controllerActions.handleOnboardingComplete}\n          acVersion=${controllerState.acVersion}\n        />\n      </div>\n      <${ToastContainer} />\n    `;\n  }\n\n  return html`\n    <div\n      class=\"app-shell\"\n      ref=${shellRefs.appShellRef}\n      style=${{ \"--sidebar-width\": `${shellState.sidebarWidthPx}px` }}\n    >\n      <${GlobalRestartBanner}\n        visible=${controllerState.isAnyRestartRequired}\n        restarting=${controllerState.restartingGateway}\n        onRestart=${controllerActions.handleGatewayRestart}\n        onDismiss=${controllerActions.dismissRestartBanner}\n      />\n      <${AppSidebar}\n        mobileSidebarOpen=${shellState.mobileSidebarOpen}\n        authEnabled=${controllerState.authEnabled}\n        menuRef=${shellRefs.menuRef}\n        menuOpen=${shellState.menuOpen}\n        onToggleMenu=${shellActions.onToggleMenu}\n        onLogout=${handleSidebarLogout}\n        sidebarTab=${browseState.sidebarTab}\n        onSelectSidebarTab=${browseActions.handleSelectSidebarTab}\n        navSections=${browseConstants.kNavSections}\n        selectedNavId=${browseState.selectedNavId}\n        onSelectNavItem=${browseActions.handleSelectNavItem}\n        selectedBrowsePath=${browseState.selectedBrowsePath}\n        onSelectBrowseFile=${browseActions.navigateToBrowseFile}\n        onPreviewBrowseFile=${browseActions.handleBrowsePreviewFile}\n        acHasUpdate=${controllerState.acHasUpdate}\n        acVersion=${controllerState.acVersion}\n        acCurrentOpenclawVersion=${controllerState.acCurrentOpenclawVersion}\n        acLatest=${controllerState.acLatest}\n        acLatestOpenclawVersion=${controllerState.acLatestOpenclawVersion}\n        acUpdateStrategy=${controllerState.acUpdateStrategy}\n        acUpdating=${controllerState.acUpdating}\n        onAcUpdate=${controllerActions.handleAcUpdate}\n        agents=${agentsState.agents}\n        selectedAgentId=${selectedAgentId}\n        onSelectAgent=${(agentId) => setLocation(`/agents/${encodeURIComponent(agentId)}`)}\n        onAddAgent=${() => {\n          if (isAgentsRoute) {\n            window.dispatchEvent(new Event(\"alphaclaw:create-agent\"));\n            return;\n          }\n          window[kPendingCreateAgentWindowFlag] = true;\n          setLocation(\"/agents\");\n        }}\n        chatSessions=${chatSessions}\n        selectedChatSessionKey=${selectedChatSessionKey}\n        onSelectChatSession=${(sessionKey) => {\n          setSelectedChatSessionKey(sessionKey);\n          if (!isChatRoute) setLocation(\"/chat\");\n        }}\n      />\n      <div\n        class=${`sidebar-resizer ${shellState.isResizingSidebar ? \"is-resizing\" : \"\"}`}\n        onpointerdown=${shellActions.onSidebarResizerPointerDown}\n        role=\"separator\"\n        aria-orientation=\"vertical\"\n        aria-label=\"Resize sidebar\"\n      ></div>\n\n      <div\n        class=${`mobile-sidebar-overlay ${shellState.mobileSidebarOpen ? \"active\" : \"\"}`}\n        onclick=${shellActions.closeMobileSidebar}\n      />\n\n      <div class=\"app-content\">\n        <div\n          class=${`mobile-topbar ${shellState.mobileTopbarScrolled ? \"is-scrolled\" : \"\"}`}\n        >\n          <button\n            class=\"mobile-topbar-menu\"\n            onclick=${() =>\n              shellActions.setMobileSidebarOpen((open) => !open)}\n            aria-label=\"Open menu\"\n            aria-expanded=${shellState.mobileSidebarOpen ? \"true\" : \"false\"}\n          >\n            <svg\n              width=\"18\"\n              height=\"18\"\n              viewBox=\"0 0 16 16\"\n              fill=\"currentColor\"\n            >\n              <path\n                d=\"M2 3.75a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z\"\n              />\n            </svg>\n          </button>\n          <span class=\"mobile-topbar-title\">\n            <span style=\"color: var(--accent)\">alpha</span>claw\n          </span>\n        </div>\n        ${browseState.isBrowseRoute\n          ? html`\n              <div class=\"app-content-pane browse-pane\">\n                <${BrowseRoute}\n                  activeBrowsePath=${browseState.activeBrowsePath}\n                  browseView=${browseState.browseViewerMode}\n                  lineTarget=${browseState.browseLineTarget}\n                  lineEndTarget=${browseState.browseLineEndTarget}\n                  selectedBrowsePath=${browseState.selectedBrowsePath}\n                  onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}\n                  onEditSelectedBrowseFile=${() =>\n                    setLocation(\n                      browseActions.buildBrowseRoute(browseState.selectedBrowsePath, {\n                        view: \"edit\",\n                      }),\n                    )}\n                  onClearSelection=${() => {\n                    browseActions.clearBrowsePreview();\n                    setLocation(\"/browse\");\n                  }}\n                />\n              </div>\n            `\n          : null}\n        ${isAgentsRoute\n          ? html`\n              <div class=\"app-content-pane agents-pane\">\n                <${AgentsRoute}\n                  agents=${agentsState.agents}\n                  loading=${agentsState.loading}\n                  saving=${agentsState.saving}\n                  agentsActions=${agentsActions}\n                  selectedAgentId=${selectedAgentId}\n                  activeTab=${agentDetailTab}\n                  onSelectAgent=${(agentId) =>\n                    setLocation(`/agents/${encodeURIComponent(agentId)}`)}\n                  onSelectTab=${(tab) => {\n                    const safePath = tab && tab !== \"overview\"\n                      ? `/agents/${encodeURIComponent(selectedAgentId)}/${tab}`\n                      : `/agents/${encodeURIComponent(selectedAgentId)}`;\n                    setLocation(safePath);\n                  }}\n                  onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}\n                  onSetLocation=${setLocation}\n                />\n              </div>\n            `\n          : null}\n        ${isChatRoute\n          ? html`\n              <div class=\"app-content-pane chat-pane\">\n                <${ChatRoute}\n                  sessions=${chatSessions}\n                  selectedSessionKey=${selectedChatSessionKey}\n                />\n              </div>\n            `\n          : null}\n        ${isCronRoute\n          ? html`\n              <div class=\"app-content-pane cron-pane\">\n                <${CronRoute}\n                  jobId=${selectedCronJobId}\n                  onSetLocation=${setLocation}\n                />\n              </div>\n            `\n          : null}\n        ${isEnvarsRoute\n          ? html`\n              <div class=\"app-content-pane ac-fixed-header-pane\">\n                <${EnvarsRoute}\n                  onRestartRequired=${controllerActions.setRestartRequired}\n                />\n              </div>\n            `\n          : null}\n        ${isModelsRoute\n          ? html`\n              <div class=\"app-content-pane ac-fixed-header-pane\">\n                <${ModelsRoute}\n                  onRestartRequired=${controllerActions.setRestartRequired}\n                />\n              </div>\n            `\n          : null}\n        ${isNodesRoute\n          ? html`\n              <div class=\"app-content-pane\">\n                <${NodesRoute}\n                  onRestartRequired=${controllerActions.setRestartRequired}\n                />\n              </div>\n            `\n          : null}\n        ${browseState.isBrowseRoute ||\n        isAgentsRoute ||\n        isChatRoute ||\n        isCronRoute ||\n        isEnvarsRoute ||\n        isModelsRoute ||\n        isNodesRoute\n          ? null\n          : html`\n              <div\n                class=\"app-content-pane\"\n                onscroll=${shellActions.handlePaneScroll}\n              >\n          <div class=\"max-w-2xl w-full mx-auto\">\n            <${Switch}>\n                    <${Route} path=\"/general\">\n                      <${GeneralRoute}\n                        statusData=${controllerState.sharedStatus}\n                        watchdogData=${controllerState.sharedWatchdogStatus}\n                        doctorStatusData=${controllerState.sharedDoctorStatus}\n                        agents=${agentsState.agents}\n                        doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs}\n                        onRefreshStatuses=${controllerActions.refreshSharedStatuses}\n                        onSetLocation=${setLocation}\n                        onNavigate=${browseActions.navigateToSubScreen}\n                        restartingGateway=${controllerState.restartingGateway}\n                        onRestartGateway=${controllerActions.handleGatewayRestart}\n                        restartSignal=${controllerState.gatewayRestartSignal}\n                        onRestartRequired=${controllerActions.setRestartRequired}\n                        onDismissDoctorWarning=${() =>\n                          setDoctorWarningDismissedUntilMs(\n                            Date.now() + kOneWeekMs,\n                          )}\n                      />\n                    </${Route}>\n                    <${Route} path=\"/doctor\">\n                      <${DoctorRoute} onNavigateToBrowseFile=${browseActions.navigateToBrowseFile} />\n                    </${Route}>\n                    <${Route} path=\"/telegram/:accountId\">\n                      ${(params) => html`\n                        <${TelegramRoute}\n                          accountId=${decodeURIComponent(params.accountId || \"default\")}\n                          onBack=${browseActions.exitSubScreen}\n                        />\n                      `}\n                    </${Route}>\n                    <${Route} path=\"/telegram\">\n                      <${RouteRedirect} to=\"/telegram/default\" />\n                    </${Route}>\n                    <${Route} path=\"/providers\">\n                      <${RouteRedirect} to=\"/models\" />\n                    </${Route}>\n                    <${Route} path=\"/watchdog\">\n                      <${WatchdogRoute}\n                        statusData=${controllerState.sharedStatus}\n                        watchdogStatus=${controllerState.sharedWatchdogStatus}\n                        onRefreshStatuses=${controllerActions.refreshSharedStatuses}\n                        restartingGateway=${controllerState.restartingGateway}\n                        onRestartGateway=${controllerActions.handleGatewayRestart}\n                        restartSignal=${controllerState.gatewayRestartSignal}\n                      />\n                    </${Route}>\n                    <${Route} path=\"/usage/:sessionId\">\n                      ${(params) => html`\n                        <${UsageRoute}\n                          sessionId=${decodeURIComponent(\n                            params.sessionId || \"\",\n                          )}\n                          onSetLocation=${setLocation}\n                        />\n                      `}\n                    </${Route}>\n                    <${Route} path=\"/usage\">\n                      <${UsageRoute} onSetLocation=${setLocation} />\n                    </${Route}>\n                    <${Route} path=\"/webhooks/:hookName\">\n                      ${(params) => html`\n                        <${WebhooksRoute}\n                          hookName=${decodeURIComponent(params.hookName || \"\")}\n                          routeHistoryRef=${browseState.routeHistoryRef}\n                          getCurrentPath=${getHashRouterPath}\n                          onSetLocation=${setLocation}\n                          onRestartRequired=${controllerActions.setRestartRequired}\n                          onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}\n                        />\n                      `}\n                    </${Route}>\n                    <${Route} path=\"/webhooks\">\n                      <${WebhooksRoute}\n                        routeHistoryRef=${browseState.routeHistoryRef}\n                        getCurrentPath=${getHashRouterPath}\n                        onSetLocation=${setLocation}\n                        onRestartRequired=${controllerActions.setRestartRequired}\n                        onNavigateToBrowseFile=${browseActions.navigateToBrowseFile}\n                      />\n                    </${Route}>\n                    <${Route}>\n                      <${RouteRedirect} to=\"/general\" />\n                    </${Route}>\n                  </${Switch}>\n          </div>\n              </div>\n            `}\n        <${ToastContainer}\n          className=\"fixed top-4 right-4 z-[60] space-y-2 pointer-events-none\"\n        />\n      </div>\n\n      <div class=\"app-statusbar\">\n        <div class=\"statusbar-left\">\n          ${footerVersion\n            ? html`<span style=\"color: var(--text-muted)\">${footerVersion}</span>`\n            : null}\n        </div>\n        <div class=\"statusbar-right\">\n          <a href=\"https://docs.openclaw.ai\" target=\"_blank\" rel=\"noreferrer\"\n            >docs</a\n          >\n          <a\n            href=\"https://discord.com/invite/clawd\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            >discord</a\n          >\n          <a\n            href=\"https://github.com/openclaw/openclaw\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            >github</a\n          >\n        </div>\n      </div>\n    </div>\n  `;\n};\n\nconst rootElement = document.getElementById(\"app\");\nif (rootElement) {\n  const appBootCounter = \"__alphaclawSetupAppBootCount\";\n  window[appBootCounter] = Number(window[appBootCounter] || 0) + 1;\n  // Defensive: clear root so duplicate bootstraps cannot stack full app shells.\n  render(null, rootElement);\n  rootElement.replaceChildren();\n  render(\n    html`\n      <${Router} hook=${useHashLocation}>\n        <${App} />\n      </${Router}>\n    `,\n    rootElement,\n  );\n}\n"
  },
  {
    "path": "lib/public/js/components/action-button.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\n\nconst html = htm.bind(h);\n\nconst kStaticToneClassByTone = {\n  primary: \"ac-btn-cyan\",\n  secondary: \"ac-btn-secondary\",\n  success: \"ac-btn-green\",\n  danger: \"ac-btn-danger\",\n  ghost: \"ac-btn-ghost\",\n};\n\nconst getToneClass = (tone, isInteractive) => {\n  if (tone === \"subtle\") {\n    return isInteractive\n      ? \"border border-border text-fg-muted hover:text-body hover:border-fg-muted\"\n      : \"border border-border text-fg-muted\";\n  }\n  if (tone === \"neutral\") {\n    return isInteractive\n      ? \"border border-border text-fg-muted hover:text-body hover:border-fg-muted\"\n      : \"border border-border text-fg-muted\";\n  }\n  if (tone === \"warning\") {\n    return isInteractive\n      ? \"border border-yellow-500/35 text-status-warning-muted bg-yellow-500/10 hover:border-yellow-400/60 hover:text-status-warning hover:bg-yellow-500/15\"\n      : \"border border-yellow-500/35 text-status-warning-muted bg-yellow-500/10\";\n  }\n  return kStaticToneClassByTone[tone] || kStaticToneClassByTone.primary;\n};\n\nconst kSizeClassBySize = {\n  sm: \"h-7 text-xs leading-none px-2.5 py-1 rounded-lg\",\n  md: \"h-9 text-sm font-medium leading-none px-4 rounded-xl\",\n  lg: \"h-10 text-sm font-medium leading-none px-5 rounded-lg\",\n};\nconst kIconOnlySizeClassBySize = {\n  sm: \"h-7 w-7 p-0 rounded-lg\",\n  md: \"h-9 w-9 p-0 rounded-xl\",\n  lg: \"h-10 w-10 p-0 rounded-lg\",\n};\n\nexport const ActionButton = ({\n  onClick,\n  type = \"button\",\n  disabled = false,\n  loading = false,\n  tone = \"primary\",\n  size = \"sm\",\n  idleLabel = \"Action\",\n  loadingLabel = \"Working...\",\n  loadingMode = \"replace\",\n  className = \"\",\n  idleIcon = null,\n  idleIconClassName = \"h-3 w-3\",\n  iconOnly = false,\n  title = \"\",\n  ariaLabel = \"\",\n}) => {\n  const isDisabled = disabled || loading;\n  const isInteractive = !isDisabled;\n  const toneClass = getToneClass(tone, isInteractive);\n  const sizeClass = iconOnly\n    ? kIconOnlySizeClassBySize[size] || kIconOnlySizeClassBySize.sm\n    : kSizeClassBySize[size] || kSizeClassBySize.sm;\n  const loadingClass = loading\n    ? `cursor-not-allowed ${\n        tone === \"warning\"\n          ? \"opacity-90 animate-pulse shadow-[0_0_0_1px_rgba(234,179,8,0.22),0_0_18px_rgba(234,179,8,0.12)]\"\n          : \"opacity-80\"\n      }`\n    : \"\";\n  const spinnerSizeClass =\n    size === \"md\" || size === \"lg\" ? \"h-4 w-4\" : \"h-3 w-3\";\n  const isInlineLoading = loadingMode === \"inline\";\n  const IdleIcon = idleIcon;\n  const idleContent =\n    iconOnly && IdleIcon\n      ? html`<${IdleIcon} className=${idleIconClassName} />`\n      : IdleIcon\n        ? html`\n            <span class=\"inline-flex items-center gap-1.5 leading-none\">\n              <${IdleIcon} className=${idleIconClassName} />\n              ${idleLabel}\n            </span>\n          `\n        : idleLabel;\n  const currentLabel = loading && !isInlineLoading ? loadingLabel : idleContent;\n\n  return html`\n    <button\n      type=${type}\n      onclick=${onClick}\n      disabled=${isDisabled}\n      title=${title}\n      aria-label=${ariaLabel || null}\n      class=\"inline-flex items-center justify-center transition-colors whitespace-nowrap ${sizeClass} ${toneClass} ${loadingClass} ${className}\"\n    >\n      ${isInlineLoading\n        ? html`\n            <span\n              class=\"relative inline-flex items-center justify-center leading-none\"\n            >\n              <span class=${loading ? \"invisible\" : \"\"}>${currentLabel}</span>\n              ${loading\n                ? html`\n                    <span\n                      class=\"absolute inset-0 inline-flex items-center justify-center\"\n                    >\n                      <${LoadingSpinner} className=${spinnerSizeClass} />\n                    </span>\n                  `\n                : null}\n            </span>\n          `\n        : loading\n          ? html`\n              <span class=\"inline-flex items-center gap-1.5 leading-none\">\n                <${LoadingSpinner} className=${spinnerSizeClass} />\n                ${currentLabel}\n              </span>\n            `\n          : currentLabel}\n    </button>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/add-channel-menu.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"./action-button.js\";\nimport { AddLineIcon } from \"./icons.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"./overflow-menu.js\";\n\nconst html = htm.bind(h);\n\nexport const AddChannelMenu = ({\n  open = false,\n  onClose = () => {},\n  onToggle = () => {},\n  triggerDisabled = false,\n  channelIds = [],\n  getChannelMeta = () => ({ label: \"Channel\", iconSrc: \"\" }),\n  isChannelDisabled = () => false,\n  onSelectChannel = () => {},\n}) => html`\n  <${OverflowMenu}\n    open=${open}\n    ariaLabel=\"Add channel\"\n    title=\"Add channel\"\n    onClose=${onClose}\n    onToggle=${onToggle}\n    renderTrigger=${({ onToggle: handleToggle, ariaLabel, title }) => html`\n      <${ActionButton}\n        onClick=${handleToggle}\n        disabled=${triggerDisabled}\n        loading=${false}\n        loadingMode=\"inline\"\n        tone=\"subtle\"\n        size=\"sm\"\n        idleLabel=\"Add channel\"\n        loadingLabel=\"Opening...\"\n        idleIcon=${AddLineIcon}\n        idleIconClassName=\"h-3.5 w-3.5\"\n        iconOnly=${true}\n        title=${title}\n        ariaLabel=${ariaLabel}\n      />\n    `}\n  >\n    ${channelIds.map((channelId) => {\n      const channelMeta = getChannelMeta(channelId);\n      const disabled = !!isChannelDisabled(channelId);\n      return html`\n        <${OverflowMenuItem}\n          key=${channelId}\n          iconSrc=${channelMeta.iconSrc}\n          disabled=${disabled}\n          onClick=${() => onSelectChannel(channelId)}\n        >\n          ${channelMeta.label}\n        </${OverflowMenuItem}>\n      `;\n    })}\n  </${OverflowMenu}>\n`;\n\n"
  },
  {
    "path": "lib/public/js/components/agent-send-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ModalShell } from \"./modal-shell.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport { PageHeader } from \"./page-header.js\";\nimport { CloseIcon } from \"./icons.js\";\nimport { SessionSelectField } from \"./session-select-field.js\";\nimport { useAgentSessions } from \"../hooks/useAgentSessions.js\";\n\nconst html = htm.bind(h);\n\nexport const AgentSendModal = ({\n  visible = false,\n  title = \"Send to agent\",\n  messageLabel = \"Message\",\n  messageRows = 8,\n  initialMessage = \"\",\n  resetKey = \"\",\n  submitLabel = \"Send message\",\n  loadingLabel = \"Sending...\",\n  cancelLabel = \"Cancel\",\n  onClose = () => {},\n  onSubmit = async () => true,\n  sessionFilter = undefined,\n}) => {\n  const {\n    sessions,\n    selectedSessionKey,\n    setSelectedSessionKey,\n    selectedSession,\n    loading: loadingSessions,\n    error: loadError,\n  } = useAgentSessions({ enabled: visible, filter: sessionFilter });\n  const [messageText, setMessageText] = useState(\"\");\n  const [sending, setSending] = useState(false);\n\n  useEffect(() => {\n    if (!visible) return;\n    setMessageText(String(initialMessage || \"\"));\n  }, [visible, initialMessage, resetKey]);\n\n  const handleSend = async () => {\n    if (!selectedSession || sending) return;\n    const trimmedMessage = String(messageText || \"\").trim();\n    if (!trimmedMessage) return;\n    setSending(true);\n    try {\n      const shouldClose = await onSubmit({\n        selectedSession,\n        selectedSessionKey,\n        message: trimmedMessage,\n      });\n      if (shouldClose !== false) {\n        onClose();\n      }\n    } finally {\n      setSending(false);\n    }\n  };\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${() => {\n        if (sending) return;\n        onClose();\n      }}\n      panelClassName=\"bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4\"\n    >\n      <${PageHeader}\n        title=${title}\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${() => {\n              if (sending) return;\n              onClose();\n            }}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n          </button>\n        `}\n      />\n      <${SessionSelectField}\n        label=\"Send to session\"\n        sessions=${sessions}\n        selectedSessionKey=${selectedSessionKey}\n        onChangeSessionKey=${setSelectedSessionKey}\n        disabled=${loadingSessions || sending}\n        loading=${loadingSessions}\n        error=${loadError}\n        emptyOptionLabel=\"No sessions available\"\n      />\n      <div class=\"space-y-2\">\n        <label class=\"text-xs text-fg-muted\">${messageLabel}</label>\n        <textarea\n          value=${messageText}\n          onInput=${(event) =>\n            setMessageText(String(event.currentTarget?.value || \"\"))}\n          disabled=${sending}\n          rows=${messageRows}\n          class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body focus:border-fg-muted font-mono leading-5\"\n        ></textarea>\n      </div>\n      <div class=\"flex items-center justify-end gap-2\">\n        <${ActionButton}\n          onClick=${onClose}\n          disabled=${sending}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=${cancelLabel}\n        />\n        <${ActionButton}\n          onClick=${handleSend}\n          disabled=${!selectedSession || loadingSessions || !!loadError || !String(messageText || \"\").trim()}\n          loading=${sending}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=${submitLabel}\n          loadingLabel=${loadingLabel}\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-bindings-section/channel-item-trailing.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Badge } from \"../../badge.js\";\nimport { ChannelAccountStatusBadge } from \"../../channel-account-status-badge.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"../../overflow-menu.js\";\n\nconst html = htm.bind(h);\n\nexport const ChannelItemTrailing = ({\n  item = {},\n  menuOpenId = \"\",\n  setMenuOpenId = () => {},\n  openDeleteChannelDialog = () => {},\n  openEditChannelModal = () => {},\n  requestBindAccount = () => {},\n  onSetLocation = () => {},\n}) => {\n  const {\n    accountData = {},\n    accountId = \"\",\n    accountStatusInfo = {},\n    canNavigateToOwnerAgent = false,\n    channel = \"\",\n    ownerAgentId = \"\",\n    ownerAgentName = \"\",\n    isAvailable = false,\n    isOwned = false,\n  } = item;\n\n  let statusTrailing = null;\n  if (isOwned) {\n    statusTrailing =\n      accountStatusInfo?.status === \"paired\"\n        ? html`<${ChannelAccountStatusBadge}\n            status=${accountStatusInfo?.status}\n            ownerAgentName=${ownerAgentName}\n            showAgentBadge=${true}\n            channelId=${channel}\n            pairedCount=${accountStatusInfo?.paired ?? 0}\n          />`\n        : html`<${ChannelAccountStatusBadge}\n            status=${accountStatusInfo?.status}\n            ownerAgentName=\"\"\n            showAgentBadge=${false}\n            channelId=${channel}\n            pairedCount=${accountStatusInfo?.paired ?? 0}\n          />`;\n  } else if (isAvailable) {\n    statusTrailing = html`\n      <button\n        type=\"button\"\n        onclick=${(event) => {\n          event.stopPropagation();\n          requestBindAccount(accountData);\n        }}\n        class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost\"\n      >\n        Bind\n      </button>\n    `;\n  } else {\n    statusTrailing = html`\n      ${canNavigateToOwnerAgent\n        ? html`\n            <button\n              type=\"button\"\n              class=\"inline-flex rounded-full transition-[filter] hover:brightness-125 focus:outline-none focus:ring-1 focus:ring-border\"\n              onclick=${(event) => {\n                event.stopPropagation();\n                onSetLocation(`/agents/${encodeURIComponent(ownerAgentId)}`);\n              }}\n              title=${`Open ${ownerAgentName}`}\n              aria-label=${`Open ${ownerAgentName}`}\n            >\n              <${Badge} tone=\"neutral\">${ownerAgentName}</${Badge}>\n            </button>\n          `\n        : html`<${Badge} tone=\"neutral\">${ownerAgentName || \"Bound elsewhere\"}</${Badge}>`}\n    `;\n  }\n\n  const showBindAction = accountData.isBoundElsewhere;\n  const canEditOrDelete = !accountData.isBoundElsewhere;\n\n  return html`\n    <div class=\"flex items-center gap-1.5\">\n      ${statusTrailing}\n      <${OverflowMenu}\n        open=${menuOpenId === `${channel}:${accountId}`}\n        ariaLabel=\"Open channel actions\"\n        title=\"Open channel actions\"\n        onClose=${() => setMenuOpenId(\"\")}\n        onToggle=${() =>\n          setMenuOpenId((current) =>\n            current === `${channel}:${accountId}`\n              ? \"\"\n              : `${channel}:${accountId}`,\n          )}\n      >\n        ${canEditOrDelete\n          ? html`\n              <${OverflowMenuItem}\n                onClick=${() => openEditChannelModal(accountData)}\n              >\n                Edit\n              </${OverflowMenuItem}>\n            `\n          : null}\n        ${showBindAction\n          ? html`\n              <${OverflowMenuItem}\n                onClick=${() => requestBindAccount(accountData)}\n              >\n                Bind\n              </${OverflowMenuItem}>\n            `\n          : null}\n        ${canEditOrDelete\n          ? html`\n              <${OverflowMenuItem}\n                className=\"text-status-error hover:text-status-error\"\n                onClick=${() => openDeleteChannelDialog(accountData)}\n              >\n                Delete\n              </${OverflowMenuItem}>\n            `\n          : null}\n      </${OverflowMenu}>\n    </div>\n  `;\n};\n\nexport const ChannelCardItem = ({\n  item = {},\n  channelMeta = {},\n  menuOpenId = \"\",\n  setMenuOpenId = () => {},\n  openDeleteChannelDialog = () => {},\n  openEditChannelModal = () => {},\n  requestBindAccount = () => {},\n  onSetLocation = () => {},\n}) => {\n  const canOpenWorkspace = !!item?.canOpenWorkspace;\n  const accountId = String(item?.accountId || \"\").trim() || \"default\";\n  return html`\n    <div\n      key=${item.id || item.channel}\n      class=\"flex justify-between items-center py-1.5 ${canOpenWorkspace\n        ? \"cursor-pointer hover:bg-surface -mx-2 px-2 rounded-lg transition-colors\"\n        : \"\"}\"\n      onclick=${canOpenWorkspace\n        ? () => onSetLocation(`/telegram/${encodeURIComponent(accountId)}`)\n        : undefined}\n    >\n      <span class=\"font-medium text-sm flex items-center gap-2 min-w-0\">\n        ${channelMeta?.iconSrc\n          ? html`\n              <img\n                src=${channelMeta.iconSrc}\n                alt=\"\"\n                class=\"w-4 h-4 rounded-sm\"\n                aria-hidden=\"true\"\n              />\n            `\n          : null}\n        <span class=\"truncate ${item?.dimmedLabel ? \"text-fg-muted\" : \"\"} ${item?.labelClassName || \"\"}\">\n          ${item?.label || channelMeta?.label || \"Channel\"}\n        </span>\n        ${canOpenWorkspace\n          ? html`\n              <span class=\"text-xs text-fg-muted ml-1 shrink-0\">Workspace</span>\n              <svg\n                width=\"14\"\n                height=\"14\"\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                class=\"text-fg-dim shrink-0\"\n              >\n                <path\n                  d=\"M6 3.5L10.5 8L6 12.5\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                />\n              </svg>\n            `\n          : null}\n      </span>\n      <span class=\"flex items-center gap-2 shrink-0\">\n        <${ChannelItemTrailing}\n          item=${item}\n          menuOpenId=${menuOpenId}\n          setMenuOpenId=${setMenuOpenId}\n          openDeleteChannelDialog=${openDeleteChannelDialog}\n          openEditChannelModal=${openEditChannelModal}\n          requestBindAccount=${requestBindAccount}\n          onSetLocation=${onSetLocation}\n        />\n      </span>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-bindings-section/helpers.js",
    "content": "import {\n  isImplicitDefaultAccount,\n  resolveChannelAccountLabel,\n} from \"../../../lib/channel-accounts.js\";\n\nexport const announceBindingsChanged = (agentId) => {\n  window.dispatchEvent(\n    new CustomEvent(\"alphaclaw:agent-bindings-changed\", {\n      detail: { agentId: String(agentId || \"\").trim() },\n    }),\n  );\n};\n\nexport const announceRestartRequired = () => {\n  window.dispatchEvent(new CustomEvent(\"alphaclaw:restart-required\"));\n};\n\nexport { resolveChannelAccountLabel };\n\nexport const getChannelItemSortRank = (item = {}) => {\n  if (item.isAwaitingPairing) return 99;\n  if (item.isOwned) return 0;\n  if (item.isUnconfigured) return 3;\n  if (item.isAvailable) return 1;\n  return 2;\n};\n\nexport const getAccountStatusInfo = ({ statusInfo, accountId }) => {\n  const normalizedAccountId = String(accountId || \"\").trim() || \"default\";\n  const accountStatuses =\n    statusInfo?.accounts && typeof statusInfo.accounts === \"object\"\n      ? statusInfo.accounts\n      : null;\n  if (accountStatuses?.[normalizedAccountId]) {\n    return accountStatuses[normalizedAccountId];\n  }\n  if (normalizedAccountId === \"default\" && statusInfo) {\n    return statusInfo;\n  }\n  return null;\n};\n\nexport const getResolvedAccountStatusInfo = ({\n  account,\n  statusInfo,\n  accountId,\n}) => {\n  const accountStatus = String(account?.status || \"\").trim();\n  if (accountStatus) {\n    return {\n      status: accountStatus,\n      paired: Number(account?.paired || 0),\n    };\n  }\n  return getAccountStatusInfo({ statusInfo, accountId });\n};\n\nexport { isImplicitDefaultAccount };\n\nexport const canAgentBindAccount = ({\n  accountId,\n  boundAgentId,\n  agentId,\n  isDefaultAgent,\n}) => {\n  const normalizedBoundAgentId = String(boundAgentId || \"\").trim();\n  if (normalizedBoundAgentId) {\n    return normalizedBoundAgentId === String(agentId || \"\").trim();\n  }\n  if (isImplicitDefaultAccount({ accountId, boundAgentId })) {\n    return !!isDefaultAgent;\n  }\n  return true;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-bindings-section/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { isChannelProviderDisabledForAdd } from \"../../../lib/channel-provider-availability.js\";\nimport { AddChannelMenu } from \"../../add-channel-menu.js\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { ALL_CHANNELS, ChannelsCard, getChannelMeta } from \"../../channels.js\";\nimport { ConfirmDialog } from \"../../confirm-dialog.js\";\nimport { AddLineIcon } from \"../../icons.js\";\nimport { CreateChannelModal } from \"../create-channel-modal.js\";\nimport { ChannelCardItem } from \"./channel-item-trailing.js\";\nimport { useAgentBindings } from \"./use-agent-bindings.js\";\nimport { useChannelItems } from \"./use-channel-items.js\";\n\nconst html = htm.bind(h);\n\nexport const AgentBindingsSection = ({\n  agent = {},\n  agents = [],\n  onSetLocation = () => {},\n}) => {\n  const {\n    agentId,\n    agentNameMap,\n    channelStatus,\n    channels,\n    configuredChannelMap,\n    configuredChannels,\n    createLoadingLabel,\n    createProvider,\n    defaultAgentId,\n    deletingAccount,\n    editingAccount,\n    handleCreateChannel,\n    handleDeleteChannel,\n    handleQuickBind,\n    handleUpdateChannel,\n    isDefaultAgent,\n    loading,\n    menuOpenId,\n    openCreateChannelModal,\n    openDeleteChannelDialog,\n    openEditChannelModal,\n    pendingBindAccount,\n    requestBindAccount,\n    saving,\n    setCreateProvider,\n    setDeletingAccount,\n    setEditingAccount,\n    setMenuOpenId,\n    setPendingBindAccount,\n    setShowCreateModal,\n    showCreateModal,\n  } = useAgentBindings({ agent, agents });\n  const { mergedChannelItems } = useChannelItems({\n    agentId,\n    agentNameMap,\n    channelStatus,\n    configuredChannelMap,\n    configuredChannels,\n    defaultAgentId,\n    isDefaultAgent,\n  });\n\n  return html`\n    <div class=\"space-y-3\">\n      ${loading\n        ? html`\n            <${ChannelsCard}\n              title=\"Channels\"\n              items=${[]}\n              loadingLabel=\"Loading channels...\"\n              actions=${html`\n                <div class=\"relative\">\n                  <${ActionButton}\n                    onClick=${() => {}}\n                    disabled=${true}\n                    tone=\"subtle\"\n                    size=\"sm\"\n                    idleIcon=${AddLineIcon}\n                    idleIconClassName=\"h-3.5 w-3.5\"\n                    iconOnly=${true}\n                    title=\"Add channel\"\n                    ariaLabel=\"Add channel\"\n                    idleLabel=\"Add channel\"\n                  />\n                </div>\n              `}\n            />\n          `\n        : html`\n            <div class=\"space-y-3\">\n              <${ChannelsCard}\n                title=\"Channels\"\n                items=${mergedChannelItems}\n                loadingLabel=\"No channels assigned to this agent.\"\n                renderItem=${({ item, channelMeta }) => {\n                  if (String(item?.id || \"\").trim() === \"__assigned_elsewhere_toggle\") {\n                    return null;\n                  }\n                  return html`<${ChannelCardItem}\n                    item=${item}\n                    channelMeta=${channelMeta}\n                    menuOpenId=${menuOpenId}\n                    setMenuOpenId=${setMenuOpenId}\n                    openDeleteChannelDialog=${openDeleteChannelDialog}\n                    openEditChannelModal=${openEditChannelModal}\n                    requestBindAccount=${requestBindAccount}\n                    onSetLocation=${onSetLocation}\n                  />`;\n                }}\n                actions=${html`\n                  <${AddChannelMenu}\n                    open=${menuOpenId === \"__create_channel\"}\n                    onClose=${() => setMenuOpenId(\"\")}\n                    onToggle=${() =>\n                      setMenuOpenId((current) =>\n                        current === \"__create_channel\" ? \"\" : \"__create_channel\",\n                      )}\n                    triggerDisabled=${saving}\n                    channelIds=${ALL_CHANNELS}\n                    getChannelMeta=${getChannelMeta}\n                    isChannelDisabled=${(channelId) =>\n                      isChannelProviderDisabledForAdd({\n                        configuredChannelMap,\n                        provider: channelId,\n                      })}\n                    onSelectChannel=${openCreateChannelModal}\n                  />\n                `}\n              />\n            </div>\n          `}\n      <${CreateChannelModal}\n        visible=${showCreateModal}\n        loading=${saving}\n        createLoadingLabel=${createLoadingLabel}\n        agents=${agents}\n        existingChannels=${channels}\n        initialAgentId=${agentId}\n        initialProvider=${createProvider}\n        onClose=${() => {\n          setShowCreateModal(false);\n          setCreateProvider(\"\");\n        }}\n        onSubmit=${handleCreateChannel}\n      />\n      <${CreateChannelModal}\n        visible=${!!editingAccount}\n        loading=${saving}\n        agents=${agents}\n        existingChannels=${channels}\n        mode=\"edit\"\n        account=${editingAccount}\n        initialAgentId=${String(editingAccount?.ownerAgentId || agentId || \"\").trim()}\n        initialProvider=${String(editingAccount?.provider || \"\").trim()}\n        onClose=${() => setEditingAccount(null)}\n        onSubmit=${handleUpdateChannel}\n      />\n      <${ConfirmDialog}\n        visible=${!!pendingBindAccount}\n        title=${`Bind ${String(pendingBindAccount?.name || \"this channel\").trim()} to ${String(agent?.name || agentId).trim()}?`}\n        message=\"\"\n        details=${pendingBindAccount\n          ? html`\n              <p class=\"text-xs text-fg-muted\">\n                This will remove access for ${String(\n                  pendingBindAccount?.ownerAgentName || \"the other agent\",\n                ).trim()} to this channel.\n              </p>\n            `\n          : null}\n        confirmLabel=\"Bind channel\"\n        confirmLoadingLabel=\"Binding...\"\n        confirmTone=\"warning\"\n        confirmLoading=${saving}\n        onConfirm=${() => handleQuickBind(pendingBindAccount)}\n        onCancel=${() => {\n          if (saving) return;\n          setPendingBindAccount(null);\n        }}\n      />\n      <${ConfirmDialog}\n        visible=${!!deletingAccount}\n        title=\"Delete channel?\"\n        message=${`Remove ${String(deletingAccount?.name || \"this channel\").trim()} from your configured channels?`}\n        confirmLabel=\"Delete\"\n        confirmLoadingLabel=\"Deleting...\"\n        confirmTone=\"warning\"\n        confirmLoading=${saving}\n        onConfirm=${handleDeleteChannel}\n        onCancel=${() => {\n          if (saving) return;\n          setDeletingAccount(null);\n        }}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-bindings-section/use-agent-bindings.js",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"preact/hooks\";\nimport {\n  deleteChannelAccount,\n  fetchChannelAccounts,\n  fetchStatus,\n  updateChannelAccount,\n} from \"../../../lib/api.js\";\nimport { createChannelAccountWithProgress } from \"../../../lib/channel-create-operation.js\";\nimport { showToast } from \"../../toast.js\";\nimport { announceBindingsChanged, announceRestartRequired } from \"./helpers.js\";\n\nexport const useAgentBindings = ({ agent = {}, agents = [] }) => {\n  const [channels, setChannels] = useState([]);\n  const [channelStatus, setChannelStatus] = useState({});\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [createLoadingLabel, setCreateLoadingLabel] = useState(\"Creating...\");\n  const [showCreateModal, setShowCreateModal] = useState(false);\n  const [createProvider, setCreateProvider] = useState(\"\");\n  const [menuOpenId, setMenuOpenId] = useState(\"\");\n  const [editingAccount, setEditingAccount] = useState(null);\n  const [deletingAccount, setDeletingAccount] = useState(null);\n  const [pendingBindAccount, setPendingBindAccount] = useState(null);\n\n  const agentId = String(agent?.id || \"\").trim();\n  const isDefaultAgent = !!agent?.default;\n  const defaultAgentId = useMemo(\n    () => String(agents.find((entry) => entry?.default)?.id || \"\").trim(),\n    [agents],\n  );\n  const agentNameMap = useMemo(\n    () =>\n      new Map(\n        agents.map((entry) => [\n          String(entry?.id || \"\").trim(),\n          String(entry?.name || \"\").trim() || String(entry?.id || \"\").trim(),\n        ]),\n      ),\n    [agents],\n  );\n\n  const load = useCallback(\n    async ({ includeStatus = true } = {}) => {\n      setLoading(true);\n      try {\n        const requests = [\n          fetchChannelAccounts(),\n          includeStatus ? fetchStatus() : Promise.resolve(null),\n        ];\n        const [channelsResult, statusResult] = await Promise.all(requests);\n        setChannels(\n          Array.isArray(channelsResult?.channels) ? channelsResult.channels : [],\n        );\n        if (includeStatus && statusResult) {\n          setChannelStatus(statusResult?.channels || {});\n        }\n      } finally {\n        setLoading(false);\n      }\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (!agentId) return;\n    load().catch(() => {});\n  }, [agentId, load]);\n\n  useEffect(() => {\n    const handlePairingsChanged = (event) => {\n      const changedAgentId = String(event?.detail?.agentId || \"\").trim();\n      if (changedAgentId && changedAgentId !== agentId) return;\n      load({ includeStatus: true }).catch(() => {});\n    };\n    window.addEventListener(\"alphaclaw:pairings-changed\", handlePairingsChanged);\n    return () => {\n      window.removeEventListener(\n        \"alphaclaw:pairings-changed\",\n        handlePairingsChanged,\n      );\n    };\n  }, [agentId, load]);\n\n  const configuredChannels = useMemo(\n    () =>\n      channels.filter(\n        (entry) =>\n          String(entry?.channel || \"\").trim() &&\n          Array.isArray(entry?.accounts) &&\n          entry.accounts.length > 0,\n      ),\n    [channels],\n  );\n\n  const configuredChannelMap = useMemo(\n    () =>\n      new Map(\n        configuredChannels.map((entry) => [\n          String(entry.channel || \"\").trim(),\n          entry,\n        ]),\n      ),\n    [configuredChannels],\n  );\n\n  const openCreateChannelModal = (channelId = \"\") => {\n    setMenuOpenId(\"\");\n    setCreateProvider(String(channelId || \"\").trim());\n    setShowCreateModal(true);\n  };\n\n  const openEditChannelModal = (account) => {\n    setMenuOpenId(\"\");\n    setEditingAccount(account);\n  };\n\n  const openDeleteChannelDialog = (account) => {\n    setMenuOpenId(\"\");\n    setDeletingAccount(account);\n  };\n\n  const handleCreateChannel = async (payload) => {\n    setSaving(true);\n    setCreateLoadingLabel(\"Creating...\");\n    try {\n      const result = await createChannelAccountWithProgress({\n        payload,\n        onPhase: (label) => {\n          setCreateLoadingLabel(String(label || \"\").trim() || \"Creating...\");\n        },\n      });\n      announceBindingsChanged(\n        String(result?.binding?.agentId || payload.agentId || \"\").trim(),\n      );\n      showToast(\"Channel added\", \"success\");\n      await load({ includeStatus: false });\n      setShowCreateModal(false);\n      setCreateProvider(\"\");\n    } catch (error) {\n      showToast(error.message || \"Could not add channel\", \"error\");\n    } finally {\n      setSaving(false);\n      setCreateLoadingLabel(\"Creating...\");\n    }\n  };\n\n  const handleUpdateChannel = async (payload) => {\n    setSaving(true);\n    try {\n      const result = await updateChannelAccount(payload);\n      setEditingAccount(null);\n      announceBindingsChanged(String(payload.agentId || \"\").trim());\n      showToast(\"Channel updated\", \"success\");\n      if (result?.restartRequired) {\n        announceRestartRequired();\n      }\n      await load();\n    } catch (error) {\n      showToast(error.message || \"Could not update channel\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleDeleteChannel = async () => {\n    if (!deletingAccount) return;\n    setSaving(true);\n    try {\n      await deleteChannelAccount({\n        provider: deletingAccount.provider,\n        accountId: deletingAccount.id,\n      });\n      setDeletingAccount(null);\n      announceBindingsChanged(agentId);\n      showToast(\"Channel deleted\", \"success\");\n      await load({ includeStatus: false });\n    } catch (error) {\n      showToast(error.message || \"Could not delete channel\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleQuickBind = async (account) => {\n    if (!account) return;\n    setSaving(true);\n    try {\n      await updateChannelAccount({\n        provider: account.provider,\n        accountId: account.id,\n        name: account.name,\n        agentId,\n      });\n      setMenuOpenId(\"\");\n      setPendingBindAccount(null);\n      announceBindingsChanged(agentId);\n      showToast(\"Channel bound\", \"success\");\n      await load();\n    } catch (error) {\n      showToast(error.message || \"Could not bind channel\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const requestBindAccount = (account) => {\n    if (!account) return;\n    const ownerAgentId = String(account?.ownerAgentId || \"\").trim();\n    const ownerAgentName = String(account?.ownerAgentName || \"\").trim();\n    if (ownerAgentId && ownerAgentId !== agentId && ownerAgentName) {\n      setMenuOpenId(\"\");\n      setPendingBindAccount(account);\n      return;\n    }\n    handleQuickBind(account);\n  };\n\n  return {\n    agentId,\n    agentNameMap,\n    channelStatus,\n    channels,\n    configuredChannelMap,\n    configuredChannels,\n    createLoadingLabel,\n    createProvider,\n    defaultAgentId,\n    deletingAccount,\n    editingAccount,\n    handleCreateChannel,\n    handleDeleteChannel,\n    handleQuickBind,\n    handleUpdateChannel,\n    isDefaultAgent,\n    loading,\n    menuOpenId,\n    openCreateChannelModal,\n    openDeleteChannelDialog,\n    openEditChannelModal,\n    pendingBindAccount,\n    requestBindAccount,\n    saving,\n    setCreateProvider,\n    setDeletingAccount,\n    setEditingAccount,\n    setMenuOpenId,\n    setPendingBindAccount,\n    setShowCreateModal,\n    showCreateModal,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  canAgentBindAccount,\n  getChannelItemSortRank,\n  getResolvedAccountStatusInfo,\n  isImplicitDefaultAccount,\n  resolveChannelAccountLabel,\n} from \"./helpers.js\";\n\nconst html = htm.bind(h);\n\nexport const useChannelItems = ({\n  agentId = \"\",\n  agentNameMap = new Map(),\n  channelStatus = {},\n  configuredChannelMap = new Map(),\n  configuredChannels = [],\n  defaultAgentId = \"\",\n  isDefaultAgent = false,\n}) => {\n  const [showAssignedElsewhere, setShowAssignedElsewhere] = useState(false);\n\n  const channelItemData = useMemo(() => {\n    const channelOrderMap = new Map(\n      configuredChannels.map((entry, index) => [\n        String(entry?.channel || \"\").trim(),\n        index,\n      ]),\n    );\n    const accountOrderMap = new Map(\n      configuredChannels.flatMap((entry) =>\n        (Array.isArray(entry?.accounts) ? entry.accounts : []).map(\n          (account, accountIndex) => [\n            `${String(entry?.channel || \"\").trim()}:${String(account?.id || \"\").trim() || \"default\"}`,\n            accountIndex,\n          ],\n        ),\n      ),\n    );\n    const channelIds = Array.from(\n      new Set([\n        ...configuredChannels.map((entry) => String(entry.channel || \"\").trim()),\n      ]),\n    ).filter(Boolean);\n\n    return channelIds\n      .flatMap((channelId) => {\n        const configuredChannel = configuredChannelMap.get(channelId);\n        const statusInfo = channelStatus?.[channelId] || null;\n        const accounts = Array.isArray(configuredChannel?.accounts)\n          ? configuredChannel.accounts\n          : [];\n\n        if (!configuredChannel && !statusInfo) return [];\n\n        return accounts.map((account) => {\n          const accountId = String(account?.id || \"\").trim() || \"default\";\n          const boundAgentId = String(account?.boundAgentId || \"\").trim();\n          const accountStatusInfo = getResolvedAccountStatusInfo({\n            account,\n            statusInfo,\n            accountId,\n          });\n          const isImplicitDefaultOwned =\n            isDefaultAgent &&\n            isImplicitDefaultAccount({ accountId, boundAgentId });\n          const isOwned = boundAgentId === agentId || isImplicitDefaultOwned;\n          const isImplicitDefaultElsewhere =\n            !isDefaultAgent &&\n            isImplicitDefaultAccount({ accountId, boundAgentId });\n          const isAvailable = canAgentBindAccount({\n            accountId,\n            boundAgentId,\n            agentId,\n            isDefaultAgent,\n          });\n          const ownerAgentId =\n            boundAgentId ||\n            (isImplicitDefaultAccount({ accountId, boundAgentId })\n              ? defaultAgentId\n              : \"\");\n          const ownerAgentName = String(\n            agentNameMap.get(ownerAgentId) || ownerAgentId || \"\",\n          ).trim();\n          const canNavigateToOwnerAgent =\n            !!ownerAgentId && ownerAgentId !== agentId && !!ownerAgentName;\n          const canOpenWorkspace =\n            channelId === \"telegram\" &&\n            isOwned &&\n            accountStatusInfo?.status === \"paired\";\n\n          const accountData = {\n            id: accountId,\n            provider: channelId,\n            name: resolveChannelAccountLabel({ channelId, account }),\n            rawName: String(account?.name || \"\").trim(),\n            ownerAgentId,\n            ownerAgentName,\n            boundAgentId,\n            isOwned,\n            envKey: String(account?.envKey || \"\").trim(),\n            token: String(account?.token || \"\").trim(),\n            isAvailable,\n            isBoundElsewhere:\n              !isOwned &&\n              (!isAvailable || isImplicitDefaultElsewhere || !!ownerAgentId),\n          };\n\n          return {\n            id: `${channelId}:${accountId}`,\n            channel: channelId,\n            accountId,\n            channelOrder: Number(channelOrderMap.get(channelId) ?? 9999),\n            accountOrder: Number(\n              accountOrderMap.get(`${channelId}:${accountId}`) ?? 9999,\n            ),\n            label: resolveChannelAccountLabel({ channelId, account }),\n            isAwaitingPairing: accountStatusInfo?.status !== \"paired\",\n            canOpenWorkspace,\n            canNavigateToOwnerAgent,\n            ownerAgentId,\n            ownerAgentName,\n            accountStatusInfo,\n            accountData,\n            isOwned,\n            isAvailable,\n            dimmedLabel: accountData.isBoundElsewhere,\n            isBoundElsewhere: accountData.isBoundElsewhere,\n          };\n        });\n      })\n      .filter(Boolean)\n      .sort((a, b) => {\n        const rankDiff = getChannelItemSortRank(a) - getChannelItemSortRank(b);\n        if (rankDiff !== 0) return rankDiff;\n        const channelOrderDiff =\n          Number(a?.channelOrder ?? 9999) - Number(b?.channelOrder ?? 9999);\n        if (channelOrderDiff !== 0) return channelOrderDiff;\n        const accountOrderDiff =\n          Number(a?.accountOrder ?? 9999) - Number(b?.accountOrder ?? 9999);\n        if (accountOrderDiff !== 0) return accountOrderDiff;\n        return String(a?.label || \"\").localeCompare(String(b?.label || \"\"));\n      });\n  }, [\n    agentId,\n    agentNameMap,\n    channelStatus,\n    configuredChannelMap,\n    configuredChannels,\n    defaultAgentId,\n    isDefaultAgent,\n  ]);\n\n  const visibleChannelItems = channelItemData.filter(\n    (item) => !item?.isBoundElsewhere,\n  );\n  const assignedElsewhereItems = channelItemData.filter(\n    (item) => !!item?.isBoundElsewhere,\n  );\n\n  useEffect(() => {\n    if (assignedElsewhereItems.length === 0) {\n      setShowAssignedElsewhere(false);\n      return;\n    }\n    if (visibleChannelItems.length === 0) {\n      setShowAssignedElsewhere(true);\n    }\n  }, [agentId, assignedElsewhereItems.length, visibleChannelItems.length]);\n\n  const mergedChannelItems = useMemo(() => {\n    const baseItems = [...visibleChannelItems];\n    if (assignedElsewhereItems.length === 0) return baseItems;\n    baseItems.push({\n      id: \"__assigned_elsewhere_toggle\",\n      label: html`\n        <span class=\"inline-flex items-center gap-1.5\">\n          <span class=${`arrow inline-block ${showAssignedElsewhere ? \"\" : \"-rotate-90\"}`}>▼</span>\n          <span>Assigned elsewhere</span>\n        </span>\n      `,\n      labelClassName: \"text-xs\",\n      clickable: true,\n      onClick: () => setShowAssignedElsewhere((current) => !current),\n      dimmedLabel: true,\n      trailing: html`\n        <span class=\"inline-flex items-center gap-1.5 text-fg-muted\">\n          <span class=\"text-[11px] px-2 py-0.5 rounded-full border border-border\">\n            ${assignedElsewhereItems.length}\n          </span>\n        </span>\n      `,\n    });\n    if (showAssignedElsewhere) {\n      baseItems.push(...assignedElsewhereItems);\n    }\n    return baseItems;\n  }, [assignedElsewhereItems, showAssignedElsewhere, visibleChannelItems]);\n\n  return {\n    mergedChannelItems,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-detail-panel.js",
    "content": "import { h } from \"preact\";\nimport { useState, useCallback, useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { Badge } from \"../badge.js\";\nimport { PillTabs } from \"../pill-tabs.js\";\nimport { PopActions } from \"../pop-actions.js\";\nimport { AgentOverview } from \"./agent-overview/index.js\";\nimport { AgentToolsPanel } from \"./agent-tools/index.js\";\nimport { useAgentTools } from \"./agent-tools/use-agent-tools.js\";\n\nconst html = htm.bind(h);\n\nconst kDetailTabs = [\n  { label: \"Overview\", value: \"overview\" },\n  { label: \"Tools\", value: \"tools\" },\n];\n\nconst PencilIcon = ({ className = \"w-3.5 h-3.5\" }) => html`\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    class=${className}\n  >\n    <path\n      d=\"M15.7279 9.57627L14.3137 8.16206L5 17.4758V18.89H6.41421L15.7279 9.57627ZM17.1421 8.16206L18.5563 6.74785L17.1421 5.33363L15.7279 6.74785L17.1421 8.16206ZM7.24264 20.89H3V16.6473L16.435 3.21231C16.8256 2.82179 17.4587 2.82179 17.8492 3.21231L20.6777 6.04074C21.0682 6.43126 21.0682 7.06443 20.6777 7.45495L7.24264 20.89Z\"\n    />\n  </svg>\n`;\n\nexport const AgentDetailPanel = ({\n  agent = null,\n  agents = [],\n  activeTab = \"overview\",\n  saving = false,\n  onUpdateAgent = async () => {},\n  onSetLocation = () => {},\n  onSelectTab = () => {},\n  onEdit = () => {},\n  onDelete = () => {},\n  onSetDefault = () => {},\n  onOpenWorkspace = () => {},\n}) => {\n  const tools = useAgentTools({ agent: agent || {} });\n  const [savingTools, setSavingTools] = useState(false);\n\n  const handleSaveTools = useCallback(async () => {\n    if (!agent) return;\n    setSavingTools(true);\n    try {\n      const nextAgent = await onUpdateAgent(\n        agent.id,\n        { tools: tools.toolsConfig },\n        \"Tool access updated\",\n      );\n      tools.markSaved(nextAgent?.tools || tools.toolsConfig);\n    } catch {\n      // toast handled by parent\n    } finally {\n      setSavingTools(false);\n    }\n  }, [agent, tools.toolsConfig, tools.markSaved, onUpdateAgent]);\n\n  const isSaving = saving || savingTools;\n\n  const toolsSummary = useMemo(() => ({\n    profile: tools.profile,\n    enabledCount: (tools.toolStates || []).filter((t) => t.enabled).length,\n    totalCount: (tools.toolStates || []).length,\n  }), [tools.profile, tools.toolStates]);\n\n  if (!agent) {\n    return html`\n      <div class=\"agents-detail-panel\">\n        <div class=\"agents-empty-state\">\n          <span class=\"text-sm\">Select an agent to view details</span>\n        </div>\n      </div>\n    `;\n  }\n\n  return html`\n    <div class=\"agents-detail-panel\">\n      <div class=\"agents-detail-header-area\">\n        <div class=\"agents-detail-header-area-inner\">\n          <div class=\"agents-detail-header\">\n            <div class=\"min-w-0\">\n              <div class=\"flex items-center gap-2 min-w-0\">\n                <span class=\"agents-detail-header-title\">\n                  ${agent.name || agent.id}\n                </span>\n                <button\n                  type=\"button\"\n                  class=\"text-fg-muted hover:text-body transition-colors p-0.5 -ml-0.5\"\n                  onclick=${() => onEdit(agent)}\n                  title=\"Edit agent name\"\n                >\n                  <${PencilIcon} />\n                </button>\n                ${agent.default\n                  ? html`<${Badge} tone=\"cyan\">Default</${Badge}>`\n                  : null}\n              </div>\n              <div class=\"mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 min-w-0 text-xs text-fg-muted\">\n                <span class=\"font-mono\">${agent.id}</span>\n              </div>\n            </div>\n            <${PopActions} visible=${tools.dirty}>\n              <${ActionButton}\n                onClick=${tools.reset}\n                disabled=${isSaving}\n                tone=\"secondary\"\n                size=\"sm\"\n                idleLabel=\"Cancel\"\n                className=\"text-xs\"\n              />\n              <${ActionButton}\n                onClick=${handleSaveTools}\n                disabled=${isSaving}\n                loading=${isSaving}\n                loadingMode=\"inline\"\n                tone=\"primary\"\n                size=\"sm\"\n                idleLabel=\"Save changes\"\n                loadingLabel=\"Saving…\"\n                className=\"text-xs\"\n              />\n            </${PopActions}>\n          </div>\n          <${PillTabs}\n            tabs=${kDetailTabs}\n            activeTab=${activeTab}\n            onSelectTab=${onSelectTab}\n            className=\"flex items-center gap-2 pt-6\"\n          />\n        </div>\n      </div>\n      <div class=\"agents-detail-body\">\n        <div class=\"agents-detail-content\">\n          ${activeTab === \"overview\"\n            ? html`\n                <${AgentOverview}\n                  agent=${agent}\n                  agents=${agents}\n                  saving=${saving}\n                  toolsSummary=${toolsSummary}\n                  onUpdateAgent=${onUpdateAgent}\n                  onSetLocation=${onSetLocation}\n                  onOpenWorkspace=${onOpenWorkspace}\n                  onSwitchToModels=${() => onSetLocation(\"/models\")}\n                  onSwitchToTools=${() => onSelectTab(\"tools\")}\n                  onSetDefault=${onSetDefault}\n                  onDelete=${onDelete}\n                />\n              `\n            : html`\n                <${AgentToolsPanel}\n                  agent=${agent}\n                  tools=${tools}\n                />\n              `}\n        </div>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-identity-section.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\n\nconst html = htm.bind(h);\n\nconst kPropertyRowClass =\n  \"flex items-start justify-between gap-4 py-2.5 border-b border-border last:border-b-0\";\nconst kLabelClass = \"text-xs text-fg-muted shrink-0 w-28\";\nconst kValueClass = \"text-sm text-body text-right min-w-0 break-all\";\n\nconst normalizeIdentity = (identity = {}) => ({\n  name: String(identity?.name || \"\").trim(),\n  emoji: String(identity?.emoji || \"\").trim(),\n  avatar: String(identity?.avatar || \"\").trim(),\n  theme: String(identity?.theme || \"\").trim(),\n});\n\nexport const AgentIdentitySection = ({\n  agent = {},\n  saving = false,\n  onUpdateAgent = async () => {},\n}) => {\n  const [editing, setEditing] = useState(false);\n  const [form, setForm] = useState(() => normalizeIdentity(agent.identity));\n  const [error, setError] = useState(\"\");\n\n  useEffect(() => {\n    setEditing(false);\n    setError(\"\");\n    setForm(normalizeIdentity(agent.identity));\n  }, [agent.id, agent.identity]);\n\n  const identity = normalizeIdentity(agent.identity);\n\n  const updateField = (key, value) => {\n    setForm((current) => ({\n      ...current,\n      [key]: value,\n    }));\n  };\n\n  const handleSave = async () => {\n    setError(\"\");\n    try {\n      const nextIdentity = normalizeIdentity(form);\n      await onUpdateAgent(String(agent.id || \"\").trim(), {\n        identity: nextIdentity,\n      });\n      setEditing(false);\n    } catch (nextError) {\n      setError(nextError.message || \"Could not save identity\");\n    }\n  };\n\n  const handleCancel = () => {\n    setEditing(false);\n    setError(\"\");\n    setForm(normalizeIdentity(agent.identity));\n  };\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <div class=\"flex items-center justify-between gap-3 mb-3\">\n        <h3 class=\"card-label\">Identity</h3>\n        ${editing\n          ? html`\n              <div class=\"flex items-center gap-2\">\n                <${ActionButton}\n                  onClick=${handleCancel}\n                  disabled=${saving}\n                  tone=\"secondary\"\n                  size=\"sm\"\n                  idleLabel=\"Cancel\"\n                />\n                <${ActionButton}\n                  onClick=${handleSave}\n                  disabled=${saving}\n                  loading=${saving}\n                  tone=\"primary\"\n                  size=\"sm\"\n                  idleLabel=\"Save\"\n                  loadingLabel=\"Saving...\"\n                />\n              </div>\n            `\n          : html`\n              <${ActionButton}\n                onClick=${() => setEditing(true)}\n                disabled=${saving}\n                tone=\"secondary\"\n                size=\"sm\"\n                idleLabel=\"Edit identity\"\n              />\n            `}\n      </div>\n\n      ${editing\n        ? html`\n            <div class=\"space-y-3\">\n              <label class=\"block space-y-1\">\n                <span class=\"text-xs text-fg-muted\">Identity name</span>\n                <input\n                  type=\"text\"\n                  value=${form.name}\n                  onInput=${(event) => updateField(\"name\", event.target.value)}\n                  class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n                  placeholder=\"Optional persona name\"\n                />\n              </label>\n              <label class=\"block space-y-1\">\n                <span class=\"text-xs text-fg-muted\">Emoji</span>\n                <input\n                  type=\"text\"\n                  value=${form.emoji}\n                  onInput=${(event) => updateField(\"emoji\", event.target.value)}\n                  class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n                  placeholder=\"Single emoji, e.g. ✨\"\n                />\n              </label>\n              <label class=\"block space-y-1\">\n                <span class=\"text-xs text-fg-muted\">Avatar</span>\n                <input\n                  type=\"text\"\n                  value=${form.avatar}\n                  onInput=${(event) => updateField(\"avatar\", event.target.value)}\n                  class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n                  placeholder=\"Workspace-relative path, URL, or data URI\"\n                />\n              </label>\n              <label class=\"block space-y-1\">\n                <span class=\"text-xs text-fg-muted\">Theme</span>\n                <input\n                  type=\"text\"\n                  value=${form.theme}\n                  onInput=${(event) => updateField(\"theme\", event.target.value)}\n                  class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n                  placeholder=\"Optional persona theme\"\n                />\n              </label>\n              ${error ? html`<p class=\"text-xs text-status-error-muted\">${error}</p>` : null}\n            </div>\n          `\n        : html`\n            <div class=\"divide-y divide-border\">\n              <div class=${kPropertyRowClass}>\n                <span class=${kLabelClass}>Name</span>\n                <span class=${kValueClass}>\n                  ${identity.name || html`<span class=\"text-fg-muted\">—</span>`}\n                </span>\n              </div>\n              <div class=${kPropertyRowClass}>\n                <span class=${kLabelClass}>Emoji</span>\n                <span class=${kValueClass}>\n                  ${identity.emoji || html`<span class=\"text-fg-muted\">—</span>`}\n                </span>\n              </div>\n              <div class=${kPropertyRowClass}>\n                <span class=${kLabelClass}>Avatar</span>\n                <span class=\"${kValueClass} font-mono\">\n                  ${identity.avatar || html`<span class=\"text-fg-muted\">—</span>`}\n                </span>\n              </div>\n              <div class=${kPropertyRowClass}>\n                <span class=${kLabelClass}>Theme</span>\n                <span class=${kValueClass}>\n                  ${identity.theme || html`<span class=\"text-fg-muted\">—</span>`}\n                </span>\n              </div>\n            </div>\n          `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ChannelOperationsPanel } from \"../../channel-operations-panel.js\";\nimport { ManageCard } from \"./manage-card.js\";\nimport { AgentModelCard } from \"./model-card.js\";\nimport { AgentToolsCard } from \"./tools-card.js\";\nimport { WorkspaceCard } from \"./workspace-card.js\";\n\nconst html = htm.bind(h);\n\nexport const AgentOverview = ({\n  agent = {},\n  agents = [],\n  saving = false,\n  toolsSummary = {},\n  onUpdateAgent = async () => {},\n  onSetLocation = () => {},\n  onOpenWorkspace = () => {},\n  onSwitchToModels = () => {},\n  onSwitchToTools = () => {},\n  onSetDefault = () => {},\n  onDelete = () => {},\n}) => {\n  const isMain = String(agent.id || \"\") === \"main\";\n  const showManageSection = !agent.default || !isMain;\n\n  return html`\n    <div class=\"space-y-4\">\n      <${WorkspaceCard}\n        agent=${agent}\n        onOpenWorkspace=${onOpenWorkspace}\n      />\n      <${AgentModelCard}\n        agent=${agent}\n        saving=${saving}\n        onUpdateAgent=${onUpdateAgent}\n        onSwitchToModels=${onSwitchToModels}\n      />\n      <${AgentToolsCard}\n        profile=${toolsSummary.profile || \"full\"}\n        enabledCount=${toolsSummary.enabledCount || 0}\n        totalCount=${toolsSummary.totalCount || 0}\n        onSwitchToTools=${onSwitchToTools}\n      />\n      <${ChannelOperationsPanel}\n        agent=${agent}\n        agents=${agents}\n        onSetLocation=${onSetLocation}\n      />\n      ${showManageSection\n        ? html`\n            <${ManageCard}\n              agent=${agent}\n              saving=${saving}\n              onSetDefault=${onSetDefault}\n              onDelete=${onDelete}\n            />\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/manage-card.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../../action-button.js\";\n\nconst html = htm.bind(h);\n\nexport const ManageCard = ({\n  agent = {},\n  saving = false,\n  onSetDefault = () => {},\n  onDelete = () => {},\n}) => {\n  const isMain = String(agent.id || \"\") === \"main\";\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <h3 class=\"card-label mb-3\">Manage</h3>\n      <div class=\"flex flex-wrap items-center gap-2\">\n        ${!agent.default\n          ? html`\n              <${ActionButton}\n                onClick=${() => onSetDefault(agent.id)}\n                disabled=${saving}\n                tone=\"secondary\"\n                size=\"sm\"\n                idleLabel=\"Set as default\"\n              />\n            `\n          : null}\n        ${!isMain\n          ? html`\n              <${ActionButton}\n                onClick=${() => onDelete(agent)}\n                disabled=${saving}\n                tone=\"danger\"\n                size=\"sm\"\n                idleLabel=\"Delete agent\"\n              />\n            `\n          : null}\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/model-card.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Badge } from \"../../badge.js\";\nimport { LoadingSpinner } from \"../../loading-spinner.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"../../overflow-menu.js\";\nimport {\n  getModelDisplayLabel,\n  SearchableModelPicker,\n} from \"../../models-tab/model-picker.js\";\nimport { useModelCard } from \"./use-model-card.js\";\n\nconst html = htm.bind(h);\n\nexport const AgentModelCard = ({\n  agent = {},\n  saving = false,\n  onUpdateAgent = async () => {},\n  onSwitchToModels = () => {},\n}) => {\n  const {\n    authorizedModelOptions,\n    canEditModel,\n    effectiveModel,\n    effectiveModelEntry,\n    handleClearModelOverride,\n    handleSelectModel,\n    hasDistinctModelOverride,\n    loading,\n    menuOpen,\n    modelEntries,\n    popularModels,\n    remainingModelOptions,\n    setMenuOpen,\n    updatingModel,\n  } = useModelCard({\n    agent,\n    onUpdateAgent,\n  });\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <h3 class=\"card-label\">Model</h3>\n        ${loading\n          ? null\n          : html`\n              <div class=\"flex items-center gap-2 min-h-6\">\n                ${effectiveModelEntry && !hasDistinctModelOverride\n                  ? html`<${Badge} tone=\"neutral\">Inherited</${Badge}>`\n                  : null}\n                <${OverflowMenu}\n                  open=${menuOpen}\n                  ariaLabel=\"Open model actions\"\n                  title=\"Open model actions\"\n                  onClose=${() => setMenuOpen(false)}\n                  onToggle=${() => setMenuOpen((current) => !current)}\n                >\n                  ${hasDistinctModelOverride\n                    ? html`\n                        <${OverflowMenuItem}\n                          onClick=${() => {\n                            setMenuOpen(false);\n                            handleClearModelOverride();\n                          }}\n                        >\n                          Inherit from defaults\n                        </${OverflowMenuItem}>\n                      `\n                    : null}\n                  <${OverflowMenuItem}\n                    onClick=${() => {\n                      setMenuOpen(false);\n                      onSwitchToModels();\n                    }}\n                  >\n                    Manage models\n                  </${OverflowMenuItem}>\n                </${OverflowMenu}>\n              </div>\n            `}\n      </div>\n      ${loading\n        ? html`\n            <div class=\"flex items-center gap-2 text-sm text-fg-muted py-1\">\n              <${LoadingSpinner} className=\"h-4 w-4\" />\n              Loading model settings...\n            </div>\n          `\n        : modelEntries.length === 0\n          ? html`<p class=\"text-xs text-fg-muted\">\n              No authorized models available yet. Add one from the Models tab\n              first.\n            </p>`\n          : html`\n              <div class=\"space-y-1\">\n                ${modelEntries.map(\n                  (entry) => html`\n                    <div\n                      key=${entry.key}\n                      class=\"flex items-center justify-between py-1\"\n                    >\n                      <div class=\"flex items-center gap-2 min-w-0\">\n                        <span class=\"text-sm text-body truncate\">\n                          ${getModelDisplayLabel(entry)}\n                        </span>\n                        ${entry.key === effectiveModel\n                          ? html`<${Badge} tone=\"cyan\">Primary</${Badge}>`\n                          : html`\n                              <button\n                                type=\"button\"\n                                onclick=${() => handleSelectModel(entry.key)}\n                                class=\"text-xs px-2 py-0.5 rounded-full text-fg-muted hover:text-body hover:bg-surface\"\n                              >\n                                Set primary\n                              </button>\n                            `}\n                      </div>\n                    </div>\n                  `,\n                )}\n              </div>\n            `}\n      ${loading\n        ? null\n        : remainingModelOptions.length > 0\n          ? html`\n              <div class=\"space-y-2\">\n                <${SearchableModelPicker}\n                  options=${remainingModelOptions}\n                  popularModels=${popularModels}\n                  placeholder=${authorizedModelOptions.length > 0\n                    ? \"Add model...\"\n                    : \"No authorized models available\"}\n                  onSelect=${handleSelectModel}\n                  disabled=${saving ||\n                  updatingModel ||\n                  !canEditModel ||\n                  remainingModelOptions.length === 0}\n                />\n                ${authorizedModelOptions.length === 0\n                  ? html`\n                      <p class=\"text-xs text-fg-muted\">\n                        Add and authorize models from the Models tab before\n                        assigning one here.\n                      </p>\n                    `\n                  : html`\n                      <p class=\"text-xs text-fg-muted\">\n                        Only models that already have working auth are\n                        available here.\n                      </p>\n                    `}\n              </div>\n            `\n          : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/tools-card.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { kProfileLabels } from \"../agent-tools/tool-catalog.js\";\n\nconst html = htm.bind(h);\n\nexport const AgentToolsCard = ({\n  profile = \"full\",\n  enabledCount = 0,\n  totalCount = 0,\n  onSwitchToTools = () => {},\n}) => {\n  const profileLabel = kProfileLabels[profile] || profile;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <h2 class=\"card-label mb-3\">Tools</h2>\n      <div\n        class=\"flex items-center justify-between gap-3 cursor-pointer hover:bg-surface -mx-2 px-2 py-1.5 rounded-lg transition-colors\"\n        role=\"button\"\n        tabindex=\"0\"\n        onclick=${onSwitchToTools}\n        onKeyDown=${(e) => {\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault();\n            onSwitchToTools();\n          }\n        }}\n      >\n        <span class=\"font-medium text-sm\">${profileLabel}</span>\n        <span class=\"flex items-center gap-2 shrink-0\">\n          <span class=\"text-xs text-fg-muted\">\n            ${enabledCount}/${totalCount} enabled\n          </span>\n          <svg\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            class=\"text-fg-dim\"\n          >\n            <path\n              d=\"M6 3.5L10.5 8L6 12.5\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n            />\n          </svg>\n        </span>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/use-model-card.js",
    "content": "import { useEffect, useMemo, useState } from \"preact/hooks\";\nimport { useModels } from \"../../models-tab/use-models.js\";\nimport {\n  buildProviderHasAuth,\n  buildSyntheticModelEntry,\n  getModelCatalogProvider,\n  getModelsTabAuthProvider,\n  getProviderSortIndex,\n} from \"../../models-tab/model-picker.js\";\n\nconst resolveModelDisplay = (model) => {\n  if (!model) return null;\n  if (typeof model === \"string\") return model;\n  return model.primary || null;\n};\n\nconst resolveCatalogModel = (catalog = [], modelKey = \"\") =>\n  catalog.find(\n    (model) =>\n      String(model?.key || \"\").trim() === String(modelKey || \"\").trim(),\n  ) || null;\n\nexport const useModelCard = ({\n  agent = {},\n  onUpdateAgent = async () => {},\n}) => {\n  const [updatingModel, setUpdatingModel] = useState(false);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const {\n    catalog,\n    primary: defaultPrimaryModel,\n    configuredModels,\n    authProfiles,\n    codexStatus,\n    loading: loadingModels,\n    ready: modelsReady,\n  } = useModels();\n\n  const explicitModel = resolveModelDisplay(agent.model);\n  const effectiveModel = explicitModel || defaultPrimaryModel || \"\";\n  const hasDistinctModelOverride =\n    !!explicitModel &&\n    String(explicitModel).trim() !== String(defaultPrimaryModel || \"\").trim();\n\n  const providerHasAuth = useMemo(\n    () => buildProviderHasAuth({ authProfiles, codexStatus }),\n    [authProfiles, codexStatus],\n  );\n\n  const authorizedModelOptions = useMemo(\n    () =>\n      Object.keys(configuredModels || {})\n        .map(\n          (modelKey) =>\n            resolveCatalogModel(catalog, modelKey) ||\n            buildSyntheticModelEntry(modelKey),\n        )\n        .filter((model) => {\n          const provider = getModelsTabAuthProvider(model.key);\n          return !!providerHasAuth[provider];\n        })\n        .sort((left, right) => {\n          const providerCompare =\n            getProviderSortIndex(getModelCatalogProvider(left)) -\n            getProviderSortIndex(getModelCatalogProvider(right));\n          if (providerCompare !== 0) return providerCompare;\n          return String(left?.label || left?.key).localeCompare(\n            String(right?.label || right?.key),\n          );\n        }),\n    [catalog, configuredModels, providerHasAuth],\n  );\n\n  const effectiveModelEntry = useMemo(\n    () =>\n      resolveCatalogModel(catalog, effectiveModel) ||\n      (effectiveModel ? buildSyntheticModelEntry(effectiveModel) : null),\n    [catalog, effectiveModel],\n  );\n\n  const popularModels = useMemo(\n    () =>\n      authorizedModelOptions.filter((model) => {\n        const normalizedProvider = getModelCatalogProvider(model);\n        return (\n          normalizedProvider === \"anthropic\" || normalizedProvider === \"openai\"\n        );\n      }),\n    [authorizedModelOptions],\n  );\n\n  const modelEntries = useMemo(() => {\n    if (!effectiveModelEntry) return [];\n    const currentKey = String(effectiveModelEntry?.key || \"\").trim();\n    const rest = authorizedModelOptions.filter(\n      (model) => String(model?.key || \"\").trim() !== currentKey,\n    );\n    return [effectiveModelEntry, ...rest];\n  }, [authorizedModelOptions, effectiveModelEntry]);\n\n  const modelEntryKeySet = useMemo(\n    () =>\n      new Set(\n        modelEntries\n          .map((entry) => String(entry?.key || \"\").trim())\n          .filter(Boolean),\n      ),\n    [modelEntries],\n  );\n\n  const remainingModelOptions = useMemo(\n    () =>\n      authorizedModelOptions.filter(\n        (model) => !modelEntryKeySet.has(String(model?.key || \"\").trim()),\n      ),\n    [authorizedModelOptions, modelEntryKeySet],\n  );\n\n  const handleSelectModel = async (modelKey) => {\n    const normalizedModelKey = String(modelKey || \"\").trim();\n    if (!normalizedModelKey || normalizedModelKey === effectiveModel) return;\n    setUpdatingModel(true);\n    try {\n      await onUpdateAgent(\n        String(agent.id || \"\").trim(),\n        {\n          model: { primary: normalizedModelKey },\n        },\n        \"Agent model updated\",\n      );\n    } finally {\n      setUpdatingModel(false);\n    }\n  };\n\n  const handleClearModelOverride = async () => {\n    if (!hasDistinctModelOverride) return;\n    setUpdatingModel(true);\n    try {\n      await onUpdateAgent(\n        String(agent.id || \"\").trim(),\n        {\n          model: null,\n        },\n        \"Agent model reset to default\",\n      );\n    } finally {\n      setUpdatingModel(false);\n    }\n  };\n\n  return {\n    authorizedModelOptions,\n    canEditModel: modelsReady && !loadingModels,\n    effectiveModel,\n    effectiveModelEntry,\n    handleClearModelOverride,\n    handleSelectModel,\n    hasDistinctModelOverride,\n    loading: !modelsReady || loadingModels,\n    menuOpen,\n    modelEntries,\n    popularModels,\n    remainingModelOptions,\n    setMenuOpen,\n    updatingModel,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/use-workspace-card.js",
    "content": "import { useEffect, useState } from \"preact/hooks\";\nimport { fetchAgentWorkspaceSize } from \"../../../lib/api.js\";\n\nexport const useWorkspaceCard = ({ agent = {} }) => {\n  const [workspaceSizeBytes, setWorkspaceSizeBytes] = useState(null);\n  const [workspaceSizeExists, setWorkspaceSizeExists] = useState(true);\n  const [loadingWorkspaceSize, setLoadingWorkspaceSize] = useState(false);\n\n  useEffect(() => {\n    let cancelled = false;\n    const agentId = String(agent?.id || \"\").trim();\n    const workspacePath = String(agent?.workspace || \"\").trim();\n    if (!agentId || !workspacePath) {\n      setWorkspaceSizeBytes(null);\n      setWorkspaceSizeExists(true);\n      setLoadingWorkspaceSize(false);\n      return undefined;\n    }\n    setLoadingWorkspaceSize(true);\n    fetchAgentWorkspaceSize(agentId)\n      .then((result) => {\n        if (cancelled) return;\n        setWorkspaceSizeBytes(Number(result?.sizeBytes || 0));\n        setWorkspaceSizeExists(result?.exists !== false);\n      })\n      .catch(() => {\n        if (cancelled) return;\n        setWorkspaceSizeBytes(null);\n        setWorkspaceSizeExists(false);\n      })\n      .finally(() => {\n        if (cancelled) return;\n        setLoadingWorkspaceSize(false);\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [agent?.id, agent?.workspace]);\n\n  return {\n    loadingWorkspaceSize,\n    workspaceSizeBytes,\n    workspaceSizeExists,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-overview/workspace-card.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { formatBytes } from \"../../../lib/format.js\";\nimport { useWorkspaceCard } from \"./use-workspace-card.js\";\n\nconst html = htm.bind(h);\n\nexport const WorkspaceCard = ({\n  agent = {},\n  onOpenWorkspace = () => {},\n}) => {\n  const {\n    loadingWorkspaceSize,\n    workspaceSizeBytes,\n    workspaceSizeExists,\n  } = useWorkspaceCard({ agent });\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-2\">\n      <h3 class=\"card-label\">Workspace</h3>\n      ${agent.workspace\n        ? html`\n            <div\n              class=\"flex flex-col gap-1 md:flex-row md:items-start md:justify-between md:gap-3\"\n            >\n              <button\n                type=\"button\"\n                class=\"text-sm font-mono break-all text-left ac-tip-link hover:underline md:min-w-0\"\n                onclick=${() => onOpenWorkspace(agent.workspace)}\n              >\n                ${agent.workspace}\n              </button>\n              <div class=\"text-xs text-fg-muted md:shrink-0 md:text-right\">\n                ${loadingWorkspaceSize\n                  ? \"Calculating size...\"\n                  : workspaceSizeBytes != null\n                    ? formatBytes(workspaceSizeBytes)\n                    : workspaceSizeExists\n                      ? \"Size unavailable\"\n                      : \"Workspace directory not found\"}\n              </div>\n            </div>\n          `\n        : html`<p class=\"text-sm text-fg-muted\">No workspace configured</p>`}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-pairing-section.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport { Pairings } from \"../pairings.js\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport {\n  approvePairing,\n  fetchAgentBindings,\n  fetchChannelAccounts,\n  fetchPairings,\n  rejectPairing,\n} from \"../../lib/api.js\";\nimport { showToast } from \"../toast.js\";\nimport { useCachedFetch } from \"../../hooks/use-cached-fetch.js\";\n\nconst html = htm.bind(h);\n\nconst toOwnedAccountKey = (channel, accountId) => {\n  const normalizedChannel = String(channel || \"\").trim();\n  const normalizedAccountId = String(accountId || \"\").trim() || \"default\";\n  return normalizedChannel ? `${normalizedChannel}:${normalizedAccountId}` : \"\";\n};\n\nconst announcePairingsChanged = (agentId) => {\n  window.dispatchEvent(\n    new CustomEvent(\"alphaclaw:pairings-changed\", {\n      detail: { agentId: String(agentId || \"\").trim() },\n    }),\n  );\n};\n\nexport const AgentPairingSection = ({ agent = {} }) => {\n  const [bindings, setBindings] = useState([]);\n  const [channels, setChannels] = useState([]);\n  const [loadingBindings, setLoadingBindings] = useState(true);\n  const [pairingStatusRefreshing, setPairingStatusRefreshing] = useState(false);\n  const pairingRefreshTimerRef = useRef(null);\n  const pairingDelayedRefreshTimerRefs = useRef([]);\n  const agentId = String(agent?.id || \"\").trim();\n  const isDefaultAgent = !!agent?.default;\n  const {\n    data: bindingsPayload,\n    loading: bindingsLoading,\n    refresh: refreshBindingsPayload,\n  } = useCachedFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}/bindings`,\n    () => fetchAgentBindings(agent.id),\n    {\n      enabled: Boolean(agentId),\n      maxAgeMs: 30000,\n    },\n  );\n  const {\n    data: channelsPayload,\n    loading: channelsLoading,\n    refresh: refreshChannelsPayload,\n  } = useCachedFetch(\"/api/channels/accounts\", fetchChannelAccounts, {\n    maxAgeMs: 30000,\n  });\n\n  const loadBindings = useCallback(async () => {\n    setLoadingBindings(true);\n    try {\n      const [nextBindingsPayload, nextChannelsPayload] = await Promise.all([\n        refreshBindingsPayload({ force: true }),\n        refreshChannelsPayload({ force: true }),\n      ]);\n      setBindings(\n        Array.isArray(nextBindingsPayload?.bindings)\n          ? nextBindingsPayload.bindings\n          : [],\n      );\n      setChannels(\n        Array.isArray(nextChannelsPayload?.channels)\n          ? nextChannelsPayload.channels\n          : [],\n      );\n    } catch {\n      setBindings([]);\n      setChannels([]);\n    } finally {\n      setLoadingBindings(false);\n    }\n  }, [refreshBindingsPayload, refreshChannelsPayload]);\n\n  useEffect(() => {\n    setBindings(\n      Array.isArray(bindingsPayload?.bindings) ? bindingsPayload.bindings : [],\n    );\n    setChannels(\n      Array.isArray(channelsPayload?.channels) ? channelsPayload.channels : [],\n    );\n    setLoadingBindings(Boolean(bindingsLoading || channelsLoading));\n  }, [bindingsLoading, bindingsPayload, channelsLoading, channelsPayload]);\n\n  useEffect(() => {\n    const handleBindingsChanged = (event) => {\n      const changedAgentId = String(event?.detail?.agentId || \"\").trim();\n      if (changedAgentId !== agentId) return;\n      loadBindings();\n    };\n    window.addEventListener(\"alphaclaw:agent-bindings-changed\", handleBindingsChanged);\n    return () => {\n      window.removeEventListener(\"alphaclaw:agent-bindings-changed\", handleBindingsChanged);\n    };\n  }, [agentId, loadBindings]);\n  useEffect(\n    () => () => {\n      if (pairingRefreshTimerRef.current) {\n        clearTimeout(pairingRefreshTimerRef.current);\n      }\n      for (const timerId of pairingDelayedRefreshTimerRefs.current) {\n        clearTimeout(timerId);\n      }\n      pairingDelayedRefreshTimerRefs.current = [];\n    },\n    [],\n  );\n\n  const ownedAccounts = useMemo(\n    () => {\n      const ownedAccountMap = new Map();\n      for (const binding of bindings) {\n        const channelId = String(binding?.match?.channel || \"\").trim();\n        if (!channelId) continue;\n        const accountId = String(binding?.match?.accountId || \"\").trim() || \"default\";\n        const key = toOwnedAccountKey(channelId, accountId);\n        if (!key) continue;\n        ownedAccountMap.set(key, { channel: channelId, accountId });\n      }\n      for (const channel of channels) {\n        const channelId = String(channel?.channel || \"\").trim();\n        const accounts = Array.isArray(channel?.accounts) ? channel.accounts : [];\n        const defaultAccount = accounts.find(\n          (entry) => String(entry?.id || \"\").trim() === \"default\",\n        );\n        if (\n          isDefaultAgent\n          && channelId\n          && defaultAccount\n          && !String(defaultAccount?.boundAgentId || \"\").trim()\n        ) {\n          const key = toOwnedAccountKey(channelId, \"default\");\n          ownedAccountMap.set(key, { channel: channelId, accountId: \"default\" });\n        }\n      }\n      return Array.from(ownedAccountMap.values());\n    },\n    [bindings, channels, isDefaultAgent],\n  );\n\n  const boundChannels = useMemo(\n    () => Array.from(new Set(ownedAccounts.map((entry) => entry.channel))).filter(Boolean),\n    [ownedAccounts],\n  );\n\n  const ownedAccountKeySet = useMemo(\n    () =>\n      new Set(\n        ownedAccounts\n          .map((entry) => toOwnedAccountKey(entry.channel, entry.accountId))\n          .filter(Boolean),\n      ),\n    [ownedAccounts],\n  );\n\n  const accountNameMap = useMemo(() => {\n    const nextMap = new Map();\n    for (const channel of channels) {\n      const channelId = String(channel?.channel || \"\").trim();\n      const accounts = Array.isArray(channel?.accounts) ? channel.accounts : [];\n      for (const account of accounts) {\n        const accountId = String(account?.id || \"\").trim() || \"default\";\n        const key = toOwnedAccountKey(channelId, accountId);\n        if (!key) continue;\n        const configuredName = String(account?.name || \"\").trim();\n        nextMap.set(key, configuredName || accountId);\n      }\n    }\n    return nextMap;\n  }, [channels]);\n\n  const ownedChannelsStatus = useMemo(() => {\n    const nextStatus = {};\n    for (const entry of ownedAccounts) {\n      const channelId = String(entry?.channel || \"\").trim();\n      if (!channelId) continue;\n      const key = toOwnedAccountKey(channelId, entry?.accountId);\n      const account = channels\n        .find((channel) => String(channel?.channel || \"\").trim() === channelId)\n        ?.accounts?.find(\n          (accountEntry) =>\n            (String(accountEntry?.id || \"\").trim() || \"default\")\n            === (String(entry?.accountId || \"\").trim() || \"default\"),\n        );\n      const status = String(account?.status || \"\").trim() || \"configured\";\n      if (!nextStatus[channelId] || status !== \"paired\") {\n        nextStatus[channelId] = {\n          status: status === \"paired\" ? \"paired\" : \"configured\",\n          accountName: accountNameMap.get(key) || \"\",\n        };\n      }\n    }\n    return nextStatus;\n  }, [accountNameMap, channels, ownedAccounts]);\n\n  const hasUnpaired = useMemo(\n    () =>\n      Object.values(ownedChannelsStatus).some(\n        (entry) => String(entry?.status || \"\").trim() !== \"paired\",\n      ),\n    [ownedChannelsStatus],\n  );\n\n  const pairingsPoll = usePolling(\n    async () => {\n      const data = await fetchPairings();\n      const pending = Array.isArray(data?.pending) ? data.pending : [];\n      return pending\n        .filter((entry) =>\n          ownedAccountKeySet.has(\n            toOwnedAccountKey(\n              String(entry?.channel || \"\").trim(),\n              String(entry?.accountId || \"\").trim() || \"default\",\n            ),\n          ),\n        )\n        .map((entry) => {\n          const key = toOwnedAccountKey(entry?.channel, entry?.accountId);\n          return {\n            ...entry,\n            accountName: accountNameMap.get(key) || \"\",\n          };\n        });\n    },\n    3000,\n    {\n      enabled: ownedAccounts.length > 0,\n      cacheKey: `/api/pairings?agent=${encodeURIComponent(agentId)}`,\n      dedupeInFlight: true,\n    },\n  );\n\n  const pending = pairingsPoll.data || [];\n  const showPairings = hasUnpaired || pending.length > 0 || pairingStatusRefreshing;\n\n  const refreshAfterPairingAction = useCallback(() => {\n    setPairingStatusRefreshing(true);\n    if (pairingRefreshTimerRef.current) {\n      clearTimeout(pairingRefreshTimerRef.current);\n    }\n    pairingRefreshTimerRef.current = setTimeout(() => {\n      setPairingStatusRefreshing(false);\n      pairingRefreshTimerRef.current = null;\n    }, 2800);\n    for (const timerId of pairingDelayedRefreshTimerRefs.current) {\n      clearTimeout(timerId);\n    }\n    pairingDelayedRefreshTimerRefs.current = [];\n    const refresh = () => {\n      pairingsPoll.refresh({ force: true });\n      loadBindings();\n      announcePairingsChanged(agentId);\n    };\n    refresh();\n    pairingDelayedRefreshTimerRefs.current.push(setTimeout(refresh, 500));\n    pairingDelayedRefreshTimerRefs.current.push(setTimeout(refresh, 2000));\n  }, [agentId, loadBindings, pairingsPoll]);\n\n  const handleApprove = async (id, channel, accountId = \"\") => {\n    try {\n      const result = await approvePairing(id, channel, accountId);\n      if (!result.ok) throw new Error(result.error || \"Could not approve pairing\");\n      refreshAfterPairingAction();\n    } catch (err) {\n      showToast(err.message || \"Could not approve pairing\", \"error\");\n      throw err;\n    }\n  };\n\n  const handleReject = async (id, channel, accountId = \"\") => {\n    try {\n      await rejectPairing(id, channel, accountId);\n      refreshAfterPairingAction();\n    } catch (err) {\n      showToast(err.message || \"Could not reject pairing\", \"error\");\n      throw err;\n    }\n  };\n\n  if (loadingBindings) {\n    return html`\n      <div class=\"bg-surface border border-border rounded-xl p-4\">\n        <h3 class=\"card-label mb-3\">Pairing</h3>\n        <p class=\"text-sm text-fg-muted\">Loading pairing status...</p>\n      </div>\n    `;\n  }\n\n  if (!showPairings) return null;\n\n  return html`\n    <${Pairings}\n      pending=${pending}\n      channels=${ownedChannelsStatus}\n      visible=${showPairings}\n      pollingInFlight=${pairingsPoll.isPolling}\n      statusRefreshing=${pairingStatusRefreshing}\n      onApprove=${handleApprove}\n      onReject=${handleReject}\n    />\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-tools/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ToggleSwitch } from \"../../toggle-switch.js\";\nimport { InfoTooltip } from \"../../info-tooltip.js\";\nimport { SegmentedControl } from \"../../segmented-control.js\";\nimport { kSections, kToolProfiles, kProfileLabels } from \"./tool-catalog.js\";\n\nconst html = htm.bind(h);\n\nconst kProfileDescriptions = {\n  minimal: \"Only session status — grant specific tools with alsoAllow\",\n  messaging: \"Session access and messaging — ideal for notification agents\",\n  coding: \"File I/O, shell, memory, sessions, cron, and image generation\",\n  full: \"All tools enabled, no restrictions\",\n};\n\nconst kProfileOptions = kToolProfiles.map((p) => ({\n  label: kProfileLabels[p],\n  value: p,\n  title: kProfileDescriptions[p],\n}));\n\nconst ToolRow = ({ tool, onToggle }) => html`\n  <div class=\"flex items-center justify-between gap-3 py-2.5 px-4\">\n    <div class=\"min-w-0\">\n      <div class=\"text-sm text-body flex items-center gap-1.5\">\n        <span>${tool.label}</span>\n        ${tool.help\n          ? html`<${InfoTooltip} text=${tool.help} widthClass=\"w-72\" />`\n          : null}\n      </div>\n      <span class=\"text-xs font-mono text-fg-muted\">${tool.id}</span>\n    </div>\n    <${ToggleSwitch}\n      checked=${tool.enabled}\n      onChange=${(checked) => onToggle(tool.id, checked)}\n      label=${null}\n    />\n  </div>\n`;\n\nconst ToolSection = ({ section, toolStates, onToggle }) => {\n  const sectionTools = toolStates.filter((t) => t.section === section.id);\n  if (!sectionTools.length) return null;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl overflow-hidden\">\n      <h3 class=\"card-label text-xs px-4 pt-3 pb-2\">${section.label}</h3>\n      <div class=\"divide-y divide-border\">\n        ${sectionTools.map(\n          (tool) =>\n            html`<${ToolRow}\n              key=${tool.id}\n              tool=${tool}\n              onToggle=${onToggle}\n            />`,\n        )}\n      </div>\n    </div>\n  `;\n};\n\nexport const AgentToolsPanel = ({ agent = {}, tools = {} }) => {\n  const { profile, toolStates, setProfile, toggleTool } = tools;\n\n  const enabledTotal = (toolStates || []).filter((t) => t.enabled).length;\n  const totalTools = (toolStates || []).length;\n\n  return html`\n    <div class=\"space-y-4\">\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-4\">\n        <div>\n          <div class=\"flex items-center justify-between mb-3\">\n            <h3 class=\"card-label text-xs\">Preset</h3>\n            <span class=\"text-xs text-fg-muted\"\n              >${enabledTotal}/${totalTools} tools enabled</span\n            >\n          </div>\n          <${SegmentedControl}\n            options=${kProfileOptions}\n            value=${profile}\n            onChange=${setProfile}\n            fullWidth\n            className=\"ac-segmented-control-dark\"\n          />\n        </div>\n      </div>\n\n      <div style=\"columns: 2; column-gap: 0.75rem;\">\n        ${kSections.map(\n          (section) => html`\n            <div style=\"break-inside: avoid; margin-bottom: 0.75rem;\">\n              <${ToolSection}\n                key=${section.id}\n                section=${section}\n                toolStates=${toolStates || []}\n                onToggle=${toggleTool}\n              />\n            </div>\n          `,\n        )}\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-tools/tool-catalog.js",
    "content": "/**\n * Static tool catalog mirroring OpenClaw's tool-catalog.ts.\n * Grouped and labeled for the AlphaClaw Setup UI.\n */\n\nexport const kToolProfiles = [\"minimal\", \"messaging\", \"coding\", \"full\"];\n\nexport const kProfileLabels = {\n  minimal: \"Minimal\",\n  messaging: \"Messaging\",\n  coding: \"Coding\",\n  full: \"Full\",\n};\n\nconst kTools = [\n  {\n    id: \"read\",\n    label: \"Read files\",\n    profiles: [\"coding\"],\n    section: \"filesystem\",\n  },\n  {\n    id: \"edit\",\n    label: \"Edit files\",\n    profiles: [\"coding\"],\n    section: \"filesystem\",\n  },\n  {\n    id: \"write\",\n    label: \"Write files\",\n    profiles: [\"coding\"],\n    section: \"filesystem\",\n  },\n  {\n    id: \"apply_patch\",\n    label: \"Apply patches\",\n    help: \"Make targeted patch edits, mainly for OpenAI-compatible patch workflows.\",\n    profiles: [\"coding\"],\n    section: \"filesystem\",\n  },\n  {\n    id: \"exec\",\n    label: \"Run commands\",\n    help: \"Execute shell commands inside the agent environment.\",\n    profiles: [\"coding\"],\n    section: \"execution\",\n  },\n  {\n    id: \"process\",\n    label: \"Manage processes\",\n    help: \"Inspect and control long-running background processes.\",\n    profiles: [\"coding\"],\n    section: \"execution\",\n  },\n  {\n    id: \"message\",\n    label: \"Send messages\",\n    help: \"Send outbound messages through configured messaging channels.\",\n    profiles: [\"messaging\"],\n    section: \"communication\",\n  },\n  {\n    id: \"tts\",\n    label: \"Text-to-speech\",\n    help: \"Convert text responses into generated speech audio.\",\n    profiles: [],\n    section: \"communication\",\n  },\n  {\n    id: \"browser\",\n    label: \"Control browser\",\n    help: \"Drive a browser for page navigation and interactive web tasks.\",\n    profiles: [],\n    section: \"web\",\n  },\n  {\n    id: \"web_search\",\n    label: \"Search the web\",\n    help: \"Run web searches to discover external information.\",\n    profiles: [],\n    section: \"web\",\n  },\n  {\n    id: \"web_fetch\",\n    label: \"Fetch URLs\",\n    help: \"Fetch and read webpage content from a specific URL.\",\n    profiles: [],\n    section: \"web\",\n  },\n  {\n    id: \"memory_search\",\n    label: \"Semantic search\",\n    help: \"Search memory semantically to find related notes and prior context.\",\n    profiles: [\"coding\"],\n    section: \"memory\",\n  },\n  {\n    id: \"memory_get\",\n    label: \"Read memories\",\n    help: \"Read stored memory files and saved context entries.\",\n    profiles: [\"coding\"],\n    section: \"memory\",\n  },\n  {\n    id: \"agents_list\",\n    label: \"List agents\",\n    help: \"List known agent IDs that can be targeted in multi-agent flows.\",\n    profiles: [],\n    section: \"multiagent\",\n  },\n  {\n    id: \"sessions_spawn\",\n    label: \"Spawn sessions\",\n    help: \"Start a new background session/run; this is the base primitive used by sub-agent workflows.\",\n    profiles: [\"coding\"],\n    section: \"multiagent\",\n  },\n  {\n    id: \"sessions_send\",\n    label: \"Send to session\",\n    help: \"Send messages or tasks into an existing running session.\",\n    profiles: [\"coding\", \"messaging\"],\n    section: \"multiagent\",\n  },\n  {\n    id: \"sessions_list\",\n    label: \"List sessions\",\n    help: \"List active or recent sessions available to the agent.\",\n    profiles: [\"coding\", \"messaging\"],\n    section: \"multiagent\",\n  },\n  {\n    id: \"sessions_history\",\n    label: \"Session history\",\n    help: \"Read the transcript and prior exchanges from a session.\",\n    profiles: [\"coding\", \"messaging\"],\n    section: \"multiagent\",\n  },\n  {\n    id: \"session_status\",\n    label: \"Session status\",\n    help: \"Check whether a session is running and inspect runtime health/state.\",\n    profiles: [\"minimal\", \"coding\", \"messaging\"],\n    section: \"multiagent\",\n  },\n  {\n    id: \"subagents\",\n    label: \"Sub-agents\",\n    help: \"Launch specialized delegated agents (higher-level orchestration built on session spawning).\",\n    profiles: [\"coding\"],\n    section: \"multiagent\",\n  },\n  {\n    id: \"cron\",\n    label: \"Scheduled jobs\",\n    help: \"Create and manage scheduled automation jobs.\",\n    profiles: [\"coding\"],\n    section: \"scheduling\",\n  },\n  {\n    id: \"gateway\",\n    label: \"Gateway control\",\n    help: \"Inspect and control the running Gateway service (status, health, and control actions like restart).\",\n    profiles: [],\n    section: \"scheduling\",\n  },\n  {\n    id: \"image\",\n    label: \"Generate images\",\n    help: \"Generate or analyze images with image-capable model tools.\",\n    profiles: [\"coding\"],\n    section: \"creative\",\n  },\n  {\n    id: \"canvas\",\n    label: \"Visual canvas\",\n    help: \"Control the Canvas panel (present, navigate, eval, snapshot). Primarily a macOS app capability when a canvas-capable node is connected.\",\n    profiles: [],\n    section: \"creative\",\n  },\n  {\n    id: \"nodes\",\n    label: \"Node workflows\",\n    help: \"Use paired device/node capabilities (for example canvas, camera, notifications, and system actions).\",\n    profiles: [],\n    section: \"creative\",\n  },\n];\n\nexport const kSections = [\n  {\n    id: \"filesystem\",\n    label: \"Filesystem\",\n    description: \"Read, edit, and write files\",\n  },\n  {\n    id: \"execution\",\n    label: \"Execution\",\n    description: \"Run shell commands and scripts\",\n  },\n  {\n    id: \"communication\",\n    label: \"Communication\",\n    description: \"Send messages across Telegram, Slack, Discord\",\n  },\n  {\n    id: \"web\",\n    label: \"Web & Browser\",\n    description: \"Browse pages, search the web, fetch URLs\",\n  },\n  {\n    id: \"memory\",\n    label: \"Memory\",\n    description:\n      \"Semantic search and retrieval across the agent's stored knowledge\",\n  },\n  {\n    id: \"multiagent\",\n    label: \"Multi-Agent\",\n    description:\n      \"List agents, spawn sessions, send messages between agents. Orchestrate sub-agents.\",\n  },\n  {\n    id: \"scheduling\",\n    label: \"Scheduling\",\n    description: \"Create and manage scheduled jobs\",\n  },\n  {\n    id: \"creative\",\n    label: \"Creative\",\n    description: \"Generate images, visual canvas, node-based workflows\",\n  },\n];\n\nexport const getToolsForSection = (sectionId) =>\n  kTools.filter((t) => t.section === sectionId);\n\nexport const getAllToolIds = () => kTools.map((t) => t.id);\n\nexport const getProfileToolIds = (profileId) => {\n  if (profileId === \"full\") return kTools.map((t) => t.id);\n  return kTools.filter((t) => t.profiles.includes(profileId)).map((t) => t.id);\n};\n\n/**\n * Given a profile + alsoAllow + deny, resolve whether each tool is enabled.\n */\nexport const resolveToolStates = ({\n  profile = \"full\",\n  alsoAllow = [],\n  deny = [],\n}) => {\n  const profileTools = new Set(getProfileToolIds(profile));\n  const alsoAllowSet = new Set(alsoAllow);\n  const denySet = new Set(deny);\n\n  return kTools.map((tool) => {\n    const inProfile = profileTools.has(tool.id);\n    const isDenied = denySet.has(tool.id);\n    const isAlsoAllowed = alsoAllowSet.has(tool.id);\n    const enabled = isDenied ? false : inProfile || isAlsoAllowed;\n\n    return { ...tool, enabled, inProfile, isDenied, isAlsoAllowed };\n  });\n};\n\n/**\n * Derive the minimal tools config from the resolved tool states\n * relative to the selected profile.\n */\nexport const deriveToolsConfig = ({ profile, toolStates }) => {\n  const profileTools = new Set(getProfileToolIds(profile));\n  const alsoAllow = [];\n  const deny = [];\n\n  for (const tool of toolStates) {\n    const inProfile = profileTools.has(tool.id);\n    if (tool.enabled && !inProfile) {\n      alsoAllow.push(tool.id);\n    } else if (!tool.enabled && inProfile) {\n      deny.push(tool.id);\n    }\n  }\n\n  const config = { profile };\n  if (alsoAllow.length) config.alsoAllow = alsoAllow;\n  if (deny.length) config.deny = deny;\n  return config;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js",
    "content": "import { useState, useCallback, useMemo, useEffect, useRef } from \"preact/hooks\";\nimport {\n  resolveToolStates,\n  deriveToolsConfig,\n  getProfileToolIds,\n} from \"./tool-catalog.js\";\n\nconst buildOverridesMap = (alsoAllow = [], deny = []) => {\n  const map = {};\n  for (const id of alsoAllow) map[id] = true;\n  for (const id of deny) map[id] = false;\n  return map;\n};\n\nconst normalizeToolsConfig = ({\n  profile = \"full\",\n  alsoAllow = [],\n  deny = [],\n} = {}) => ({\n  profile: String(profile || \"full\"),\n  alsoAllow: [...(Array.isArray(alsoAllow) ? alsoAllow : [])]\n    .map(String)\n    .filter(Boolean)\n    .sort(),\n  deny: [...(Array.isArray(deny) ? deny : [])]\n    .map(String)\n    .filter(Boolean)\n    .sort(),\n});\n\n/**\n * Manages local tool-toggle state derived from an agent's tools config.\n * Returns the current resolved states plus actions for profile/tool changes.\n */\nexport const useAgentTools = ({ agent = {} } = {}) => {\n  const agentTools = agent.tools || {};\n  const initialConfig = normalizeToolsConfig(agentTools);\n\n  const initialProfile = initialConfig.profile;\n  const initialAlsoAllow = initialConfig.alsoAllow;\n  const initialDeny = initialConfig.deny;\n\n  const [profile, setProfileRaw] = useState(initialProfile);\n  const [overrides, setOverrides] = useState(() =>\n    buildOverridesMap(initialAlsoAllow, initialDeny),\n  );\n  const [savedConfig, setSavedConfig] = useState(initialConfig);\n\n  const agentToolsKey = JSON.stringify([agent.id, agentTools]);\n  const prevKeyRef = useRef(agentToolsKey);\n  useEffect(() => {\n    if (prevKeyRef.current !== agentToolsKey) {\n      prevKeyRef.current = agentToolsKey;\n      setProfileRaw(initialProfile);\n      setOverrides(buildOverridesMap(initialAlsoAllow, initialDeny));\n      setSavedConfig(initialConfig);\n    }\n  }, [agentToolsKey, initialProfile, initialAlsoAllow, initialDeny, initialConfig]);\n\n  const toolStates = useMemo(() => {\n    const profileSet = new Set(getProfileToolIds(profile));\n    const alsoAllow = [];\n    const deny = [];\n    for (const [id, enabled] of Object.entries(overrides)) {\n      if (enabled && !profileSet.has(id)) alsoAllow.push(id);\n      else if (!enabled && profileSet.has(id)) deny.push(id);\n    }\n    return resolveToolStates({ profile, alsoAllow, deny });\n  }, [profile, overrides]);\n\n  const toolsConfig = useMemo(\n    () => deriveToolsConfig({ profile, toolStates }),\n    [profile, toolStates],\n  );\n\n  const dirty = useMemo(() => {\n    const next = normalizeToolsConfig(toolsConfig);\n    return JSON.stringify(savedConfig) !== JSON.stringify(next);\n  }, [savedConfig, toolsConfig]);\n\n  const setProfile = useCallback((nextProfile) => {\n    setProfileRaw(nextProfile);\n    setOverrides({});\n  }, []);\n\n  const toggleTool = useCallback(\n    (toolId, enabled) => {\n      setOverrides((prev) => {\n        const next = { ...prev };\n        const profileSet = new Set(getProfileToolIds(profile));\n        const isDefault = profileSet.has(toolId) === enabled;\n        if (isDefault) {\n          delete next[toolId];\n        } else {\n          next[toolId] = enabled;\n        }\n        return next;\n      });\n    },\n    [profile],\n  );\n\n  const reset = useCallback(() => {\n    setProfileRaw(savedConfig.profile);\n    const map = {};\n    for (const id of savedConfig.alsoAllow) map[id] = true;\n    for (const id of savedConfig.deny) map[id] = false;\n    setOverrides(map);\n  }, [savedConfig]);\n\n  const markSaved = useCallback((nextConfig = {}) => {\n    const normalized = normalizeToolsConfig(nextConfig);\n    setSavedConfig(normalized);\n    setProfileRaw(normalized.profile);\n    setOverrides(buildOverridesMap(normalized.alsoAllow, normalized.deny));\n  }, []);\n\n  return {\n    profile,\n    toolStates,\n    toolsConfig,\n    dirty,\n    setProfile,\n    toggleTool,\n    reset,\n    markSaved,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/create-agent-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { CloseIcon } from \"../icons.js\";\nimport { ModalShell } from \"../modal-shell.js\";\nimport { PageHeader } from \"../page-header.js\";\n\nconst html = htm.bind(h);\n\nconst kAgentIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nconst kWorkspaceFolderPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\n\nconst slugifyAgentId = (value) =>\n  String(value || \"\")\n    .toLowerCase()\n    .trim()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n\nexport const CreateAgentModal = ({\n  visible = false,\n  loading = false,\n  onClose = () => {},\n  onSubmit = () => {},\n}) => {\n  const [displayName, setDisplayName] = useState(\"\");\n  const [agentId, setAgentId] = useState(\"\");\n  const [workspaceSuffix, setWorkspaceSuffix] = useState(\"\");\n  const [error, setError] = useState(\"\");\n  const [idEditedManually, setIdEditedManually] = useState(false);\n  const [workspaceEditedManually, setWorkspaceEditedManually] = useState(false);\n\n  useEffect(() => {\n    if (!visible) return;\n    setDisplayName(\"\");\n    setAgentId(\"\");\n    setWorkspaceSuffix(\"\");\n    setError(\"\");\n    setIdEditedManually(false);\n    setWorkspaceEditedManually(false);\n  }, [visible]);\n\n  useEffect(() => {\n    if (idEditedManually) return;\n    const derivedId = slugifyAgentId(displayName);\n    setAgentId(derivedId);\n  }, [displayName, idEditedManually]);\n\n  useEffect(() => {\n    if (workspaceEditedManually) return;\n    const trimmedId = String(agentId || \"\").trim();\n    if (!trimmedId) {\n      setWorkspaceSuffix(\"\");\n      return;\n    }\n    setWorkspaceSuffix(trimmedId);\n  }, [agentId, workspaceEditedManually]);\n\n  const workspaceFolder = useMemo(\n    () => `workspace-${String(workspaceSuffix || \"\").trim()}`,\n    [workspaceSuffix],\n  );\n\n  const canSubmit =\n    String(displayName || \"\").trim().length > 0 &&\n    kAgentIdPattern.test(String(agentId || \"\").trim()) &&\n    kWorkspaceFolderPattern.test(String(workspaceSuffix || \"\").trim());\n\n  if (!visible) return null;\n\n  const submit = async () => {\n    const nextDisplayName = String(displayName || \"\").trim();\n    const nextAgentId = String(agentId || \"\").trim();\n    const nextWorkspaceSuffix = String(workspaceSuffix || \"\").trim();\n    const nextWorkspaceFolder = `workspace-${nextWorkspaceSuffix}`;\n\n    if (!nextDisplayName) {\n      setError(\"Display name is required\");\n      return;\n    }\n    if (!kAgentIdPattern.test(nextAgentId)) {\n      setError(\"Agent ID must be lowercase letters, numbers, and hyphens\");\n      return;\n    }\n    if (!kWorkspaceFolderPattern.test(nextWorkspaceSuffix)) {\n      setError(\"Workspace folder must be lowercase letters, numbers, and hyphens\");\n      return;\n    }\n\n    setError(\"\");\n    const payload = {\n      name: nextDisplayName,\n      id: nextAgentId,\n      workspaceFolder: nextWorkspaceFolder,\n    };\n    await onSubmit(payload);\n  };\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      panelClassName=\"bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4\"\n    >\n      <${PageHeader}\n        title=\"Add Agent\"\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${onClose}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n          </button>\n        `}\n      />\n\n      <div class=\"space-y-3\">\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Display name</span>\n          <input\n            type=\"text\"\n            value=${displayName}\n            onInput=${(event) => setDisplayName(event.target.value)}\n            placeholder=\"Ops Agent\"\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n          />\n        </label>\n\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Agent ID</span>\n          <input\n            type=\"text\"\n            value=${agentId}\n            onInput=${(event) => {\n              setIdEditedManually(true);\n              setAgentId(slugifyAgentId(event.target.value));\n            }}\n            placeholder=\"ops-agent\"\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted\"\n          />\n        </label>\n\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Workspace folder</span>\n          <div class=\"flex items-center bg-field border border-border rounded-lg focus-within:border-fg-muted\">\n            <span class=\"px-3 py-2 text-xs font-mono text-fg-muted border-r border-border\">\n              .openclaw/workspace-\n            </span>\n            <input\n              type=\"text\"\n              value=${workspaceSuffix}\n              onInput=${(event) => {\n                setWorkspaceEditedManually(true);\n                setWorkspaceSuffix(slugifyAgentId(event.target.value));\n              }}\n              placeholder=\"ops-agent\"\n              class=\"flex-1 bg-transparent px-3 py-2 text-sm font-mono text-body outline-none\"\n            />\n          </div>\n        </label>\n\n        ${error ? html`<p class=\"text-xs text-status-error-muted\">${error}</p>` : null}\n      </div>\n\n      <div class=\"flex justify-end gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onClose}\n          disabled=${loading}\n          loading=${false}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=\"Cancel\"\n        />\n        <${ActionButton}\n          onClick=${submit}\n          disabled=${loading || !canSubmit}\n          loading=${loading}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=\"Create Agent\"\n          loadingLabel=\"Creating...\"\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/create-channel-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { CloseIcon, FileCopyLineIcon } from \"../icons.js\";\nimport { ModalShell } from \"../modal-shell.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { SecretInput } from \"../secret-input.js\";\nimport { fetchChannelAccountToken } from \"../../lib/api.js\";\nimport { copyTextToClipboard } from \"../../lib/clipboard.js\";\nimport { isSingleAccountChannelProvider } from \"../../lib/channel-provider-availability.js\";\nimport { ALL_CHANNELS, getChannelMeta } from \"../channels.js\";\nimport { showToast } from \"../toast.js\";\n\nconst html = htm.bind(h);\n\nconst kChannelEnvKeys = {\n  telegram: \"TELEGRAM_BOT_TOKEN\",\n  discord: \"DISCORD_BOT_TOKEN\",\n  slack: \"SLACK_BOT_TOKEN\",\n  whatsapp: \"WHATSAPP_OWNER_NUMBER\",\n};\n\nconst kChannelExtraEnvKeys = {\n  slack: \"SLACK_APP_TOKEN\",\n};\nconst kSlackBotScopes = [\n  \"app_mentions:read\",\n  \"channels:history\",\n  \"channels:read\",\n  \"chat:write\",\n  \"commands\",\n  \"emoji:read\",\n  \"files:read\",\n  \"files:write\",\n  \"groups:read\",\n  \"groups:history\",\n  \"im:history\",\n  \"im:read\",\n  \"im:write\",\n  \"mpim:history\",\n  \"mpim:read\",\n  \"mpim:write\",\n  \"pins:read\",\n  \"pins:write\",\n  \"reactions:read\",\n  \"reactions:write\",\n  \"users:read\",\n];\nconst kSlackBotEvents = [\n  \"app_mention\",\n  \"message.channels\",\n  \"message.groups\",\n  \"message.im\",\n  \"message.mpim\",\n  \"reaction_added\",\n  \"reaction_removed\",\n];\nconst kSlackInstructionsLink = \"https://docs.openclaw.ai/channels/slack\";\n\nconst slugifyChannelAccountId = (value) =>\n  String(value || \"\")\n    .toLowerCase()\n    .trim()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n\nconst deriveChannelEnvKey = ({ provider, accountId }) => {\n  const baseKey = kChannelEnvKeys[String(provider || \"\").trim()] || \"\";\n  const normalizedAccountId = String(accountId || \"\").trim();\n  if (!baseKey) return \"\";\n  if (!normalizedAccountId || normalizedAccountId === \"default\") return baseKey;\n  return `${baseKey}_${normalizedAccountId.replace(/-/g, \"_\").toUpperCase()}`;\n};\nconst deriveChannelExtraEnvKey = ({ provider, accountId, index = 0 }) => {\n  const baseKeys = [kChannelExtraEnvKeys[String(provider || \"\").trim()]].filter(\n    Boolean,\n  );\n  const baseKey = String(baseKeys[index] || \"\").trim();\n  const normalizedAccountId = String(accountId || \"\").trim();\n  if (!baseKey) return \"\";\n  if (!normalizedAccountId || normalizedAccountId === \"default\") return baseKey;\n  return `${baseKey}_${normalizedAccountId.replace(/-/g, \"_\").toUpperCase()}`;\n};\nconst isMaskedTokenValue = (value) => /^\\*+$/.test(String(value || \"\").trim());\nconst buildSlackManifest = (appName = \"AlphaClaw\") =>\n  JSON.stringify(\n    {\n      _metadata: {\n        major_version: 1,\n      },\n      display_information: {\n        name: String(appName || \"\").trim() || \"AlphaClaw\",\n        description: \"Slack connector for AlphaClaw\",\n      },\n      features: {\n        bot_user: {\n          display_name: String(appName || \"\").trim() || \"AlphaClaw\",\n          always_online: false,\n        },\n        app_home: {\n          messages_tab_enabled: true,\n          messages_tab_read_only_enabled: false,\n        },\n      },\n      oauth_config: {\n        scopes: {\n          bot: kSlackBotScopes,\n        },\n      },\n      settings: {\n        event_subscriptions: {\n          bot_events: kSlackBotEvents,\n        },\n        org_deploy_enabled: false,\n        socket_mode_enabled: true,\n        is_hosted: false,\n        token_rotation_enabled: false,\n      },\n    },\n    null,\n    2,\n  );\nconst copyAndToast = async (value, label = \"text\") => {\n  const copied = await copyTextToClipboard(value);\n  if (copied) {\n    showToast(\"Copied to clipboard\", \"success\");\n    return;\n  }\n  showToast(`Could not copy ${label}`, \"error\");\n};\n\nexport const CreateChannelModal = ({\n  visible = false,\n  loading = false,\n  createLoadingLabel = \"Creating...\",\n  agents = [],\n  existingChannels = [],\n  mode = \"create\",\n  account = null,\n  initialAgentId = \"\",\n  initialProvider = \"\",\n  onClose = () => {},\n  onSubmit = async () => {},\n}) => {\n  const isEditMode = mode === \"edit\";\n  const [provider, setProvider] = useState(\"telegram\");\n  const [name, setName] = useState(\"\");\n  const [token, setToken] = useState(\"\");\n  const [initialToken, setInitialToken] = useState(\"\");\n  const [appToken, setAppToken] = useState(\"\");\n  const [agentId, setAgentId] = useState(\"\");\n  const [error, setError] = useState(\"\");\n  const [nameEditedManually, setNameEditedManually] = useState(false);\n  const [loadingToken, setLoadingToken] = useState(false);\n\n  useEffect(() => {\n    if (!visible) return;\n    const nextProvider = isEditMode\n      ? String(account?.provider || \"\").trim() || \"telegram\"\n      : ALL_CHANNELS.includes(initialProvider)\n        ? initialProvider\n        : ALL_CHANNELS[0] || \"telegram\";\n    const providerLabel = getChannelMeta(nextProvider).label || \"Channel\";\n    const nextSelectedChannel =\n      existingChannels.find(\n        (entry) =>\n          String(entry?.channel || \"\").trim() ===\n          String(nextProvider || \"\").trim(),\n      ) || null;\n    const nextProviderHasAccounts =\n      Array.isArray(nextSelectedChannel?.accounts) &&\n      nextSelectedChannel.accounts.length > 0;\n    const nextName = isEditMode\n      ? String(account?.name || \"\").trim() || providerLabel\n      : nextProviderHasAccounts\n        ? \"\"\n        : providerLabel;\n    const nextAgentId = isEditMode\n      ? String(account?.ownerAgentId || \"\").trim() ||\n        String(initialAgentId || \"\").trim() ||\n        String(agents[0]?.id || \"\").trim()\n      : String(initialAgentId || \"\").trim() ||\n        String(agents[0]?.id || \"\").trim();\n    setProvider(nextProvider);\n    setName(nextName);\n    const nextToken = isEditMode\n      ? (() => {\n          const raw = String(account?.token || \"\").trim();\n          return isMaskedTokenValue(raw) ? \"\" : raw;\n        })()\n      : \"\";\n    setToken(nextToken);\n    setInitialToken(nextToken);\n    setAppToken(\"\");\n    setAgentId(nextAgentId);\n    setError(\"\");\n    setNameEditedManually(isEditMode);\n  }, [\n    visible,\n    initialAgentId,\n    initialProvider,\n    agents,\n    existingChannels,\n    isEditMode,\n    account,\n  ]);\n\n  const selectedChannel = useMemo(\n    () =>\n      existingChannels.find(\n        (entry) =>\n          String(entry?.channel || \"\").trim() === String(provider || \"\").trim(),\n      ) || null,\n    [existingChannels, provider],\n  );\n\n  const providerHasAccounts = useMemo(\n    () =>\n      Array.isArray(selectedChannel?.accounts) &&\n      selectedChannel.accounts.length > 0,\n    [selectedChannel],\n  );\n  useEffect(() => {\n    if (nameEditedManually) return;\n    const providerLabel = getChannelMeta(provider).label || \"Channel\";\n    if (!isEditMode && providerHasAccounts) {\n      setName(\"\");\n      return;\n    }\n    setName(providerLabel);\n  }, [provider, providerHasAccounts, nameEditedManually, isEditMode]);\n  const normalizedProvider = String(provider || \"\").trim();\n  const isSingleAccountProvider = isSingleAccountChannelProvider(provider);\n  const needsAppToken = normalizedProvider === \"slack\";\n  const isWhatsApp = normalizedProvider === \"whatsapp\";\n\n  const accountId = useMemo(() => {\n    if (isEditMode) {\n      return String(account?.id || \"\").trim() || \"default\";\n    }\n    if (isSingleAccountProvider) return \"default\";\n    if (!providerHasAccounts) return \"default\";\n    return slugifyChannelAccountId(name);\n  }, [name, providerHasAccounts, isEditMode, account, isSingleAccountProvider]);\n\n  const envKey = useMemo(\n    () => deriveChannelEnvKey({ provider, accountId }),\n    [provider, accountId],\n  );\n  const extraEnvKey = useMemo(\n    () =>\n      deriveChannelExtraEnvKey({\n        provider,\n        accountId,\n      }),\n    [provider, accountId],\n  );\n  const slackManifestName = useMemo(() => {\n    const normalizedName = String(name || \"\").trim();\n    if (!normalizedName) return \"AlphaClaw\";\n    if (normalizedName.toLowerCase() === \"slack\") return \"AlphaClaw\";\n    return normalizedName;\n  }, [name]);\n  const slackManifest = useMemo(\n    () => buildSlackManifest(slackManifestName),\n    [slackManifestName],\n  );\n\n  const accountExists = useMemo(\n    () =>\n      Array.isArray(selectedChannel?.accounts) &&\n      selectedChannel.accounts.some(\n        (entry) =>\n          String(entry?.id || \"\").trim() === String(accountId || \"\").trim(),\n      ),\n    [selectedChannel, accountId],\n  );\n  useEffect(() => {\n    if (!visible || !isEditMode) return;\n    let cancelled = false;\n    const loadToken = async () => {\n      setLoadingToken(true);\n      try {\n        const result = await fetchChannelAccountToken({\n          provider,\n          accountId,\n        });\n        if (cancelled) return;\n        const nextToken = String(result?.token || \"\");\n        const nextAppToken = String(result?.appToken || \"\");\n        setToken(nextToken);\n        setInitialToken(nextToken);\n        setAppToken(nextAppToken);\n      } catch {\n        // Keep existing fallback value.\n      } finally {\n        if (!cancelled) {\n          setLoadingToken(false);\n        }\n      }\n    };\n    loadToken();\n    return () => {\n      cancelled = true;\n    };\n  }, [visible, isEditMode, provider, accountId]);\n\n  const canSubmit =\n    !!String(provider || \"\").trim() &&\n    !!String(name || \"\").trim() &&\n    !!String(accountId || \"\").trim() &&\n    !!String(agentId || \"\").trim() &&\n    (isEditMode || !!String(token || \"\").trim()) &&\n    (isEditMode || !needsAppToken || !!String(appToken || \"\").trim()) &&\n    (isEditMode || !accountExists) &&\n    !loadingToken;\n\n  if (!visible) return null;\n\n  const handleSubmit = async () => {\n    if (!String(name || \"\").trim()) {\n      setError(\"Name is required\");\n      return;\n    }\n    if (!String(accountId || \"\").trim()) {\n      setError(\"Channel id could not be derived from the name\");\n      return;\n    }\n    if (!isEditMode && !String(token || \"\").trim()) {\n      setError(\"Token is required\");\n      return;\n    }\n    if (!isEditMode && needsAppToken && !String(appToken || \"\").trim()) {\n      setError(\"App Token is required for Slack\");\n      return;\n    }\n    if (!String(agentId || \"\").trim()) {\n      setError(\"Agent is required\");\n      return;\n    }\n    if (!isEditMode && accountExists) {\n      setError(\"That channel id is already configured for this provider\");\n      return;\n    }\n\n    setError(\"\");\n    const trimmedToken = String(token || \"\").trim();\n    const tokenWasUpdated =\n      trimmedToken && trimmedToken !== String(initialToken || \"\").trim();\n    const trimmedAppToken = String(appToken || \"\").trim();\n    await onSubmit({\n      provider,\n      name: String(name || \"\").trim(),\n      accountId,\n      agentId,\n      ...(tokenWasUpdated ? { token: trimmedToken } : {}),\n      ...(needsAppToken && trimmedAppToken\n        ? { appToken: trimmedAppToken }\n        : {}),\n    });\n  };\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      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\"\n    >\n      <${PageHeader}\n        title=${\n          isEditMode\n            ? \"Edit Channel\"\n            : `Add ${getChannelMeta(provider).label || \"Channel\"} Channel`\n        }\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${onClose}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n          </button>\n        `}\n      />\n\n      <div class=\"space-y-3\">\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Name</span>\n          <input\n            type=\"text\"\n            value=${name}\n            onInput=${(event) => {\n              setNameEditedManually(true);\n              setName(event.target.value);\n            }}\n            placeholder=${getChannelMeta(provider).label || \"Channel\"}\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n          />\n        </label>\n\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Id</span>\n          <input\n            type=\"text\"\n            value=${accountId}\n            readOnly=${true}\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-fg-muted outline-none\"\n          />\n          <p class=\"text-xs text-fg-muted\">\n            ${\n              isEditMode\n                ? \"Channel id is fixed after creation.\"\n                : isSingleAccountProvider\n                  ? `${getChannelMeta(provider).label} supports one channel account and uses the default id.`\n                  : providerHasAccounts\n                    ? \"Derived from the channel name.\"\n                    : \"First account uses the default id for this provider.\"\n            }\n          </p>\n        </label>\n\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-gray-400\">\n            ${isWhatsApp ? \"Owner Number\" : needsAppToken ? \"Bot Token\" : \"Token\"}\n          </span>\n          ${isWhatsApp\n            ? html`\n                <input\n                  type=\"text\"\n                  value=${token}\n                  onInput=${(event) => setToken(event.target.value)}\n                  placeholder=\"+15551234567\"\n                  class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted\"\n                />\n              `\n            : html`\n                <${SecretInput}\n                  value=${token}\n                  onInput=${(event) => setToken(event.target.value)}\n                  placeholder=${token ? \"\" : \"Paste bot token\"}\n                  loading=${loadingToken}\n                  isSecret=${true}\n                  inputClass=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted\"\n                />\n              `}\n          <p class=\"text-xs text-fg-muted\">\n            ${isWhatsApp\n              ? \"E.164 format phone number used for allowlist pairing.\"\n              : html`Saved behind the scenes as\n                <code class=\"font-mono text-fg-muted ml-1\">${envKey || \"CHANNEL_TOKEN\"}</code>.`}\n          </p>\n        </label>\n\n        ${\n          needsAppToken\n            ? html`\n                <label class=\"block space-y-1\">\n                  <span class=\"text-xs text-fg-muted\"\n                    >App Token (Socket Mode)</span\n                  >\n                  <${SecretInput}\n                    value=${appToken}\n                    onInput=${(event) => setAppToken(event.target.value)}\n                    placeholder=\"xapp-...\"\n                    isSecret=${true}\n                    inputClass=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-body outline-none focus:border-fg-muted\"\n                  />\n                  <p class=\"text-xs text-fg-muted\">\n                    Saved behind the scenes as\n                    <code class=\"font-mono text-fg-muted ml-1\">\n                      ${extraEnvKey || kChannelExtraEnvKeys.slack}\n                    </code>\n                    .\n                  </p>\n                </label>\n              `\n            : null\n        }\n        ${\n          needsAppToken\n            ? html`\n                <div class=\"space-y-2\">\n                  <details\n                    class=\"rounded-lg border border-border bg-field px-3 py-2.5\"\n                  >\n                    <summary\n                      class=\"cursor-pointer text-xs text-body hover:text-body\"\n                    >\n                      <span class=\"inline-block ml-1\">\n                        Create app from manifest (recommended)\n                      </span>\n                    </summary>\n                    <div class=\"mt-2 space-y-2 text-xs text-fg-muted\">\n                      <div class=\"flex items-center justify-between gap-3 pt-1\">\n                        <div class=\"space-y-0.5\">\n                          <p class=\"text-[12px] text-fg-muted\">\n                            ${slackManifestName} App Manifest\n                          </p>\n                        </div>\n                        <button\n                          type=\"button\"\n                          onclick=${() =>\n                            copyAndToast(slackManifest, \"Slack manifest\")}\n                          class=\"text-xs px-2 py-1 rounded-lg ac-btn-cyan inline-flex items-center gap-1.5 shrink-0\"\n                        >\n                          <${FileCopyLineIcon} className=\"w-3.5 h-3.5\" />\n                          Copy\n                        </button>\n                      </div>\n                      <pre\n                        class=\"max-h-72 overflow-auto rounded-lg border border-border bg-field p-3 text-[11px] leading-5 whitespace-pre-wrap break-all font-mono text-body\"\n                      >\n${slackManifest}</pre\n                      >\n                      <ol\n                        class=\"list-decimal list-inside space-y-1.5 text-[11px] text-fg-muted\"\n                      >\n                        <li>\n                          In Slack, click ${\" \"}\n                          <span class=\"text-body\"\n                            >Create app from manifest</span\n                          >\n                          ${\" \"} and paste this manifest.\n                        </li>\n                        <li>\n                          Open ${\" \"}\n                          <span class=\"text-body\">Basic Information</span>\n                          ${\" \"} and create an ${\" \"}\n                          <span class=\"text-body\">App-Level Token</span>\n                          ${\" \"} with\n                          <code class=\"font-mono text-fg-muted ml-1\"\n                            >connections:write</code\n                          >.\n                        </li>\n                        <li>\n                          Open ${\" \"}\n                          <span class=\"text-body\">OAuth & Permissions</span>\n                          ${\" \"} and use ${\" \"}\n                          <span class=\"text-body\">Install to Workspace</span>\n                          ${\" \"} or ${\" \"}\n                          <span class=\"text-body\">Reinstall to Workspace</span>\n                          ${\" \"} so Slack issues a bot token.\n                        </li>\n                        <li>\n                          In ${\" \"}\n                          <span class=\"text-body\">OAuth & Permissions</span>\n                          ${\" \"} copy the ${\" \"}\n                          <span class=\"text-body\">Bot User OAuth Token</span>\n                          ${\" \"} (\n                          <code class=\"font-mono text-fg-muted\">xoxb-...</code>\n                          ).\n                        </li>\n                        <li>\n                          Paste the generated ${\" \"}\n                          <code class=\"font-mono text-fg-muted\">xoxb-...</code>\n                          ${\" \"} and ${\" \"}\n                          <code class=\"font-mono text-fg-muted\">xapp-...</code>\n                          ${\" \"} tokens here.\n                        </li>\n                      </ol>\n                    </div>\n                  </details>\n                  <details\n                    class=\"rounded-lg border border-border bg-field px-3 py-2.5\"\n                  >\n                    <summary\n                      class=\"cursor-pointer text-xs text-body hover:text-body\"\n                    >\n                      <span class=\"inline-block ml-1\">\n                        Manual setup instructions\n                      </span>\n                    </summary>\n                    <div class=\"mt-2 space-y-2 text-xs text-fg-muted\">\n                      <p>\n                        Use this if you want to configure the Slack app by hand\n                        instead of importing a manifest.\n                      </p>\n                      <ol class=\"list-decimal list-inside space-y-1.5\">\n                        <li>\n                          In Slack app settings, turn on ${\" \"}\n                          <span class=\"text-body\">Socket Mode</span>.\n                        </li>\n                        <li>\n                          In ${\" \"}\n                          <span class=\"text-body\">App Home</span>, enable\n                          <code class=\"font-mono text-fg-muted ml-1\">\n                            Allow users to send Slash commands and messages from\n                            the messages tab </code\n                          >.\n                        </li>\n                        <li>\n                          In ${\" \"}\n                          <span class=\"text-body\">Event Subscriptions</span>,\n                          toggle on\n                          <code class=\"font-mono text-fg-muted ml-1\"\n                            >Subscribe to bot events</code\n                          >\n                          ${\" \"} and add\n                          <code class=\"font-mono text-fg-muted ml-1\"\n                            >message.im</code\n                          >.\n                        </li>\n                        <li>\n                          In ${\" \"}\n                          <span class=\"text-body\">OAuth & Permissions</span>,\n                          add the bot scopes:\n                          <code class=\"font-mono text-fg-muted ml-1\">\n                            ${kSlackBotScopes.join(\", \")}\n                          </code>\n                        </li>\n                        <li>\n                          In ${\" \"}\n                          <span class=\"text-body\">Basic Information</span>,\n                          create an App Token (<code\n                            class=\"font-mono text-fg-muted\"\n                            >xapp-...</code\n                          >) with\n                          <code class=\"font-mono text-fg-muted ml-1\"\n                            >connections:write</code\n                          >.\n                        </li>\n                        <li>\n                          Back in ${\" \"}\n                          <span class=\"text-body\">OAuth & Permissions</span>,\n                          install or reinstall the app, then copy the ${\" \"}\n                          <span class=\"text-body\">Bot User OAuth Token</span>\n                          ${\" \"} (\n                          <code class=\"font-mono text-fg-muted\">xoxb-...</code>\n                          ).\n                        </li>\n                      </ol>\n                      <a\n                        href=${kSlackInstructionsLink}\n                        target=\"_blank\"\n                        class=\"hover:underline\"\n                        style=\"color: var(--accent-link)\"\n                      >\n                        Open full Slack setup guide\n                      </a>\n                    </div>\n                  </details>\n                </div>\n              `\n            : null\n        }\n\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Agent</span>\n          <select\n            value=${agentId}\n            onInput=${(event) => setAgentId(event.target.value)}\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n          >\n            ${agents.map(\n              (agent) => html`\n                <option key=${agent.id} value=${agent.id}>\n                  ${agent.name || agent.id}\n                </option>\n              `,\n            )}\n          </select>\n        </label>\n\n        ${\n          !isEditMode && accountExists\n            ? html`\n                <p class=\"text-xs text-status-error-muted\">\n                  ${isSingleAccountProvider\n                    ? `${getChannelMeta(provider).label} already has a configured channel account.`\n                    : `A ${getChannelMeta(provider).label} account with this id already exists.`}\n                </p>\n              `\n            : null\n        }\n        ${error ? html`<p class=\"text-xs text-status-error-muted\">${error}</p>` : null}\n      </div>\n\n      <div class=\"flex justify-end gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onClose}\n          disabled=${loading}\n          loading=${false}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=\"Cancel\"\n        />\n        <${ActionButton}\n          onClick=${handleSubmit}\n          disabled=${loading || !canSubmit}\n          loading=${loading}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=${isEditMode ? \"Save Changes\" : \"Create Channel\"}\n          loadingLabel=${isEditMode ? \"Saving...\" : createLoadingLabel}\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/delete-agent-dialog.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ConfirmDialog } from \"../confirm-dialog.js\";\nimport { ToggleSwitch } from \"../toggle-switch.js\";\n\nconst html = htm.bind(h);\n\nexport const DeleteAgentDialog = ({\n  visible = false,\n  loading = false,\n  agent = null,\n  onCancel = () => {},\n  onConfirm = () => {},\n}) => {\n  const [keepWorkspace, setKeepWorkspace] = useState(true);\n\n  useEffect(() => {\n    if (!visible) return;\n    setKeepWorkspace(true);\n  }, [visible]);\n\n  return html`\n    <${ConfirmDialog}\n      visible=${visible}\n      title=\"Delete agent\"\n      message=${`Delete \"${String(agent?.name || agent?.id || \"agent\")}\"?`}\n      details=${html`\n        <div class=\"mt-2 pt-2 border-t border-border\">\n          <${ToggleSwitch}\n            checked=${keepWorkspace}\n            disabled=${loading}\n            onChange=${setKeepWorkspace}\n            label=\"Keep workspace files\"\n          />\n        </div>\n      `}\n      confirmLabel=\"Delete agent\"\n      confirmLoadingLabel=\"Deleting...\"\n      confirmTone=\"warning\"\n      confirmLoading=${loading}\n      onCancel=${onCancel}\n      onConfirm=${() =>\n        onConfirm({\n          id: String(agent?.id || \"\").trim(),\n          keepWorkspace,\n        })}\n    />\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/edit-agent-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { CloseIcon } from \"../icons.js\";\nimport { ModalShell } from \"../modal-shell.js\";\nimport { PageHeader } from \"../page-header.js\";\n\nconst html = htm.bind(h);\n\nexport const EditAgentModal = ({\n  visible = false,\n  loading = false,\n  agent = null,\n  onClose = () => {},\n  onSubmit = () => {},\n}) => {\n  const [name, setName] = useState(\"\");\n  const [error, setError] = useState(\"\");\n\n  useEffect(() => {\n    if (!visible) return;\n    setName(String(agent?.name || \"\"));\n    setError(\"\");\n  }, [visible, agent]);\n\n  if (!visible) return null;\n\n  const submit = async () => {\n    const nextName = String(name || \"\").trim();\n    if (!nextName) {\n      setError(\"Display name is required\");\n      return;\n    }\n    setError(\"\");\n    await onSubmit({\n      id: String(agent?.id || \"\").trim(),\n      patch: {\n        name: nextName,\n      },\n    });\n  };\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      panelClassName=\"bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4\"\n    >\n      <${PageHeader}\n        title=\"Edit Agent\"\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${onClose}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n          </button>\n        `}\n      />\n\n      <div class=\"space-y-3\">\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Display name</span>\n          <input\n            type=\"text\"\n            value=${name}\n            onInput=${(event) => setName(event.target.value)}\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n          />\n        </label>\n\n        <label class=\"block space-y-1\">\n          <span class=\"text-xs text-fg-muted\">Agent ID</span>\n          <input\n            type=\"text\"\n            value=${String(agent?.id || \"\")}\n            disabled=${true}\n            class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm font-mono text-fg-muted outline-none\"\n          />\n        </label>\n\n        ${error ? html`<p class=\"text-xs text-status-error-muted\">${error}</p>` : null}\n      </div>\n\n      <div class=\"flex justify-end gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onClose}\n          disabled=${loading}\n          loading=${false}\n          tone=\"secondary\"\n          size=\"sm\"\n          idleLabel=\"Cancel\"\n        />\n        <${ActionButton}\n          onClick=${submit}\n          disabled=${loading}\n          loading=${loading}\n          tone=\"primary\"\n          size=\"sm\"\n          idleLabel=\"Save\"\n          loadingLabel=\"Saving...\"\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/index.js",
    "content": "import { h } from \"preact\";\nimport { useState, useEffect, useCallback } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\nimport { showToast } from \"../toast.js\";\nimport { AgentDetailPanel } from \"./agent-detail-panel.js\";\nimport { CreateAgentModal } from \"./create-agent-modal.js\";\nimport { DeleteAgentDialog } from \"./delete-agent-dialog.js\";\nimport { EditAgentModal } from \"./edit-agent-modal.js\";\n\nconst html = htm.bind(h);\n\nconst resolveWorkspaceBrowsePath = (workspacePath) => {\n  const rawPath = String(workspacePath || \"\").trim();\n  if (!rawPath) return \"\";\n  const openclawMatch = rawPath.match(/[\\\\/]\\.openclaw[\\\\/](.+)$/);\n  if (openclawMatch?.[1]) {\n    return String(openclawMatch[1]).replace(/\\\\/g, \"/\");\n  }\n  const segments = rawPath.split(/[\\\\/]/).filter(Boolean);\n  return segments[segments.length - 1] || \"\";\n};\n\nexport const AgentsTab = ({\n  agents = [],\n  loading = false,\n  saving = false,\n  agentsActions = {},\n  selectedAgentId = \"\",\n  activeTab = \"overview\",\n  onSelectAgent = () => {},\n  onSelectTab = () => {},\n  onNavigateToBrowseFile = () => {},\n  onSetLocation = () => {},\n}) => {\n  const { create, remove, setDefault, update } = agentsActions;\n\n  const [createModalVisible, setCreateModalVisible] = useState(false);\n  const [editingAgent, setEditingAgent] = useState(null);\n  const [deletingAgent, setDeletingAgent] = useState(null);\n\n  useEffect(() => {\n    const handleCreateEvent = () => setCreateModalVisible(true);\n    window.addEventListener(\"alphaclaw:create-agent\", handleCreateEvent);\n    return () => window.removeEventListener(\"alphaclaw:create-agent\", handleCreateEvent);\n  }, []);\n\n  const selectedAgent = agents.find((a) => a.id === selectedAgentId) || null;\n\n  const handleCreate = async ({ id, name, workspaceFolder }) => {\n    try {\n      const newAgent = await create({ id, name, workspaceFolder });\n      setCreateModalVisible(false);\n      onSelectAgent(newAgent.id);\n      showToast(\"Agent created\", \"success\");\n    } catch (error) {\n      showToast(error.message || \"Could not create agent\", \"error\");\n    }\n  };\n\n  const handleSetDefault = async (id) => {\n    try {\n      await setDefault(id);\n      showToast(\"Default agent updated\", \"success\");\n    } catch (error) {\n      showToast(error.message || \"Could not set default agent\", \"error\");\n    }\n  };\n\n  const handleUpdateAgent = async (id, patch, successMessage = \"Agent updated\") => {\n    try {\n      const nextAgent = await update(id, patch);\n      showToast(successMessage, \"success\");\n      return nextAgent;\n    } catch (error) {\n      showToast(error.message || \"Could not update agent\", \"error\");\n      throw error;\n    }\n  };\n\n  const handleEdit = async ({ id, patch }) => {\n    try {\n      await handleUpdateAgent(id, patch);\n      setEditingAgent(null);\n    } catch (error) {\n      return;\n    }\n  };\n\n  const handleDelete = async ({ id, keepWorkspace }) => {\n    try {\n      await remove(id, { keepWorkspace });\n      setDeletingAgent(null);\n      showToast(\"Agent deleted\", \"success\");\n    } catch (error) {\n      showToast(error.message || \"Could not delete agent\", \"error\");\n    }\n  };\n\n  const handleOpenWorkspace = (workspacePath) => {\n    const browsePath = resolveWorkspaceBrowsePath(workspacePath);\n    if (!browsePath) return;\n    onNavigateToBrowseFile(browsePath, { view: \"edit\", directory: true });\n  };\n\n  if (loading) {\n    return html`\n      <div class=\"agents-detail-panel\">\n        <div class=\"flex items-center justify-center w-full py-16\">\n          <${LoadingSpinner} className=\"h-5 w-5\" />\n        </div>\n      </div>\n    `;\n  }\n\n  return html`\n    <${AgentDetailPanel}\n      agent=${selectedAgent}\n      agents=${agents}\n      activeTab=${activeTab}\n      saving=${saving}\n      onUpdateAgent=${handleUpdateAgent}\n      onSetLocation=${onSetLocation}\n      onSelectTab=${onSelectTab}\n      onEdit=${setEditingAgent}\n      onDelete=${setDeletingAgent}\n      onSetDefault=${handleSetDefault}\n      onOpenWorkspace=${handleOpenWorkspace}\n    />\n\n    <${CreateAgentModal}\n      visible=${createModalVisible}\n      loading=${saving}\n      onClose=${() => setCreateModalVisible(false)}\n      onSubmit=${handleCreate}\n    />\n    <${EditAgentModal}\n      visible=${!!editingAgent}\n      loading=${saving}\n      agent=${editingAgent}\n      onClose=${() => setEditingAgent(null)}\n      onSubmit=${handleEdit}\n    />\n    <${DeleteAgentDialog}\n      visible=${!!deletingAgent}\n      loading=${saving}\n      agent=${deletingAgent}\n      onCancel=${() => setDeletingAgent(null)}\n      onConfirm=${handleDelete}\n    />\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/agents-tab/use-agents.js",
    "content": "import { useCallback, useEffect, useState } from \"preact/hooks\";\nimport {\n  createAgent,\n  deleteAgent,\n  fetchAgents,\n  setDefaultAgent,\n  updateAgent,\n} from \"../../lib/api.js\";\n\nexport const useAgents = () => {\n  const [agents, setAgents] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n\n  const loadAgents = useCallback(async () => {\n    setLoading(true);\n    try {\n      const payload = await fetchAgents();\n      setAgents(Array.isArray(payload?.agents) ? payload.agents : []);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    loadAgents();\n  }, [loadAgents]);\n\n  const create = useCallback(async (input) => {\n    setSaving(true);\n    try {\n      const payload = await createAgent(input);\n      setAgents((previous) => [...previous, payload.agent]);\n      return payload.agent;\n    } finally {\n      setSaving(false);\n    }\n  }, []);\n\n  const update = useCallback(async (agentId, patch) => {\n    setSaving(true);\n    try {\n      const payload = await updateAgent(agentId, patch);\n      setAgents((previous) =>\n        previous.map((entry) => (entry.id === agentId ? payload.agent : entry)),\n      );\n      return payload.agent;\n    } finally {\n      setSaving(false);\n    }\n  }, []);\n\n  const setDefault = useCallback(async (agentId) => {\n    setSaving(true);\n    try {\n      await setDefaultAgent(agentId);\n      setAgents((previous) =>\n        previous.map((entry) => ({ ...entry, default: entry.id === agentId })),\n      );\n    } finally {\n      setSaving(false);\n    }\n  }, []);\n\n  const remove = useCallback(async (agentId, options = {}) => {\n    setSaving(true);\n    try {\n      await deleteAgent(agentId, options);\n      setAgents((previous) => previous.filter((entry) => entry.id !== agentId));\n    } finally {\n      setSaving(false);\n    }\n  }, []);\n\n  return {\n    state: {\n      agents,\n      loading,\n      saving,\n    },\n    actions: {\n      create,\n      loadAgents,\n      remove,\n      setDefault,\n      update,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/badge.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst kToneClasses = {\n  success: \"bg-green-500/10 text-status-success-muted\",\n  warning: \"bg-yellow-500/10 text-status-warning-muted\",\n  danger: \"bg-red-500/10 text-status-error-muted\",\n  neutral: \"bg-gray-500/10 text-fg-muted\",\n  info: \"bg-blue-500/10 text-blue-400\",\n  accent: \"bg-purple-500/10 text-purple-400\",\n  cyan: \"bg-cyan-500/10 text-cyan-400\",\n  secondary: \"bg-indigo-500/10 text-indigo-300\",\n};\n\nexport const Badge = ({ tone = \"neutral\", children }) => html`\n  <span class=\"text-xs px-2 py-0.5 rounded-full font-medium ${kToneClasses[tone] || kToneClasses.neutral}\">\n    ${children}\n  </span>\n`;\n"
  },
  {
    "path": "lib/public/js/components/channel-account-status-badge.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Badge } from \"./badge.js\";\n\nconst html = htm.bind(h);\n\nexport const ChannelAccountStatusBadge = ({\n  status = \"configured\",\n  ownerAgentName = \"\",\n  showAgentBadge = false,\n  channelId = \"\",\n  pairedCount = 0,\n}) => {\n  const normalizedStatus = String(status || \"\").trim();\n  if (normalizedStatus !== \"paired\") {\n    return html`<${Badge} tone=\"warning\">Awaiting pairing</${Badge}>`;\n  }\n  if (showAgentBadge && ownerAgentName) {\n    return html`\n      <${Badge} tone=\"neutral\">\n        <span class=\"inline-flex items-center gap-1.5\">\n          <span class=\"h-1.5 w-1.5 rounded-full bg-green-400\"></span>\n          ${ownerAgentName}\n        </span>\n      </${Badge}>\n    `;\n  }\n  return html`\n    <${Badge} tone=\"success\">\n      ${channelId === \"telegram\" || Number(pairedCount) <= 1\n        ? \"Paired\"\n        : `Paired (${Number(pairedCount)})`}\n    </${Badge}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/channel-login-modal.js",
    "content": "import { h } from \"https://esm.sh/preact\";\nimport htm from \"https://esm.sh/htm\";\nimport { ActionButton } from \"./action-button.js\";\nimport { CloseIcon } from \"./icons.js\";\nimport { ModalShell } from \"./modal-shell.js\";\nimport { PageHeader } from \"./page-header.js\";\n\nconst html = htm.bind(h);\n\nexport const ChannelLoginModal = ({\n  visible = false,\n  loading = false,\n  title = \"Link Channel\",\n  output = \"\",\n  error = \"\",\n  runDisabled = false,\n  runLabel = \"Generate QR\",\n  runLoadingLabel = \"Running...\",\n  closeLabel = \"Close\",\n  onRun = async () => {},\n  onClose = () => {},\n}) => {\n  if (!visible) return null;\n  const hasOutput = !!String(output || \"\").trim();\n  const hasError = !!String(error || \"\").trim();\n  const displayOutput = hasOutput\n    ? String(output)\n    : hasError\n      ? String(error)\n      : \"No output yet. Generate QR to start login.\";\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      panelClassName=\"bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4\"\n    >\n      <${PageHeader}\n        title=${title}\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${onClose}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-gray-300\" />\n          </button>\n        `}\n      />\n      <div class=\"space-y-3\">\n        <p class=\"text-xs text-gray-500\">\n          Click \"Generate QR\" to run channel login and capture terminal output.\n        </p>\n        <textarea\n          readonly\n          wrap=\"off\"\n          value=${displayOutput}\n          class=\"w-full h-[440px] max-h-[70vh] text-[11px] leading-[1.1] font-mono text-gray-300 bg-black/30 border border-border rounded-lg p-3 outline-none resize-y overflow-auto\"\n        />\n      </div>\n      <div class=\"flex justify-end gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onClose}\n          disabled=${loading}\n          loading=${false}\n          tone=\"secondary\"\n          size=\"sm\"\n          idleLabel=${closeLabel}\n        />\n        <${ActionButton}\n          onClick=${onRun}\n          disabled=${loading || runDisabled}\n          loading=${loading}\n          tone=\"primary\"\n          size=\"sm\"\n          idleLabel=${runLabel}\n          loadingLabel=${runLoadingLabel}\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/channel-operations-panel.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { AgentBindingsSection } from \"./agents-tab/agent-bindings-section/index.js\";\nimport { AgentPairingSection } from \"./agents-tab/agent-pairing-section.js\";\n\nconst html = htm.bind(h);\n\nexport const ChannelOperationsPanel = ({\n  agent = null,\n  agents = [],\n  onSetLocation = () => {},\n  channelsSection = null,\n  pairingsSection = null,\n}) => {\n  if (agent) {\n    return html`\n      <div class=\"space-y-4\">\n        <${AgentBindingsSection}\n          agent=${agent}\n          agents=${agents}\n          onSetLocation=${onSetLocation}\n        />\n        <${AgentPairingSection} agent=${agent} />\n      </div>\n    `;\n  }\n  return html`\n    <div class=\"space-y-4\">\n      ${channelsSection}\n      ${pairingsSection}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/channels.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport { AddChannelMenu } from \"./add-channel-menu.js\";\nimport { ChannelAccountStatusBadge } from \"./channel-account-status-badge.js\";\nimport { ChannelLoginModal } from \"./channel-login-modal.js\";\nimport { ConfirmDialog } from \"./confirm-dialog.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"./overflow-menu.js\";\nimport {\n  deleteChannelAccount,\n  fetchChannelAccounts,\n  fetchChannelAccountLoginStatus,\n  fetchRestartStatus,\n  runChannelAccountLogin,\n  updateChannelAccount,\n} from \"../lib/api.js\";\nimport { useCachedFetch } from \"../hooks/use-cached-fetch.js\";\nimport { usePolling } from \"../hooks/usePolling.js\";\nimport {\n  isImplicitDefaultAccount,\n  resolveChannelAccountLabel,\n} from \"../lib/channel-accounts.js\";\nimport { createChannelAccountWithProgress } from \"../lib/channel-create-operation.js\";\nimport { isChannelProviderDisabledForAdd } from \"../lib/channel-provider-availability.js\";\nimport { CreateChannelModal } from \"./agents-tab/create-channel-modal.js\";\nimport { showToast } from \"./toast.js\";\n\nconst html = htm.bind(h);\n\nconst ALL_CHANNELS = [\"telegram\", \"discord\", \"slack\", \"whatsapp\"];\nconst kChannelMeta = {\n  telegram: { label: \"Telegram\", iconSrc: \"/assets/icons/telegram.svg\" },\n  discord: { label: \"Discord\", iconSrc: \"/assets/icons/discord.svg\" },\n  slack: { label: \"Slack\", iconSrc: \"/assets/icons/slack.svg\" },\n  whatsapp: { label: \"WhatsApp\", iconSrc: \"/assets/icons/whatsapp.svg\" },\n};\n\nconst getChannelMeta = (channelId = \"\") => {\n  const normalized = String(channelId || \"\").trim();\n  return (\n    kChannelMeta[normalized] || {\n      label: normalized\n        ? normalized.charAt(0).toUpperCase() + normalized.slice(1)\n        : \"Channel\",\n      iconSrc: \"\",\n    }\n  );\n};\n\nconst announceRestartRequired = () =>\n  window.dispatchEvent(new CustomEvent(\"alphaclaw:restart-required\"));\n\nconst appendTerminalOutput = (previousOutput = \"\", nextChunk = \"\") =>\n  [String(previousOutput || \"\").trim(), String(nextChunk || \"\").trim()]\n    .filter(Boolean)\n    .join(\"\\n\\n\");\n\nconst cloneLoginModalState = (state = {}) => ({\n  loginAccount: state.loginAccount || null,\n  loginOutput: String(state.loginOutput || \"\"),\n  loginError: String(state.loginError || \"\"),\n  loginRunning: !!state.loginRunning,\n  loginMonitoring: !!state.loginMonitoring,\n  loginCompleted: !!state.loginCompleted,\n  loginLinked: !!state.loginLinked,\n  loginRestartingGateway: !!state.loginRestartingGateway,\n  loginRestartedGateway: !!state.loginRestartedGateway,\n});\n\nlet kPreservedChannelLoginModalState = null;\n\nconst clearChannelLoginModalState = ({\n  setLoginAccount,\n  setLoginOutput,\n  setLoginError,\n  setLoginRunning,\n  setLoginMonitoring,\n  setLoginCompleted,\n  setLoginLinked,\n  setLoginRestartingGateway,\n  setLoginRestartedGateway,\n}) => {\n  kPreservedChannelLoginModalState = null;\n  setLoginAccount(null);\n  setLoginOutput(\"\");\n  setLoginError(\"\");\n  setLoginRunning(false);\n  setLoginMonitoring(false);\n  setLoginCompleted(false);\n  setLoginLinked(false);\n  setLoginRestartingGateway(false);\n  setLoginRestartedGateway(false);\n};\n\nexport const ChannelsCard = ({\n  title = \"Channels\",\n  items = [],\n  loadingLabel = \"Loading...\",\n  actions = null,\n  renderItem = null,\n}) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <div class=\"flex items-center justify-between gap-3 mb-3\">\n      <h2 class=\"card-label\">${title}</h2>\n      ${actions ? html`<div class=\"shrink-0\">${actions}</div>` : null}\n    </div>\n    <div class=\"space-y-2\">\n      ${items.length > 0\n        ? items.map((item) => {\n            const channelMeta = getChannelMeta(item.channel || item.id);\n            const clickable = !!item.clickable;\n            const customItem = renderItem\n              ? renderItem({ item, channelMeta, clickable })\n              : null;\n            if (customItem) return customItem;\n            return html`\n              <div\n                key=${item.id || item.channel}\n                class=\"flex justify-between items-center py-1.5 ${clickable\n                  ? \"cursor-pointer hover:bg-surface -mx-2 px-2 rounded-lg transition-colors\"\n                  : \"\"}\"\n                onclick=${clickable ? item.onClick : undefined}\n              >\n                <span\n                  class=\"font-medium text-sm flex items-center gap-2 min-w-0\"\n                >\n                  ${channelMeta.iconSrc\n                    ? html`\n                        <img\n                          src=${channelMeta.iconSrc}\n                          alt=\"\"\n                          class=\"w-4 h-4 rounded-sm\"\n                          aria-hidden=\"true\"\n                        />\n                      `\n                    : null}\n                  <span\n                    class=\"truncate ${item.dimmedLabel ? \"text-fg-muted\" : \"\"} ${item.labelClassName || \"\"}\"\n                    >${item.label || channelMeta.label}</span\n                  >\n                  ${item.detailText\n                    ? html`\n                        <span class=\"text-xs text-fg-muted ml-1 shrink-0\">\n                          ${item.detailText}\n                        </span>\n                      `\n                    : null}\n                  ${item.detailChevron\n                    ? html`\n                        <svg\n                          width=\"14\"\n                          height=\"14\"\n                          viewBox=\"0 0 16 16\"\n                          fill=\"none\"\n                          class=\"text-fg-dim shrink-0\"\n                        >\n                          <path\n                            d=\"M6 3.5L10.5 8L6 12.5\"\n                            stroke=\"currentColor\"\n                            stroke-width=\"2\"\n                            stroke-linecap=\"round\"\n                            stroke-linejoin=\"round\"\n                          />\n                        </svg>\n                      `\n                    : null}\n                </span>\n                <span class=\"flex items-center gap-2 shrink-0\">\n                  ${item.trailing || null}\n                </span>\n              </div>\n            `;\n          })\n        : html`<div class=\"text-fg-muted text-sm text-center py-2\">\n            ${loadingLabel}\n          </div>`}\n    </div>\n  </div>\n`;\n\nexport const Channels = ({\n  channels = null,\n  agents = [],\n  onNavigate = () => {},\n  onRefreshStatuses = () => {},\n  onRestartGateway = async () => ({ ok: false }),\n}) => {\n  const preservedLoginState = cloneLoginModalState(kPreservedChannelLoginModalState || {});\n  const [saving, setSaving] = useState(false);\n  const [createLoadingLabel, setCreateLoadingLabel] = useState(\"Creating...\");\n  const [menuOpenId, setMenuOpenId] = useState(\"\");\n  const [editingAccount, setEditingAccount] = useState(null);\n  const [deletingAccount, setDeletingAccount] = useState(null);\n  const {\n    data: channelAccountsPayload,\n    loading: loadingAccounts,\n    refresh: refreshChannelAccounts,\n  } = useCachedFetch(\"/api/channels/accounts\", fetchChannelAccounts, {\n    maxAgeMs: 30000,\n  });\n  const channelAccounts = Array.isArray(channelAccountsPayload?.channels)\n    ? channelAccountsPayload.channels\n    : [];\n  const [loginAccount, setLoginAccount] = useState(preservedLoginState.loginAccount);\n  const [loginOutput, setLoginOutput] = useState(preservedLoginState.loginOutput);\n  const [loginError, setLoginError] = useState(preservedLoginState.loginError);\n  const [loginRunning, setLoginRunning] = useState(preservedLoginState.loginRunning);\n  const [loginMonitoring, setLoginMonitoring] = useState(preservedLoginState.loginMonitoring);\n  const [loginCompleted, setLoginCompleted] = useState(preservedLoginState.loginCompleted);\n  const [loginLinked, setLoginLinked] = useState(preservedLoginState.loginLinked);\n  const [loginRestartingGateway, setLoginRestartingGateway] = useState(\n    preservedLoginState.loginRestartingGateway,\n  );\n  const [loginRestartedGateway, setLoginRestartedGateway] = useState(\n    preservedLoginState.loginRestartedGateway,\n  );\n  const [loginRestartStatusChecked, setLoginRestartStatusChecked] = useState(false);\n\n  const loadChannelAccounts = useCallback(async () => {\n    try {\n      await refreshChannelAccounts({ force: true });\n    } catch {}\n  }, [refreshChannelAccounts]);\n\n  const loginStatusPoll = usePolling(\n    () =>\n      fetchChannelAccountLoginStatus({\n        provider: loginAccount?.provider,\n        accountId: loginAccount?.id,\n      }),\n    1000,\n    {\n      enabled:\n        !!loginAccount &&\n        !!loginMonitoring &&\n        String(loginAccount?.provider || \"\").trim() === \"whatsapp\",\n    },\n  );\n  const restartStatusPoll = usePolling(fetchRestartStatus, 2000, {\n    enabled: !!loginAccount && !!loginRestartingGateway,\n  });\n\n  const appendLoginOutput = useCallback((nextChunk = \"\") => {\n    setLoginOutput((currentOutput) => appendTerminalOutput(currentOutput, nextChunk));\n  }, []);\n\n  useEffect(() => {\n    const nextState = cloneLoginModalState({\n      loginAccount,\n      loginOutput,\n      loginError,\n      loginRunning,\n      loginMonitoring,\n      loginCompleted,\n      loginLinked,\n      loginRestartingGateway,\n      loginRestartedGateway,\n    });\n    const hasActiveLoginState =\n      !!nextState.loginAccount ||\n      !!nextState.loginOutput ||\n      !!nextState.loginError ||\n      !!nextState.loginRunning ||\n      !!nextState.loginMonitoring ||\n      !!nextState.loginCompleted ||\n      !!nextState.loginLinked ||\n      !!nextState.loginRestartingGateway ||\n      !!nextState.loginRestartedGateway;\n    kPreservedChannelLoginModalState = hasActiveLoginState ? nextState : null;\n  }, [\n    loginAccount,\n    loginCompleted,\n    loginError,\n    loginLinked,\n    loginMonitoring,\n    loginOutput,\n    loginRestartedGateway,\n    loginRestartingGateway,\n    loginRunning,\n  ]);\n\n  const configuredChannelMap = useMemo(\n    () =>\n      new Map(\n        channelAccounts.map((entry) => [\n          String(entry?.channel || \"\").trim(),\n          entry,\n        ]),\n      ),\n    [channelAccounts],\n  );\n\n  const agentNameMap = useMemo(\n    () =>\n      new Map(\n        agents.map((agent) => [\n          String(agent?.id || \"\").trim(),\n          String(agent?.name || \"\").trim() || String(agent?.id || \"\").trim(),\n        ]),\n      ),\n    [agents],\n  );\n\n  const defaultAgentId = useMemo(\n    () => String(agents.find((entry) => entry?.default)?.id || \"\").trim(),\n    [agents],\n  );\n  const showAgentBadge = agents.length > 0;\n\n  useEffect(() => {\n    const handleOpenWhatsAppQr = () => {\n      const configuredWhatsApp = channelAccounts.find(\n        (entry) => String(entry?.channel || \"\").trim() === \"whatsapp\",\n      );\n      const account = Array.isArray(configuredWhatsApp?.accounts)\n        ? configuredWhatsApp.accounts[0]\n        : null;\n      if (!account) return;\n      const accountId = String(account?.id || \"\").trim() || \"default\";\n      const boundAgentId = String(account?.boundAgentId || \"\").trim();\n      const ownerAgentId =\n        boundAgentId ||\n        (isImplicitDefaultAccount({ accountId, boundAgentId })\n          ? defaultAgentId\n          : \"\");\n      const accountData = {\n        id: accountId,\n        provider: \"whatsapp\",\n        name: resolveChannelAccountLabel({\n          channelId: \"whatsapp\",\n          account,\n          providerLabel: getChannelMeta(\"whatsapp\").label || \"WhatsApp\",\n        }),\n        ownerAgentId,\n        envKey: String(account?.envKey || \"\").trim(),\n        token: String(account?.token || \"\").trim(),\n      };\n      setLoginAccount(accountData);\n      setLoginOutput(\"\");\n      setLoginError(\"\");\n      setLoginRunning(false);\n      setLoginMonitoring(false);\n      setLoginCompleted(false);\n      setLoginLinked(false);\n      setLoginRestartingGateway(false);\n      setLoginRestartedGateway(false);\n      setLoginRestartStatusChecked(false);\n    };\n    window.addEventListener(\"alphaclaw:open-whatsapp-qr\", handleOpenWhatsAppQr);\n    return () => {\n      window.removeEventListener(\"alphaclaw:open-whatsapp-qr\", handleOpenWhatsAppQr);\n    };\n  }, [channelAccounts, defaultAgentId]);\n\n  const handleUpdateChannel = async (payload) => {\n    setSaving(true);\n    try {\n      const result = await updateChannelAccount(payload);\n      setEditingAccount(null);\n      showToast(\"Channel updated\", \"success\");\n      if (result?.restartRequired) {\n        announceRestartRequired();\n      }\n      await Promise.all([\n        loadChannelAccounts(),\n        Promise.resolve(onRefreshStatuses?.()),\n      ]);\n    } catch (error) {\n      showToast(error.message || \"Could not update channel\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleCreateChannel = async (payload) => {\n    setSaving(true);\n    setCreateLoadingLabel(\"Creating...\");\n    try {\n      const result = await createChannelAccountWithProgress({\n        payload,\n        onPhase: (label) => {\n          setCreateLoadingLabel(String(label || \"\").trim() || \"Creating...\");\n        },\n      });\n      setEditingAccount(null);\n      showToast(\"Channel configured\", \"success\");\n      if (result?.restartRequired) {\n        announceRestartRequired();\n      }\n      await Promise.all([\n        loadChannelAccounts(),\n        Promise.resolve(onRefreshStatuses?.()),\n      ]);\n    } catch (error) {\n      showToast(error.message || \"Could not configure channel\", \"error\");\n    } finally {\n      setSaving(false);\n      setCreateLoadingLabel(\"Creating...\");\n    }\n  };\n\n  const handleDeleteChannel = async () => {\n    if (!deletingAccount) return;\n    setSaving(true);\n    try {\n      await deleteChannelAccount({\n        provider: deletingAccount.provider,\n        accountId: deletingAccount.id,\n      });\n      setDeletingAccount(null);\n      showToast(\"Channel deleted\", \"success\");\n      await Promise.all([\n        loadChannelAccounts(),\n        Promise.resolve(onRefreshStatuses?.()),\n      ]);\n    } catch (error) {\n      showToast(error.message || \"Could not delete channel\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  };\n  const handleRunChannelLogin = async () => {\n    if (!loginAccount) return;\n    setLoginRunning(true);\n    setLoginMonitoring(true);\n    setLoginCompleted(false);\n    setLoginLinked(false);\n    setLoginRestartingGateway(false);\n    setLoginRestartedGateway(false);\n    setLoginRestartStatusChecked(false);\n    setLoginError(\"\");\n    setLoginOutput(\"\");\n    try {\n      const result = await runChannelAccountLogin({\n        provider: loginAccount.provider,\n        accountId: loginAccount.id,\n      });\n      const combinedOutput = appendTerminalOutput(result?.stdout || \"\", result?.stderr || \"\");\n      setLoginOutput(combinedOutput || \"No terminal output captured.\");\n      setLoginCompleted(!!result?.completed);\n      if (result?.completed) {\n        await loginStatusPoll.refresh();\n      }\n    } catch (error) {\n      setLoginError(String(error?.message || \"Could not start channel login\"));\n      setLoginMonitoring(false);\n    } finally {\n      setLoginRunning(false);\n    }\n  };\n\n  useEffect(() => {\n    if (!loginAccount || !loginMonitoring || loginLinked || loginRestartingGateway) {\n      return;\n    }\n    if (!loginStatusPoll.data?.linked) return;\n\n    let cancelled = false;\n    setLoginLinked(true);\n    setLoginError(\"\");\n    appendLoginOutput(\"✅ Saved WhatsApp credentials detected.\");\n\n    (async () => {\n      setLoginRestartingGateway(true);\n      setLoginRestartStatusChecked(false);\n      appendLoginOutput(\"Restarting the gateway so the new WhatsApp session comes online...\");\n      try {\n        const restartResult = await onRestartGateway();\n        if (restartResult && restartResult.ok === false) {\n          throw new Error(restartResult.error || \"Could not restart gateway\");\n        }\n        if (cancelled) return;\n        appendLoginOutput(\"✅ Gateway restart triggered. Waiting for it to come back online...\");\n        await restartStatusPoll.refresh();\n        if (cancelled) return;\n        setLoginRestartStatusChecked(true);\n      } catch (error) {\n        if (cancelled) return;\n        setLoginError(String(error?.message || \"Could not restart gateway\"));\n        appendLoginOutput(\n          \"WhatsApp linked, but the gateway restart failed. You may need to restart it manually.\",\n        );\n        setLoginRestartStatusChecked(false);\n        setLoginRestartingGateway(false);\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [\n    appendLoginOutput,\n    loadChannelAccounts,\n    loginAccount,\n    loginLinked,\n    loginMonitoring,\n    loginRestartingGateway,\n    loginStatusPoll.data?.linked,\n    onRefreshStatuses,\n    onRestartGateway,\n    restartStatusPoll.refresh,\n  ]);\n\n  useEffect(() => {\n    if (!loginAccount || !loginRestartingGateway) return;\n    if (!loginRestartStatusChecked) return;\n    const restartInProgress = !!restartStatusPoll.data?.restartInProgress;\n    const gatewayRunning = restartStatusPoll.data?.gatewayRunning !== false;\n    if (restartInProgress || !gatewayRunning) return;\n\n    let cancelled = false;\n\n    (async () => {\n      setLoginRestartedGateway(true);\n      setLoginMonitoring(false);\n      appendLoginOutput(\"✅ Gateway restart complete.\");\n      showToast(\"Channel linked\", \"success\");\n      await Promise.all([\n        loadChannelAccounts(),\n        Promise.resolve(onRefreshStatuses?.()),\n      ]);\n      if (cancelled) return;\n      clearChannelLoginModalState({\n        setLoginAccount,\n        setLoginOutput,\n        setLoginError,\n        setLoginRunning,\n        setLoginMonitoring,\n        setLoginCompleted,\n        setLoginLinked,\n        setLoginRestartingGateway,\n        setLoginRestartedGateway,\n      });\n    })();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [\n    appendLoginOutput,\n    loadChannelAccounts,\n    loginAccount,\n    loginRestartStatusChecked,\n    loginRestartingGateway,\n    onRefreshStatuses,\n    restartStatusPoll.data?.gatewayRunning,\n    restartStatusPoll.data?.restartInProgress,\n  ]);\n\n  const openCreateChannelModal = (provider) => {\n    setMenuOpenId(\"\");\n    setEditingAccount({\n      id: \"default\",\n      provider,\n      name: getChannelMeta(provider).label,\n      ownerAgentId: defaultAgentId,\n      mode: \"create\",\n    });\n  };\n  const items = useMemo(\n    () => {\n      if (loadingAccounts || !channels) return [];\n      const channelOrderMap = new Map(\n        channelAccounts.map((entry, index) => [\n          String(entry?.channel || \"\").trim(),\n          index,\n        ]),\n      );\n      const accountOrderMap = new Map(\n        channelAccounts.flatMap((entry) =>\n          (Array.isArray(entry?.accounts) ? entry.accounts : []).map(\n            (account, accountIndex) => [\n              `${String(entry?.channel || \"\").trim()}:${String(account?.id || \"\").trim() || \"default\"}`,\n              accountIndex,\n            ],\n          ),\n        ),\n      );\n      return Array.from(\n        new Set([\n          ...channelAccounts.map((entry) =>\n            String(entry?.channel || \"\").trim(),\n          ),\n        ]),\n      )\n            .filter(Boolean)\n            .flatMap((channelId) => {\n              const info = channels[channelId];\n              const configuredChannel = configuredChannelMap.get(channelId);\n              const accounts = Array.isArray(configuredChannel?.accounts)\n                ? configuredChannel.accounts\n                : [];\n              if (!configuredChannel) return [];\n\n              return accounts.map((account) => {\n                const accountId = String(account?.id || \"\").trim() || \"default\";\n                const accountStatusInfo =\n                  info?.accounts?.[accountId] || info || null;\n                const accountStatus = String(\n                  accountStatusInfo?.status || account?.status || \"configured\",\n                ).trim();\n                const pairedCount = Number(\n                  accountStatusInfo?.paired ??\n                    account?.paired ??\n                    info?.paired ??\n                    0,\n                );\n                const isClickable =\n                  channelId === \"telegram\" &&\n                  accountStatus === \"paired\" &&\n                  onNavigate;\n                const boundAgentId = String(account?.boundAgentId || \"\").trim();\n                const ownerAgentId =\n                  boundAgentId ||\n                  (isImplicitDefaultAccount({ accountId, boundAgentId })\n                    ? defaultAgentId\n                    : \"\");\n                const ownerAgentName =\n                  agentNameMap.get(ownerAgentId) || ownerAgentId || \"\";\n                const accountData = {\n                  id: accountId,\n                  provider: channelId,\n                  name: resolveChannelAccountLabel({\n                    channelId,\n                    account,\n                    providerLabel: getChannelMeta(channelId).label || \"Channel\",\n                  }),\n                  ownerAgentId,\n                  envKey: String(account?.envKey || \"\").trim(),\n                  token: String(account?.token || \"\").trim(),\n                };\n\n                const trailing = html`\n                  <div class=\"flex items-center gap-1.5\">\n                    ${\n                      showAgentBadge &&\n                      ownerAgentName &&\n                      accountStatus === \"paired\"\n                        ? html`<${ChannelAccountStatusBadge}\n                            status=${accountStatus}\n                            ownerAgentName=${ownerAgentName}\n                            showAgentBadge=${showAgentBadge}\n                            channelId=${channelId}\n                            pairedCount=${pairedCount}\n                          />`\n                        : null\n                    }\n                    ${\n                      accountStatus === \"paired\"\n                        ? showAgentBadge && ownerAgentName\n                          ? null\n                          : html`<${ChannelAccountStatusBadge}\n                              status=${accountStatus}\n                              ownerAgentName=\"\"\n                              showAgentBadge=${false}\n                              channelId=${channelId}\n                              pairedCount=${pairedCount}\n                            />`\n                        : html`<${ChannelAccountStatusBadge}\n                            status=${accountStatus}\n                            ownerAgentName=\"\"\n                            showAgentBadge=${false}\n                            channelId=${channelId}\n                            pairedCount=${pairedCount}\n                          />`\n                    }\n                    <${OverflowMenu}\n                      open=${menuOpenId === `${channelId}:${accountId}`}\n                      ariaLabel=\"Open channel actions\"\n                      title=\"Open channel actions\"\n                      onClose=${() => setMenuOpenId(\"\")}\n                      onToggle=${() =>\n                        setMenuOpenId((current) =>\n                          current === `${channelId}:${accountId}`\n                            ? \"\"\n                            : `${channelId}:${accountId}`,\n                        )}\n                    >\n                      <${OverflowMenuItem}\n                        onClick=${() => {\n                          setMenuOpenId(\"\");\n                          setEditingAccount(accountData);\n                        }}\n                      >\n                        Edit\n                      </${OverflowMenuItem}>\n                      ${channelId === \"whatsapp\"\n                        ? html`\n                            <${OverflowMenuItem}\n                              onClick=${() => {\n                                setMenuOpenId(\"\");\n                                setLoginAccount(accountData);\n                                setLoginOutput(\"\");\n                                setLoginError(\"\");\n                                setLoginRunning(false);\n                                setLoginMonitoring(false);\n                                setLoginCompleted(false);\n                                setLoginLinked(false);\n                                setLoginRestartingGateway(false);\n                                setLoginRestartedGateway(false);\n                                setLoginRestartStatusChecked(false);\n                              }}\n                            >\n                              Link WhatsApp (QR)\n                            </${OverflowMenuItem}>\n                          `\n                        : null}\n                      <${OverflowMenuItem}\n                        className=\"text-status-error hover:text-status-error\"\n                        onClick=${() => {\n                          setMenuOpenId(\"\");\n                          setDeletingAccount(accountData);\n                        }}\n                      >\n                        Delete\n                      </${OverflowMenuItem}>\n                    </${OverflowMenu}>\n                  </div>\n                `;\n\n                return {\n                  id: `${channelId}:${accountId}`,\n                  channel: channelId,\n                  channelOrder: Number(channelOrderMap.get(channelId) ?? 9999),\n                  accountOrder: Number(\n                    accountOrderMap.get(`${channelId}:${accountId}`) ?? 9999,\n                  ),\n                  label: resolveChannelAccountLabel({\n                    channelId,\n                    account,\n                    providerLabel: getChannelMeta(channelId).label || \"Channel\",\n                  }),\n                  isAwaitingPairing: accountStatus !== \"paired\",\n                  detailText: isClickable ? \"Workspace\" : \"\",\n                  detailChevron: isClickable,\n                  clickable: isClickable,\n                  onClick: isClickable\n                    ? () =>\n                        onNavigate(`telegram/${encodeURIComponent(accountId)}`)\n                    : undefined,\n                  trailing,\n                };\n              });\n            })\n            .sort((a, b) => {\n              const awaitingDiff =\n                Number(!!a?.isAwaitingPairing) - Number(!!b?.isAwaitingPairing);\n              if (awaitingDiff !== 0) return awaitingDiff;\n              const channelOrderDiff =\n                Number(a?.channelOrder ?? 9999) - Number(b?.channelOrder ?? 9999);\n              if (channelOrderDiff !== 0) return channelOrderDiff;\n              const accountOrderDiff =\n                Number(a?.accountOrder ?? 9999) - Number(b?.accountOrder ?? 9999);\n              if (accountOrderDiff !== 0) return accountOrderDiff;\n              return String(a?.label || \"\").localeCompare(String(b?.label || \"\"));\n            })\n        ;\n    },\n    [\n      agentNameMap,\n      agents.length,\n      channelAccounts,\n      channels,\n      configuredChannelMap,\n      defaultAgentId,\n      loadingAccounts,\n      menuOpenId,\n      onNavigate,\n      showAgentBadge,\n    ],\n  );\n\n  return html`\n    <div class=\"space-y-3\">\n      <${ChannelsCard}\n        title=\"Channels\"\n        items=${items}\n        loadingLabel=${loadingAccounts\n          ? \"Loading...\"\n          : \"No channels configured\"}\n        actions=${html`\n          <${AddChannelMenu}\n            open=${menuOpenId === \"__create_channel\"}\n            onClose=${() => setMenuOpenId(\"\")}\n            onToggle=${() =>\n              setMenuOpenId((current) =>\n                current === \"__create_channel\" ? \"\" : \"__create_channel\",\n              )}\n            triggerDisabled=${saving || loadingAccounts}\n            channelIds=${ALL_CHANNELS}\n            getChannelMeta=${getChannelMeta}\n            isChannelDisabled=${(channelId) =>\n              isChannelProviderDisabledForAdd({\n                configuredChannelMap,\n                provider: channelId,\n              })}\n            onSelectChannel=${openCreateChannelModal}\n          />\n        `}\n      />\n      <${CreateChannelModal}\n        visible=${!!editingAccount}\n        loading=${saving}\n        createLoadingLabel=${createLoadingLabel}\n        agents=${agents}\n        existingChannels=${channelAccounts}\n        mode=${editingAccount?.mode === \"create\" ? \"create\" : \"edit\"}\n        account=${editingAccount}\n        initialAgentId=${String(editingAccount?.ownerAgentId || \"\").trim()}\n        initialProvider=${String(editingAccount?.provider || \"\").trim()}\n        onClose=${() => setEditingAccount(null)}\n        onSubmit=${editingAccount?.mode === \"create\"\n          ? handleCreateChannel\n          : handleUpdateChannel}\n      />\n      <${ConfirmDialog}\n        visible=${!!deletingAccount}\n        title=\"Delete channel?\"\n        message=${`Remove ${String(deletingAccount?.name || \"this channel\").trim()} from your configured channels?`}\n        confirmLabel=\"Delete\"\n        confirmLoadingLabel=\"Deleting...\"\n        confirmTone=\"warning\"\n        confirmLoading=${saving}\n        onConfirm=${handleDeleteChannel}\n        onCancel=${() => {\n          if (saving) return;\n          setDeletingAccount(null);\n        }}\n      />\n      <${ChannelLoginModal}\n        visible=${!!loginAccount}\n        loading=${loginRunning || loginRestartingGateway}\n        title=${`Link ${String(loginAccount?.name || \"WhatsApp\").trim()} via QR`}\n        output=${loginOutput}\n        error=${loginError}\n        onRun=${handleRunChannelLogin}\n        onClose=${() => {\n          if (loginRunning || loginRestartingGateway) return;\n          clearChannelLoginModalState({\n            setLoginAccount,\n            setLoginOutput,\n            setLoginError,\n            setLoginRunning,\n            setLoginMonitoring,\n            setLoginCompleted,\n            setLoginLinked,\n            setLoginRestartingGateway,\n            setLoginRestartedGateway,\n          });\n        }}\n        runDisabled=${loginRunning || loginRestartingGateway || loginRestartedGateway}\n        runLabel=${loginLinked\n          ? loginRestartingGateway\n            ? \"Restarting...\"\n            : loginRestartedGateway\n              ? \"Linked\"\n              : \"Awaiting restart...\"\n          : \"Generate QR\"}\n        runLoadingLabel=${loginRestartingGateway ? \"Restarting...\" : \"Running...\"}\n        closeLabel=${loginRestartedGateway ? \"Done\" : \"Close\"}\n      />\n    </div>\n  `;\n};\n\nexport { ALL_CHANNELS, getChannelMeta, kChannelMeta };\n"
  },
  {
    "path": "lib/public/js/components/confirm-dialog.js",
    "content": "import { h } from \"preact\";\nimport { useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"./action-button.js\";\n\nconst html = htm.bind(h);\n\nexport const ConfirmDialog = ({\n  visible = false,\n  title = \"Confirm action\",\n  message = \"Are you sure you want to continue?\",\n  details = null,\n  confirmLabel = \"Confirm\",\n  confirmLoadingLabel = \"Working...\",\n  cancelLabel = \"Cancel\",\n  onConfirm,\n  onCancel,\n  confirmTone = \"primary\",\n  confirmLoading = false,\n  confirmDisabled = false,\n}) => {\n  useEffect(() => {\n    if (!visible) return;\n\n    const handleKeydown = (event) => {\n      if (event.key === \"Escape\") {\n        onCancel?.();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeydown);\n    return () => window.removeEventListener(\"keydown\", handleKeydown);\n  }, [visible, onCancel]);\n\n  if (!visible) return null;\n  const actionTone = confirmTone === \"warning\" ? \"warning\" : \"primary\";\n\n  return html`\n    <div\n      class=\"fixed inset-0 bg-overlay flex items-center justify-center p-4 z-50\"\n      onclick=${(event) => {\n        if (event.target === event.currentTarget) onCancel?.();\n      }}\n    >\n      <div class=\"bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3\">\n        <h2 class=\"text-base font-semibold\">${title}</h2>\n        <p class=\"text-sm text-fg-muted\">${message}</p>\n        ${details}\n        <div class=\"pt-1 flex items-center justify-end gap-2\">\n          <${ActionButton}\n            onClick=${onCancel}\n            disabled=${confirmLoading}\n            tone=\"secondary\"\n            size=\"md\"\n            idleLabel=${cancelLabel}\n            className=\"px-4 py-2 rounded-lg text-sm\"\n          />\n          <${ActionButton}\n            onClick=${onConfirm}\n            disabled=${confirmDisabled}\n            loading=${confirmLoading}\n            tone=${actionTone}\n            size=\"md\"\n            idleLabel=${confirmLabel}\n            loadingLabel=${confirmLoadingLabel}\n            className=\"px-4 py-2 rounded-lg text-sm\"\n          />\n        </div>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/credentials-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { saveGoogleCredentials } from \"../lib/api.js\";\nimport { SecretInput } from \"./secret-input.js\";\nimport { ModalShell } from \"./modal-shell.js\";\nimport { PageHeader } from \"./page-header.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport { CloseIcon } from \"./icons.js\";\nconst html = htm.bind(h);\n\nexport const CredentialsModal = ({\n  visible,\n  onClose,\n  onSaved,\n  title = \"Connect Google Workspace\",\n  submitLabel = \"Connect Google\",\n  defaultInstrType = \"workspace\",\n  client = \"default\",\n  personal = false,\n  accountId = \"\",\n  initialValues = {},\n}) => {\n  const [clientId, setClientId] = useState(\"\");\n  const [clientSecret, setClientSecret] = useState(\"\");\n  const [email, setEmail] = useState(\"\");\n  const [error, setError] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [instrType, setInstrType] = useState(defaultInstrType);\n  const [redirectUriCopied, setRedirectUriCopied] = useState(false);\n  const fileRef = useRef(null);\n\n  useEffect(() => {\n    if (!visible) return;\n    setClientId(String(initialValues.clientId || \"\"));\n    setClientSecret(String(initialValues.clientSecret || \"\"));\n    setEmail(String(initialValues.email || \"\"));\n    setInstrType(defaultInstrType);\n    setError(\"\");\n    setRedirectUriCopied(false);\n  }, [visible, initialValues, defaultInstrType]);\n\n  if (!visible) return null;\n\n  const redirectUri = `${window.location.origin}/auth/google/callback`;\n\n  const copyRedirectUri = async () => {\n    try {\n      await navigator.clipboard.writeText(redirectUri);\n      setRedirectUriCopied(true);\n      window.setTimeout(() => setRedirectUriCopied(false), 1500);\n    } catch {\n      setError(\"Unable to copy redirect URI\");\n    }\n  };\n\n  const handleFile = async (e) => {\n    const file = e.target.files[0];\n    if (!file) return;\n    try {\n      const text = await file.text();\n      const json = JSON.parse(text);\n      const creds = json.installed || json.web || json;\n      if (creds.client_id) setClientId(creds.client_id);\n      if (creds.client_secret) setClientSecret(creds.client_secret);\n    } catch {\n      setError(\"Invalid JSON file\");\n    }\n  };\n\n  const submit = async () => {\n    setError(\"\");\n    if (!clientId || !clientSecret || !email) {\n      setError(\"Client ID, Client Secret, and Email are required\");\n      return;\n    }\n    setSaving(true);\n    try {\n      const data = await saveGoogleCredentials({\n        clientId,\n        clientSecret,\n        email,\n        client,\n        personal,\n        accountId,\n      });\n      if (data.ok) {\n        onClose();\n        onSaved?.(data.account);\n      } else setError(data.error || \"Failed to save credentials\");\n    } catch {\n      setError(\"Request failed\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const btnCls = (type) =>\n    `flex-1 text-center border-0 cursor-pointer transition-colors` +\n    ` ${instrType === type ? \"\" : \"hover:text-white\"}`;\n\n  const btnStyle = (type) =>\n    `font-family: inherit; font-size: 11px; letter-spacing: 0.03em; padding: 5px 10px;` +\n    (instrType === type\n      ? ` color: var(--accent); background: var(--bg-active);`\n      : ` color: var(--text-muted); background: transparent;`);\n\n  const renderRedirectUriInstruction = () => html`\n    <div class=\"mt-1 flex items-center gap-2\">\n      <input\n        type=\"text\"\n        readonly\n        value=${redirectUri}\n        onFocus=${(e) => e.target.select()}\n        onclick=${(e) => e.target.select()}\n        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\"\n      />\n      <button\n        type=\"button\"\n        onclick=${copyRedirectUri}\n        class=\"shrink-0 px-2 py-1 rounded border border-border text-xs text-body hover:border-fg-muted\"\n      >\n        ${redirectUriCopied ? \"Copied\" : \"Copy\"}\n      </button>\n    </div>\n  `;\n\n  return html` <${ModalShell}\n    visible=${visible}\n    onClose=${onClose}\n    closeOnOverlayClick=${false}\n    panelClassName=\"bg-modal border border-border rounded-xl p-6 max-w-lg w-full space-y-4\"\n  >\n      <${PageHeader}\n        title=${title}\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${onClose}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n          </button>\n        `}\n      />\n      <div class=\"space-y-3\">\n        <div>\n          <p class=\"text-fg-muted text-sm mb-3\">\n            You'll need a Google Cloud OAuth app.${\" \"}\n            <a\n              href=\"https://console.cloud.google.com/apis/credentials\"\n              target=\"_blank\"\n              class=\"hover:text-white\"\n              style=\"color: rgba(99, 235, 255, 0.6)\"\n              >Create one →</a\n            >\n          </p>\n          <details\n            class=\"text-sm text-fg-muted mb-3 bg-field border border-border rounded-lg px-3 py-2\"\n          >\n            <summary class=\"cursor-pointer font-medium hover:text-body\">\n              Step-by-step instructions\n            </summary>\n            <div\n              class=\"mt-2 mb-2 flex overflow-hidden\"\n              style=\"border: 1px solid var(--border); border-radius: 6px; background: rgba(255,255,255,0.02)\"\n            >\n              <button\n                onclick=${() => setInstrType(\"workspace\")}\n                class=${btnCls(\"workspace\")}\n                style=${btnStyle(\"workspace\")}\n              >\n                Google Workspace\n              </button>\n              <button\n                onclick=${() => setInstrType(\"personal\")}\n                class=${btnCls(\"personal\")}\n                style=${btnStyle(\"personal\")}\n              >\n                Personal Gmail\n              </button>\n            </div>\n            ${instrType === \"personal\"\n              ? html`\n                  <div style=\"line-height: 1.7\">\n                    <ol class=\"list-decimal list-inside space-y-2.5 ml-1\">\n                      <li>\n                        ${\" \"}<a\n                          href=\"https://console.cloud.google.com/projectcreate\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Create a Google Cloud project</a\n                        >${\" \"}(or use existing)\n                      </li>\n                      <li>\n                        Go to${\" \"}<a\n                          href=\"https://console.cloud.google.com/auth/audience\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >OAuth consent screen</a\n                        >${\" \"}→ set to <strong>External</strong>\n                      </li>\n                      <li>\n                        Under${\" \"}<a\n                          href=\"https://console.cloud.google.com/auth/audience\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Test users</a\n                        >, <strong>add your own email</strong>\n                      </li>\n                      <li>\n                        ${\" \"}<a\n                          href=\"https://console.cloud.google.com/apis/library\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Enable APIs</a\n                        >${\" \"}for the services you selected below\n                      </li>\n                      <li>\n                        Go to${\" \"}<a\n                          href=\"https://console.cloud.google.com/apis/credentials\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Credentials</a\n                        >${\" \"}→ Create OAuth 2.0 Client ID (Web application)\n                      </li>\n                      <li>\n                        Add redirect URI:${renderRedirectUriInstruction()}\n                      </li>\n                      <li>\n                        Copy Client ID + Secret (or download credentials JSON)\n                      </li>\n                    </ol>\n                    <p class=\"mt-3 text-status-warning-muted/80\">\n                      ⚠️ App will be in \"Testing\" mode. Only emails added as\n                      Test Users can sign in (up to 100).\n                    </p>\n                  </div>\n                `\n              : html`\n                  <div style=\"line-height: 1.7\">\n                    <ol class=\"list-decimal list-inside space-y-2.5 ml-1\">\n                      <li>\n                        ${\" \"}<a\n                          href=\"https://console.cloud.google.com/projectcreate\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Create a Google Cloud project</a\n                        >${\" \"}(or use existing)\n                      </li>\n                      <li>\n                        Go to${\" \"}<a\n                          href=\"https://console.cloud.google.com/auth/audience\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >OAuth consent screen</a\n                        >${\" \"}→ set to <strong>Internal</strong> (Workspace\n                        only)\n                      </li>\n                      <li>\n                        ${\" \"}<a\n                          href=\"https://console.cloud.google.com/apis/library\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Enable APIs</a\n                        >${\" \"}for the services you selected below\n                      </li>\n                      <li>\n                        Go to${\" \"}<a\n                          href=\"https://console.cloud.google.com/apis/credentials\"\n                          target=\"_blank\"\n                          class=\"hover:text-white\"\n                          style=\"color: rgba(99, 235, 255, 0.6)\"\n                          >Credentials</a\n                        >${\" \"}→ Create OAuth 2.0 Client ID (Web application)\n                      </li>\n                      <li>\n                        Add redirect URI:${renderRedirectUriInstruction()}\n                      </li>\n                      <li>\n                        Copy Client ID + Secret (or download credentials JSON)\n                      </li>\n                    </ol>\n                    <p class=\"mt-3 text-status-success-muted/80\">\n                      ✓ Internal apps skip test users and verification. Only\n                      users in your Workspace org can authorize this Google app.\n                    </p>\n                  </div>\n                `}\n          </details>\n        </div>\n        <div\n          class=\"bg-field border border-border rounded-lg p-3 space-y-3 mt-2\"\n        >\n          <div class=\"flex flex-col items-center text-center gap-2 py-2\">\n            <label class=\"text-sm text-body font-medium\"\n              >Upload credentials.json</label\n            >\n            <input\n              type=\"file\"\n              ref=${fileRef}\n              accept=\".json\"\n              onchange=${handleFile}\n              class=\"hidden\"\n            />\n            <button\n              type=\"button\"\n              onclick=${() => fileRef.current?.click()}\n              class=\"text-sm px-3 py-1.5 rounded-lg border border-border text-body hover:border-fg-muted\"\n            >\n              Choose file\n            </button>\n          </div>\n          <div class=\"flex items-center gap-3 py-1\">\n            <div class=\"h-px flex-1 bg-border\"></div>\n            <span class=\"text-fg-muted text-xs\">or enter manually</span>\n            <div class=\"h-px flex-1 bg-border\"></div>\n          </div>\n          <div>\n            <label class=\"text-sm text-fg-muted block mb-1\">Client ID</label>\n            <${SecretInput}\n              value=${clientId}\n              onInput=${(e) => setClientId(e.target.value)}\n              placeholder=\"xxxx.apps.googleusercontent.com\"\n              inputClass=\"flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted\"\n            />\n          </div>\n          <div>\n            <label class=\"text-sm text-fg-muted block mb-1\"\n              >Client Secret</label\n            >\n            <${SecretInput}\n              value=${clientSecret}\n              onInput=${(e) => setClientSecret(e.target.value)}\n              placeholder=\"GOCSPX-...\"\n              inputClass=\"flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted\"\n            />\n          </div>\n          <div>\n            <label class=\"text-sm text-fg-muted block mb-1\"\n              >Email (Google account to authorize)</label\n            >\n            <input\n              type=\"email\"\n              value=${email}\n              onInput=${(e) => setEmail(e.target.value)}\n              placeholder=\"you@gmail.com\"\n              class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted\"\n            />\n          </div>\n        </div>\n      </div>\n      <div class=\"flex gap-2 pt-2\">\n        <${ActionButton}\n          onClick=${submit}\n          disabled=${saving}\n          loading=${saving}\n          tone=\"primary\"\n          size=\"lg\"\n          idleLabel=${submitLabel}\n          loadingLabel=\"Saving...\"\n          className=\"w-full px-4 py-2 rounded-lg text-sm\"\n        />\n      </div>\n      ${error ? html`<div class=\"text-status-error-muted text-xs\">${error}</div>` : null}\n  </${ModalShell}>`;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-calendar-helpers.js",
    "content": "const kMinuteMs = 60 * 1000;\nconst kHourMs = 60 * kMinuteMs;\nconst kDayMs = 24 * kHourMs;\nconst kRollingPastDays = 3;\nconst kRollingFutureDays = 3;\n\nconst toFiniteNumber = (value, fallback = 0) => {\n  const parsed = Number(value);\n  return Number.isFinite(parsed) ? parsed : fallback;\n};\n\nconst startOfHourMs = (valueMs) => {\n  const dateValue = new Date(toFiniteNumber(valueMs, Date.now()));\n  dateValue.setMinutes(0, 0, 0);\n  return dateValue.getTime();\n};\n\nconst startOfDayMs = (valueMs) => {\n  const dateValue = new Date(toFiniteNumber(valueMs, Date.now()));\n  dateValue.setHours(0, 0, 0, 0);\n  return dateValue.getTime();\n};\n\nconst parseCronFields = (schedule = {}) => {\n  const cronExpr = String(\n    schedule?.expr || schedule?.cron || schedule?.cronExpr || \"\",\n  ).trim();\n  const fields = cronExpr.split(/\\s+/);\n  if (fields.length < 5) return null;\n  const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields;\n  return {\n    minuteField,\n    hourField,\n    dayOfMonthField,\n    monthField,\n    dayOfWeekField,\n  };\n};\n\nconst parseToken = (token = \"\", minValue = 0, maxValue = 0) => {\n  if (/^\\d+$/.test(token)) {\n    const parsed = Number.parseInt(token, 10);\n    if (Number.isFinite(parsed) && parsed >= minValue && parsed <= maxValue) return [parsed];\n    return [];\n  }\n  const rangeMatch = token.match(/^(\\d+)-(\\d+)$/);\n  if (rangeMatch) {\n    const startValue = Number.parseInt(rangeMatch[1], 10);\n    const endValue = Number.parseInt(rangeMatch[2], 10);\n    if (!Number.isFinite(startValue) || !Number.isFinite(endValue)) return [];\n    const safeStart = Math.max(minValue, Math.min(maxValue, startValue));\n    const safeEnd = Math.max(minValue, Math.min(maxValue, endValue));\n    if (safeStart > safeEnd) return [];\n    return Array.from({ length: safeEnd - safeStart + 1 }, (_, index) => safeStart + index);\n  }\n  const stepMatch = token.match(/^\\*\\/(\\d+)$/);\n  if (stepMatch) {\n    const step = Number.parseInt(stepMatch[1], 10);\n    if (!Number.isFinite(step) || step <= 0) return [];\n    const values = [];\n    for (let value = minValue; value <= maxValue; value += step) values.push(value);\n    return values;\n  }\n  if (token === \"*\") {\n    return Array.from({ length: maxValue - minValue + 1 }, (_, index) => minValue + index);\n  }\n  return [];\n};\n\nconst parseCronFieldSet = (field = \"\", minValue = 0, maxValue = 0) => {\n  const raw = String(field || \"\").trim();\n  if (!raw) return new Set();\n  const tokens = raw.split(\",\").map((segment) => segment.trim()).filter(Boolean);\n  const values = tokens.flatMap((token) => parseToken(token, minValue, maxValue));\n  return new Set(values);\n};\n\nconst buildCronMatcher = (cronFields = null) => {\n  if (!cronFields) return null;\n  return {\n    minuteSet: parseCronFieldSet(cronFields.minuteField, 0, 59),\n    hourSet: parseCronFieldSet(cronFields.hourField, 0, 23),\n    dayOfMonthSet: parseCronFieldSet(cronFields.dayOfMonthField, 1, 31),\n    monthSet: parseCronFieldSet(cronFields.monthField, 1, 12),\n    dayOfWeekSet: parseCronFieldSet(cronFields.dayOfWeekField, 0, 7),\n  };\n};\n\nconst cronFieldMatches = (dateValue, cronMatcher = null) => {\n  if (!cronMatcher) return false;\n  const { minuteSet, hourSet, dayOfMonthSet, monthSet, dayOfWeekSet } = cronMatcher;\n\n  const minute = dateValue.getMinutes();\n  const hour = dateValue.getHours();\n  const dayOfMonth = dateValue.getDate();\n  const month = dateValue.getMonth() + 1;\n  const dayOfWeek = dateValue.getDay();\n  const dayOfWeekAliases = dayOfWeek === 0 ? [0, 7] : [dayOfWeek];\n\n  const minuteMatches = minuteSet.size === 0 || minuteSet.has(minute);\n  const hourMatches = hourSet.size === 0 || hourSet.has(hour);\n  const dayOfMonthMatches = dayOfMonthSet.size === 0 || dayOfMonthSet.has(dayOfMonth);\n  const monthMatches = monthSet.size === 0 || monthSet.has(month);\n  const dayOfWeekMatches =\n    dayOfWeekSet.size === 0 || dayOfWeekAliases.some((candidate) => dayOfWeekSet.has(candidate));\n\n  return minuteMatches && hourMatches && dayOfMonthMatches && monthMatches && dayOfWeekMatches;\n};\n\nconst toDayKey = (valueMs) => {\n  const dateValue = new Date(valueMs);\n  const year = dateValue.getFullYear();\n  const month = String(dateValue.getMonth() + 1).padStart(2, \"0\");\n  const day = String(dateValue.getDate()).padStart(2, \"0\");\n  return `${year}-${month}-${day}`;\n};\n\nexport const getRollingRange = ({\n  nowMs = Date.now(),\n  pastDays = kRollingPastDays,\n  futureDays = kRollingFutureDays,\n} = {}) => {\n  const safeNowMs = toFiniteNumber(nowMs, Date.now());\n  const safePastDays = Math.max(0, Number.parseInt(String(pastDays), 10) || kRollingPastDays);\n  const safeFutureDays = Math.max(\n    0,\n    Number.parseInt(String(futureDays), 10) || kRollingFutureDays,\n  );\n  const rangeStartMs = startOfDayMs(safeNowMs - safePastDays * kDayMs);\n  const rangeEndMs = startOfDayMs(safeNowMs + safeFutureDays * kDayMs) + kDayMs - 1;\n  return {\n    nowMs: safeNowMs,\n    rangeStartMs,\n    rangeEndMs,\n    dayCount: safePastDays + safeFutureDays + 1,\n  };\n};\n\nexport const buildSlotKey = ({ jobId = \"\", scheduledAtMs = 0 } = {}) =>\n  `${String(jobId || \"\")}:${toFiniteNumber(scheduledAtMs, 0)}`;\n\nconst isHighFrequencyCronJob = (job = {}) => {\n  const scheduleKind = String(job?.schedule?.kind || \"\").trim().toLowerCase();\n  if (scheduleKind !== \"cron\") return false;\n  const cronFields = parseCronFields(job?.schedule || {});\n  if (!cronFields) return false;\n  if (cronFields.dayOfMonthField !== \"*\" || cronFields.monthField !== \"*\") return false;\n\n  const minuteStepMatch = String(cronFields.minuteField || \"\").trim().match(/^\\*\\/(\\d+)$/);\n  const minuteSet = parseCronFieldSet(cronFields.minuteField, 0, 59);\n  const hourSet = parseCronFieldSet(cronFields.hourField, 0, 23);\n  const activeHoursPerDay = hourSet.size > 0 ? hourSet.size : 24;\n  const runsPerHour = minuteSet.size > 0 ? minuteSet.size : 1;\n\n  if (minuteStepMatch) {\n    const stepMinutes = Number.parseInt(minuteStepMatch[1], 10);\n    if (!Number.isFinite(stepMinutes) || stepMinutes <= 0) return false;\n    // Treat frequent minute-step schedules over broad hour windows as \"repeating/noisy\".\n    return stepMinutes <= 30 && activeHoursPerDay >= 4;\n  }\n\n  // Catch dense minute lists over broad hour windows, e.g. 0,15,30,45 6-13 * * 1-5.\n  return runsPerHour >= 3 && activeHoursPerDay >= 4;\n};\n\nexport const classifyRepeatingJobs = (jobs = []) => {\n  const repeatingJobs = [];\n  const scheduledJobs = [];\n  jobs.forEach((job) => {\n    const scheduleKind = String(job?.schedule?.kind || \"\").trim().toLowerCase();\n    if (scheduleKind === \"every\" || isHighFrequencyCronJob(job)) repeatingJobs.push(job);\n    else scheduledJobs.push(job);\n  });\n  return { repeatingJobs, scheduledJobs };\n};\n\nexport const expandJobsToRollingSlots = ({\n  jobs = [],\n  nowMs = Date.now(),\n  pastDays = kRollingPastDays,\n  futureDays = kRollingFutureDays,\n} = {}) => {\n  const range = getRollingRange({ nowMs, pastDays, futureDays });\n  const slots = [];\n  const days = Array.from({ length: range.dayCount }, (_, offset) => {\n    const dayStartMs = startOfDayMs(range.rangeStartMs + offset * kDayMs);\n    return {\n      dayStartMs,\n      dayKey: toDayKey(dayStartMs),\n      label: new Date(dayStartMs).toLocaleDateString([], {\n        weekday: \"short\",\n        month: \"numeric\",\n        day: \"numeric\",\n      }),\n    };\n  });\n\n  jobs.forEach((job) => {\n    const scheduleKind = String(job?.schedule?.kind || \"\").trim().toLowerCase();\n    if (scheduleKind === \"every\") return;\n    if (scheduleKind === \"at\") {\n      const atMs = toFiniteNumber(job?.schedule?.at, 0);\n      if (atMs < range.rangeStartMs || atMs > range.rangeEndMs) return;\n      const slotMs = startOfHourMs(atMs);\n      slots.push({\n        key: buildSlotKey({ jobId: job.id, scheduledAtMs: atMs }),\n        jobId: String(job?.id || \"\"),\n        jobName: String(job?.name || job?.id || \"\"),\n        scheduledAtMs: atMs,\n        hourBucketMs: slotMs,\n        dayKey: toDayKey(slotMs),\n        hourOfDay: new Date(slotMs).getHours(),\n      });\n      return;\n    }\n    const cronFields = parseCronFields(job?.schedule || {});\n    if (!cronFields) return;\n    const cronMatcher = buildCronMatcher(cronFields);\n    if (!cronMatcher) return;\n    for (\n      let tickMs = range.rangeStartMs;\n      tickMs <= range.rangeEndMs;\n      tickMs += kMinuteMs\n    ) {\n      const dateValue = new Date(tickMs);\n      if (!cronFieldMatches(dateValue, cronMatcher)) continue;\n      const hourBucketMs = startOfHourMs(tickMs);\n      slots.push({\n        key: buildSlotKey({ jobId: job.id, scheduledAtMs: tickMs }),\n        jobId: String(job?.id || \"\"),\n        jobName: String(job?.name || job?.id || \"\"),\n        scheduledAtMs: tickMs,\n        hourBucketMs,\n        dayKey: toDayKey(tickMs),\n        hourOfDay: dateValue.getHours(),\n      });\n    }\n  });\n\n  slots.sort((left, right) => left.scheduledAtMs - right.scheduledAtMs);\n  return {\n    range,\n    days,\n    slots,\n  };\n};\n\nconst normalizeRunStatus = (value = \"\") => {\n  const normalized = String(value || \"\").trim().toLowerCase();\n  if (normalized === \"ok\" || normalized === \"error\" || normalized === \"skipped\") {\n    return normalized;\n  }\n  return \"\";\n};\n\nexport const mapRunStatusesToSlots = ({\n  slots = [],\n  bulkRunsByJobId = {},\n  nowMs = Date.now(),\n  toleranceMs = 45 * kMinuteMs,\n} = {}) => {\n  const statusBySlotKey = {};\n  const safeNowMs = toFiniteNumber(nowMs, Date.now());\n  const safeToleranceMs = Math.max(0, toFiniteNumber(toleranceMs, 45 * kMinuteMs));\n\n  const runEntriesByJobId = {};\n  Object.entries(bulkRunsByJobId || {}).forEach(([jobId, runResult]) => {\n    const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];\n    const normalizedEntries = entries\n      .map((entry) => ({\n        ts: toFiniteNumber(entry?.ts, 0),\n        status: normalizeRunStatus(entry?.status),\n      }))\n      .filter((entry) => entry.ts > 0 && entry.status)\n      .sort((left, right) => left.ts - right.ts);\n    runEntriesByJobId[jobId] = normalizedEntries;\n  });\n\n  const consumedRunTimestampsByJobId = {};\n  slots.forEach((slot) => {\n    if (slot.scheduledAtMs > safeNowMs) return;\n    const jobId = String(slot.jobId || \"\");\n    const runEntries = runEntriesByJobId[jobId] || [];\n    if (runEntries.length === 0) return;\n    const consumedSet = consumedRunTimestampsByJobId[jobId] || new Set();\n    consumedRunTimestampsByJobId[jobId] = consumedSet;\n\n    let nearestEntry = null;\n    let nearestDeltaMs = Number.MAX_SAFE_INTEGER;\n    runEntries.forEach((entry) => {\n      if (consumedSet.has(entry.ts)) return;\n      const deltaMs = Math.abs(entry.ts - slot.scheduledAtMs);\n      if (deltaMs > safeToleranceMs) return;\n      if (deltaMs < nearestDeltaMs) {\n        nearestDeltaMs = deltaMs;\n        nearestEntry = entry;\n      }\n    });\n\n    if (!nearestEntry) return;\n    consumedSet.add(nearestEntry.ts);\n    statusBySlotKey[slot.key] = nearestEntry.status;\n  });\n\n  return statusBySlotKey;\n};\n\nconst readAvgTokens = (usageByJobId = {}, jobId = \"\") => {\n  const usage = usageByJobId?.[jobId] || {};\n  const avg = toFiniteNumber(\n    usage.avgTokensPerRun,\n    usage.runCount > 0 ? Math.round(toFiniteNumber(usage.totalTokens, 0) / usage.runCount) : 0,\n  );\n  return Math.max(0, avg);\n};\n\nexport const kNext24hMs = 24 * kHourMs;\n\nexport const getUpcomingSlots = ({\n  slots = [],\n  nowMs = Date.now(),\n  windowMs = kNext24hMs,\n  limit = 12,\n} = {}) => {\n  const safeNowMs = toFiniteNumber(nowMs, Date.now());\n  const cutoffMs = safeNowMs + toFiniteNumber(windowMs, kNext24hMs);\n  return slots\n    .filter((slot) => slot.scheduledAtMs > safeNowMs && slot.scheduledAtMs <= cutoffMs)\n    .sort((left, right) => left.scheduledAtMs - right.scheduledAtMs)\n    .slice(0, limit);\n};\n\nexport const buildTokenTierByJobId = ({ jobs = [], usageByJobId = {} } = {}) => {\n  const avgValues = jobs\n    .filter((job) => job?.enabled !== false)\n    .map((job) => readAvgTokens(usageByJobId, String(job?.id || \"\")))\n    .filter((value) => value > 0)\n    .sort((left, right) => left - right);\n\n  if (avgValues.length === 0) {\n    return jobs.reduce((accumulator, job) => {\n      const jobId = String(job?.id || \"\");\n      accumulator[jobId] = job?.enabled === false ? \"disabled\" : \"unknown\";\n      return accumulator;\n    }, {});\n  }\n\n  const percentileAt = (indexRatio) => {\n    const index = Math.min(\n      avgValues.length - 1,\n      Math.floor((avgValues.length - 1) * indexRatio),\n    );\n    return avgValues[Math.max(0, index)];\n  };\n  const q1 = percentileAt(0.25);\n  const q2 = percentileAt(0.5);\n  const p90 = percentileAt(0.9);\n\n  return jobs.reduce((accumulator, job) => {\n    const jobId = String(job?.id || \"\");\n    if (job?.enabled === false) {\n      accumulator[jobId] = \"disabled\";\n      return accumulator;\n    }\n    const avgTokens = readAvgTokens(usageByJobId, jobId);\n    if (avgTokens <= 0) {\n      accumulator[jobId] = \"unknown\";\n      return accumulator;\n    }\n    if (avgTokens <= q1) {\n      accumulator[jobId] = \"low\";\n      return accumulator;\n    }\n    if (avgTokens <= q2) {\n      accumulator[jobId] = \"medium\";\n      return accumulator;\n    }\n    if (avgTokens <= p90) {\n      accumulator[jobId] = \"high\";\n      return accumulator;\n    }\n    accumulator[jobId] = \"very-high\";\n    return accumulator;\n  }, {});\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-calendar.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport { Tooltip } from \"../tooltip.js\";\nimport { ModalShell } from \"../modal-shell.js\";\nimport { CloseIcon } from \"../icons.js\";\nimport {\n  formatCost,\n  formatCronScheduleLabel,\n  formatTokenCount,\n  getCronRunEstimatedCost,\n  getCronRunTotalTokens,\n} from \"./cron-helpers.js\";\nimport {\n  classifyRepeatingJobs,\n  expandJobsToRollingSlots,\n  getUpcomingSlots,\n  mapRunStatusesToSlots,\n} from \"./cron-calendar-helpers.js\";\n\nconst html = htm.bind(h);\n\nconst formatHourLabel = (hourOfDay) => {\n  const dateValue = new Date();\n  dateValue.setHours(hourOfDay, 0, 0, 0);\n  return dateValue.toLocaleTimeString([], {\n    hour: \"numeric\",\n    minute: \"2-digit\",\n  });\n};\n\nconst buildCellKey = (dayKey, hourOfDay) =>\n  `${String(dayKey || \"\")}:${hourOfDay}`;\nconst toLocalDayKey = (valueMs) => {\n  const dateValue = new Date(valueMs);\n  const year = dateValue.getFullYear();\n  const month = String(dateValue.getMonth() + 1).padStart(2, \"0\");\n  const day = String(dateValue.getDate()).padStart(2, \"0\");\n  return `${year}-${month}-${day}`;\n};\n\nconst slotStateClassName = ({\n  isPast = false,\n  mappedStatus = \"\",\n  tokenTier = \"low\",\n} = {}) => {\n  const tierClassNameByKey = {\n    unknown: \"cron-calendar-slot-tier-unknown\",\n    low: \"cron-calendar-slot-tier-low\",\n    medium: \"cron-calendar-slot-tier-medium\",\n    high: \"cron-calendar-slot-tier-high\",\n    \"very-high\": \"cron-calendar-slot-tier-very-high\",\n    disabled: \"cron-calendar-slot-tier-disabled\",\n  };\n  const tierClassName = tierClassNameByKey[tokenTier] || tierClassNameByKey.low;\n  if (!isPast) return `${tierClassName} cron-calendar-slot-upcoming`;\n  if (mappedStatus === \"ok\") return `${tierClassName} cron-calendar-slot-ok`;\n  if (mappedStatus === \"error\")\n    return `${tierClassName} cron-calendar-slot-error`;\n  if (mappedStatus === \"skipped\")\n    return `${tierClassName} cron-calendar-slot-skipped`;\n  return `${tierClassName} cron-calendar-slot-past`;\n};\n\nconst renderLegend = () => html`\n  <div class=\"cron-calendar-legend\">\n    <span class=\"cron-calendar-legend-label\">Token intensity</span>\n    <span class=\"cron-calendar-legend-pill cron-calendar-slot-tier-low\"\n      >Low</span\n    >\n    <span class=\"cron-calendar-legend-pill cron-calendar-slot-tier-medium\"\n      >Medium</span\n    >\n    <span class=\"cron-calendar-legend-pill cron-calendar-slot-tier-high\"\n      >High</span\n    >\n    <span class=\"cron-calendar-legend-pill cron-calendar-slot-tier-very-high\"\n      >Very high</span\n    >\n  </div>\n`;\n\nconst kNowRefreshMs = 60 * 1000;\nconst kRunWindow7dMs = 7 * 24 * 60 * 60 * 1000;\nconst kSlotRunToleranceMs = 45 * 60 * 1000;\nconst kUnknownTier = \"unknown\";\n\nconst formatUpcomingTime = (timestampMs) => {\n  const dateValue = new Date(timestampMs);\n  return dateValue.toLocaleTimeString([], {\n    hour: \"numeric\",\n    minute: \"2-digit\",\n  });\n};\n\nconst buildRunSummaryByJobId = ({\n  runsByJobId = {},\n  nowMs = Date.now(),\n} = {}) => {\n  const cutoffMs = Number(nowMs || Date.now()) - kRunWindow7dMs;\n  return Object.entries(runsByJobId || {}).reduce(\n    (accumulator, [jobId, runResult]) => {\n      const entries = Array.isArray(runResult?.entries)\n        ? runResult.entries\n        : [];\n      const recentEntries = entries.filter((entry) => {\n        const timestampMs = Number(entry?.ts || 0);\n        return (\n          Number.isFinite(timestampMs) &&\n          timestampMs >= cutoffMs &&\n          timestampMs <= nowMs\n        );\n      });\n      const runCount = recentEntries.length;\n      const totalTokens = recentEntries.reduce(\n        (sum, entry) => sum + Number(getCronRunTotalTokens(entry) || 0),\n        0,\n      );\n      const totalCost = recentEntries.reduce((sum, entry) => {\n        const cost = getCronRunEstimatedCost(entry);\n        return sum + Number(cost == null ? 0 : cost);\n      }, 0);\n      accumulator[String(jobId || \"\")] = {\n        runCount,\n        totalTokens,\n        totalCost,\n        avgTokensPerRun: runCount > 0 ? Math.round(totalTokens / runCount) : 0,\n        avgCostPerRun: runCount > 0 ? totalCost / runCount : 0,\n      };\n      return accumulator;\n    },\n    {},\n  );\n};\n\nconst mapRunsToSlots = ({\n  slots = [],\n  runsByJobId = {},\n  nowMs = Date.now(),\n} = {}) => {\n  const runsBySlotKey = {};\n  const consumedRunTimestampsByJobId = {};\n  const runEntriesByJobId = Object.entries(runsByJobId || {}).reduce(\n    (accumulator, [jobId, runResult]) => {\n      const entries = Array.isArray(runResult?.entries)\n        ? runResult.entries\n        : [];\n      const normalizedEntries = entries\n        .map((entry) => ({ ...entry, ts: Number(entry?.ts || 0) }))\n        .filter((entry) => Number.isFinite(entry.ts) && entry.ts > 0)\n        .sort((left, right) => left.ts - right.ts);\n      accumulator[String(jobId || \"\")] = normalizedEntries;\n      return accumulator;\n    },\n    {},\n  );\n  slots.forEach((slot) => {\n    if (Number(slot?.scheduledAtMs || 0) > nowMs) return;\n    const jobId = String(slot?.jobId || \"\");\n    const runEntries = runEntriesByJobId[jobId] || [];\n    if (runEntries.length === 0) return;\n    const consumedSet = consumedRunTimestampsByJobId[jobId] || new Set();\n    consumedRunTimestampsByJobId[jobId] = consumedSet;\n    let nearestEntry = null;\n    let nearestDeltaMs = Number.MAX_SAFE_INTEGER;\n    runEntries.forEach((entry) => {\n      if (consumedSet.has(entry.ts)) return;\n      const deltaMs = Math.abs(entry.ts - Number(slot?.scheduledAtMs || 0));\n      if (deltaMs > kSlotRunToleranceMs) return;\n      if (deltaMs < nearestDeltaMs) {\n        nearestDeltaMs = deltaMs;\n        nearestEntry = entry;\n      }\n    });\n    if (!nearestEntry) return;\n    consumedSet.add(nearestEntry.ts);\n    runsBySlotKey[String(slot?.key || \"\")] = nearestEntry;\n  });\n  return runsBySlotKey;\n};\n\nconst buildTierThresholds = (values = []) => {\n  const sortedValues = values\n    .map((value) => Number(value || 0))\n    .filter((value) => Number.isFinite(value) && value > 0)\n    .sort((left, right) => left - right);\n  if (sortedValues.length === 0) return null;\n  const percentileAt = (indexRatio = 0) => {\n    const index = Math.min(\n      sortedValues.length - 1,\n      Math.floor((sortedValues.length - 1) * indexRatio),\n    );\n    return sortedValues[Math.max(0, index)];\n  };\n  return {\n    q1: percentileAt(0.25),\n    q2: percentileAt(0.5),\n    p90: percentileAt(0.9),\n  };\n};\n\nconst classifyTokenTier = ({\n  enabled = true,\n  tokenValue = 0,\n  thresholds = null,\n} = {}) => {\n  if (!enabled) return \"disabled\";\n  const safeValue = Number(tokenValue || 0);\n  if (!Number.isFinite(safeValue) || safeValue <= 0 || !thresholds)\n    return kUnknownTier;\n  if (safeValue <= thresholds.q1) return \"low\";\n  if (safeValue <= thresholds.q2) return \"medium\";\n  if (safeValue <= thresholds.p90) return \"high\";\n  return \"very-high\";\n};\n\nconst buildJobTooltipText = ({\n  jobName = \"\",\n  job = null,\n  runSummary7d = {},\n  slotRun = null,\n  latestRun = null,\n  scheduledAtMs = 0,\n  scheduledStatus = \"\",\n  nowMs = Date.now(),\n} = {}) => {\n  const isPastSlot =\n    Number(scheduledAtMs || 0) > 0 && Number(scheduledAtMs || 0) <= nowMs;\n  const runCount7d = Number(runSummary7d?.runCount || 0);\n  const avgTokensPerRun7d = Number(runSummary7d?.avgTokensPerRun || 0);\n  const avgCostPerRun7d = Number(runSummary7d?.avgCostPerRun || 0);\n  const slotRunTokens = getCronRunTotalTokens(slotRun || {});\n  const slotRunCost = getCronRunEstimatedCost(slotRun || {});\n  const slotRunStatus = String(slotRun?.status || \"\")\n    .trim()\n    .toLowerCase();\n\n  const lines = [String(jobName || \"Job\")];\n  if (isPastSlot) {\n    lines.push(\n      `Run tokens: ${slotRun ? formatTokenCount(slotRunTokens) : \"—\"}`,\n    );\n    lines.push(\n      `Run cost: ${slotRunCost == null ? \"—\" : formatCost(slotRunCost)}`,\n    );\n    lines.push(`Run status: ${slotRunStatus || scheduledStatus || \"unknown\"}`);\n    if (slotRun?.ts) {\n      lines.push(\n        `Run time: ${new Date(Number(slotRun.ts || 0)).toLocaleString()}`,\n      );\n    }\n  } else {\n    lines.push(\n      `Avg tokens/run (last 7d): ${runCount7d > 0 ? formatTokenCount(avgTokensPerRun7d) : \"—\"}`,\n    );\n    lines.push(\n      `Avg cost/run (last 7d): ${runCount7d > 0 ? formatCost(avgCostPerRun7d) : \"—\"}`,\n    );\n    lines.push(\n      `Runs (last 7d): ${runCount7d > 0 ? formatTokenCount(runCount7d) : \"none\"}`,\n    );\n  }\n\n  if (!isPastSlot && latestRun?.status) {\n    lines.push(\n      `Latest run: ${latestRun.status} (${new Date(Number(latestRun.ts || 0)).toLocaleString()})`,\n    );\n  } else if (!isPastSlot) {\n    lines.push(\"Latest run: none\");\n  }\n  if (Number(job?.state?.runningAtMs || 0) > 0) {\n    lines.push(\n      `Current run: active (${new Date(Number(job.state.runningAtMs)).toLocaleString()})`,\n    );\n  }\n\n  if (scheduledAtMs > 0) {\n    const slotLabel = new Date(scheduledAtMs).toLocaleString();\n    const slotState =\n      scheduledStatus || (scheduledAtMs <= Date.now() ? \"past\" : \"upcoming\");\n    lines.push(`Slot: ${slotState} (${slotLabel})`);\n  }\n  return lines.join(\"\\n\");\n};\n\nexport const CronCalendar = ({\n  jobs = [],\n  runsByJobId = {},\n  onSelectJob = () => {},\n}) => {\n  const [calendarLightboxOpen, setCalendarLightboxOpen] = useState(false);\n  const [showNoisyUpcoming, setShowNoisyUpcoming] = useState(false);\n\n  const [nowMs, setNowMs] = useState(() => Date.now());\n  useEffect(() => {\n    const intervalId = window.setInterval(() => {\n      setNowMs(Date.now());\n    }, kNowRefreshMs);\n    return () => {\n      window.clearInterval(intervalId);\n    };\n  }, []);\n  const todayDayKey = toLocalDayKey(nowMs);\n  const nowDateValue = useMemo(() => new Date(nowMs), [nowMs]);\n  const currentHourOfDay = nowDateValue.getHours();\n  const currentMinuteProgress = nowDateValue.getMinutes() / 60;\n  const { repeatingJobs, scheduledJobs } = useMemo(\n    () => classifyRepeatingJobs(jobs),\n    [jobs],\n  );\n  const timeline = useMemo(\n    () => expandJobsToRollingSlots({ jobs: scheduledJobs, nowMs }),\n    [scheduledJobs, nowMs],\n  );\n  const statusBySlotKey = useMemo(\n    () =>\n      mapRunStatusesToSlots({\n        slots: timeline.slots,\n        bulkRunsByJobId: runsByJobId,\n        nowMs,\n      }),\n    [timeline.slots, runsByJobId, nowMs],\n  );\n  const jobById = useMemo(\n    () =>\n      jobs.reduce((accumulator, job) => {\n        const jobId = String(job?.id || \"\");\n        if (jobId) accumulator[jobId] = job;\n        return accumulator;\n      }, {}),\n    [jobs],\n  );\n  const latestRunByJobId = useMemo(\n    () =>\n      Object.entries(runsByJobId || {}).reduce(\n        (accumulator, [jobId, runResult]) => {\n          const entries = Array.isArray(runResult?.entries)\n            ? runResult.entries\n            : [];\n          const latest = entries\n            .filter((entry) => Number(entry?.ts || 0) > 0)\n            .sort(\n              (left, right) => Number(right?.ts || 0) - Number(left?.ts || 0),\n            )[0];\n          accumulator[jobId] = latest || null;\n          return accumulator;\n        },\n        {},\n      ),\n    [runsByJobId],\n  );\n  const runSummary7dByJobId = useMemo(\n    () => buildRunSummaryByJobId({ runsByJobId, nowMs }),\n    [runsByJobId, nowMs],\n  );\n  const runBySlotKey = useMemo(\n    () => mapRunsToSlots({ slots: timeline.slots, runsByJobId, nowMs }),\n    [timeline.slots, runsByJobId, nowMs],\n  );\n  const slotTierThresholds = useMemo(() => {\n    const values = [];\n    timeline.slots.forEach((slot) => {\n      const job = jobById[slot.jobId] || null;\n      if (!job || job.enabled === false) return;\n      const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;\n      if (isPastSlot) {\n        const slotRunTokens = getCronRunTotalTokens(runBySlotKey[slot.key] || {});\n        if (slotRunTokens > 0) values.push(slotRunTokens);\n        return;\n      }\n      const projectedAvgTokens = Number(\n        runSummary7dByJobId[slot.jobId]?.avgTokensPerRun || 0,\n      );\n      if (projectedAvgTokens > 0) values.push(projectedAvgTokens);\n    });\n    repeatingJobs.forEach((job) => {\n      const jobId = String(job?.id || \"\");\n      const projectedAvgTokens = Number(\n        runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,\n      );\n      if (projectedAvgTokens > 0) values.push(projectedAvgTokens);\n    });\n    return buildTierThresholds(values);\n  }, [\n    jobById,\n    nowMs,\n    repeatingJobs,\n    runBySlotKey,\n    runSummary7dByJobId,\n    timeline.slots,\n  ]);\n  const getSlotTokenTier = useCallback(\n    (slot = null) => {\n      const jobId = String(slot?.jobId || \"\");\n      const job = jobById[jobId] || null;\n      const enabled = job?.enabled !== false;\n      const isPastSlot = Number(slot?.scheduledAtMs || 0) <= nowMs;\n      if (isPastSlot) {\n        const slotRunTokens = getCronRunTotalTokens(\n          runBySlotKey[String(slot?.key || \"\")] || {},\n        );\n        return classifyTokenTier({\n          enabled,\n          tokenValue: slotRunTokens,\n          thresholds: slotTierThresholds,\n        });\n      }\n      const projectedAvgTokens = Number(\n        runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,\n      );\n      return classifyTokenTier({\n        enabled,\n        tokenValue: projectedAvgTokens,\n        thresholds: slotTierThresholds,\n      });\n    },\n    [jobById, nowMs, runBySlotKey, runSummary7dByJobId, slotTierThresholds],\n  );\n  const getJobProjectedTier = useCallback(\n    (jobId = \"\") => {\n      const job = jobById[jobId] || null;\n      return classifyTokenTier({\n        enabled: job?.enabled !== false,\n        tokenValue: Number(runSummary7dByJobId[jobId]?.avgTokensPerRun || 0),\n        thresholds: slotTierThresholds,\n      });\n    },\n    [jobById, runSummary7dByJobId, slotTierThresholds],\n  );\n\n  const upcomingSlotsPreview = useMemo(\n    () => getUpcomingSlots({ slots: timeline.slots, nowMs, limit: 3 }),\n    [timeline.slots, nowMs],\n  );\n  const noisyUpcomingItems = useMemo(() => {\n    const windowEndMs = nowMs + 24 * 60 * 60 * 1000;\n    return repeatingJobs\n      .map((job) => {\n        const jobId = String(job?.id || \"\");\n        const nextRunAtMs = Number(job?.state?.nextRunAtMs || 0);\n        if (!jobId || !Number.isFinite(nextRunAtMs) || nextRunAtMs <= nowMs) return null;\n        if (nextRunAtMs > windowEndMs) return null;\n        return {\n          key: `noisy:${jobId}:${nextRunAtMs}`,\n          jobId,\n          jobName: String(job?.name || jobId),\n          scheduledAtMs: nextRunAtMs,\n        };\n      })\n      .filter(Boolean)\n      .sort((left, right) => left.scheduledAtMs - right.scheduledAtMs);\n  }, [repeatingJobs, nowMs]);\n  const displayedUpcomingItems = useMemo(() => {\n    if (!showNoisyUpcoming) return upcomingSlotsPreview;\n    return [...upcomingSlotsPreview, ...noisyUpcomingItems].sort(\n      (left, right) => Number(left?.scheduledAtMs || 0) - Number(right?.scheduledAtMs || 0),\n    );\n  }, [noisyUpcomingItems, showNoisyUpcoming, upcomingSlotsPreview]);\n\n  const hourRows = useMemo(() => {\n    const uniqueHours = new Set(timeline.slots.map((slot) => slot.hourOfDay));\n    return [...uniqueHours].sort((left, right) => left - right);\n  }, [timeline.slots]);\n\n  const slotsByCellKey = useMemo(\n    () =>\n      timeline.slots.reduce((accumulator, slot) => {\n        const cellKey = buildCellKey(slot.dayKey, slot.hourOfDay);\n        const currentValue = accumulator[cellKey] || [];\n        currentValue.push(slot);\n        accumulator[cellKey] = currentValue;\n        return accumulator;\n      }, {}),\n    [timeline.slots],\n  );\n\n  const renderCompactStrip = () => {\n    return html`\n      <div class=\"space-y-2\">\n        ${displayedUpcomingItems.length === 0\n          ? html`<div class=\"text-xs text-fg-muted py-1\">\n              No upcoming jobs in the next 24 hours.\n            </div>`\n          : html`\n              <div class=\"cron-calendar-compact-list\">\n                ${displayedUpcomingItems.map((slot) => {\n                  const summary7d = runSummary7dByJobId[slot.jobId] || {};\n                  const avgTokensPerRun = Number(summary7d?.avgTokensPerRun || 0);\n                  const avgCostPerRun = Number(summary7d?.avgCostPerRun || 0);\n                  const estimateLabel =\n                    avgTokensPerRun > 0 || avgCostPerRun > 0\n                      ? `Est. ${avgTokensPerRun > 0 ? `${formatTokenCount(avgTokensPerRun)} tk` : \"— tk\"} · ${avgCostPerRun > 0 ? formatCost(avgCostPerRun) : \"—\"}`\n                      : \"Est. —\";\n                  const tooltipText = buildJobTooltipText({\n                    jobName: slot.jobName,\n                    job: jobById[slot.jobId] || null,\n                    runSummary7d: runSummary7dByJobId[slot.jobId] || {},\n                    slotRun: runBySlotKey[slot.key] || null,\n                    latestRun: latestRunByJobId[slot.jobId],\n                    scheduledAtMs: slot.scheduledAtMs,\n                    nowMs,\n                  });\n                  return html`\n                    <${Tooltip}\n                      text=${tooltipText}\n                      widthClass=\"w-72\"\n                      tooltipClassName=\"whitespace-pre-line\"\n                      triggerClassName=\"block w-full\"\n                    >\n                      <button\n                        key=${slot.key}\n                        type=\"button\"\n                        class=${`cron-calendar-compact-row ${slotStateClassName({\n                          isPast: false,\n                          mappedStatus: \"\",\n                          tokenTier: getJobProjectedTier(slot.jobId),\n                        })}`}\n                        onClick=${() => onSelectJob(slot.jobId)}\n                      >\n                        <span class=\"cron-calendar-compact-main\">\n                          <span class=\"cron-calendar-compact-time\"\n                            >${formatUpcomingTime(slot.scheduledAtMs)}</span\n                          >\n                          <span class=\"cron-calendar-compact-name truncate\"\n                            >${slot.jobName}</span\n                          >\n                        </span>\n                        <span class=\"cron-calendar-compact-estimate\"\n                          >${estimateLabel}</span\n                        >\n                      </button>\n                    </${Tooltip}>\n                  `;\n                })}\n              </div>\n            `}\n        <div class=\"flex items-center justify-between mt-2\">\n          ${\n            noisyUpcomingItems.length > 0\n              ? html`\n                  <button\n                    type=\"button\"\n                    class=\"ac-btn-ghost text-xs px-2.5 py-1 rounded-lg\"\n                    onClick=${() => setShowNoisyUpcoming((value) => !value)}\n                  >\n                    ${\n                      showNoisyUpcoming\n                        ? \"Show fewer\"\n                        : `+${noisyUpcomingItems.length} noisy runs`\n                    }\n                  </button>\n                `\n              : html`<span></span>`\n          }\n          <${renderLegend} />\n        </div>\n      </div>\n    `;\n  };\n\n  const renderFullGrid = () => html`\n    <div class=\"space-y-3\">\n      ${hourRows.length === 0\n        ? html`<div class=\"text-sm text-fg-muted\">\n            No scheduled jobs in this rolling window.\n          </div>`\n        : html`\n            <div class=\"cron-calendar-grid-wrap\">\n              <div class=\"cron-calendar-grid-header\">\n                <div class=\"cron-calendar-hour-cell cron-calendar-grid-corner\"></div>\n                ${timeline.days.map(\n                  (day) => html`\n                    <div\n                      key=${day.dayKey}\n                      class=${`cron-calendar-day-header ${day.dayKey === todayDayKey ? \"is-today\" : \"\"}`}\n                    >\n                      ${day.label}\n                    </div>\n                  `,\n                )}\n              </div>\n              <div class=\"cron-calendar-grid-body\">\n                ${hourRows.map(\n                  (hourOfDay) => html`\n                    <div key=${hourOfDay} class=\"cron-calendar-grid-row\">\n                      <div class=\"cron-calendar-hour-cell\">\n                        ${formatHourLabel(hourOfDay)}\n                      </div>\n                      ${timeline.days.map((day) => {\n                        const cellKey = buildCellKey(day.dayKey, hourOfDay);\n                        const cellSlots = slotsByCellKey[cellKey] || [];\n                        const visibleSlots = cellSlots.slice(0, 3);\n                        const overflowCount = Math.max(\n                          0,\n                          cellSlots.length - visibleSlots.length,\n                        );\n                        return html`\n                          <div\n                            key=${cellKey}\n                            class=${`cron-calendar-grid-cell ${day.dayKey === todayDayKey ? \"is-today\" : \"\"}`}\n                          >\n                            ${day.dayKey === todayDayKey &&\n                            hourOfDay === currentHourOfDay\n                              ? html`\n                                  <div\n                                    class=\"cron-calendar-now-indicator\"\n                                    style=${`top: ${Math.max(0, Math.min(100, currentMinuteProgress * 100))}%;`}\n                                    aria-hidden=\"true\"\n                                  >\n                                    <span class=\"cron-calendar-now-indicator-dot\"></span>\n                                  </div>\n                                `\n                              : null}\n                            ${visibleSlots.map((slot) => {\n                              const status = statusBySlotKey[slot.key] || \"\";\n                              const isPast = slot.scheduledAtMs <= nowMs;\n                              const tokenTier = getSlotTokenTier(slot);\n                              const tooltipText = buildJobTooltipText({\n                                jobName: slot.jobName,\n                                job: jobById[slot.jobId] || null,\n                                runSummary7d:\n                                  runSummary7dByJobId[slot.jobId] || {},\n                                slotRun: runBySlotKey[slot.key] || null,\n                                latestRun: latestRunByJobId[slot.jobId],\n                                scheduledAtMs: slot.scheduledAtMs,\n                                scheduledStatus: status,\n                                nowMs,\n                              });\n                              return html`\n                              <${Tooltip}\n                                text=${tooltipText}\n                                widthClass=\"w-72\"\n                                tooltipClassName=\"whitespace-pre-line\"\n                                triggerClassName=\"inline-flex w-full\"\n                              >\n                                <div\n                                  key=${slot.key}\n                                  class=${`cron-calendar-slot-chip ${slotStateClassName(\n                                    {\n                                      isPast,\n                                      mappedStatus: status,\n                                      tokenTier,\n                                    },\n                                  )}`}\n                                  role=\"button\"\n                                  tabindex=\"0\"\n                                  onClick=${() => onSelectJob(slot.jobId)}\n                                  onKeyDown=${(event) => {\n                                    if (\n                                      event.key !== \"Enter\" &&\n                                      event.key !== \" \"\n                                    )\n                                      return;\n                                    event.preventDefault();\n                                    onSelectJob(slot.jobId);\n                                  }}\n                                >\n                                  <span class=\"truncate\">${slot.jobName}</span>\n                                </div>\n                              </${Tooltip}>\n                            `;\n                            })}\n                            ${overflowCount > 0\n                              ? html`<div class=\"cron-calendar-slot-overflow\">\n                                  +${overflowCount} more\n                                </div>`\n                              : null}\n                          </div>\n                        `;\n                      })}\n                    </div>\n                  `,\n                )}\n              </div>\n            </div>\n          `}\n      ${repeatingJobs.length > 0\n        ? html`\n            <div class=\"cron-calendar-repeating-strip\">\n              <div class=\"text-xs text-fg-muted\">Repeating</div>\n              <div class=\"cron-calendar-repeating-list\">\n                ${repeatingJobs.map((job) => {\n                  const jobId = String(job?.id || \"\");\n                  const avgTokensPerRun = Number(\n                    runSummary7dByJobId[jobId]?.avgTokensPerRun || 0,\n                  );\n                  const tooltipText = buildJobTooltipText({\n                    jobName: job.name || job.id,\n                    job,\n                    runSummary7d: runSummary7dByJobId[jobId] || {},\n                    slotRun: null,\n                    latestRun: latestRunByJobId[jobId],\n                    nowMs,\n                  });\n                  return html`\n                    <${Tooltip}\n                      text=${tooltipText}\n                      widthClass=\"w-72\"\n                      tooltipClassName=\"whitespace-pre-line\"\n                      triggerClassName=\"inline-flex max-w-full\"\n                    >\n                      <div\n                        class=${`cron-calendar-repeating-pill ${slotStateClassName(\n                          {\n                            isPast: false,\n                            mappedStatus: \"\",\n                            tokenTier: getJobProjectedTier(jobId),\n                          },\n                        )}`}\n                        role=\"button\"\n                        tabindex=\"0\"\n                        onClick=${() => onSelectJob(jobId)}\n                        onKeyDown=${(event) => {\n                          if (event.key !== \"Enter\" && event.key !== \" \")\n                            return;\n                          event.preventDefault();\n                          onSelectJob(jobId);\n                        }}\n                      >\n                        <span class=\"truncate\">${job.name || job.id}</span>\n                        <span class=\"text-[10px] opacity-80\">\n                          ${formatCronScheduleLabel(job.schedule, {\n                            includeTimeZoneWhenDifferent: true,\n                          })}\n                          ${\n                            avgTokensPerRun > 0\n                              ? ` | avg ${formatTokenCount(avgTokensPerRun)} tk`\n                              : \"\"\n                          }\n                        </span>\n                      </div>\n                    </${Tooltip}>\n                  `;\n                })}\n              </div>\n            </div>\n          `\n        : null}\n    </div>\n  `;\n\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label card-label-bright\">Up next</h3>\n        <button\n          type=\"button\"\n          class=\"ac-btn-secondary text-xs px-3 py-1.5 rounded-lg\"\n          onClick=${() => setCalendarLightboxOpen(true)}\n        >\n          Open calendar\n        </button>\n      </div>\n\n      ${renderCompactStrip()}\n    </section>\n    <${ModalShell}\n      visible=${calendarLightboxOpen}\n      onClose=${() => setCalendarLightboxOpen(false)}\n      panelClassName=\"cron-calendar-lightbox-panel\"\n    >\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label cron-calendar-title\">Calendar</h3>\n        <button\n          type=\"button\"\n          class=\"cron-calendar-lightbox-close\"\n          onClick=${() => setCalendarLightboxOpen(false)}\n          aria-label=\"Close expanded calendar\"\n        >\n          <${CloseIcon} className=\"w-4 h-4\" />\n        </button>\n      </div>\n      <div class=\"flex items-center justify-center\">\n        <${renderLegend} />\n      </div>\n      <div class=\"cron-calendar-lightbox-body\">\n        ${renderFullGrid()}\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-helpers.js",
    "content": "import {\n  formatDurationCompactMs,\n  formatInteger,\n  formatLocaleDateTimeWithTodayTime,\n  formatUsd,\n} from \"../../lib/format.js\";\n\nexport const kAllCronJobsRouteKey = \"__all__\";\n\nexport const readCronJobPrompt = (job = {}) => {\n  const payload = job?.payload && typeof job.payload === \"object\" ? job.payload : {};\n  const kind = String(payload?.kind || \"\").trim();\n  if (kind === \"systemEvent\" && typeof payload.text === \"string\") {\n    return payload.text;\n  }\n  if (kind === \"agentTurn\" && typeof payload.message === \"string\") {\n    return payload.message;\n  }\n  return \"\";\n};\n\nconst kWeekdayLabelByCronValue = {\n  \"0\": \"Sun\",\n  \"1\": \"Mon\",\n  \"2\": \"Tue\",\n  \"3\": \"Wed\",\n  \"4\": \"Thu\",\n  \"5\": \"Fri\",\n  \"6\": \"Sat\",\n  \"7\": \"Sun\",\n};\n\nconst formatHourMinute = ({ hourField, minuteField }) => {\n  const hour = Number.parseInt(String(hourField || \"\"), 10);\n  const minute = Number.parseInt(String(minuteField || \"\"), 10);\n  if (!Number.isFinite(hour) || !Number.isFinite(minute)) return \"\";\n  const normalizedHour = ((hour % 24) + 24) % 24;\n  const suffix = normalizedHour >= 12 ? \"pm\" : \"am\";\n  const twelveHour = normalizedHour % 12 === 0 ? 12 : normalizedHour % 12;\n  const paddedMinute = String(minute).padStart(2, \"0\");\n  return `${twelveHour}:${paddedMinute}${suffix}`;\n};\n\nconst formatHourOnly = (hourField) => {\n  const hour = Number.parseInt(String(hourField || \"\"), 10);\n  if (!Number.isFinite(hour)) return \"\";\n  const normalizedHour = ((hour % 24) + 24) % 24;\n  const suffix = normalizedHour >= 12 ? \"pm\" : \"am\";\n  const twelveHour = normalizedHour % 12 === 0 ? 12 : normalizedHour % 12;\n  return `${twelveHour}${suffix}`;\n};\n\nconst formatHourRange = (hourRangeField = \"\") => {\n  const match = String(hourRangeField || \"\").match(/^(\\d{1,2})-(\\d{1,2})$/);\n  if (!match) return \"\";\n  const startHour = formatHourOnly(match[1]);\n  const endHour = formatHourOnly(match[2]);\n  if (!startHour || !endHour) return \"\";\n  return `${startHour}-${endHour}`;\n};\n\nconst humanizeCronExpression = (expr = \"\") => {\n  const fields = String(expr || \"\").trim().split(/\\s+/);\n  if (fields.length < 5) return \"\";\n  const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields;\n\n  if (\n    dayOfMonthField === \"*\" &&\n    monthField === \"*\" &&\n    dayOfWeekField === \"*\" &&\n    hourField === \"*\" &&\n    /^\\*\\/\\d+$/.test(minuteField)\n  ) {\n    return `Every ${minuteField.slice(2)}m`;\n  }\n\n  if (\n    dayOfMonthField === \"*\" &&\n    monthField === \"*\" &&\n    dayOfWeekField === \"*\" &&\n    minuteField === \"0\" &&\n    /^\\*\\/\\d+$/.test(hourField)\n  ) {\n    return `Every ${hourField.slice(2)}h`;\n  }\n\n  const formattedTime = formatHourMinute({ hourField, minuteField });\n  if (\n    dayOfMonthField === \"*\" &&\n    monthField === \"*\" &&\n    dayOfWeekField === \"1-5\" &&\n    /^\\*\\/\\d+$/.test(minuteField) &&\n    /^\\d{1,2}-\\d{1,2}$/.test(hourField)\n  ) {\n    const minutesStep = minuteField.slice(2);\n    const hourRange = formatHourRange(hourField);\n    if (hourRange) {\n      return `Every ${minutesStep}m, ${hourRange} weekdays`;\n    }\n  }\n\n  if (\n    dayOfMonthField === \"*\" &&\n    monthField === \"*\" &&\n    dayOfWeekField === \"1-5\" &&\n    formattedTime\n  ) {\n    return `Weekdays at ${formattedTime}`;\n  }\n\n  if (\n    dayOfMonthField === \"*\" &&\n    monthField === \"*\" &&\n    /^([0-7])(,[0-7])*$/.test(dayOfWeekField) &&\n    formattedTime\n  ) {\n    const dayLabels = dayOfWeekField\n      .split(\",\")\n      .map((value) => kWeekdayLabelByCronValue[value] || value)\n      .join(\", \");\n    return `Every ${dayLabels} at ${formattedTime}`;\n  }\n\n  if (\n    dayOfMonthField === \"*\" &&\n    monthField === \"*\" &&\n    dayOfWeekField === \"*\" &&\n    formattedTime\n  ) {\n    return `Daily at ${formattedTime}`;\n  }\n\n  if (\n    /^\\d{1,2}$/.test(dayOfMonthField) &&\n    monthField === \"*\" &&\n    dayOfWeekField === \"*\" &&\n    formattedTime\n  ) {\n    const dayOfMonth = Number.parseInt(dayOfMonthField, 10);\n    if (Number.isFinite(dayOfMonth) && dayOfMonth >= 1 && dayOfMonth <= 31) {\n      return `Monthly on day ${dayOfMonth} at ${formattedTime}`;\n    }\n  }\n\n  return \"\";\n};\n\nconst normalizeTimeZoneName = (value = \"\") => String(value || \"\").trim().toLowerCase();\n\nconst getClientTimeZone = () => {\n  try {\n    return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || \"\";\n  } catch {\n    return \"\";\n  }\n};\n\nconst shouldAppendTimeZone = ({\n  scheduleTimeZone = \"\",\n  includeTimeZone = false,\n  includeTimeZoneWhenDifferent = false,\n  clientTimeZone = \"\",\n}) => {\n  const normalizedScheduleTimeZone = normalizeTimeZoneName(scheduleTimeZone);\n  if (!normalizedScheduleTimeZone) return false;\n  if (includeTimeZone) return true;\n  if (!includeTimeZoneWhenDifferent) return false;\n  const normalizedClientTimeZone = normalizeTimeZoneName(\n    clientTimeZone || getClientTimeZone(),\n  );\n  if (!normalizedClientTimeZone) return true;\n  return normalizedClientTimeZone !== normalizedScheduleTimeZone;\n};\n\nexport const formatCronScheduleLabel = (\n  schedule = {},\n  { includeTimeZone = false, includeTimeZoneWhenDifferent = false, clientTimeZone = \"\" } = {},\n) => {\n  const kind = String(schedule?.kind || \"\").trim();\n  if (kind === \"every\") {\n    const everyMs = Number(schedule?.everyMs || 0);\n    if (everyMs > 0) return `Every ${formatDurationCompactMs(everyMs)}`;\n    return \"Every interval\";\n  }\n  if (kind === \"at\") {\n    return `At ${formatLocaleDateTimeWithTodayTime(schedule?.at, { fallback: \"scheduled time\" })}`;\n  }\n  if (kind === \"cron\") {\n    const expr = String(schedule?.expr || \"\").trim();\n    if (!expr) return \"Cron\";\n    const humanized = humanizeCronExpression(expr);\n    const tz = String(schedule?.tz || \"\").trim();\n    const appendTimeZone = shouldAppendTimeZone({\n      scheduleTimeZone: tz,\n      includeTimeZone,\n      includeTimeZoneWhenDifferent,\n      clientTimeZone,\n    });\n    if (humanized) {\n      return appendTimeZone ? `${humanized} (${tz})` : humanized;\n    }\n    return appendTimeZone ? `${expr} (${tz})` : expr;\n  }\n  const fallbackCronExpr = String(\n    schedule?.expr || schedule?.cron || schedule?.cronExpr || \"\",\n  ).trim();\n  if (fallbackCronExpr) {\n    const humanized = humanizeCronExpression(fallbackCronExpr);\n    const tz = String(schedule?.tz || schedule?.timezone || \"\").trim();\n    const appendTimeZone = shouldAppendTimeZone({\n      scheduleTimeZone: tz,\n      includeTimeZone,\n      includeTimeZoneWhenDifferent,\n      clientTimeZone,\n    });\n    if (humanized) {\n      return appendTimeZone ? `${humanized} (${tz})` : humanized;\n    }\n    return appendTimeZone ? `${fallbackCronExpr} (${tz})` : fallbackCronExpr;\n  }\n  return \"Unknown schedule\";\n};\n\nexport const formatRelativeMs = (targetMs, nowMs = Date.now()) => {\n  const value = Number(targetMs || 0);\n  if (!Number.isFinite(value) || value <= 0) return \"—\";\n  const deltaMs = value - nowMs;\n  const isFuture = deltaMs > 0;\n  const absSeconds = Math.round(Math.abs(deltaMs) / 1000);\n  if (absSeconds < 60) return isFuture ? \"in <1m\" : \"just now\";\n  const absMinutes = Math.round(absSeconds / 60);\n  if (absMinutes < 60) return isFuture ? `in ${absMinutes}m` : `${absMinutes}m ago`;\n  const absHours = Math.round(absMinutes / 60);\n  if (absHours < 24) return isFuture ? `in ${absHours}h` : `${absHours}h ago`;\n  const absDays = Math.round(absHours / 24);\n  return isFuture ? `in ${absDays}d` : `${absDays}d ago`;\n};\n\nexport const formatRelativeCompact = (targetMs, nowMs = Date.now()) => {\n  const value = Number(targetMs || 0);\n  if (!Number.isFinite(value) || value <= 0) return \"—\";\n  const deltaMs = Math.abs(value - nowMs);\n  const totalSeconds = Math.max(0, Math.round(deltaMs / 1000));\n  if (totalSeconds < 60) return `${totalSeconds}s`;\n  const totalMinutes = Math.round(totalSeconds / 60);\n  if (totalMinutes < 60) return `${totalMinutes}m`;\n  const totalHours = Math.round(totalMinutes / 60);\n  if (totalHours < 24) return `${totalHours}h`;\n  const totalDays = Math.round(totalHours / 24);\n  if (totalDays < 30) return `${totalDays}d`;\n  const totalMonths = Math.round(totalDays / 30);\n  return `${totalMonths}mo`;\n};\n\nexport const formatNextRunRelativeMs = (nextRunAtMs, nowMs = Date.now()) => {\n  const value = Number(nextRunAtMs || 0);\n  if (!Number.isFinite(value) || value <= 0) return \"—\";\n  if (value >= nowMs) return formatRelativeMs(value, nowMs);\n  const overdueDeltaMs = nowMs - value;\n  if (overdueDeltaMs < 60 * 1000) return \"due now\";\n  const overdueSeconds = Math.round(overdueDeltaMs / 1000);\n  if (overdueSeconds < 60) return \"overdue by <1m\";\n  const overdueMinutes = Math.round(overdueSeconds / 60);\n  if (overdueMinutes < 60) return `overdue by ${overdueMinutes}m`;\n  const overdueHours = Math.round(overdueMinutes / 60);\n  if (overdueHours < 24) return `overdue by ${overdueHours}h`;\n  const overdueDays = Math.round(overdueHours / 24);\n  return `overdue by ${overdueDays}d`;\n};\n\nexport const getCronJobHealth = (job = {}) => {\n  if (job.enabled === false) return \"disabled\";\n  if (job?.state?.runningAtMs) return \"running\";\n  const lastStatus = String(job?.state?.lastStatus || job?.state?.lastRunStatus || \"\")\n    .trim()\n    .toLowerCase();\n  if (lastStatus === \"error\") return \"error\";\n  if (lastStatus === \"ok\") return \"ok\";\n  return \"unknown\";\n};\n\nexport const getCronJobHealthClassName = (health = \"\") => {\n  if (health === \"ok\") return \"bg-green-500\";\n  if (health === \"error\") return \"bg-red-500\";\n  if (health === \"running\") return \"bg-yellow-400\";\n  return \"bg-gray-500\";\n};\n\nexport const formatTokenCount = (value) => formatInteger(Number(value || 0));\nexport const formatCost = (value) => formatUsd(Number(value || 0));\nexport const getCronRunTotalTokens = (entry = {}) => {\n  const usage = entry?.usage || {};\n  const componentCandidates = [\n    usage?.input_tokens,\n    usage?.inputTokens,\n    usage?.output_tokens,\n    usage?.outputTokens,\n    usage?.cache_read_tokens,\n    usage?.cacheReadTokens,\n    usage?.cache_write_tokens,\n    usage?.cacheWriteTokens,\n  ];\n  const componentTotal = componentCandidates.reduce((sum, candidate) => {\n    const numericValue = Number(candidate);\n    if (!Number.isFinite(numericValue) || numericValue < 0) return sum;\n    return sum + numericValue;\n  }, 0);\n  if (componentTotal > 0) return componentTotal;\n  const totalCandidates = [\n    usage?.total_tokens,\n    usage?.totalTokens,\n    entry?.total_tokens,\n    entry?.totalTokens,\n  ];\n  for (const candidate of totalCandidates) {\n    const numericValue = Number(candidate);\n    if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;\n  }\n  return 0;\n};\nexport const getCronRunEstimatedCost = (entry = {}) => {\n  const usage = entry?.usage || {};\n  const candidates = [\n    entry?.estimatedCost,\n    entry?.estimated_cost,\n    usage?.estimatedCost,\n    usage?.estimated_cost,\n    usage?.totalCost,\n    usage?.total_cost,\n    usage?.costUsd,\n    usage?.cost,\n  ];\n  for (const candidate of candidates) {\n    const numericValue = Number(candidate);\n    if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;\n  }\n  return null;\n};\nconst hasHeartbeatOnlySummary = (job = {}) => {\n  const state = job?.state || {};\n  try {\n    return String(JSON.stringify(state) || \"\")\n      .toUpperCase()\n      .includes(\"HEARTBEAT_OK\");\n  } catch {\n    return false;\n  }\n};\nconst hasHeartbeatSummaryInLatestRun = ({\n  jobId = \"\",\n  bulkRunsByJobId = {},\n} = {}) => {\n  const safeJobId = String(jobId || \"\").trim();\n  if (!safeJobId) return false;\n  const entries = Array.isArray(bulkRunsByJobId?.[safeJobId]?.entries)\n    ? bulkRunsByJobId[safeJobId].entries\n    : [];\n  if (entries.length === 0) return false;\n  const latestEntry = entries.reduce((latestValue, candidate) =>\n    Number(candidate?.ts || 0) > Number(latestValue?.ts || 0)\n      ? candidate\n      : latestValue);\n  const summaryCandidates = [\n    latestEntry?.summary,\n    latestEntry?.result?.summary,\n    latestEntry?.payload?.summary,\n  ];\n  return summaryCandidates.some((value) =>\n    String(value || \"\")\n      .toUpperCase()\n      .includes(\"HEARTBEAT_OK\"));\n};\nconst getLatestRunStatus = ({\n  job = {},\n  jobId = \"\",\n  bulkRunsByJobId = {},\n} = {}) => {\n  const safeJobId = String(jobId || \"\").trim();\n  const entries = Array.isArray(bulkRunsByJobId?.[safeJobId]?.entries)\n    ? bulkRunsByJobId[safeJobId].entries\n    : [];\n  const latestEntry = entries.length > 0\n    ? entries.reduce((latestValue, candidate) =>\n      Number(candidate?.ts || 0) > Number(latestValue?.ts || 0)\n        ? candidate\n        : latestValue)\n    : null;\n  const status = String(\n    latestEntry?.status ||\n    job?.state?.lastStatus ||\n    job?.state?.lastRunStatus ||\n    \"\",\n  )\n    .trim()\n    .toLowerCase();\n  return status;\n};\n\nexport const buildCronOptimizationWarnings = (jobs = [], bulkRunsByJobId = {}) => {\n  const warnings = [];\n  jobs.forEach((job) => {\n    const jobId = String(job?.id || \"\");\n    const prompt = readCronJobPrompt(job).toLowerCase();\n    const deliveryMode = String(job?.delivery?.mode || \"\").toLowerCase();\n    if (\n      deliveryMode === \"none\" &&\n      (prompt.includes(\"message tool\") || prompt.includes(\"send to telegram\"))\n    ) {\n      warnings.push({\n        tone: \"warning\",\n        jobId: String(job?.id || \"\"),\n        title: `${job.name || job.id}: delivery mismatch`,\n        body: \"Job uses delivery.mode=none but prompt asks to send via message tool.\",\n      });\n    }\n    if (Number(job?.state?.consecutiveErrors || 0) >= 2) {\n      warnings.push({\n        tone: \"error\",\n        jobId: String(job?.id || \"\"),\n        title: `${job.name || job.id}: repeated errors`,\n        body: `Consecutive errors: ${Number(job?.state?.consecutiveErrors || 0)}.`,\n      });\n    }\n    const latestStatus = getLatestRunStatus({\n      job,\n      jobId,\n      bulkRunsByJobId,\n    });\n    if (\n      job?.state?.lastDelivered === false &&\n      String(job?.state?.lastDeliveryStatus || \"\").trim().toLowerCase() === \"not-delivered\" &&\n      latestStatus !== \"ok\" &&\n      !hasHeartbeatOnlySummary(job) &&\n      !hasHeartbeatSummaryInLatestRun({ jobId, bulkRunsByJobId })\n    ) {\n      warnings.push({\n        tone: \"warning\",\n        jobId,\n        title: `${job.name || job.id}: not delivered`,\n        body: \"Latest run completed but was not delivered.\",\n      });\n    }\n  });\n  return warnings.slice(0, 8);\n};\n\nexport const getNextScheduledRunAcrossJobs = (jobs = []) => {\n  const nextMs = jobs\n    .filter((job) => job?.enabled !== false)\n    .map((job) => Number(job?.state?.nextRunAtMs || 0))\n    .filter((value) => Number.isFinite(value) && value > 0)\n    .sort((left, right) => left - right)[0];\n  return nextMs || null;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-insights-panel.js",
    "content": "import { h } from \"preact\";\nimport { useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport { Badge } from \"../badge.js\";\nimport {\n  formatCost,\n  formatTokenCount,\n  getCronRunEstimatedCost,\n  getCronRunTotalTokens,\n} from \"./cron-helpers.js\";\n\nconst html = htm.bind(h);\n\nconst kRange24h = \"24h\";\nconst kRange7d = \"7d\";\nconst kRange30d = \"30d\";\nconst kRangeOptions = [\n  { label: \"24h\", value: kRange24h },\n  { label: \"7d\", value: kRange7d },\n  { label: \"30d\", value: kRange30d },\n];\nconst kRangeWindowMsByValue = {\n  [kRange24h]: 24 * 60 * 60 * 1000,\n  [kRange7d]: 7 * 24 * 60 * 60 * 1000,\n  [kRange30d]: 30 * 24 * 60 * 60 * 1000,\n};\nconst kTopListLimit = 3;\nconst kBadgeToneByInsight = {\n  \"Token hungry\": \"warning\",\n  \"Potentially wasteful\": \"danger\",\n  \"Most expensive\": \"accent\",\n};\nconst kWastefulMinRuns = 10;\nconst kTokenHungryMinAvgTokensPerRun = 100000;\nconst kTokenHungryMinRuns = 3;\nconst formatRunCountLabel = (count = 0) => {\n  const safeCount = Number(count || 0);\n  const countLabel = formatTokenCount(safeCount);\n  return `${countLabel} ${safeCount === 1 ? \"run\" : \"runs\"}`;\n};\n\nconst readDeliveryMode = (job = null) =>\n  String(job?.delivery?.mode || job?.deliveryMode || \"none\")\n    .trim()\n    .toLowerCase();\n\nconst sortDescBy = (items = [], selectors = []) =>\n  [...items].sort((left, right) => {\n    for (const selector of selectors) {\n      const leftValue = Number(selector(left) || 0);\n      const rightValue = Number(selector(right) || 0);\n      if (leftValue === rightValue) continue;\n      return rightValue - leftValue;\n    }\n    return String(left?.jobName || \"\").localeCompare(\n      String(right?.jobName || \"\"),\n    );\n  });\n\nconst buildInsightMetrics = ({\n  jobs = [],\n  bulkRunsByJobId = {},\n  rangeValue = kRange7d,\n}) => {\n  const nowMs = Date.now();\n  const windowMs = Number(\n    kRangeWindowMsByValue[rangeValue] || kRangeWindowMsByValue[kRange7d],\n  );\n  const cutoffMs = nowMs - windowMs;\n  const metricsByJobId = jobs.reduce((accumulator, job) => {\n    const jobId = String(job?.id || \"\");\n    if (!jobId) return accumulator;\n    accumulator[jobId] = {\n      jobId,\n      jobName: String(job?.name || jobId),\n      runCount: 0,\n      totalTokens: 0,\n      totalCost: 0,\n      hasCostData: false,\n      hasDelivery: readDeliveryMode(job) !== \"none\",\n    };\n    return accumulator;\n  }, {});\n\n  Object.entries(bulkRunsByJobId || {}).forEach(([jobIdValue, runResult]) => {\n    const jobId = String(jobIdValue || \"\");\n    if (!jobId) return;\n    if (!metricsByJobId[jobId]) {\n      metricsByJobId[jobId] = {\n        jobId,\n        jobName: jobId,\n        runCount: 0,\n        totalTokens: 0,\n        totalCost: 0,\n        hasCostData: false,\n        hasDelivery: false,\n      };\n    }\n    const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];\n    entries.forEach((entry) => {\n      const timestampMs = Number(entry?.ts || 0);\n      if (\n        !Number.isFinite(timestampMs) ||\n        timestampMs < cutoffMs ||\n        timestampMs > nowMs\n      ) {\n        return;\n      }\n      metricsByJobId[jobId].runCount += 1;\n      metricsByJobId[jobId].totalTokens += Number(getCronRunTotalTokens(entry) || 0);\n      const estimatedCost = getCronRunEstimatedCost(entry);\n      if (estimatedCost != null) {\n        metricsByJobId[jobId].hasCostData = true;\n        metricsByJobId[jobId].totalCost += Number(estimatedCost || 0);\n      }\n    });\n  });\n\n  return Object.values(metricsByJobId).map((entry) => ({\n    ...entry,\n    avgTokensPerRun:\n      entry.runCount > 0 ? Math.round(entry.totalTokens / entry.runCount) : 0,\n    avgCostPerRun: entry.runCount > 0 ? entry.totalCost / entry.runCount : 0,\n  }));\n};\n\nconst renderInsightRow = ({\n  title = \"\",\n  rows = [],\n  onSelectJob = () => {},\n}) => {\n  const badgeTone = kBadgeToneByInsight[title] || \"neutral\";\n  const topRow = rows[0];\n  const overflowRows = rows.slice(1);\n  return html`\n    <div class=\"rounded-lg border border-border bg-field px-3 py-2 space-y-1.5\">\n      <button\n        type=\"button\"\n        class=\"w-full text-left hover:brightness-110 transition\"\n        onClick=${() => onSelectJob(topRow.jobId)}\n      >\n        <div class=\"flex items-start justify-between gap-3\">\n          <div class=\"min-w-0\">\n            <div class=\"text-sm text-bright truncate\">${topRow.jobName}</div>\n            <div class=\"text-xs text-fg-muted truncate mt-1\">\n              ${`${topRow.primaryLabel} · ${topRow.secondaryLabel}`}\n            </div>\n          </div>\n          <div class=\"shrink-0 inline-flex items-center gap-1.5\">\n            <${Badge} tone=${badgeTone}>${title}</${Badge}>\n          </div>\n        </div>\n      </button>\n      ${\n        overflowRows.length > 0\n          ? html`\n              <details class=\"group\">\n                <summary\n                  class=\"list-none cursor-pointer text-[11px] text-fg-muted hover:text-body\"\n                >\n                  Show more\n                </summary>\n                <div class=\"mt-1.5 divide-y divide-border\">\n                  ${overflowRows.map(\n                    (row, index) => html`\n                      <button\n                        key=${`${title}:${row.jobId}`}\n                        type=\"button\"\n                        class=\"w-full text-left py-2 hover:brightness-110 transition\"\n                        onClick=${() => onSelectJob(row.jobId)}\n                      >\n                        <div class=\"flex items-start justify-between gap-3\">\n                          <div class=\"min-w-0\">\n                            <div class=\"text-sm text-body truncate\">\n                              ${row.jobName}\n                            </div>\n                            <div\n                              class=\"text-[11px] text-fg-muted truncate mt-1\"\n                            >\n                              ${`${row.primaryLabel} · ${row.secondaryLabel}`}\n                            </div>\n                          </div>\n                          <div\n                            class=\"text-[11px] uppercase tracking-wide text-fg-muted\"\n                          >\n                            #${index + 2}\n                          </div>\n                        </div>\n                      </button>\n                    `,\n                  )}\n                </div>\n              </details>\n            `\n          : null\n      }\n    </div>\n  `;\n};\n\nexport const CronInsightsPanel = ({\n  jobs = [],\n  bulkRunsByJobId = {},\n  onSelectJob = () => {},\n}) => {\n  const [rangeValue, setRangeValue] = useState(kRange7d);\n  const metrics = useMemo(\n    () => buildInsightMetrics({ jobs, bulkRunsByJobId, rangeValue }),\n    [bulkRunsByJobId, jobs, rangeValue],\n  );\n\n  const tokenHungryRows = useMemo(\n    () =>\n      sortDescBy(\n        metrics.filter(\n          (entry) =>\n            entry.runCount >= kTokenHungryMinRuns &&\n            entry.avgTokensPerRun >= kTokenHungryMinAvgTokensPerRun,\n        ),\n        [(entry) => entry.avgTokensPerRun, (entry) => entry.totalTokens],\n      )\n        .slice(0, kTopListLimit)\n        .map((entry) => ({\n          ...entry,\n          primaryLabel: `${formatTokenCount(entry.avgTokensPerRun)} avg tokens/run`,\n          secondaryLabel: `${formatTokenCount(entry.totalTokens)} total tokens · ${formatRunCountLabel(entry.runCount)}`,\n        })),\n    [metrics],\n  );\n\n  const wastefulRows = useMemo(\n    () =>\n      sortDescBy(\n        metrics.filter(\n          (entry) =>\n            entry.runCount >= kWastefulMinRuns &&\n            entry.hasDelivery === false &&\n            (entry.totalTokens > 0 || entry.totalCost > 0),\n        ),\n        [(entry) => entry.totalTokens, (entry) => entry.runCount],\n      )\n        .slice(0, kTopListLimit)\n        .map((entry) => ({\n          ...entry,\n          primaryLabel: `${formatRunCountLabel(entry.runCount)} with no delivery`,\n          secondaryLabel: `${formatTokenCount(entry.totalTokens)} total tokens${entry.hasCostData ? ` · ${formatCost(entry.totalCost)}` : \"\"}`,\n        })),\n    [metrics],\n  );\n\n  const expensiveRows = useMemo(\n    () =>\n      sortDescBy(\n        metrics.filter((entry) => entry.runCount > 0 && entry.totalCost > 0),\n        [(entry) => entry.totalCost, (entry) => entry.avgCostPerRun],\n      )\n        .slice(0, kTopListLimit)\n        .map((entry) => ({\n          ...entry,\n          primaryLabel: `${formatCost(entry.totalCost)} total estimated cost`,\n          secondaryLabel: `${formatCost(entry.avgCostPerRun)} avg/run · ${formatRunCountLabel(entry.runCount)}`,\n        })),\n    [metrics],\n  );\n  const insightRows = [\n    { title: \"Token hungry\", rows: tokenHungryRows },\n    { title: \"Potentially wasteful\", rows: wastefulRows },\n    { title: \"Most expensive\", rows: expensiveRows },\n  ].filter((group) => Array.isArray(group.rows) && group.rows.length > 0);\n  if (insightRows.length === 0) return null;\n\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label cron-calendar-title\">Insights</h3>\n        <${SegmentedControl}\n          options=${kRangeOptions}\n          value=${rangeValue}\n          onChange=${setRangeValue}\n        />\n      </div>\n\n      <div class=\"grid grid-cols-1 gap-2\">\n        ${insightRows.map((group) =>\n          renderInsightRow({\n            title: group.title,\n            rows: group.rows,\n            onSelectJob,\n          }))}\n      </div>\n    </section>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-job-detail.js",
    "content": "import { h } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { formatTokenCount } from \"./cron-helpers.js\";\nimport { CronJobUsage } from \"./cron-job-usage.js\";\nimport { CronJobTrendsPanel } from \"./cron-job-trends-panel.js\";\nimport { CronRunHistoryPanel } from \"./cron-run-history-panel.js\";\nimport { CronPromptEditor } from \"./cron-prompt-editor.js\";\nimport { CronJobSettingsCard } from \"./cron-job-settings-card.js\";\n\nconst html = htm.bind(h);\nconst kRunStatusFilterOptions = [\n  { label: \"all\", value: \"all\" },\n  { label: \"ok\", value: \"ok\" },\n  { label: \"error\", value: \"error\" },\n  { label: \"skipped\", value: \"skipped\" },\n];\n\nexport const CronJobDetail = ({\n  job = null,\n  runEntries = [],\n  filteredRunEntries = [],\n  runTotal = 0,\n  runHasMore = false,\n  loadingMoreRuns = false,\n  runStatusFilter = \"all\",\n  onSetRunStatusFilter = () => {},\n  onLoadMoreRuns = () => {},\n  onRunNow = () => {},\n  runningJob = false,\n  onToggleEnabled = () => {},\n  togglingJobEnabled = false,\n  usage = null,\n  jobTrends = null,\n  jobTrendRange = \"7d\",\n  selectedJobTrendBucketFilter = null,\n  usageDays = 30,\n  onSetUsageDays = () => {},\n  onSetJobTrendRange = () => {},\n  onSetSelectedJobTrendBucketFilter = () => {},\n  promptValue = \"\",\n  savedPromptValue = \"\",\n  onChangePrompt = () => {},\n  onSaveChanges = () => {},\n  savingChanges = false,\n  routingDraft = null,\n  onChangeRoutingDraft = () => {},\n  deliverySessions = [],\n  loadingDeliverySessions = false,\n  deliverySessionsError = \"\",\n  destinationSessionKey = \"\",\n  onChangeDestinationSessionKey = () => {},\n}) => {\n  if (!job) {\n    return html`\n      <div class=\"h-full flex items-center justify-center text-sm text-fg-muted\">\n        Select a cron job to view details.\n      </div>\n    `;\n  }\n\n  const isRoutingDirty = useMemo(() => {\n    const sessionTarget = String(\n      routingDraft?.sessionTarget || job?.sessionTarget || \"main\",\n    );\n    const wakeMode = String(routingDraft?.wakeMode || job?.wakeMode || \"now\");\n    const deliveryMode = String(\n      routingDraft?.deliveryMode || job?.delivery?.mode || \"none\",\n    );\n    const currentSessionTarget = String(job?.sessionTarget || \"main\");\n    const currentWakeMode = String(job?.wakeMode || \"now\");\n    const currentDeliveryMode = String(job?.delivery?.mode || \"none\");\n    return (\n      sessionTarget !== currentSessionTarget ||\n      wakeMode !== currentWakeMode ||\n      deliveryMode !== currentDeliveryMode\n    );\n  }, [job, routingDraft?.deliveryMode, routingDraft?.sessionTarget, routingDraft?.wakeMode]);\n  const isPromptDirty = promptValue !== savedPromptValue;\n  const hasUnsavedChanges = isRoutingDirty || isPromptDirty;\n\n  return html`\n    <div class=\"cron-detail-scroll\">\n      <div class=\"cron-detail-content\">\n        <${CronJobSettingsCard}\n          job=${job}\n          routingDraft=${routingDraft}\n          onChangeRoutingDraft=${onChangeRoutingDraft}\n          destinationSessionKey=${destinationSessionKey}\n          onChangeDestinationSessionKey=${onChangeDestinationSessionKey}\n          deliverySessions=${deliverySessions}\n          loadingDeliverySessions=${loadingDeliverySessions}\n          deliverySessionsError=${deliverySessionsError}\n          savingChanges=${savingChanges}\n          togglingJobEnabled=${togglingJobEnabled}\n          onToggleEnabled=${onToggleEnabled}\n          onRunNow=${onRunNow}\n          runningJob=${runningJob}\n          hasUnsavedChanges=${hasUnsavedChanges}\n        />\n\n        <${CronPromptEditor}\n          promptValue=${promptValue}\n          savedPromptValue=${savedPromptValue}\n          onChangePrompt=${onChangePrompt}\n          onSaveChanges=${onSaveChanges}\n        />\n\n        <${CronJobUsage}\n          usage=${usage}\n          usageDays=${usageDays}\n          onSetUsageDays=${onSetUsageDays}\n        />\n        <${CronJobTrendsPanel}\n          trends=${jobTrends}\n          range=${jobTrendRange}\n          onChangeRange=${onSetJobTrendRange}\n          selectedBucketFilter=${selectedJobTrendBucketFilter}\n          onChangeSelectedBucketFilter=${onSetSelectedJobTrendBucketFilter}\n        />\n\n        <${CronRunHistoryPanel}\n          entryCountLabel=${`${formatTokenCount(selectedJobTrendBucketFilter ? filteredRunEntries.length : runTotal)} entries`}\n          primaryFilterOptions=${kRunStatusFilterOptions}\n          primaryFilterValue=${runStatusFilter}\n          onChangePrimaryFilter=${onSetRunStatusFilter}\n          activeFilterLabel=${selectedJobTrendBucketFilter?.label || \"\"}\n          onClearActiveFilter=${() => onSetSelectedJobTrendBucketFilter(null)}\n          rows=${selectedJobTrendBucketFilter ? filteredRunEntries : runEntries}\n          variant=\"detail\"\n          footer=${runHasMore\n            ? html`\n                <div class=\"pt-2\">\n                  <${ActionButton}\n                    onClick=${onLoadMoreRuns}\n                    loading=${loadingMoreRuns}\n                    tone=\"secondary\"\n                    size=\"sm\"\n                    idleLabel=\"Load More\"\n                    loadingLabel=\"Loading...\"\n                  />\n                </div>\n              `\n            : null}\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-job-list.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  formatCronScheduleLabel,\n  formatRelativeCompact,\n  getCronJobHealth,\n  getCronJobHealthClassName,\n  kAllCronJobsRouteKey,\n} from \"./cron-helpers.js\";\n\nconst html = htm.bind(h);\nconst kGroupOrder = [\"daily\", \"weekly\", \"monthly\", \"other\"];\nconst kGroupLabelByKey = {\n  daily: \"Daily\",\n  weekly: \"Weekly\",\n  monthly: \"Monthly\",\n  other: \"Other\",\n};\nconst kMinutesPerHour = 60;\n\nconst parseCronNumeric = (value = \"\") => {\n  const parsed = Number.parseInt(String(value || \"\").trim(), 10);\n  return Number.isFinite(parsed) ? parsed : null;\n};\n\nconst normalizeCronWeekday = (value) => {\n  if (!Number.isFinite(value)) return null;\n  const normalized = value === 7 ? 0 : value;\n  return normalized >= 0 && normalized <= 6 ? normalized : null;\n};\n\nconst parseCronWeekdayField = (field = \"\") => {\n  const raw = String(field || \"\").trim().toLowerCase();\n  if (!raw || raw === \"*\") return null;\n  const segments = raw.split(\",\").map((segment) => segment.trim()).filter(Boolean);\n  const weekdays = [];\n  segments.forEach((segment) => {\n    const rangeMatch = segment.match(/^(\\d{1,2})-(\\d{1,2})$/);\n    if (rangeMatch) {\n      const start = normalizeCronWeekday(parseCronNumeric(rangeMatch[1]));\n      const end = normalizeCronWeekday(parseCronNumeric(rangeMatch[2]));\n      if (start == null || end == null) return;\n      if (start <= end) {\n        for (let value = start; value <= end; value += 1) weekdays.push(value);\n      } else {\n        for (let value = start; value <= 6; value += 1) weekdays.push(value);\n        for (let value = 0; value <= end; value += 1) weekdays.push(value);\n      }\n      return;\n    }\n    const single = normalizeCronWeekday(parseCronNumeric(segment));\n    if (single != null) weekdays.push(single);\n  });\n  if (weekdays.length === 0) return null;\n  return Math.min(...weekdays);\n};\nconst parseCronWeekdayValues = (field = \"\") => {\n  const raw = String(field || \"\").trim().toLowerCase();\n  if (!raw || raw === \"*\") return [];\n  const segments = raw.split(\",\").map((segment) => segment.trim()).filter(Boolean);\n  const weekdays = new Set();\n  segments.forEach((segment) => {\n    const rangeMatch = segment.match(/^(\\d{1,2})-(\\d{1,2})$/);\n    if (rangeMatch) {\n      const start = normalizeCronWeekday(parseCronNumeric(rangeMatch[1]));\n      const end = normalizeCronWeekday(parseCronNumeric(rangeMatch[2]));\n      if (start == null || end == null) return;\n      if (start <= end) {\n        for (let value = start; value <= end; value += 1) weekdays.add(value);\n      } else {\n        for (let value = start; value <= 6; value += 1) weekdays.add(value);\n        for (let value = 0; value <= end; value += 1) weekdays.add(value);\n      }\n      return;\n    }\n    const single = normalizeCronWeekday(parseCronNumeric(segment));\n    if (single != null) weekdays.add(single);\n  });\n  return [...weekdays].sort((left, right) => left - right);\n};\nconst isWeekdaysOnlyField = (field = \"\") => {\n  const weekdayValues = parseCronWeekdayValues(field);\n  if (weekdayValues.length !== 5) return false;\n  return weekdayValues.join(\",\") === \"1,2,3,4,5\";\n};\n\nconst parseCronMinuteOfDay = ({ minuteField = \"\", hourField = \"\" }) => {\n  const minute = parseCronNumeric(minuteField);\n  const hour = parseCronNumeric(hourField);\n  if (minute == null || hour == null) return null;\n  if (minute < 0 || minute > 59 || hour < 0 || hour > 23) return null;\n  return hour * kMinutesPerHour + minute;\n};\n\nconst parseCronFields = (schedule = {}) => {\n  const cronExpr = String(\n    schedule?.expr || schedule?.cron || schedule?.cronExpr || \"\",\n  ).trim();\n  const cronFields = cronExpr.split(/\\s+/);\n  if (cronFields.length < 5) return null;\n  const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = cronFields;\n  return {\n    minuteField,\n    hourField,\n    dayOfMonthField,\n    monthField,\n    dayOfWeekField,\n  };\n};\n\nconst getInternalSortMeta = (job = {}, groupKey = \"other\") => {\n  const schedule = job?.schedule || {};\n  const scheduleKind = String(schedule?.kind || \"\").trim().toLowerCase();\n  const cronFields = parseCronFields(schedule);\n  const minuteOfDay = cronFields\n    ? parseCronMinuteOfDay({\n        minuteField: cronFields.minuteField,\n        hourField: cronFields.hourField,\n      })\n    : null;\n  const nameKey = String(job?.name || job?.id || \"\").toLowerCase();\n  if (groupKey === \"daily\") {\n    if (scheduleKind === \"every\") {\n      const everyMs = Number(schedule?.everyMs || Number.MAX_SAFE_INTEGER);\n      return {\n        groupRank: 0,\n        primary: Number.isFinite(everyMs) ? everyMs : Number.MAX_SAFE_INTEGER,\n        secondary: nameKey,\n      };\n    }\n    if (\n      cronFields &&\n      cronFields.dayOfMonthField === \"*\" &&\n      cronFields.monthField === \"*\" &&\n      cronFields.dayOfWeekField === \"*\" &&\n      minuteOfDay != null\n    ) {\n      return {\n        groupRank: 1,\n        primary: minuteOfDay,\n        secondary: nameKey,\n      };\n    }\n    if (\n      cronFields &&\n      cronFields.dayOfMonthField === \"*\" &&\n      cronFields.monthField === \"*\" &&\n      isWeekdaysOnlyField(cronFields.dayOfWeekField) &&\n      minuteOfDay != null\n    ) {\n      return {\n        groupRank: 2,\n        primary: minuteOfDay,\n        secondary: nameKey,\n      };\n    }\n  }\n  if (groupKey === \"weekly\" && cronFields) {\n    const weekday = parseCronWeekdayField(cronFields.dayOfWeekField);\n    return {\n      groupRank: 0,\n      primary: weekday == null ? Number.MAX_SAFE_INTEGER : weekday,\n      secondary: minuteOfDay == null ? Number.MAX_SAFE_INTEGER : minuteOfDay,\n      tertiary: nameKey,\n    };\n  }\n  if (groupKey === \"monthly\" && cronFields) {\n    const dayOfMonth = parseCronNumeric(cronFields.dayOfMonthField);\n    return {\n      groupRank: 0,\n      primary: dayOfMonth == null ? Number.MAX_SAFE_INTEGER : dayOfMonth,\n      secondary: minuteOfDay == null ? Number.MAX_SAFE_INTEGER : minuteOfDay,\n      tertiary: nameKey,\n    };\n  }\n  return {\n    groupRank: 99,\n    primary: Number.MAX_SAFE_INTEGER,\n    secondary: Number.MAX_SAFE_INTEGER,\n    tertiary: nameKey,\n  };\n};\n\nconst compareSortable = (left, right) => {\n  if (left === right) return 0;\n  return left > right ? 1 : -1;\n};\n\nconst sortGroupItems = (items = [], groupKey = \"other\") =>\n  [...items].sort((leftJob, rightJob) => {\n    const leftMeta = getInternalSortMeta(leftJob, groupKey);\n    const rightMeta = getInternalSortMeta(rightJob, groupKey);\n    const rankResult = compareSortable(leftMeta.groupRank, rightMeta.groupRank);\n    if (rankResult !== 0) return rankResult;\n    const primaryResult = compareSortable(leftMeta.primary, rightMeta.primary);\n    if (primaryResult !== 0) return primaryResult;\n    const secondaryResult = compareSortable(leftMeta.secondary, rightMeta.secondary);\n    if (secondaryResult !== 0) return secondaryResult;\n    return compareSortable(leftMeta.tertiary, rightMeta.tertiary);\n  });\n\nconst getScheduleGroupKey = (schedule = {}) => {\n  const kind = String(schedule?.kind || \"\").trim().toLowerCase();\n  if (kind === \"every\") {\n    const everyMs = Number(schedule?.everyMs || 0);\n    if (Number.isFinite(everyMs) && everyMs > 0) {\n      if (everyMs <= 24 * 60 * 60 * 1000) return \"daily\";\n      if (everyMs <= 7 * 24 * 60 * 60 * 1000) return \"weekly\";\n      if (everyMs <= 31 * 24 * 60 * 60 * 1000) return \"monthly\";\n    }\n    return \"other\";\n  }\n  const cronExpr = String(\n    schedule?.expr || schedule?.cron || schedule?.cronExpr || \"\",\n  ).trim();\n  const cronFields = cronExpr.split(/\\s+/);\n  if (cronFields.length >= 5) {\n    const [, , dayOfMonthField, monthField, dayOfWeekField] = cronFields;\n    if (\n      dayOfMonthField === \"*\" &&\n      monthField === \"*\" &&\n      dayOfWeekField === \"*\"\n    ) {\n      return \"daily\";\n    }\n    if (\n      dayOfMonthField === \"*\" &&\n      monthField === \"*\" &&\n      dayOfWeekField !== \"*\"\n    ) {\n      if (isWeekdaysOnlyField(dayOfWeekField)) return \"daily\";\n      return \"weekly\";\n    }\n    if (dayOfMonthField !== \"*\" && monthField === \"*\") {\n      return \"monthly\";\n    }\n  }\n  return \"other\";\n};\n\nexport const CronJobList = ({\n  jobs = [],\n  selectedRouteKey = kAllCronJobsRouteKey,\n  onSelectAllJobs = () => {},\n  onSelectJob = () => {},\n}) => {\n  const searchInputRef = useRef(null);\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  useEffect(() => {\n    const frameId = window.requestAnimationFrame(() => {\n      searchInputRef.current?.focus();\n    });\n    return () => {\n      window.cancelAnimationFrame(frameId);\n    };\n  }, []);\n  const normalizedQuery = String(searchQuery || \"\").trim().toLowerCase();\n  const filteredJobs = useMemo(() => {\n    if (!normalizedQuery) return jobs;\n    return jobs.filter((job) => {\n      const name = String(job?.name || \"\").toLowerCase();\n      const id = String(job?.id || \"\").toLowerCase();\n      return name.includes(normalizedQuery) || id.includes(normalizedQuery);\n    });\n  }, [jobs, normalizedQuery]);\n  const groupedJobs = useMemo(() => {\n    const groups = {\n      daily: [],\n      weekly: [],\n      monthly: [],\n      other: [],\n    };\n    filteredJobs.forEach((job) => {\n      const groupKey = getScheduleGroupKey(job?.schedule);\n      if (!groups[groupKey]) groups.other.push(job);\n      else groups[groupKey].push(job);\n    });\n    return {\n      daily: sortGroupItems(groups.daily, \"daily\"),\n      weekly: sortGroupItems(groups.weekly, \"weekly\"),\n      monthly: sortGroupItems(groups.monthly, \"monthly\"),\n      other: sortGroupItems(groups.other, \"other\"),\n    };\n  }, [filteredJobs]);\n\n  return html`\n    <div class=\"cron-list-panel-inner\">\n      <div class=\"cron-list-sticky-search\">\n        <input\n          ref=${searchInputRef}\n          type=\"text\"\n          value=${searchQuery}\n          placeholder=\"Search cron jobs...\"\n          class=\"cron-list-search-input\"\n          onInput=${(event) => setSearchQuery(event.target.value)}\n        />\n      </div>\n      <button\n        type=\"button\"\n        class=${`cron-list-item cron-list-all ${selectedRouteKey === kAllCronJobsRouteKey ? \"is-selected\" : \"\"}`}\n        onclick=${onSelectAllJobs}\n      >\n        <span class=\"cron-list-item-title\">All Jobs</span>\n        <span class=\"cron-list-item-subtitle\">${jobs.length} total</span>\n      </button>\n\n      <div class=\"cron-list-items\">\n        ${kGroupOrder.map((groupKey) => {\n          const groupItems = groupedJobs[groupKey] || [];\n          if (groupItems.length === 0) return null;\n          return html`\n            <div key=${groupKey} class=\"cron-list-group\">\n              <div class=\"cron-list-group-header\">${kGroupLabelByKey[groupKey] || \"Other\"}</div>\n              <div class=\"cron-list-group-items\">\n                ${groupItems.map((job) => {\n                  const health = getCronJobHealth(job);\n                  const selected = selectedRouteKey === String(job.id || \"\");\n                  return html`\n                    <button\n                      key=${job.id}\n                      type=\"button\"\n                      class=${`cron-list-item ${selected ? \"is-selected\" : \"\"}`}\n                      onclick=${() => onSelectJob(job.id)}\n                    >\n                      <span class=\"cron-list-item-row\">\n                        <span class=\"cron-list-item-title truncate\">${job.name || job.id}</span>\n                        <span class=\"cron-list-status-inline\">\n                          <span class=\"cron-list-last-run\">\n                            ${formatRelativeCompact(job?.state?.lastRunAtMs)}\n                          </span>\n                          <span\n                            class=${`cron-list-health-dot ${getCronJobHealthClassName(health)}`}\n                            title=${health}\n                          ></span>\n                        </span>\n                      </span>\n                      <span class=\"cron-list-item-subtitle\">\n                        ${formatCronScheduleLabel(job.schedule, {\n                          includeTimeZoneWhenDifferent: true,\n                        })}\n                      </span>\n                    </button>\n                  `;\n                })}\n              </div>\n            </div>\n          `;\n        })}\n      </div>\n      ${filteredJobs.length === 0\n        ? html`\n            <div class=\"text-xs text-fg-muted px-1 py-2\">No cron jobs match your search.</div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-job-settings-card.js",
    "content": "import { h } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport { ToggleSwitch } from \"../toggle-switch.js\";\nimport { getSessionDisplayLabel } from \"../../lib/session-keys.js\";\nimport {\n  formatCronScheduleLabel,\n  formatNextRunRelativeMs,\n} from \"./cron-helpers.js\";\n\nconst html = htm.bind(h);\nconst kMetaCardClassName = \"ac-surface-inset rounded-lg p-2.5 space-y-1.5\";\nconst kSessionTargetOptions = [\n  { label: \"main\", value: \"main\" },\n  { label: \"isolated\", value: \"isolated\" },\n];\nconst kWakeModeOptions = [\n  { label: \"now\", value: \"now\" },\n  { label: \"next-heartbeat\", value: \"next-heartbeat\" },\n];\nconst kDeliveryNoneValue = \"__none__\";\n\nconst isSameCalendarDay = (leftDate, rightDate) =>\n  leftDate.getFullYear() === rightDate.getFullYear() &&\n  leftDate.getMonth() === rightDate.getMonth() &&\n  leftDate.getDate() === rightDate.getDate();\n\nconst formatCompactMeridiemTime = (dateValue) =>\n  dateValue\n    .toLocaleTimeString([], {\n      hour: \"numeric\",\n      minute: \"2-digit\",\n    })\n    .replace(/\\s*([AP])M$/i, (_, marker) =>\n      `${String(marker || \"\").toLowerCase()}m`,\n    )\n    .replace(/\\s+/g, \"\");\n\nconst formatNextRunAbsolute = (value) => {\n  const timestamp = Number(value || 0);\n  if (!Number.isFinite(timestamp) || timestamp <= 0) return \"—\";\n  const dateValue = new Date(timestamp);\n  if (Number.isNaN(dateValue.getTime())) return \"—\";\n  const nowValue = new Date();\n  const tomorrowValue = new Date(nowValue);\n  tomorrowValue.setDate(nowValue.getDate() + 1);\n  const isToday = isSameCalendarDay(dateValue, nowValue);\n  const isTomorrow = isSameCalendarDay(dateValue, tomorrowValue);\n  const compactTime = formatCompactMeridiemTime(dateValue);\n  if (isToday) return compactTime;\n  if (isTomorrow) return `Tomorrow ${compactTime}`;\n  return `${dateValue.toLocaleDateString()} ${compactTime}`;\n};\n\nexport const CronJobSettingsCard = ({\n  job = null,\n  routingDraft = null,\n  onChangeRoutingDraft = () => {},\n  destinationSessionKey = \"\",\n  onChangeDestinationSessionKey = () => {},\n  deliverySessions = [],\n  loadingDeliverySessions = false,\n  deliverySessionsError = \"\",\n  savingChanges = false,\n  togglingJobEnabled = false,\n  onToggleEnabled = () => {},\n  onRunNow = () => {},\n  runningJob = false,\n  hasUnsavedChanges = false,\n}) => {\n  if (!job) return null;\n\n  const sessionTarget = String(\n    routingDraft?.sessionTarget || job?.sessionTarget || \"main\",\n  );\n  const wakeMode = String(routingDraft?.wakeMode || job?.wakeMode || \"now\");\n  const deliveryMode = String(\n    routingDraft?.deliveryMode || job?.delivery?.mode || \"none\",\n  );\n  const deliverySessionOptions = useMemo(() => {\n    const seenLabels = new Set();\n    const deduped = [];\n    const selectedKey = String(destinationSessionKey || \"\").trim();\n    let selectedPresent = false;\n    (Array.isArray(deliverySessions) ? deliverySessions : []).forEach(\n      (sessionRow) => {\n        const key = String(sessionRow?.key || \"\").trim();\n        if (!key) return;\n        if (key === selectedKey) selectedPresent = true;\n        const label = String(\n          getSessionDisplayLabel(sessionRow) ||\n            sessionRow?.key ||\n            \"Session\",\n        ).trim();\n        const dedupeKey = label.toLowerCase();\n        if (seenLabels.has(dedupeKey)) return;\n        seenLabels.add(dedupeKey);\n        deduped.push(sessionRow);\n      },\n    );\n    if (!selectedPresent && selectedKey) {\n      const selectedRow = (\n        Array.isArray(deliverySessions) ? deliverySessions : []\n      ).find((sessionRow) => String(sessionRow?.key || \"\").trim() === selectedKey);\n      if (selectedRow) deduped.unshift(selectedRow);\n    }\n    return deduped;\n  }, [deliverySessions, destinationSessionKey]);\n  const deliverySelectValue =\n    deliveryMode === \"announce\" && String(destinationSessionKey || \"\").trim()\n      ? String(destinationSessionKey || \"\")\n      : kDeliveryNoneValue;\n\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <div class=\"text-xs text-fg-muted\">ID: <code>${job.id}</code></div>\n      </div>\n      <div class=\"grid grid-cols-2 gap-2 text-xs\">\n        <div class=${kMetaCardClassName}>\n          <div class=\"text-fg-muted\">Schedule</div>\n          <div class=\"text-body font-mono\">\n            ${formatCronScheduleLabel(job.schedule, {\n              includeTimeZoneWhenDifferent: true,\n            })}\n          </div>\n        </div>\n        <div class=${kMetaCardClassName}>\n          <div class=\"text-fg-muted\">Next run</div>\n          <div class=\"text-body font-mono\">\n            ${formatNextRunAbsolute(job?.state?.nextRunAtMs)}\n            <span class=\"text-fg-muted\">\n              ${` (${formatNextRunRelativeMs(job?.state?.nextRunAtMs)})`}\n            </span>\n          </div>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-3 gap-2 text-xs\">\n        <div class=${kMetaCardClassName}>\n          <div class=\"text-fg-muted\">Session target</div>\n          <div class=\"pt-1\">\n            <${SegmentedControl}\n              options=${kSessionTargetOptions}\n              value=${sessionTarget}\n              onChange=${(value) =>\n                onChangeRoutingDraft((currentValue = {}) => ({\n                  ...currentValue,\n                  sessionTarget: String(value || \"main\"),\n                }))}\n            />\n          </div>\n        </div>\n        <div class=${kMetaCardClassName}>\n          <div class=\"text-fg-muted\">Wake mode</div>\n          <div class=\"pt-1\">\n            <${SegmentedControl}\n              options=${kWakeModeOptions}\n              value=${wakeMode}\n              onChange=${(value) =>\n                onChangeRoutingDraft((currentValue = {}) => ({\n                  ...currentValue,\n                  wakeMode: String(value || \"now\"),\n                }))}\n            />\n          </div>\n        </div>\n        <div class=${kMetaCardClassName}>\n          <div class=\"text-fg-muted\">Delivery</div>\n          <div class=\"pt-1\">\n            <select\n              value=${deliverySelectValue}\n              onInput=${(event) => {\n                const nextValue = String(event.currentTarget?.value || \"\");\n                if (!nextValue || nextValue === kDeliveryNoneValue) {\n                  onChangeRoutingDraft((currentValue = {}) => ({\n                    ...currentValue,\n                    deliveryMode: \"none\",\n                    deliveryChannel: \"\",\n                    deliveryTo: \"\",\n                  }));\n                  onChangeDestinationSessionKey(\"\");\n                  return;\n                }\n                onChangeDestinationSessionKey(nextValue);\n                onChangeRoutingDraft((currentValue = {}) => ({\n                  ...currentValue,\n                  deliveryMode: \"announce\",\n                }));\n              }}\n              disabled=${savingChanges}\n              class=\"w-full bg-field border border-border rounded-lg px-2 py-1.5 text-[11px] text-body focus:border-fg-muted\"\n            >\n              <option value=${kDeliveryNoneValue}>None</option>\n              ${deliverySessionOptions.map(\n                (sessionRow) => html`\n                  <option value=${String(sessionRow?.key || \"\")}>\n                    ${String(\n                      getSessionDisplayLabel(sessionRow) ||\n                        sessionRow?.key ||\n                        \"Session\",\n                    )}\n                  </option>\n                `,\n              )}\n            </select>\n          </div>\n          ${loadingDeliverySessions\n            ? html`<div class=\"text-[11px] text-fg-muted pt-1\">\n                Loading delivery sessions...\n              </div>`\n            : null}\n          ${deliverySessionsError\n            ? html`<div class=\"text-[11px] text-status-error-muted pt-1\">\n                ${deliverySessionsError}\n              </div>`\n            : null}\n        </div>\n      </div>\n      <div class=\"flex items-center justify-between gap-3\">\n        <${ToggleSwitch}\n          checked=${job.enabled !== false}\n          disabled=${togglingJobEnabled || savingChanges}\n          onChange=${onToggleEnabled}\n          label=${job.enabled === false ? \"Disabled\" : \"Enabled\"}\n        />\n        <${ActionButton}\n          onClick=${onRunNow}\n          loading=${runningJob}\n          disabled=${hasUnsavedChanges || savingChanges}\n          tone=\"secondary\"\n          size=\"sm\"\n          idleLabel=\"Run now\"\n          loadingLabel=\"Running...\"\n        />\n      </div>\n    </section>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-job-trends-panel.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport Chart from \"chart.js/auto\";\nimport { formatCost, formatTokenCount } from \"./cron-helpers.js\";\nimport { formatChartBucketLabel, formatDurationCompactMs } from \"../../lib/format.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\n\nconst html = htm.bind(h);\nconst kMetricOutcomes = \"outcomes\";\nconst kMetricTokens = \"tokens\";\nconst kMetricDuration = \"duration\";\nconst kMetricCost = \"cost\";\nconst kRange24h = \"24h\";\nconst kRange7d = \"7d\";\nconst kRange30d = \"30d\";\nconst kRangeOptions = [\n  { label: \"24h\", value: kRange24h },\n  { label: \"7d\", value: kRange7d },\n  { label: \"30d\", value: kRange30d },\n];\nconst kMetricOptions = [\n  { label: \"outcomes\", value: kMetricOutcomes },\n  { label: \"tokens\", value: kMetricTokens },\n  { label: \"duration\", value: kMetricDuration },\n  { label: \"cost\", value: kMetricCost },\n];\nconst buildChartData = ({\n  trends = null,\n  metric = kMetricOutcomes,\n  selectedBucketKey = \"\",\n} = {}) => {\n  const points = Array.isArray(trends?.points) ? trends.points : [];\n  const range = String(trends?.range || kRange7d);\n  const labels = points.map((point) =>\n    formatChartBucketLabel(point.startMs, {\n      range,\n      valueType: \"epoch-ms\",\n    }));\n  const dimAlpha = \"0.22\";\n  const fullAlpha = \"0.86\";\n  const isDimmed = (index) =>\n    selectedBucketKey && String(points[index]?.key || \"\") !== selectedBucketKey;\n  if (metric === kMetricOutcomes) {\n    return {\n      labels,\n      datasets: [\n        {\n          label: \"ok\",\n          data: points.map((point) => Number(point?.ok || 0)),\n          stack: \"outcomes\",\n          backgroundColor: points.map((_, index) =>\n            `rgba(34,255,170,${isDimmed(index) ? dimAlpha : fullAlpha})`),\n          borderColor: points.map((_, index) =>\n            `rgba(34,255,170,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n          borderWidth: 1,\n          borderRadius: 0,\n          borderSkipped: false,\n        },\n        {\n          label: \"error\",\n          data: points.map((point) => Number(point?.error || 0)),\n          stack: \"outcomes\",\n          backgroundColor: points.map((_, index) =>\n            `rgba(255,74,138,${isDimmed(index) ? dimAlpha : fullAlpha})`),\n          borderColor: points.map((_, index) =>\n            `rgba(255,74,138,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n          borderWidth: 1,\n          borderRadius: 0,\n          borderSkipped: false,\n        },\n        {\n          label: \"skipped\",\n          data: points.map((point) => Number(point?.skipped || 0)),\n          stack: \"outcomes\",\n          backgroundColor: points.map((_, index) =>\n            `rgba(255,214,64,${isDimmed(index) ? dimAlpha : fullAlpha})`),\n          borderColor: points.map((_, index) =>\n            `rgba(255,214,64,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n          borderWidth: 1,\n          borderRadius: 0,\n          borderSkipped: false,\n        },\n      ],\n    };\n  }\n  const valueByPoint = points.map((point) => {\n    if (metric === kMetricTokens) return Number(point?.totalTokens || 0);\n    if (metric === kMetricCost) return Number(point?.totalCost || 0);\n    return Number(point?.avgDurationMs || 0);\n  });\n  return {\n    labels,\n    datasets: [\n      {\n        label:\n          metric === kMetricTokens\n            ? \"tokens\"\n            : metric === kMetricCost\n              ? \"cost\"\n              : \"avg duration\",\n        data: valueByPoint,\n        backgroundColor: points.map((_, index) =>\n          metric === kMetricTokens\n            ? `rgba(34,211,238,${isDimmed(index) ? dimAlpha : \"0.72\"})`\n            : metric === kMetricCost\n              ? `rgba(167,139,250,${isDimmed(index) ? dimAlpha : \"0.72\"})`\n              : `rgba(148,163,184,${isDimmed(index) ? dimAlpha : \"0.72\"})`),\n        borderColor: points.map((_, index) =>\n          metric === kMetricTokens\n            ? `rgba(34,211,238,${isDimmed(index) ? \"0.35\" : \"1\"})`\n            : metric === kMetricCost\n              ? `rgba(167,139,250,${isDimmed(index) ? \"0.35\" : \"1\"})`\n              : `rgba(148,163,184,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n        borderWidth: 1,\n        borderRadius: 0,\n        borderSkipped: false,\n      },\n    ],\n  };\n};\n\nexport const CronJobTrendsPanel = ({\n  trends = null,\n  range = kRange7d,\n  onChangeRange = () => {},\n  selectedBucketFilter = null,\n  onChangeSelectedBucketFilter = () => {},\n}) => {\n  const chartCanvasRef = useRef(null);\n  const chartInstanceRef = useRef(null);\n  const [metric, setMetric] = useState(kMetricOutcomes);\n  const points = useMemo(\n    () =>\n      Array.isArray(trends?.points)\n        ? trends.points.map((point, index) => ({\n          ...point,\n          key: String(point?.key || `point:${index}:${point?.startMs || 0}`),\n        }))\n        : [],\n    [trends?.points],\n  );\n  const selectedBucketKey = useMemo(() => {\n    if (!selectedBucketFilter) return \"\";\n    const matchingPoint = points.find(\n      (point) =>\n        Number(point.startMs) === Number(selectedBucketFilter.startMs) &&\n        Number(point.endMs) === Number(selectedBucketFilter.endMs),\n    );\n    return matchingPoint?.key || \"\";\n  }, [points, selectedBucketFilter]);\n  const hasData = useMemo(\n    () =>\n      points.some(\n        (point) =>\n          Number(point?.totalRuns || 0) > 0 ||\n          Number(point?.totalTokens || 0) > 0 ||\n          Number(point?.totalCost || 0) > 0 ||\n          Number(point?.avgDurationMs || 0) > 0,\n      ),\n    [points],\n  );\n  const chartData = useMemo(\n    () => buildChartData({ trends: { ...trends, points }, metric, selectedBucketKey }),\n    [metric, points, selectedBucketKey, trends],\n  );\n  useEffect(() => {\n    const canvas = chartCanvasRef.current;\n    if (!canvas || !Chart) return;\n    if (chartInstanceRef.current) {\n      chartInstanceRef.current.destroy();\n      chartInstanceRef.current = null;\n    }\n    const getBucketFilter = (index) => {\n      const selectedPoint = points[index];\n      if (!selectedPoint) return null;\n      return {\n        key: selectedPoint.key,\n        label: formatChartBucketLabel(selectedPoint.startMs, {\n          range,\n          valueType: \"epoch-ms\",\n        }),\n        startMs: Number(selectedPoint.startMs || 0),\n        endMs: Number(selectedPoint.endMs || 0),\n        range,\n      };\n    };\n    chartInstanceRef.current = new Chart(canvas, {\n      type: \"bar\",\n      data: chartData,\n      options: {\n        responsive: true,\n        maintainAspectRatio: false,\n        interaction: { mode: \"index\", intersect: false },\n        animation: false,\n        onHover: (event, elements) => {\n          const target = event?.native?.target;\n          if (!target || !target.style) return;\n          target.style.cursor = Array.isArray(elements) && elements.length > 0\n            ? \"pointer\"\n            : \"default\";\n        },\n        onClick: (_event, elements) => {\n          const index = Number(elements?.[0]?.index);\n          if (!Number.isFinite(index)) return;\n          const nextFilter = getBucketFilter(index);\n          if (!nextFilter) return;\n          if (nextFilter.key === selectedBucketKey) {\n            onChangeSelectedBucketFilter(null);\n            return;\n          }\n          onChangeSelectedBucketFilter(nextFilter);\n        },\n        scales: {\n          x: {\n            stacked: metric === kMetricOutcomes,\n            grid: { color: \"rgba(148,163,184,0.08)\" },\n            ticks: {\n              color: \"rgba(156,163,175,1)\",\n              maxRotation: 0,\n              autoSkip: true,\n            },\n          },\n          y: {\n            stacked: metric === kMetricOutcomes,\n            beginAtZero: true,\n            grid: { color: \"rgba(148,163,184,0.12)\" },\n            ticks: {\n              precision: metric === kMetricCost ? undefined : 0,\n              color: \"rgba(156,163,175,1)\",\n              callback: (value) => {\n                const numericValue = Number(value || 0);\n                if (metric === kMetricCost) return formatCost(numericValue);\n                if (metric === kMetricDuration) {\n                  return numericValue > 0 ? formatDurationCompactMs(numericValue) : \"0\";\n                }\n                return formatTokenCount(numericValue);\n              },\n            },\n          },\n        },\n        plugins: {\n          legend: {\n            position: \"bottom\",\n            labels: {\n              color: \"rgba(209,213,219,1)\",\n              boxWidth: 10,\n              boxHeight: 10,\n            },\n          },\n          tooltip: {\n            callbacks: {\n              title: (items) => String(items?.[0]?.label || \"\"),\n              label: (context) => {\n                const value = Number(context.parsed.y || 0);\n                if (metric === kMetricCost) {\n                  return `${context.dataset.label}: ${formatCost(value)}`;\n                }\n                if (metric === kMetricDuration) {\n                  return `${context.dataset.label}: ${value > 0 ? formatDurationCompactMs(value) : \"—\"}`;\n                }\n                return `${context.dataset.label}: ${formatTokenCount(value)}`;\n              },\n              footer: (items) => {\n                const index = Number(items?.[0]?.dataIndex);\n                const point = points[index];\n                if (!point) return \"\";\n                const runsLabel = `runs: ${formatTokenCount(point.totalRuns || 0)}`;\n                const tokensLabel = `tokens: ${formatTokenCount(point.totalTokens || 0)}`;\n                const costLabel = `cost: ${formatCost(point.totalCost || 0)}`;\n                return `${runsLabel}\\n${tokensLabel}\\n${costLabel}`;\n              },\n            },\n          },\n        },\n      },\n    });\n    return () => {\n      if (chartInstanceRef.current) {\n        chartInstanceRef.current.destroy();\n        chartInstanceRef.current = null;\n      }\n    };\n  }, [\n    chartData,\n    metric,\n    onChangeSelectedBucketFilter,\n    points,\n    range,\n    selectedBucketKey,\n    trends?.bucket,\n  ]);\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label card-label-bright\">Trends</h3>\n        <div class=\"flex items-center gap-2\">\n          <${SegmentedControl}\n            options=${kMetricOptions}\n            value=${metric}\n            onChange=${setMetric}\n          />\n          <${SegmentedControl}\n            options=${kRangeOptions}\n            value=${range}\n            onChange=${onChangeRange}\n          />\n        </div>\n      </div>\n      ${hasData\n        ? html`\n            <div class=\"h-44\">\n              <canvas ref=${chartCanvasRef}></canvas>\n            </div>\n          `\n        : html`<div class=\"text-xs text-fg-muted\">No run data in this window yet.</div>`}\n    </section>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-job-usage.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { formatCost, formatTokenCount } from \"./cron-helpers.js\";\nimport { formatDurationCompactMs } from \"../../lib/format.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\n\nconst html = htm.bind(h);\nconst kUsageRangeOptions = [\n  { label: \"7d\", value: 7 },\n  { label: \"30d\", value: 30 },\n];\n\nconst resolveDominantModel = (usage = null) => {\n  const list = Array.isArray(usage?.modelBreakdown) ? usage.modelBreakdown : [];\n  if (list.length === 0) return \"—\";\n  const first = list[0];\n  const model = String(first?.model || \"\").trim();\n  const provider = String(first?.provider || \"\").trim();\n  if (!model && !provider) return \"—\";\n  if (!provider) return model;\n  if (!model) return provider;\n  return `${provider} / ${model}`;\n};\n\nexport const CronJobUsage = ({ usage = null, usageDays = 30, onSetUsageDays = () => {} }) => {\n  const totals = usage?.totals || {};\n  const totalRuns = Number(totals?.runCount || 0);\n  const totalTokens = Number(totals?.totalTokens || 0);\n  const totalCost = Number(totals?.totalCost || 0);\n  const averageDurationMs = Number(totals?.avgDurationMs || 0);\n  const averageTokensPerRun = totalRuns > 0 ? Math.round(totalTokens / totalRuns) : 0;\n  const averageCostPerRun = totalRuns > 0 ? totalCost / totalRuns : 0;\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label card-label-bright\">Usage</h3>\n        <${SegmentedControl}\n          options=${kUsageRangeOptions}\n          value=${usageDays}\n          onChange=${onSetUsageDays}\n        />\n      </div>\n      <div class=\"grid grid-cols-3 gap-2 text-xs\">\n        <div class=\"ac-surface-inset rounded-lg p-2\">\n          <div class=\"text-fg-muted\">Total runs</div>\n          <div class=\"text-body font-mono\">${formatTokenCount(totalRuns)}</div>\n        </div>\n        <div class=\"ac-surface-inset rounded-lg p-2\">\n          <div class=\"text-fg-muted\">Total tokens</div>\n          <div class=\"text-body font-mono\">${formatTokenCount(totalTokens)}</div>\n        </div>\n        <div class=\"ac-surface-inset rounded-lg p-2\">\n          <div class=\"text-fg-muted\">Total cost</div>\n          <div class=\"text-body font-mono\">${formatCost(totalCost)}</div>\n        </div>\n        <div class=\"ac-surface-inset rounded-lg p-2\">\n          <div class=\"text-fg-muted\">Avg run time</div>\n          <div class=\"text-body font-mono\">\n            ${averageDurationMs > 0 ? formatDurationCompactMs(averageDurationMs) : \"—\"}\n          </div>\n        </div>\n        <div class=\"ac-surface-inset rounded-lg p-2\">\n          <div class=\"text-fg-muted\">Avg tokens/run</div>\n          <div class=\"text-body font-mono\">${formatTokenCount(averageTokensPerRun)}</div>\n        </div>\n        <div class=\"ac-surface-inset rounded-lg p-2\">\n          <div class=\"text-fg-muted\">Avg cost/run</div>\n          <div class=\"text-body font-mono\">${formatCost(averageCostPerRun)}</div>\n        </div>\n      </div>\n      <div class=\"text-xs text-fg-muted\">\n        Dominant model:${\" \"}\n        <span class=\"text-body font-mono\">${resolveDominantModel(usage)}</span>\n      </div>\n    </section>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-overview.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  buildCronOptimizationWarnings,\n  formatTokenCount,\n  getNextScheduledRunAcrossJobs,\n} from \"./cron-helpers.js\";\nimport { CronCalendar } from \"./cron-calendar.js\";\nimport { CronRunsTrendCard } from \"./cron-runs-trend-card.js\";\nimport { CronRunHistoryPanel } from \"./cron-run-history-panel.js\";\nimport { CronInsightsPanel } from \"./cron-insights-panel.js\";\nimport { SummaryStatCard } from \"../summary-stat-card.js\";\nimport { ErrorWarningLineIcon } from \"../icons.js\";\n\nconst html = htm.bind(h);\nconst kRecentRunFetchLimit = 100;\nconst kRecentRunRowsLimit = 20;\nconst kRecentRunCollapseThreshold = 2;\nconst kTrendRange24h = \"24h\";\nconst kTrendRange7d = \"7d\";\nconst kTrendRange30d = \"30d\";\nconst kTrendQueryStartKey = \"trendStart\";\nconst kTrendQueryEndKey = \"trendEnd\";\nconst kTrendQueryRangeKey = \"trendRange\";\nconst kTrendQueryLabelKey = \"trendLabel\";\n\nconst kRunStatusFilterOptions = [\n  { label: \"all\", value: \"all\" },\n  { label: \"ok\", value: \"ok\" },\n  { label: \"error\", value: \"error\" },\n  { label: \"skipped\", value: \"skipped\" },\n];\n\nconst warningClassName = (tone) => {\n  if (tone === \"error\") return \"border-status-error-border bg-status-error-bg text-status-error\";\n  if (tone === \"warning\")\n    return \"border-status-warning-border bg-status-warning-bg text-status-warning\";\n  return \"border-border bg-field text-body\";\n};\n\nconst formatWarningsAttentionText = (warnings = []) => {\n  const errorCount = warnings.filter(\n    (warning) => warning?.tone === \"error\",\n  ).length;\n  const warningCount = warnings.filter(\n    (warning) => warning?.tone === \"warning\",\n  ).length;\n  const totalCount = errorCount + warningCount;\n  if (totalCount <= 0) return \"No warnings currently need your attention\";\n  const parts = [];\n  if (errorCount > 0)\n    parts.push(`${errorCount} error${errorCount === 1 ? \"\" : \"s\"}`);\n  if (warningCount > 0)\n    parts.push(`${warningCount} warning${warningCount === 1 ? \"\" : \"s\"}`);\n  return `${parts.join(\" and \")} may need your attention`;\n};\n\nconst flattenRecentRuns = ({ bulkRunsByJobId = {}, jobs = [], limit = 0 } = {}) => {\n  const jobNameById = jobs.reduce((accumulator, job) => {\n    const jobId = String(job?.id || \"\");\n    if (!jobId) return accumulator;\n    accumulator[jobId] = String(job?.name || jobId);\n    return accumulator;\n  }, {});\n  return Object.entries(bulkRunsByJobId || {})\n    .flatMap(([jobId, runResult]) => {\n      const entries = Array.isArray(runResult?.entries)\n        ? runResult.entries\n        : [];\n      return entries.map((entry) => ({\n        ...entry,\n        jobId: String(jobId || \"\"),\n        jobName: jobNameById[jobId] || String(jobId || \"\"),\n      }));\n    })\n    .filter((entry) => Number(entry?.ts || 0) > 0)\n    .sort((left, right) => Number(right?.ts || 0) - Number(left?.ts || 0))\n    .slice(0, Number(limit || 0) > 0 ? Number(limit || 0) : undefined);\n};\n\nconst buildCollapsedRunRows = (recentRuns = []) => {\n  const rows = [];\n  let index = 0;\n  while (index < recentRuns.length && rows.length < kRecentRunRowsLimit) {\n    const current = recentRuns[index];\n    let streakEnd = index + 1;\n    while (\n      streakEnd < recentRuns.length &&\n      String(recentRuns[streakEnd]?.jobId || \"\") ===\n        String(current?.jobId || \"\")\n    ) {\n      streakEnd += 1;\n    }\n    const streak = recentRuns.slice(index, streakEnd);\n    if (streak.length >= kRecentRunCollapseThreshold) {\n      const statusCounts = streak.reduce((accumulator, runEntry) => {\n        const status = String(runEntry?.status || \"unknown\");\n        accumulator[status] = Number(accumulator[status] || 0) + 1;\n        return accumulator;\n      }, {});\n      rows.push({\n        type: \"collapsed-group\",\n        jobId: String(current?.jobId || \"\"),\n        jobName: String(current?.jobName || current?.jobId || \"\"),\n        count: streak.length,\n        newestTs: Number(streak[0]?.ts || 0),\n        oldestTs: Number(streak[streak.length - 1]?.ts || 0),\n        statusCounts,\n        entries: streak,\n      });\n      index = streakEnd;\n      continue;\n    }\n    for (const runEntry of streak) {\n      if (rows.length >= kRecentRunRowsLimit) break;\n      rows.push({\n        type: \"entry\",\n        entry: runEntry,\n      });\n    }\n    index = streakEnd;\n  }\n  return rows;\n};\n\nconst getHashRouteParts = () => {\n  const rawHash = String(window.location.hash || \"\").replace(/^#/, \"\");\n  const hashPath = rawHash || \"/cron\";\n  const [pathPart, queryPart = \"\"] = hashPath.split(\"?\");\n  return {\n    pathPart: pathPart || \"/cron\",\n    params: new URLSearchParams(queryPart),\n  };\n};\n\nconst readTrendFilterFromHash = () => {\n  const { params } = getHashRouteParts();\n  const startMs = Number(params.get(kTrendQueryStartKey) || 0);\n  const endMs = Number(params.get(kTrendQueryEndKey) || 0);\n  const range = String(params.get(kTrendQueryRangeKey) || kTrendRange24h);\n  const label = String(params.get(kTrendQueryLabelKey) || \"\");\n  const hasValidRange =\n    range === kTrendRange24h || range === kTrendRange7d || range === kTrendRange30d;\n  if (\n    !Number.isFinite(startMs) ||\n    !Number.isFinite(endMs) ||\n    endMs <= startMs\n  ) {\n    return null;\n  }\n  return {\n    startMs,\n    endMs,\n    range: hasValidRange ? range : kTrendRange24h,\n    label: label || \"selected period\",\n  };\n};\n\nconst writeTrendFilterToHash = (filterValue = null) => {\n  const { pathPart, params } = getHashRouteParts();\n  if (!filterValue) {\n    params.delete(kTrendQueryStartKey);\n    params.delete(kTrendQueryEndKey);\n    params.delete(kTrendQueryRangeKey);\n    params.delete(kTrendQueryLabelKey);\n  } else {\n    params.set(kTrendQueryStartKey, String(Number(filterValue.startMs || 0)));\n    params.set(kTrendQueryEndKey, String(Number(filterValue.endMs || 0)));\n    params.set(\n      kTrendQueryRangeKey,\n      filterValue.range === kTrendRange30d\n        ? kTrendRange30d\n        : filterValue.range === kTrendRange7d\n          ? kTrendRange7d\n          : kTrendRange24h,\n    );\n    params.set(kTrendQueryLabelKey, String(filterValue.label || \"\"));\n  }\n  const nextQuery = params.toString();\n  const nextHash = nextQuery ? `#${pathPart}?${nextQuery}` : `#${pathPart}`;\n  const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;\n  window.history.replaceState(window.history.state, \"\", nextUrl);\n};\n\nexport const CronOverview = ({\n  jobs = [],\n  bulkUsageByJobId = {},\n  bulkRunsByJobId = {},\n  onSelectJob = () => {},\n}) => {\n  const [recentRunStatusFilter, setRecentRunStatusFilter] = useState(\"all\");\n  const [selectedTrendBucketFilter, setSelectedTrendBucketFilter] = useState(\n    () => readTrendFilterFromHash(),\n  );\n  const enabledCount = jobs.filter((job) => job.enabled !== false).length;\n  const disabledCount = jobs.length - enabledCount;\n  const nextRunMs = getNextScheduledRunAcrossJobs(jobs);\n  const warnings = buildCronOptimizationWarnings(jobs, bulkRunsByJobId);\n  const allRecentRuns = useMemo(\n    () => flattenRecentRuns({ bulkRunsByJobId, jobs }),\n    [bulkRunsByJobId, jobs],\n  );\n  const recentRunsForDisplay = useMemo(\n    () => allRecentRuns.slice(0, kRecentRunFetchLimit),\n    [allRecentRuns],\n  );\n  const timeFilteredRecentRuns = useMemo(() => {\n    if (!selectedTrendBucketFilter) return recentRunsForDisplay;\n    const startMs = Number(selectedTrendBucketFilter?.startMs || 0);\n    const endMs = Number(selectedTrendBucketFilter?.endMs || 0);\n    if (\n      !Number.isFinite(startMs) ||\n      !Number.isFinite(endMs) ||\n      endMs <= startMs\n    ) {\n      return recentRunsForDisplay;\n    }\n    return allRecentRuns.filter((entry) => {\n      const timestampMs = Number(entry?.ts || 0);\n      return (\n        Number.isFinite(timestampMs) &&\n        timestampMs >= startMs &&\n        timestampMs < endMs\n      );\n    });\n  }, [allRecentRuns, recentRunsForDisplay, selectedTrendBucketFilter]);\n  const filteredRecentRuns = useMemo(\n    () =>\n      timeFilteredRecentRuns.filter((entry) =>\n        recentRunStatusFilter === \"all\"\n          ? true\n          : String(entry?.status || \"\")\n              .trim()\n              .toLowerCase() === recentRunStatusFilter,\n      ),\n    [recentRunStatusFilter, timeFilteredRecentRuns],\n  );\n  const recentRunRows = useMemo(\n    () => buildCollapsedRunRows(filteredRecentRuns),\n    [filteredRecentRuns],\n  );\n  const initialTrendRange =\n    selectedTrendBucketFilter?.range === kTrendRange30d\n      ? kTrendRange30d\n      : selectedTrendBucketFilter?.range === kTrendRange7d\n        ? kTrendRange7d\n        : kTrendRange24h;\n  useEffect(() => {\n    writeTrendFilterToHash(selectedTrendBucketFilter);\n  }, [selectedTrendBucketFilter]);\n\n  return html`\n    <div class=\"cron-detail-scroll\">\n      <div class=\"cron-detail-content\">\n        <div class=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n          <${SummaryStatCard}\n            title=\"Total jobs\"\n            value=${jobs.length}\n            monospace=${true}\n          />\n          <${SummaryStatCard}\n            title=\"Enabled\"\n            value=${enabledCount}\n            monospace=${true}\n          />\n          <${SummaryStatCard}\n            title=\"Disabled\"\n            value=${disabledCount}\n            monospace=${true}\n          />\n        </div>\n\n        <section class=\"bg-surface border border-border rounded-xl px-4 py-3\">\n          <details class=\"group\">\n            <summary class=\"list-none cursor-pointer\">\n              <div class=\"flex items-center justify-between gap-2\">\n                <div class=\"inline-flex items-center gap-2 min-w-0\">\n                  <${ErrorWarningLineIcon}\n                    className=\"w-4 h-4 text-status-warning shrink-0\"\n                  />\n                  <div class=\"text-xs text-status-warning truncate\">\n                    ${formatWarningsAttentionText(warnings)}\n                  </div>\n                </div>\n                <span\n                  class=\"text-fg-muted text-xs transition-transform group-open:rotate-90\"\n                  >▸</span\n                >\n              </div>\n            </summary>\n            <div class=\"mt-3\">\n              ${warnings.length === 0\n                ? html`<div class=\"text-xs text-fg-muted\">\n                    No warnings right now.\n                  </div>`\n                : html`\n                    <div class=\"space-y-2\">\n                      ${warnings.map(\n                        (warning, index) => html`\n                          <div\n                            key=${`warning:${index}`}\n                            class=${`rounded-xl border p-3 text-xs ${warningClassName(warning.tone)} ${warning?.jobId ? \"cursor-pointer hover:brightness-110\" : \"\"}`}\n                            role=${warning?.jobId ? \"button\" : null}\n                            tabindex=${warning?.jobId ? \"0\" : null}\n                            onclick=${() => {\n                              if (!warning?.jobId) return;\n                              onSelectJob(warning.jobId);\n                            }}\n                            onKeyDown=${(event) => {\n                              if (!warning?.jobId) return;\n                              if (event.key !== \"Enter\" && event.key !== \" \")\n                                return;\n                              event.preventDefault();\n                              onSelectJob(warning.jobId);\n                            }}\n                          >\n                            <div class=\"font-medium\">${warning.title}</div>\n                            <div class=\"mt-1 opacity-90\">${warning.body}</div>\n                          </div>\n                        `,\n                      )}\n                    </div>\n                  `}\n            </div>\n          </details>\n        </section>\n\n        <${CronCalendar}\n          jobs=${jobs}\n          usageByJobId=${bulkUsageByJobId}\n          runsByJobId=${bulkRunsByJobId}\n          onSelectJob=${onSelectJob}\n        />\n\n        <${CronInsightsPanel}\n          jobs=${jobs}\n          bulkRunsByJobId=${bulkRunsByJobId}\n          onSelectJob=${onSelectJob}\n        />\n\n        <${CronRunsTrendCard}\n          bulkRunsByJobId=${bulkRunsByJobId}\n          initialRange=${initialTrendRange}\n          selectedBucketFilter=${selectedTrendBucketFilter}\n          onBucketFilterChange=${setSelectedTrendBucketFilter}\n        />\n\n        <${CronRunHistoryPanel}\n          entryCountLabel=${`${formatTokenCount(filteredRecentRuns.length)} entries`}\n          primaryFilterOptions=${kRunStatusFilterOptions}\n          primaryFilterValue=${recentRunStatusFilter}\n          onChangePrimaryFilter=${setRecentRunStatusFilter}\n          activeFilterLabel=${selectedTrendBucketFilter?.label || \"\"}\n          onClearActiveFilter=${() => setSelectedTrendBucketFilter(null)}\n          rows=${recentRunRows}\n          variant=\"overview\"\n          onSelectJob=${onSelectJob}\n          showOpenJobButton=${true}\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-prompt-editor.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { EditorSurface } from \"../file-viewer/editor-surface.js\";\nimport { countTextLines, shouldUseSimpleEditorMode } from \"../file-viewer/utils.js\";\nimport {\n  kLargeFileSimpleEditorCharThreshold,\n  kLargeFileSimpleEditorLineThreshold,\n} from \"../file-viewer/constants.js\";\nimport { useEditorLineNumberSync } from \"../file-viewer/use-editor-line-number-sync.js\";\nimport { highlightEditorLines } from \"../../lib/syntax-highlighters/index.js\";\nimport { readUiSettings, writeUiSettings } from \"../../lib/ui-settings.js\";\n\nconst html = htm.bind(h);\nconst kCronPromptEditorHeightUiSettingKey = \"cronPromptEditorHeightPx\";\nconst kCronPromptEditorDefaultHeightPx = 280;\nconst kCronPromptEditorMinHeightPx = 180;\n\nconst clampPromptEditorHeight = (value) => {\n  const parsed = Number(value);\n  const normalized = Number.isFinite(parsed)\n    ? Math.round(parsed)\n    : kCronPromptEditorDefaultHeightPx;\n  return Math.max(kCronPromptEditorMinHeightPx, normalized);\n};\n\nconst readCssHeightPx = (element) => {\n  if (!element) return 0;\n  const computedHeight = Number.parseFloat(\n    window.getComputedStyle(element).height || \"0\",\n  );\n  return Number.isFinite(computedHeight) ? computedHeight : 0;\n};\n\nexport const CronPromptEditor = ({\n  promptValue = \"\",\n  savedPromptValue = \"\",\n  onChangePrompt = () => {},\n  onSaveChanges = () => {},\n}) => {\n  const promptEditorShellRef = useRef(null);\n  const editorTextareaRef = useRef(null);\n  const editorLineNumbersRef = useRef(null);\n  const editorLineNumberRowRefs = useRef([]);\n  const editorHighlightRef = useRef(null);\n  const editorHighlightLineRefs = useRef([]);\n  const [promptEditorHeightPx, setPromptEditorHeightPx] = useState(() => {\n    const settings = readUiSettings();\n    return clampPromptEditorHeight(\n      settings?.[kCronPromptEditorHeightUiSettingKey],\n    );\n  });\n\n  const lineCount = countTextLines(promptValue);\n  const shouldUseHighlightedEditor = !shouldUseSimpleEditorMode({\n    contentLength: promptValue.length,\n    lineCount,\n    charThreshold: kLargeFileSimpleEditorCharThreshold,\n    lineThreshold: kLargeFileSimpleEditorLineThreshold,\n  });\n  const highlightedEditorLines = useMemo(\n    () =>\n      shouldUseHighlightedEditor\n        ? highlightEditorLines(promptValue, \"markdown\")\n        : [],\n    [promptValue, shouldUseHighlightedEditor],\n  );\n  const editorLineCount = Math.max(\n    lineCount,\n    Array.isArray(highlightedEditorLines) ? highlightedEditorLines.length : 0,\n  );\n  const editorLineNumbers = useMemo(\n    () => Array.from({ length: editorLineCount }, (_, index) => index + 1),\n    [editorLineCount],\n  );\n  const isDirty = promptValue !== savedPromptValue;\n\n  useEditorLineNumberSync({\n    enabled: shouldUseHighlightedEditor,\n    syncKey: `${promptValue.length}:${highlightedEditorLines.length}`,\n    editorLineNumberRowRefs,\n    editorHighlightLineRefs,\n  });\n\n  const handleEditorScroll = (event) => {\n    const scrollTop = event.currentTarget.scrollTop;\n    if (editorLineNumbersRef.current)\n      editorLineNumbersRef.current.scrollTop = scrollTop;\n    if (editorHighlightRef.current) {\n      editorHighlightRef.current.scrollTop = scrollTop;\n    }\n  };\n\n  const handleEditorKeyDown = (event) => {\n    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"s\") {\n      event.preventDefault();\n      onSaveChanges();\n    }\n    if (event.key === \"Tab\") {\n      event.preventDefault();\n      const textarea = editorTextareaRef.current;\n      if (!textarea) return;\n      const start = textarea.selectionStart;\n      const end = textarea.selectionEnd;\n      const nextValue = `${promptValue.slice(0, start)}  ${promptValue.slice(end)}`;\n      onChangePrompt(nextValue);\n      window.requestAnimationFrame(() => {\n        textarea.selectionStart = start + 2;\n        textarea.selectionEnd = start + 2;\n      });\n    }\n  };\n\n  useEffect(() => {\n    const shellElement = promptEditorShellRef.current;\n    if (!shellElement || typeof ResizeObserver === \"undefined\") return () => {};\n\n    let saveTimer = null;\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries?.[0];\n      const nextHeight = clampPromptEditorHeight(readCssHeightPx(entry?.target));\n      setPromptEditorHeightPx((currentValue) =>\n        Math.abs(currentValue - nextHeight) >= 1 ? nextHeight : currentValue,\n      );\n      if (saveTimer) window.clearTimeout(saveTimer);\n      saveTimer = window.setTimeout(() => {\n        const settings = readUiSettings();\n        settings[kCronPromptEditorHeightUiSettingKey] = nextHeight;\n        writeUiSettings(settings);\n      }, 120);\n    });\n    observer.observe(shellElement);\n    return () => {\n      observer.disconnect();\n      if (saveTimer) window.clearTimeout(saveTimer);\n    };\n  }, []);\n\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label card-label-bright inline-flex items-center gap-1.5\">\n          Prompt\n          ${isDirty ? html`<span class=\"file-viewer-dirty-dot\"></span>` : null}\n        </h3>\n      </div>\n      <div\n        class=\"cron-prompt-editor-shell\"\n        ref=${promptEditorShellRef}\n        style=${{ height: `${promptEditorHeightPx}px` }}\n      >\n        <${EditorSurface}\n          editorShellClassName=\"file-viewer-editor-shell\"\n          editorLineNumbers=${editorLineNumbers}\n          editorLineNumbersRef=${editorLineNumbersRef}\n          editorLineNumberRowRefs=${editorLineNumberRowRefs}\n          shouldUseHighlightedEditor=${shouldUseHighlightedEditor}\n          highlightedEditorLines=${highlightedEditorLines}\n          editorHighlightRef=${editorHighlightRef}\n          editorHighlightLineRefs=${editorHighlightLineRefs}\n          editorTextareaRef=${editorTextareaRef}\n          renderContent=${promptValue}\n          handleContentInput=${(event) => onChangePrompt(event.target.value)}\n          handleEditorKeyDown=${handleEditorKeyDown}\n          handleEditorScroll=${handleEditorScroll}\n          handleEditorSelectionChange=${() => {}}\n          isEditBlocked=${false}\n          isPreviewOnly=${false}\n        />\n      </div>\n    </section>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-run-history-panel.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport {\n  formatDurationCompactMs,\n  formatLocaleDateTimeWithTodayTime,\n} from \"../../lib/format.js\";\nimport {\n  formatCost,\n  formatTokenCount,\n  getCronRunEstimatedCost,\n  getCronRunTotalTokens,\n} from \"./cron-helpers.js\";\n\nconst html = htm.bind(h);\nconst runStatusClassName = (status = \"\") => {\n  const normalized = String(status || \"\")\n    .trim()\n    .toLowerCase();\n  if (normalized === \"ok\") return \"text-status-success\";\n  if (normalized === \"error\") return \"text-status-error\";\n  if (normalized === \"skipped\") return \"text-status-warning\";\n  return \"text-fg-muted\";\n};\nconst runDeliveryLabel = (run) =>\n  String(run?.deliveryStatus || \"not-requested\");\nconst formatOverviewTimestamp = (timestampMs) =>\n  formatLocaleDateTimeWithTodayTime(timestampMs, {\n    fallback: \"—\",\n    valueIsEpochMs: true,\n  }).replace(\n    /\\s([AP])M\\b/g,\n    (_, marker) => `${String(marker || \"\").toLowerCase()}m`,\n  );\nconst formatDetailTimestamp = (timestampMs) =>\n  formatLocaleDateTimeWithTodayTime(timestampMs, {\n    fallback: \"—\",\n    valueIsEpochMs: true,\n  });\nconst formatRowTimestamp = (timestampMs, variant = \"overview\") =>\n  variant === \"detail\"\n    ? formatDetailTimestamp(timestampMs)\n    : formatOverviewTimestamp(timestampMs);\nconst renderEntrySummaryRow = ({ runEntry = {}, variant = \"overview\" }) => {\n  const runStatus = String(runEntry?.status || \"unknown\");\n  const runTokens = getCronRunTotalTokens(runEntry);\n  const runEstimatedCost = getCronRunEstimatedCost(runEntry);\n  const runTitle = String(runEntry?.jobName || \"\").trim();\n  const hasRunTitle = runTitle.length > 0;\n  const isDetail = variant === \"detail\";\n  return html`\n    <div class=\"ac-history-summary-row\">\n      <span class=\"inline-flex items-center gap-2 min-w-0\">\n        ${isDetail\n          ? html`\n              <span class=\"truncate text-xs text-body\">\n                ${formatRowTimestamp(runEntry.ts, variant)}\n              </span>\n            `\n          : hasRunTitle\n            ? html`\n                <span class=\"inline-flex items-center gap-2 min-w-0\">\n                  <span class=\"truncate text-xs text-body\"\n                    >${runTitle}</span\n                  >\n                  <span class=\"text-xs text-fg-muted shrink-0\">\n                    ${formatRowTimestamp(runEntry.ts, variant)}\n                  </span>\n                </span>\n              `\n            : html`\n                <span class=\"truncate text-xs text-body\">\n                  ${runEntry.jobId} -\n                  ${formatRowTimestamp(runEntry.ts, variant)}\n                </span>\n              `}\n      </span>\n      <span class=\"inline-flex items-center gap-3 shrink-0 text-xs\">\n        <span class=${runStatusClassName(runStatus)}>${runStatus}</span>\n        <span class=\"text-fg-muted\"\n          >${formatDurationCompactMs(runEntry.durationMs)}</span\n        >\n        <span class=\"text-fg-muted\">${formatTokenCount(runTokens)} tk</span>\n        ${isDetail\n          ? html`<span class=\"text-fg-muted\"\n              >${runDeliveryLabel(runEntry)}</span\n            >`\n          : html`\n              <span class=\"text-fg-muted\">\n                ${runEstimatedCost == null\n                  ? \"—\"\n                  : `~${formatCost(runEstimatedCost)}`}\n              </span>\n            `}\n      </span>\n    </div>\n  `;\n};\nconst getCollapsedGroupAggregates = (entries = []) =>\n  entries.reduce(\n    (accumulator, runEntry) => {\n      accumulator.totalTokens += getCronRunTotalTokens(runEntry);\n      const estimatedCost = getCronRunEstimatedCost(runEntry);\n      if (estimatedCost != null) {\n        accumulator.totalCost += estimatedCost;\n        accumulator.hasAnyCost = true;\n      }\n      return accumulator;\n    },\n    { totalTokens: 0, totalCost: 0, hasAnyCost: false },\n  );\nconst renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {\n  const entries = Array.isArray(row?.entries) ? row.entries : [];\n  const { totalTokens, totalCost, hasAnyCost } =\n    getCollapsedGroupAggregates(entries);\n  const timeRangeLabel = `${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}`;\n  return html`\n    <details\n      key=${`collapsed:${rowIndex}:${row.jobId}`}\n      class=\"ac-history-item\"\n    >\n      <summary class=\"ac-history-summary\">\n        <div class=\"ac-history-summary-row\">\n          <span class=\"inline-flex items-center gap-2 min-w-0\">\n            <span class=\"ac-history-toggle shrink-0\" aria-hidden=\"true\">▸</span>\n            <span class=\"inline-flex items-center gap-2 min-w-0\">\n              <span class=\"truncate text-xs text-body\">\n                ${row.jobName} - ${formatTokenCount(row.count)} runs\n              </span>\n              <span class=\"text-xs text-fg-muted shrink-0\"\n                >${timeRangeLabel}</span\n              >\n            </span>\n          </span>\n          <span class=\"inline-flex items-center gap-3 shrink-0 text-xs\">\n            <span class=\"text-fg-muted\"\n              >${formatTokenCount(totalTokens)} tk</span\n            >\n            <span class=\"text-fg-muted\">\n              ${hasAnyCost ? `~${formatCost(totalCost)}` : \"—\"}\n            </span>\n          </span>\n        </div>\n      </summary>\n      <div class=\"border-t border-border pb-2 text-xs\">\n        ${entries.length > 0\n          ? html`\n              <div class=\"ac-history-list ac-history-list-tight\">\n                ${entries.map((runEntry, entryIndex) =>\n                  renderEntryRow({\n                    row: {\n                      type: \"entry\",\n                      entry: runEntry,\n                    },\n                    rowIndex: `${rowIndex}:${entryIndex}`,\n                    variant: \"overview\",\n                    onSelectJob,\n                    showOpenJobButton: false,\n                    itemClassName:\n                      \"ac-history-item ac-history-item-flat border-b border-border rounded-none\",\n                  }),\n                )}\n              </div>\n            `\n          : null}\n        ${row?.jobId\n          ? html`\n              <div class=\"px-2.5 pt-2 pb-0.5\">\n                <button\n                  type=\"button\"\n                  class=\"text-xs px-2 py-1 rounded border border-border text-fg-muted hover:text-body\"\n                  onclick=${() => onSelectJob(row.jobId)}\n                >\n                  Open ${row.jobName || row.jobId}\n                </button>\n              </div>\n            `\n          : null}\n      </div>\n    </details>\n  `;\n};\nconst renderEntryRow = ({\n  row,\n  rowIndex,\n  variant = \"overview\",\n  onSelectJob = () => {},\n  showOpenJobButton = false,\n  itemClassName = \"ac-history-item\",\n}) => {\n  const runEntry = row?.entry || row || {};\n  const runUsage = runEntry?.usage || {};\n  const runInputTokens = Number(\n    runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,\n  );\n  const runOutputTokens = Number(\n    runUsage?.output_tokens ?? runUsage?.outputTokens ?? 0,\n  );\n  const runTokens = getCronRunTotalTokens(runEntry);\n  const runEstimatedCost = getCronRunEstimatedCost(runEntry);\n  return html`\n    <details\n      key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || \"\"}`}\n      class=${itemClassName}\n    >\n      <summary class=\"ac-history-summary\">\n        <div class=\"inline-flex items-center gap-2 min-w-0 w-full\">\n          <span class=\"ac-history-toggle shrink-0\" aria-hidden=\"true\">▸</span>\n          <div class=\"min-w-0 flex-1\">\n            ${renderEntrySummaryRow({ runEntry, variant })}\n          </div>\n        </div>\n      </summary>\n      <div class=\"ac-history-body space-y-2 text-xs\">\n        ${runEntry.summary\n          ? html`<div>\n              <span class=\"text-fg-muted\">Summary:</span> ${runEntry.summary}\n            </div>`\n          : null}\n        ${runEntry.error\n          ? html`<div class=\"text-status-error\">\n              <span class=\"text-fg-muted\">Error:</span> ${runEntry.error}\n            </div>`\n          : null}\n        <div class=\"ac-surface-inset rounded-lg p-2.5 space-y-1.5\">\n          <div class=\"text-fg-muted\">\n            Model:\n            <span class=\"text-body font-mono\"\n              >${runEntry.model || \"—\"}</span\n            >\n          </div>\n          <div class=\"text-fg-muted\">\n            Session:\n            <span class=\"text-body font-mono\"\n              >${runEntry.sessionKey || \"—\"}</span\n            >\n          </div>\n          <div class=\"text-fg-muted\">\n            Tokens in:\n            <span class=\"text-body\"\n              >${formatTokenCount(runInputTokens)}</span\n            >\n          </div>\n          <div class=\"text-fg-muted\">\n            Tokens out:\n            <span class=\"text-body\"\n              >${formatTokenCount(runOutputTokens)}</span\n            >\n          </div>\n          <div class=\"text-fg-muted\">\n            Total tokens:\n            <span class=\"text-body\">${formatTokenCount(runTokens)}</span>\n          </div>\n          <div class=\"text-fg-muted\">\n            Total cost:\n            <span class=\"text-body\">\n              ${runEstimatedCost == null\n                ? \"—\"\n                : `~${formatCost(runEstimatedCost)}`}\n            </span>\n          </div>\n        </div>\n        ${showOpenJobButton && runEntry?.jobId\n          ? html`\n              <div>\n                <button\n                  type=\"button\"\n                  class=\"text-xs px-2 py-1 rounded border border-border text-fg-muted hover:text-body\"\n                  onclick=${() => onSelectJob(runEntry.jobId)}\n                >\n                  Open ${runEntry.jobName || runEntry.jobId}\n                </button>\n              </div>\n            `\n          : null}\n      </div>\n    </details>\n  `;\n};\n\nexport const CronRunHistoryPanel = ({\n  entryCountLabel = \"\",\n  primaryFilterOptions = [],\n  primaryFilterValue = \"all\",\n  onChangePrimaryFilter = () => {},\n  secondaryFilterOptions = [],\n  secondaryFilterValue = \"all\",\n  onChangeSecondaryFilter = () => {},\n  activeFilterLabel = \"\",\n  onClearActiveFilter = () => {},\n  rows = [],\n  emptyText = \"No runs found.\",\n  variant = \"overview\",\n  onSelectJob = () => {},\n  showOpenJobButton = false,\n  footer = null,\n}) => html`\n  <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n    <div class=\"flex items-start justify-between gap-3\">\n      <div class=\"inline-flex items-center gap-3\">\n        <h3 class=\"card-label card-label-bright\">Recent runs</h3>\n        <div class=\"text-xs text-fg-muted\">${entryCountLabel}</div>\n      </div>\n      <div class=\"shrink-0 inline-flex items-center gap-2\">\n        <${SegmentedControl}\n          options=${primaryFilterOptions}\n          value=${primaryFilterValue}\n          onChange=${onChangePrimaryFilter}\n        />\n        ${Array.isArray(secondaryFilterOptions) &&\n        secondaryFilterOptions.length > 0\n          ? html`\n              <${SegmentedControl}\n                options=${secondaryFilterOptions}\n                value=${secondaryFilterValue}\n                onChange=${onChangeSecondaryFilter}\n              />\n            `\n          : null}\n      </div>\n    </div>\n    ${activeFilterLabel\n      ? html`\n          <div class=\"flex items-center\">\n            <span\n              class=\"inline-flex items-center gap-1.5 text-xs pl-2.5 pr-2 py-1 rounded-full border border-border text-body bg-field\"\n            >\n              Filtered to ${activeFilterLabel}\n              <button\n                type=\"button\"\n                class=\"text-fg-muted hover:text-body leading-none\"\n                onclick=${onClearActiveFilter}\n                aria-label=\"Clear trend filter\"\n              >\n                ×\n              </button>\n            </span>\n          </div>\n        `\n      : null}\n    ${rows.length === 0\n      ? html`<div class=\"text-sm text-fg-muted\">${emptyText}</div>`\n      : html`\n          <div class=\"ac-history-list\">\n            ${rows.map((row, rowIndex) =>\n              row?.type === \"collapsed-group\"\n                ? renderCollapsedGroupRow({ row, rowIndex, onSelectJob })\n                : renderEntryRow({\n                    row,\n                    rowIndex,\n                    variant,\n                    onSelectJob,\n                    showOpenJobButton,\n                  }),\n            )}\n          </div>\n        `}\n    ${footer}\n  </section>\n`;\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/cron-runs-trend-card.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport Chart from \"chart.js/auto\";\nimport { formatChartBucketLabel } from \"../../lib/format.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport {\n  formatCost,\n  formatTokenCount,\n  getCronRunEstimatedCost,\n  getCronRunTotalTokens,\n} from \"./cron-helpers.js\";\n\nconst html = htm.bind(h);\n\nconst kRange24h = \"24h\";\nconst kRange7d = \"7d\";\nconst kRange30d = \"30d\";\n\nconst kRanges = [\n  { label: \"24h\", value: kRange24h },\n  { label: \"7d\", value: kRange7d },\n  { label: \"30d\", value: kRange30d },\n];\nconst kMetricOutcomes = \"outcomes\";\nconst kMetricTokens = \"tokens\";\nconst kMetricCost = \"cost\";\nconst kMetricOptions = [\n  { label: \"outcomes\", value: kMetricOutcomes },\n  { label: \"tokens\", value: kMetricTokens },\n  { label: \"cost\", value: kMetricCost },\n];\n\nconst startOfLocalDayMs = (valueMs) => {\n  const dateValue = new Date(valueMs);\n  dateValue.setHours(0, 0, 0, 0);\n  return dateValue.getTime();\n};\n\nconst addLocalDaysMs = (valueMs, dayCount = 0) => {\n  const dateValue = new Date(valueMs);\n  dateValue.setDate(dateValue.getDate() + Number(dayCount || 0));\n  return dateValue.getTime();\n};\n\nconst getBucketConfig = (range = kRange7d) => {\n  if (range === kRange24h) {\n    return {\n      bucketCount: 24,\n      bucketMs: 60 * 60 * 1000,\n      formatLabel: (valueMs) =>\n        formatChartBucketLabel(valueMs, { range: kRange24h, valueType: \"epoch-ms\" }),\n      showLabel: (_, index, total) => index % 3 === 0 || index === total - 1,\n      alignToLocalDay: false,\n    };\n  }\n  if (range === kRange30d) {\n    return {\n      bucketCount: 30,\n      bucketMs: 24 * 60 * 60 * 1000,\n      formatLabel: (valueMs) =>\n        formatChartBucketLabel(valueMs, { range: kRange30d, valueType: \"epoch-ms\" }),\n      showLabel: (_, index, total) => index % 5 === 0 || index === total - 1,\n      alignToLocalDay: true,\n    };\n  }\n  return {\n    bucketCount: 7,\n    bucketMs: 24 * 60 * 60 * 1000,\n      formatLabel: (valueMs) =>\n        formatChartBucketLabel(valueMs, { range: kRange7d, valueType: \"epoch-ms\" }),\n    showLabel: () => true,\n    alignToLocalDay: true,\n  };\n};\n\nconst buildTrendData = ({ bulkRunsByJobId = {}, nowMs = Date.now(), range = kRange7d } = {}) => {\n  const config = getBucketConfig(range);\n  const safeNowMs = Number.isFinite(Number(nowMs)) ? Number(nowMs) : Date.now();\n  const baseStartMs = config.alignToLocalDay\n    ? addLocalDaysMs(startOfLocalDayMs(safeNowMs), -(config.bucketCount - 1))\n    : safeNowMs - config.bucketCount * config.bucketMs;\n  const points = Array.from({ length: config.bucketCount }, (_, index) => {\n    const startMs = config.alignToLocalDay\n      ? addLocalDaysMs(baseStartMs, index)\n      : baseStartMs + index * config.bucketMs;\n    const endMs = index === config.bucketCount - 1\n      ? safeNowMs\n      : config.alignToLocalDay\n        ? addLocalDaysMs(baseStartMs, index + 1)\n        : baseStartMs + (index + 1) * config.bucketMs;\n    return {\n      key: `trend-point-${index}`,\n      startMs,\n      endMs,\n      ok: 0,\n      error: 0,\n      skipped: 0,\n      totalTokens: 0,\n      totalCost: 0,\n      costCount: 0,\n    };\n  });\n  const dayKeyToIndex = config.alignToLocalDay\n    ? new Map(\n        points.map((point, index) => [startOfLocalDayMs(point.startMs), index]),\n      )\n    : null;\n  const windowStartMs = points[0]?.startMs || baseStartMs;\n\n  Object.values(bulkRunsByJobId || {}).forEach((runResult) => {\n    const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];\n    entries.forEach((entry) => {\n      const timestampMs = Number(entry?.ts || 0);\n      if (!Number.isFinite(timestampMs) || timestampMs < windowStartMs || timestampMs > safeNowMs) return;\n      const status = String(entry?.status || \"\").trim().toLowerCase();\n      if (![\"ok\", \"error\", \"skipped\"].includes(status)) return;\n      const bucketIndex = config.alignToLocalDay\n        ? dayKeyToIndex?.get(startOfLocalDayMs(timestampMs))\n        : Math.floor((timestampMs - windowStartMs) / config.bucketMs);\n      if (!Number.isFinite(Number(bucketIndex))) return;\n      if (bucketIndex < 0 || bucketIndex >= config.bucketCount) return;\n      points[bucketIndex][status] += 1;\n      points[bucketIndex].totalTokens += getCronRunTotalTokens(entry);\n      const estimatedCost = getCronRunEstimatedCost(entry);\n      if (estimatedCost != null) {\n        points[bucketIndex].totalCost += estimatedCost;\n        points[bucketIndex].costCount += 1;\n      }\n    });\n  });\n\n  const normalizedPoints = points.map((point, index) => {\n    const total = point.ok + point.error + point.skipped;\n    return {\n      ...point,\n      total,\n      label: config.formatLabel(point.startMs),\n      showLabel: config.showLabel(point, index, points.length),\n    };\n  });\n\n  return {\n    points: normalizedPoints,\n    maxTotal: Math.max(1, ...normalizedPoints.map((point) => point.total)),\n  };\n};\n\nexport const CronRunsTrendCard = ({\n  bulkRunsByJobId = {},\n  initialRange = kRange24h,\n  selectedBucketFilter = null,\n  onBucketFilterChange = () => {},\n}) => {\n  const chartCanvasRef = useRef(null);\n  const chartInstanceRef = useRef(null);\n  const [metric, setMetric] = useState(kMetricOutcomes);\n  const [range, setRange] = useState(\n    initialRange === kRange30d\n      ? kRange30d\n      : initialRange === kRange7d\n        ? kRange7d\n        : kRange24h,\n  );\n  const trend = useMemo(\n    () => buildTrendData({ bulkRunsByJobId, nowMs: Date.now(), range }),\n    [bulkRunsByJobId, range],\n  );\n  useEffect(() => {\n    onBucketFilterChange(null);\n  }, [range, onBucketFilterChange]);\n  const selectedBucketKey = useMemo(() => {\n    if (!selectedBucketFilter) return \"\";\n    if (selectedBucketFilter.range !== range) return \"\";\n    const matchingPoint = trend.points.find(\n      (point) =>\n        Number(point.startMs) === Number(selectedBucketFilter.startMs) &&\n        Number(point.endMs) === Number(selectedBucketFilter.endMs),\n    );\n    return matchingPoint?.key || \"\";\n  }, [range, selectedBucketFilter, trend.points]);\n  const selectedPointIndex = useMemo(\n    () => trend.points.findIndex((point) => point.key === selectedBucketKey),\n    [selectedBucketKey, trend.points],\n  );\n\n  const chartData = useMemo(() => {\n    const dimAlpha = \"0.22\";\n    const fullAlpha = \"0.86\";\n    const isDimmed = (index) => selectedPointIndex >= 0 && selectedPointIndex !== index;\n    const labels = trend.points.map((point) => (point.showLabel ? point.label : \"\"));\n    if (metric === kMetricOutcomes) {\n      return {\n        labels,\n        datasets: [\n          {\n            label: \"ok\",\n            data: trend.points.map((point) => Number(point.ok || 0)),\n            stack: \"outcomes\",\n            backgroundColor: trend.points.map((_, index) =>\n              `rgba(34,255,170,${isDimmed(index) ? dimAlpha : fullAlpha})`),\n            borderColor: trend.points.map((_, index) =>\n              `rgba(34,255,170,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n            borderWidth: 1,\n            borderRadius: 0,\n            borderSkipped: false,\n          },\n          {\n            label: \"error\",\n            data: trend.points.map((point) => Number(point.error || 0)),\n            stack: \"outcomes\",\n            backgroundColor: trend.points.map((_, index) =>\n              `rgba(255,74,138,${isDimmed(index) ? dimAlpha : fullAlpha})`),\n            borderColor: trend.points.map((_, index) =>\n              `rgba(255,74,138,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n            borderWidth: 1,\n            borderRadius: 0,\n            borderSkipped: false,\n          },\n          {\n            label: \"skipped\",\n            data: trend.points.map((point) => Number(point.skipped || 0)),\n            stack: \"outcomes\",\n            backgroundColor: trend.points.map((_, index) =>\n              `rgba(255,214,64,${isDimmed(index) ? dimAlpha : fullAlpha})`),\n            borderColor: trend.points.map((_, index) =>\n              `rgba(255,214,64,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n            borderWidth: 1,\n            borderRadius: 0,\n            borderSkipped: false,\n          },\n        ],\n      };\n    }\n    const datasetLabel = metric === kMetricTokens ? \"tokens\" : \"cost\";\n    return {\n      labels,\n      datasets: [\n        {\n          label: datasetLabel,\n          data: trend.points.map((point) =>\n            metric === kMetricTokens\n              ? Number(point.totalTokens || 0)\n              : Number(point.totalCost || 0)),\n          backgroundColor: trend.points.map((_, index) =>\n            metric === kMetricTokens\n              ? `rgba(34,211,238,${isDimmed(index) ? dimAlpha : \"0.72\"})`\n              : `rgba(167,139,250,${isDimmed(index) ? dimAlpha : \"0.72\"})`),\n          borderColor: trend.points.map((_, index) =>\n            metric === kMetricTokens\n              ? `rgba(34,211,238,${isDimmed(index) ? \"0.35\" : \"1\"})`\n              : `rgba(167,139,250,${isDimmed(index) ? \"0.35\" : \"1\"})`),\n          borderWidth: 1,\n          borderRadius: 0,\n          borderSkipped: false,\n        },\n      ],\n    };\n  }, [metric, selectedPointIndex, trend.points]);\n\n  useEffect(() => {\n    const canvas = chartCanvasRef.current;\n    if (!canvas || !Chart) return;\n    if (chartInstanceRef.current) {\n      chartInstanceRef.current.destroy();\n      chartInstanceRef.current = null;\n    }\n    const getBucketFilter = (index) => {\n      const selectedPoint = trend.points[index];\n      if (!selectedPoint) return null;\n      return {\n        key: selectedPoint.key,\n        label: selectedPoint.label,\n        range,\n        startMs: Number(selectedPoint.startMs || 0),\n        endMs: Number(selectedPoint.endMs || 0),\n      };\n    };\n    chartInstanceRef.current = new Chart(canvas, {\n      type: \"bar\",\n      data: chartData,\n      options: {\n        responsive: true,\n        maintainAspectRatio: false,\n        interaction: { mode: \"index\", intersect: false },\n        animation: false,\n        onHover: (event, elements) => {\n          const target = event?.native?.target;\n          if (!target || !target.style) return;\n          target.style.cursor = Array.isArray(elements) && elements.length > 0\n            ? \"pointer\"\n            : \"default\";\n        },\n        onClick: (_event, elements) => {\n          const index = Number(elements?.[0]?.index);\n          if (!Number.isFinite(index)) return;\n          const nextFilter = getBucketFilter(index);\n          if (!nextFilter) return;\n          if (nextFilter.key === selectedBucketKey) {\n            onBucketFilterChange(null);\n            return;\n          }\n          onBucketFilterChange(nextFilter);\n        },\n        scales: {\n          x: {\n            stacked: metric === kMetricOutcomes,\n            grid: { color: \"rgba(148,163,184,0.08)\" },\n            ticks: {\n              color: \"rgba(156,163,175,1)\",\n              maxRotation: 0,\n              autoSkip: false,\n            },\n          },\n          y: {\n            stacked: metric === kMetricOutcomes,\n            beginAtZero: true,\n            grid: { color: \"rgba(148,163,184,0.12)\" },\n            ticks: {\n              precision: metric === kMetricCost ? undefined : 0,\n              color: \"rgba(156,163,175,1)\",\n              callback: (value) =>\n                metric === kMetricCost\n                  ? formatCost(Number(value || 0))\n                  : formatTokenCount(Number(value || 0)),\n            },\n          },\n        },\n        plugins: {\n          legend: {\n            position: \"bottom\",\n            labels: {\n              color: \"rgba(209,213,219,1)\",\n              boxWidth: 10,\n              boxHeight: 10,\n            },\n          },\n          tooltip: {\n            callbacks: {\n              title: (items) => String(items?.[0]?.label || \"\"),\n              label: (context) => {\n                const value = Number(context.parsed.y || 0);\n                if (metric === kMetricCost) {\n                  return `${context.dataset.label}: ${formatCost(value)}`;\n                }\n                return `${context.dataset.label}: ${formatTokenCount(value)}`;\n              },\n              footer: (items) => {\n                const index = Number(items?.[0]?.dataIndex);\n                const point = trend.points[index];\n                if (!point) return \"\";\n                const costLabel =\n                  point.costCount > 0 ? `~${formatCost(point.totalCost)}` : \"—\";\n                const tokensLabel = formatTokenCount(point.totalTokens || 0);\n                return `runs: ${point.total}\\ntokens: ${tokensLabel}\\ncost: ${costLabel}`;\n              },\n            },\n          },\n        },\n      },\n    });\n    return () => {\n      if (chartInstanceRef.current) {\n        chartInstanceRef.current.destroy();\n        chartInstanceRef.current = null;\n      }\n    };\n  }, [chartData, metric, onBucketFilterChange, range, selectedBucketKey, trend.points]);\n\n  return html`\n    <section class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <h3 class=\"card-label cron-calendar-title\">Trends</h3>\n        <div class=\"flex items-center gap-2\">\n          <${SegmentedControl}\n            options=${kMetricOptions}\n            value=${metric}\n            onChange=${setMetric}\n          />\n          <${SegmentedControl}\n            options=${kRanges}\n            value=${range}\n            onChange=${setRange}\n          />\n        </div>\n      </div>\n      <div class=\"h-40\">\n        <canvas ref=${chartCanvasRef}></canvas>\n      </div>\n    </section>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/index.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { AlarmLineIcon } from \"../icons.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { CronJobList } from \"./cron-job-list.js\";\nimport { CronJobDetail } from \"./cron-job-detail.js\";\nimport { CronOverview } from \"./cron-overview.js\";\nimport { kAllCronJobsRouteKey } from \"./cron-helpers.js\";\nimport { useCronTab } from \"./use-cron-tab.js\";\n\nconst html = htm.bind(h);\n\nexport const CronTab = ({ jobId = \"\", onSetLocation = () => {} }) => {\n  const { state, actions } = useCronTab({ jobId, onSetLocation });\n  const [showJobSelector, setShowJobSelector] = useState(false);\n  const selectorShellRef = useRef(null);\n  const isAllJobsSelected = state.selectedRouteKey === kAllCronJobsRouteKey;\n  const noJobs = state.jobs.length === 0;\n  const selectedJob = state.selectedJob;\n  const selectedJobLabel = useMemo(() => {\n    if (isAllJobsSelected) return \"All jobs\";\n    const selectedJob = state.jobs.find(\n      (job) => String(job?.id || \"\") === String(state.selectedRouteKey || \"\"),\n    );\n    return String(selectedJob?.name || selectedJob?.id || \"All jobs\");\n  }, [isAllJobsSelected, state.jobs, state.selectedRouteKey]);\n  const hasUnsavedDetailChanges = useMemo(() => {\n    if (isAllJobsSelected || !selectedJob) return false;\n    const sessionTarget = String(\n      state.routingDraft?.sessionTarget || selectedJob?.sessionTarget || \"main\",\n    );\n    const wakeMode = String(\n      state.routingDraft?.wakeMode || selectedJob?.wakeMode || \"now\",\n    );\n    const deliveryMode = String(\n      state.routingDraft?.deliveryMode || selectedJob?.delivery?.mode || \"none\",\n    );\n    const currentSessionTarget = String(selectedJob?.sessionTarget || \"main\");\n    const currentWakeMode = String(selectedJob?.wakeMode || \"now\");\n    const currentDeliveryMode = String(selectedJob?.delivery?.mode || \"none\");\n    const isRoutingDirty =\n      sessionTarget !== currentSessionTarget ||\n      wakeMode !== currentWakeMode ||\n      deliveryMode !== currentDeliveryMode;\n    const isPromptDirty = state.promptValue !== state.savedPromptValue;\n    return isRoutingDirty || isPromptDirty;\n  }, [\n    isAllJobsSelected,\n    selectedJob,\n    state.promptValue,\n    state.routingDraft?.deliveryMode,\n    state.routingDraft?.sessionTarget,\n    state.routingDraft?.wakeMode,\n    state.savedPromptValue,\n  ]);\n\n  useEffect(() => {\n    if (!showJobSelector) return () => {};\n    const handlePointerDown = (event) => {\n      if (selectorShellRef.current?.contains(event.target)) return;\n      setShowJobSelector(false);\n    };\n    const handleKeyDown = (event) => {\n      if (event.key !== \"Escape\") return;\n      setShowJobSelector(false);\n    };\n    window.addEventListener(\"pointerdown\", handlePointerDown);\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      window.removeEventListener(\"pointerdown\", handlePointerDown);\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [showJobSelector]);\n\n  const handleSelectAllJobs = () => {\n    actions.selectAllJobs();\n    setShowJobSelector(false);\n  };\n\n  const handleSelectJob = (nextJobId) => {\n    actions.selectJob(nextJobId);\n    setShowJobSelector(false);\n  };\n\n  return html`\n    <div class=\"cron-tab-shell\">\n      <div class=\"cron-tab-header\">\n        <div class=\"cron-tab-header-content\">\n          <${PageHeader}\n            leading=${html`\n              <div class=\"cron-tab-selector-shell\" ref=${selectorShellRef}>\n                <button\n                  type=\"button\"\n                  class=${`cron-tab-selector-toggle ${showJobSelector ? \"is-open\" : \"\"}`}\n                  onClick=${() => setShowJobSelector((value) => !value)}\n                  aria-expanded=${showJobSelector}\n                  aria-haspopup=\"listbox\"\n                >\n                  <span class=\"cron-tab-selector-title\">${selectedJobLabel}</span>\n                  <span class=\"cron-tab-selector-caret\">▾</span>\n                </button>\n                ${showJobSelector\n                  ? html`\n                      <div class=\"cron-tab-selector-dropdown\">\n                        <${CronJobList}\n                          jobs=${state.jobs}\n                          selectedRouteKey=${state.selectedRouteKey}\n                          onSelectAllJobs=${handleSelectAllJobs}\n                          onSelectJob=${handleSelectJob}\n                        />\n                      </div>\n                    `\n                  : null}\n              </div>\n            `}\n            actions=${html`\n              ${isAllJobsSelected || noJobs\n                ? html`\n                    <${ActionButton}\n                      onClick=${actions.refreshAll}\n                      tone=\"secondary\"\n                      size=\"sm\"\n                      idleLabel=\"Refresh\"\n                    />\n                  `\n                : html`\n                    <${ActionButton}\n                      onClick=${actions.saveChanges}\n                      loading=${state.savingChanges}\n                      disabled=${!hasUnsavedDetailChanges}\n                      tone=\"primary\"\n                      size=\"sm\"\n                      idleLabel=\"Save changes\"\n                      loadingLabel=\"Saving...\"\n                    />\n                  `}\n            `}\n          />\n        </div>\n      </div>\n      <div class=\"cron-tab-main\">\n        <div class=\"cron-tab-main-content\">\n          <main class=\"cron-detail-panel\">\n            ${noJobs\n              ? html`\n                  <div\n                    class=\"bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center\"\n                  >\n                    <div class=\"max-w-md w-full flex flex-col items-center gap-4\">\n                      <${AlarmLineIcon} className=\"h-12 w-12 text-cyan-400\" />\n                      <div class=\"space-y-2\">\n                        <h2 class=\"font-semibold text-lg text-bright\">\n                          No cron jobs yet\n                        </h2>\n                        <p class=\"text-xs text-fg-muted leading-5\">\n                          Cron jobs are managed via the OpenClaw CLI. Once jobs are\n                          configured, schedules and run history will appear here.\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                `\n              : isAllJobsSelected\n                ? html`\n                    <${CronOverview}\n                      jobs=${state.jobs}\n                      status=${state.status}\n                      bulkUsageByJobId=${state.bulkUsageByJobId}\n                      bulkRunsByJobId=${state.bulkRunsByJobId}\n                      onSelectJob=${handleSelectJob}\n                    />\n                  `\n                : html`\n                    <${CronJobDetail}\n                      job=${state.selectedJob}\n                      runEntries=${state.runEntries}\n                      filteredRunEntries=${state.filteredRunEntries}\n                      runTotal=${state.runTotal}\n                      runHasMore=${state.runHasMore}\n                      loadingMoreRuns=${state.loadingMoreRuns}\n                      runStatusFilter=${state.runStatusFilter}\n                      onSetRunStatusFilter=${actions.setRunStatusFilter}\n                      onLoadMoreRuns=${actions.loadMoreRuns}\n                      onRunNow=${actions.runSelectedJobNow}\n                      runningJob=${state.runningJob}\n                      onToggleEnabled=${actions.setSelectedJobEnabled}\n                      togglingJobEnabled=${state.togglingJobEnabled}\n                      usage=${state.usage}\n                      jobTrends=${state.jobTrends}\n                      jobTrendRange=${state.jobTrendRange}\n                      selectedJobTrendBucketFilter=${state.selectedJobTrendBucketFilter}\n                      usageDays=${state.usageDays}\n                      onSetUsageDays=${actions.setUsageDays}\n                      onSetJobTrendRange=${actions.setJobTrendRange}\n                      onSetSelectedJobTrendBucketFilter=${actions.setSelectedJobTrendBucketFilter}\n                      promptValue=${state.promptValue}\n                      savedPromptValue=${state.savedPromptValue}\n                      onChangePrompt=${actions.setPromptValue}\n                      onSaveChanges=${actions.saveChanges}\n                      savingChanges=${state.savingChanges}\n                      routingDraft=${state.routingDraft}\n                      onChangeRoutingDraft=${actions.setRoutingDraft}\n                      deliverySessions=${state.deliverySessions}\n                      loadingDeliverySessions=${state.loadingDeliverySessions}\n                      deliverySessionsError=${state.deliverySessionsError}\n                      destinationSessionKey=${state.destinationSessionKey}\n                      onChangeDestinationSessionKey=${actions.setDestinationSessionKey}\n                    />\n                  `}\n          </main>\n        </div>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/cron-tab/use-cron-tab.js",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport { useDestinationSessionSelection } from \"../../hooks/use-destination-session-selection.js\";\nimport {\n  fetchCronBulkRuns,\n  fetchCronBulkUsage,\n  fetchCronJobRuns,\n  fetchCronJobTrends,\n  fetchCronJobs,\n  fetchCronJobUsage,\n  fetchCronStatus,\n  setCronJobEnabled,\n  triggerCronJobRun,\n  updateCronJobPrompt,\n  updateCronJobRouting,\n} from \"../../lib/api.js\";\nimport { readUiSettings, writeUiSettings } from \"../../lib/ui-settings.js\";\nimport { showToast } from \"../toast.js\";\nimport { kAllCronJobsRouteKey, readCronJobPrompt } from \"./cron-helpers.js\";\n\nconst kDefaultListPanelWidthPx = 372;\nconst kListPanelMinWidthPx = 220;\nconst kListPanelMaxWidthPx = 480;\nconst kListPanelWidthUiSettingKey = \"cronListPanelWidthPx\";\nconst kRunsPageSize = 25;\nconst kCalendarUsageDays = 30;\nconst kCalendarPastDays = 30;\nconst kTrendRange24h = \"24h\";\nconst kTrendRange7d = \"7d\";\nconst kTrendRange30d = \"30d\";\nconst kRoutingDefaults = {\n  sessionTarget: \"main\",\n  wakeMode: \"now\",\n  deliveryMode: \"none\",\n  deliveryChannel: \"\",\n  deliveryTo: \"\",\n};\nconst readRoutingDraftFromJob = (job = null) => ({\n  sessionTarget: String(job?.sessionTarget || kRoutingDefaults.sessionTarget),\n  wakeMode: String(job?.wakeMode || kRoutingDefaults.wakeMode),\n  deliveryMode: String(job?.delivery?.mode || kRoutingDefaults.deliveryMode),\n  deliveryChannel: String(job?.delivery?.channel || \"\"),\n  deliveryTo: String(job?.delivery?.to || \"\"),\n});\n\nconst clampListPanelWidth = (value) =>\n  Math.max(kListPanelMinWidthPx, Math.min(kListPanelMaxWidthPx, value));\n\nconst normalizeRouteJobId = (jobId = \"\") => {\n  const normalized = String(jobId || \"\").trim();\n  return normalized || kAllCronJobsRouteKey;\n};\n\nexport const useCronTab = ({ jobId = \"\", onSetLocation = () => {} } = {}) => {\n  const selectedRouteKey = normalizeRouteJobId(jobId);\n  const selectedJobId =\n    selectedRouteKey === kAllCronJobsRouteKey ? \"\" : selectedRouteKey;\n  const listPanelRef = useRef(null);\n  const [listPanelWidthPx, setListPanelWidthPx] = useState(() => {\n    const settings = readUiSettings();\n    if (!Number.isFinite(settings?.[kListPanelWidthUiSettingKey])) {\n      return kDefaultListPanelWidthPx;\n    }\n    return clampListPanelWidth(settings[kListPanelWidthUiSettingKey]);\n  });\n  const [isResizingListPanel, setIsResizingListPanel] = useState(false);\n  const [runStatusFilter, setRunStatusFilter] = useState(\"all\");\n  const [runEntries, setRunEntries] = useState([]);\n  const [runHasMore, setRunHasMore] = useState(false);\n  const [runNextOffset, setRunNextOffset] = useState(0);\n  const [runTotal, setRunTotal] = useState(0);\n  const [loadingMoreRuns, setLoadingMoreRuns] = useState(false);\n  const [promptValue, setPromptValue] = useState(\"\");\n  const [savedPromptValue, setSavedPromptValue] = useState(\"\");\n  const [savingChanges, setSavingChanges] = useState(false);\n  const [runningJob, setRunningJob] = useState(false);\n  const [togglingJobEnabled, setTogglingJobEnabled] = useState(false);\n  const [routingDraft, setRoutingDraft] = useState(kRoutingDefaults);\n  const [usageDays, setUsageDays] = useState(30);\n  const [jobTrendRange, setJobTrendRange] = useState(kTrendRange7d);\n  const [selectedJobTrendBucketFilter, setSelectedJobTrendBucketFilter] = useState(null);\n  const {\n    sessions: deliverySessions,\n    loading: loadingDeliverySessions,\n    error: deliverySessionsError,\n    destinationSessionKey,\n    setDestinationSessionKey,\n    selectedDestination,\n  } = useDestinationSessionSelection({\n    enabled: !!selectedJobId,\n    resetKey: String(selectedJobId || \"\"),\n  });\n\n  const jobsPoll = usePolling(\n    () => fetchCronJobs({ sortBy: \"nextRunAtMs\", sortDir: \"asc\" }),\n    15000,\n  );\n  const statusPoll = usePolling(fetchCronStatus, 30000);\n  const runsPoll = usePolling(\n    () => {\n      if (!selectedJobId) {\n        return Promise.resolve({\n          ok: true,\n          runs: { entries: [], hasMore: false, nextOffset: 0 },\n        });\n      }\n      return fetchCronJobRuns(selectedJobId, {\n        limit: kRunsPageSize,\n        offset: 0,\n        status: runStatusFilter,\n        sortDir: \"desc\",\n      });\n    },\n    10000,\n    { enabled: !!selectedJobId },\n  );\n  const usagePoll = usePolling(\n    () => {\n      if (!selectedJobId) return Promise.resolve({ ok: true, usage: null });\n      return fetchCronJobUsage(selectedJobId, { days: usageDays });\n    },\n    60000,\n    { enabled: !!selectedJobId },\n  );\n  const trendsPoll = usePolling(\n    () => {\n      if (!selectedJobId) return Promise.resolve({ ok: true, trends: null });\n      return fetchCronJobTrends(selectedJobId, { range: jobTrendRange });\n    },\n    60000,\n    { enabled: !!selectedJobId },\n  );\n  const bulkUsagePoll = usePolling(\n    () => fetchCronBulkUsage({ days: kCalendarUsageDays }),\n    60000,\n    { enabled: !selectedJobId },\n  );\n  const bulkRunsPoll = usePolling(\n    () =>\n      fetchCronBulkRuns({\n        sinceMs: Date.now() - kCalendarPastDays * 24 * 60 * 60 * 1000,\n        limitPerJob: 1200,\n      }),\n    30000,\n    { enabled: !selectedJobId },\n  );\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings[kListPanelWidthUiSettingKey] = listPanelWidthPx;\n    writeUiSettings(settings);\n  }, [listPanelWidthPx]);\n\n  useEffect(() => {\n    if (!runsPoll.data?.runs) return;\n    setRunEntries(\n      Array.isArray(runsPoll.data.runs.entries)\n        ? runsPoll.data.runs.entries\n        : [],\n    );\n    setRunHasMore(!!runsPoll.data.runs.hasMore);\n    setRunNextOffset(Number(runsPoll.data.runs.nextOffset || 0));\n    setRunTotal(Number(runsPoll.data.runs.total || 0));\n  }, [runsPoll.data]);\n\n  const jobs = useMemo(\n    () => (Array.isArray(jobsPoll.data?.jobs) ? jobsPoll.data.jobs : []),\n    [jobsPoll.data],\n  );\n\n  const selectedJob = useMemo(\n    () => jobs.find((job) => String(job?.id || \"\") === selectedJobId) || null,\n    [jobs, selectedJobId],\n  );\n  const selectedJobPrompt = readCronJobPrompt(selectedJob);\n\n  useEffect(() => {\n    if (!selectedJobId) {\n      setPromptValue(\"\");\n      setSavedPromptValue(\"\");\n      setRoutingDraft(kRoutingDefaults);\n      return;\n    }\n    const prompt = selectedJobPrompt;\n    setPromptValue(prompt);\n    setSavedPromptValue(prompt);\n    setRoutingDraft(readRoutingDraftFromJob(selectedJob));\n  }, [selectedJobId, selectedJobPrompt]);\n\n  useEffect(() => {\n    if (!selectedJobId) return;\n    setRoutingDraft(readRoutingDraftFromJob(selectedJob));\n  }, [\n    selectedJobId,\n    selectedJob?.sessionTarget,\n    selectedJob?.wakeMode,\n    selectedJob?.delivery?.mode,\n  ]);\n\n  useEffect(() => {\n    setRunEntries([]);\n    setRunHasMore(false);\n    setRunNextOffset(0);\n    setRunTotal(0);\n    if (!selectedJobId) return;\n    runsPoll.refresh();\n  }, [selectedJobId, runStatusFilter]);\n\n  useEffect(() => {\n    if (!selectedJobId) return;\n    usagePoll.refresh();\n  }, [selectedJobId, usageDays]);\n  useEffect(() => {\n    if (!selectedJobId) return;\n    setSelectedJobTrendBucketFilter(null);\n    trendsPoll.refresh();\n  }, [jobTrendRange, selectedJobId]);\n  const filteredRunEntries = useMemo(() => {\n    const entries = Array.isArray(runEntries) ? runEntries : [];\n    const filterValue = selectedJobTrendBucketFilter;\n    if (!filterValue) return entries;\n    const startMs = Number(filterValue?.startMs || 0);\n    const endMs = Number(filterValue?.endMs || 0);\n    if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {\n      return entries;\n    }\n    return entries.filter((entry) => {\n      const timestampMs = Number(entry?.ts || 0);\n      return (\n        Number.isFinite(timestampMs) &&\n        timestampMs >= startMs &&\n        timestampMs < endMs\n      );\n    });\n  }, [runEntries, selectedJobTrendBucketFilter]);\n\n  const resizeListPanelWithClientX = useCallback((clientX) => {\n    const listPanelElement = listPanelRef.current;\n    if (!listPanelElement) return;\n    const parentBounds =\n      listPanelElement.parentElement?.getBoundingClientRect();\n    if (!parentBounds) return;\n    const nextWidth = clampListPanelWidth(\n      Math.round(clientX - parentBounds.left),\n    );\n    setListPanelWidthPx(nextWidth);\n  }, []);\n\n  const onListResizerPointerDown = useCallback(\n    (event) => {\n      event.preventDefault();\n      setIsResizingListPanel(true);\n      resizeListPanelWithClientX(event.clientX);\n    },\n    [resizeListPanelWithClientX],\n  );\n\n  useEffect(() => {\n    if (!isResizingListPanel) return () => {};\n    const onPointerMove = (event) => resizeListPanelWithClientX(event.clientX);\n    const onPointerUp = () => setIsResizingListPanel(false);\n    window.addEventListener(\"pointermove\", onPointerMove);\n    window.addEventListener(\"pointerup\", onPointerUp);\n    const previousUserSelect = document.body.style.userSelect;\n    const previousCursor = document.body.style.cursor;\n    document.body.style.userSelect = \"none\";\n    document.body.style.cursor = \"col-resize\";\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n      document.body.style.userSelect = previousUserSelect;\n      document.body.style.cursor = previousCursor;\n    };\n  }, [isResizingListPanel, resizeListPanelWithClientX]);\n\n  const selectAllJobs = useCallback(() => {\n    onSetLocation(\"/cron\");\n  }, [onSetLocation]);\n\n  const selectJob = useCallback(\n    (nextJobId) => {\n      onSetLocation(`/cron/${encodeURIComponent(String(nextJobId || \"\"))}`);\n    },\n    [onSetLocation],\n  );\n\n  const refreshAll = useCallback(() => {\n    jobsPoll.refresh();\n    statusPoll.refresh();\n    runsPoll.refresh();\n    usagePoll.refresh();\n    trendsPoll.refresh();\n    bulkUsagePoll.refresh();\n    bulkRunsPoll.refresh();\n  }, [\n    bulkRunsPoll.refresh,\n    bulkUsagePoll.refresh,\n    jobsPoll.refresh,\n    runsPoll.refresh,\n    statusPoll.refresh,\n    trendsPoll.refresh,\n    usagePoll.refresh,\n  ]);\n\n  const runSelectedJobNow = useCallback(async () => {\n    if (!selectedJobId || runningJob) return;\n    setRunningJob(true);\n    try {\n      await triggerCronJobRun(selectedJobId);\n      showToast(\"Cron run triggered\", \"success\");\n      refreshAll();\n    } catch (error) {\n      showToast(error.message || \"Could not run cron job\", \"error\");\n    } finally {\n      setRunningJob(false);\n    }\n  }, [refreshAll, runningJob, selectedJobId]);\n\n  const setSelectedJobEnabled = useCallback(\n    async (enabled) => {\n      if (!selectedJobId || togglingJobEnabled) return;\n      setTogglingJobEnabled(true);\n      try {\n        await setCronJobEnabled(selectedJobId, enabled);\n        showToast(\n          enabled ? \"Cron job enabled\" : \"Cron job disabled\",\n          \"success\",\n        );\n        refreshAll();\n      } catch (error) {\n        showToast(error.message || \"Could not update cron job\", \"error\");\n      } finally {\n        setTogglingJobEnabled(false);\n      }\n    },\n    [refreshAll, selectedJobId, togglingJobEnabled],\n  );\n\n  const loadMoreRuns = useCallback(async () => {\n    if (!selectedJobId || !runHasMore || loadingMoreRuns) return;\n    setLoadingMoreRuns(true);\n    try {\n      const data = await fetchCronJobRuns(selectedJobId, {\n        limit: kRunsPageSize,\n        offset: runNextOffset,\n        status: runStatusFilter,\n        sortDir: \"desc\",\n      });\n      const nextEntries = Array.isArray(data?.runs?.entries)\n        ? data.runs.entries\n        : [];\n      setRunEntries((currentValue) => [...currentValue, ...nextEntries]);\n      setRunHasMore(!!data?.runs?.hasMore);\n      setRunNextOffset(Number(data?.runs?.nextOffset || 0));\n      setRunTotal(Number(data?.runs?.total || 0));\n    } catch (error) {\n      showToast(error.message || \"Could not load more runs\", \"error\");\n    } finally {\n      setLoadingMoreRuns(false);\n    }\n  }, [\n    loadingMoreRuns,\n    runHasMore,\n    runNextOffset,\n    runStatusFilter,\n    selectedJobId,\n  ]);\n\n  const saveChanges = useCallback(async () => {\n    if (!selectedJobId || !selectedJob || savingChanges) return;\n    const currentRouting = readRoutingDraftFromJob(selectedJob);\n    const nextRouting = {\n      sessionTarget: String(routingDraft?.sessionTarget || kRoutingDefaults.sessionTarget),\n      wakeMode: String(routingDraft?.wakeMode || kRoutingDefaults.wakeMode),\n      deliveryMode: String(routingDraft?.deliveryMode || kRoutingDefaults.deliveryMode),\n      deliveryChannel: String(routingDraft?.deliveryChannel || \"\"),\n      deliveryTo: String(routingDraft?.deliveryTo || \"\"),\n    };\n    const routingUnchanged =\n      nextRouting.sessionTarget === currentRouting.sessionTarget &&\n      nextRouting.wakeMode === currentRouting.wakeMode &&\n      nextRouting.deliveryMode === currentRouting.deliveryMode &&\n      nextRouting.deliveryChannel === currentRouting.deliveryChannel &&\n      nextRouting.deliveryTo === currentRouting.deliveryTo;\n    const promptUnchanged = promptValue === savedPromptValue;\n    if (routingUnchanged && promptUnchanged) return;\n    setSavingChanges(true);\n    try {\n      if (!routingUnchanged) {\n        await updateCronJobRouting(selectedJobId, nextRouting);\n      }\n      if (!promptUnchanged) {\n        await updateCronJobPrompt(selectedJobId, promptValue);\n        setSavedPromptValue(promptValue);\n      }\n      showToast(\"Changes saved\", \"success\");\n      refreshAll();\n    } catch (error) {\n      showToast(error.message || \"Could not save changes\", \"error\");\n    } finally {\n      setSavingChanges(false);\n    }\n  }, [\n    promptValue,\n    refreshAll,\n    routingDraft,\n    savedPromptValue,\n    savingChanges,\n    selectedJob,\n    selectedJobId,\n  ]);\n\n  useEffect(() => {\n    if (!selectedJobId) return;\n    if (String(routingDraft?.deliveryMode || \"none\") !== \"announce\") return;\n    if (!selectedDestination?.channel && !selectedDestination?.to) return;\n    setRoutingDraft((currentValue = kRoutingDefaults) => {\n      const nextChannel = String(selectedDestination?.channel || currentValue.deliveryChannel || \"\");\n      const nextTo = String(selectedDestination?.to || currentValue.deliveryTo || \"\");\n      if (\n        nextChannel === String(currentValue.deliveryChannel || \"\") &&\n        nextTo === String(currentValue.deliveryTo || \"\")\n      ) {\n        return currentValue;\n      }\n      return {\n        ...currentValue,\n        deliveryChannel: nextChannel,\n        deliveryTo: nextTo,\n      };\n    });\n  }, [\n    routingDraft?.deliveryMode,\n    selectedDestination?.channel,\n    selectedDestination?.to,\n    selectedJobId,\n  ]);\n\n  return {\n    refs: {\n      listPanelRef,\n    },\n    state: {\n      jobs,\n      jobsError: jobsPoll.error,\n      status: statusPoll.data?.status || null,\n      statusError: statusPoll.error,\n      selectedRouteKey,\n      selectedJobId,\n      selectedJob,\n      listPanelWidthPx,\n      isResizingListPanel,\n      runEntries,\n      filteredRunEntries,\n      runHasMore,\n      runNextOffset,\n      runTotal,\n      runStatusFilter,\n      runsError: runsPoll.error,\n      loadingMoreRuns,\n      usage: usagePoll.data?.usage || null,\n      jobTrends: trendsPoll.data?.trends || null,\n      usageError: usagePoll.error,\n      trendsError: trendsPoll.error,\n      usageDays,\n      jobTrendRange:\n        jobTrendRange === kTrendRange30d\n          ? kTrendRange30d\n          : jobTrendRange === kTrendRange24h\n            ? kTrendRange24h\n            : kTrendRange7d,\n      selectedJobTrendBucketFilter,\n      bulkUsageByJobId: bulkUsagePoll.data?.usage?.byJobId || {},\n      bulkUsageError: bulkUsagePoll.error,\n      bulkRunsByJobId: bulkRunsPoll.data?.runs?.byJobId || {},\n      bulkRunsError: bulkRunsPoll.error,\n      promptValue,\n      savedPromptValue,\n      savingChanges,\n      runningJob,\n      togglingJobEnabled,\n      routingDraft,\n      deliverySessions,\n      loadingDeliverySessions,\n      deliverySessionsError,\n      destinationSessionKey,\n    },\n    actions: {\n      setRunStatusFilter,\n      setUsageDays,\n      setJobTrendRange,\n      setSelectedJobTrendBucketFilter,\n      setPromptValue,\n      saveChanges,\n      refreshAll,\n      loadMoreRuns,\n      runSelectedJobNow,\n      setSelectedJobEnabled,\n      selectAllJobs,\n      selectJob,\n      onListResizerPointerDown,\n      setRoutingDraft,\n      setDestinationSessionKey,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/device-pairings.js",
    "content": "import { h } from 'preact';\nimport { useState } from 'preact/hooks';\nimport htm from 'htm';\nimport { ActionButton } from './action-button.js';\nconst html = htm.bind(h);\n\nconst kModeLabels = {\n  webchat: 'Browser',\n  cli: 'CLI',\n};\n\nconst formatTitle = (d) => kModeLabels[d.clientMode] || d.clientId || 'Device';\n\nconst formatSubtitle = (d) => {\n  const parts = [];\n  if (d.platform) parts.push(d.platform);\n  if (d.role) parts.push(d.role);\n  return parts.join(' · ');\n};\n\nconst DeviceRow = ({ d, onApprove, onReject }) => {\n  const [busy, setBusy] = useState(null);\n\n  const handle = async (action) => {\n    setBusy(action);\n    try {\n      if (action === 'approve') await onApprove(d.id);\n      else await onReject(d.id);\n    } catch {\n      setBusy(null);\n    }\n  };\n\n  const title = formatTitle(d);\n  const subtitle = formatSubtitle(d);\n\n  if (busy === 'approve') {\n    return html`\n      <div class=\"bg-field rounded-lg p-3 mb-2 flex items-center gap-2\">\n        <span class=\"text-status-success text-sm\">Approved</span>\n        <span class=\"text-fg-muted text-xs\">${title}</span>\n      </div>`;\n  }\n  if (busy === 'reject') {\n    return html`\n      <div class=\"bg-field rounded-lg p-3 mb-2 flex items-center gap-2\">\n        <span class=\"text-fg-muted text-sm\">Rejected</span>\n        <span class=\"text-fg-muted text-xs\">${title}</span>\n      </div>`;\n  }\n\n  return html`\n    <div class=\"bg-field rounded-lg p-3 mb-2\">\n      <div class=\"flex items-center gap-2 mb-2\">\n        <span class=\"font-medium text-sm\">${title}</span>\n        ${subtitle && html`<span class=\"text-xs text-fg-muted\">${subtitle}</span>`}\n      </div>\n      <div class=\"flex gap-2\">\n        <${ActionButton}\n          onClick=${() => handle('approve')}\n          tone=\"success\"\n          size=\"sm\"\n          idleLabel=\"Approve\"\n          className=\"font-medium px-3 py-1.5\"\n        />\n        <${ActionButton}\n          onClick=${() => handle('reject')}\n          tone=\"secondary\"\n          size=\"sm\"\n          idleLabel=\"Reject\"\n          className=\"font-medium px-3 py-1.5\"\n        />\n      </div>\n    </div>`;\n};\n\nexport const DevicePairings = ({ pending, onApprove, onReject }) => {\n  if (!pending || pending.length === 0) return null;\n\n  return html`\n    <div class=\"mt-3 pt-3 border-t border-border\">\n      <p class=\"text-xs text-fg-muted mb-2\">Pending device pairings</p>\n      ${pending.map((d) => html`<${DeviceRow} key=${d.id} d=${d} onApprove=${onApprove} onReject=${onReject} />`)}\n    </div>`;\n};\n"
  },
  {
    "path": "lib/public/js/components/doctor/findings-list.js",
    "content": "import { h } from \"preact\";\nimport { useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { Badge } from \"../badge.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport {\n  formatDoctorCategory,\n  getDoctorCategoryTone,\n  getDoctorPriorityTone,\n} from \"./helpers.js\";\n\nconst html = htm.bind(h);\n\nconst resolveTargetPath = (item) => {\n  if (!item) return null;\n  if (typeof item === \"string\") return { path: item };\n  if (typeof item === \"object\" && item.path) return item;\n  return null;\n};\n\nconst formatPathLabel = (filePath, startLine, endLine) => {\n  if (startLine && endLine && endLine > startLine)\n    return `${filePath}:${startLine}-${endLine}`;\n  if (startLine) return `${filePath}:${startLine}`;\n  return filePath;\n};\n\nconst buildLineOptions = (startLine, endLine) => {\n  const options = {};\n  if (startLine) options.line = startLine;\n  if (endLine && endLine > startLine) options.lineEnd = endLine;\n  return options;\n};\n\nconst renderPathLink = (filePath, onOpenFile, { startLine, endLine } = {}) => {\n  const label = formatPathLabel(filePath, startLine, endLine);\n  return html`\n    <button\n      type=\"button\"\n      class=\"text-left font-mono ac-tip-link hover:underline cursor-pointer\"\n      onClick=${(e) => {\n        e.preventDefault();\n        onOpenFile(\n          String(filePath || \"\"),\n          buildLineOptions(startLine, endLine),\n        );\n      }}\n    >\n      ${label}\n    </button>\n  `;\n};\n\nconst kSnippetCollapseThreshold = 7;\n\nconst SnippetBlock = ({ item, onOpenFile, isOutdated }) => {\n  const snippet = item.snippet;\n  const allLines = String(snippet.text || \"\").split(\"\\n\");\n  const isCollapsible = allLines.length > kSnippetCollapseThreshold;\n  const [expanded, setExpanded] = useState(!isCollapsible);\n  const visibleLines = expanded\n    ? allLines\n    : allLines.slice(0, kSnippetCollapseThreshold);\n  const gutterWidth = String(snippet.endLine || snippet.startLine || 1).length;\n  return html`\n    <div class=\"mt-1.5 rounded-lg border border-border overflow-hidden\">\n      <div\n        class=\"flex items-center justify-between px-3 py-1.5 bg-field border-b border-border\"\n      >\n        <button\n          type=\"button\"\n          class=\"text-[11px] font-mono ac-tip-link hover:underline cursor-pointer\"\n          onClick=${(e) => {\n            e.preventDefault();\n            onOpenFile(\n              String(item.path || \"\"),\n              buildLineOptions(item.startLine, item.endLine),\n            );\n          }}\n        >\n          ${formatPathLabel(item.path, item.startLine, item.endLine)}\n        </button>\n        ${isOutdated\n          ? html`<span class=\"text-[10px] text-status-warning-muted/80\"\n              >file changed since scan</span\n            >`\n          : html`<span class=\"text-[10px] text-fg-dim\">snapshot</span>`}\n      </div>\n      <div class=\"relative\">\n        <div\n          class=\"px-3 py-2 text-[11px] leading-[18px] font-mono text-body bg-field\"\n          style=\"white-space:pre-wrap;word-break:break-word\"\n        >\n          ${visibleLines.map(\n            (line, index) => html`\n              <div class=\"flex\">\n                <span\n                  class=\"text-fg-dim select-none shrink-0\"\n                  style=\"width:${gutterWidth +\n                  1}ch;text-align:right;margin-right:1ch\"\n                  >${snippet.startLine + index}</span\n                ><span>${line || \" \"}</span>\n              </div>\n            `,\n          )}\n          ${expanded && snippet.truncated\n            ? html`<div class=\"text-fg-dim italic pl-1\">... truncated</div>`\n            : \"\"}\n        </div>\n        ${isCollapsible && !expanded\n          ? html`\n              <button\n                type=\"button\"\n                class=\"absolute inset-x-0 bottom-0 flex items-end justify-center pb-2 pt-10 cursor-pointer snippet-collapse-fade\"\n                onClick=${() => setExpanded(true)}\n              >\n                <span\n                  class=\"text-[10px] text-fg-muted hover:text-white flex items-center gap-1 transition-colors\"\n                >\n                  <span\n                    class=\"inline-block text-xs transition-transform\"\n                    aria-hidden=\"true\"\n                    >▾</span\n                  >\n                  ${allLines.length} lines\n                </span>\n              </button>\n            `\n          : null}\n        ${isCollapsible && expanded\n          ? html`\n              <button\n                type=\"button\"\n                class=\"w-full flex items-center justify-center py-1 cursor-pointer bg-field border-t border-border\"\n                onClick=${() => setExpanded(false)}\n              >\n                <span class=\"text-[10px] text-fg-muted flex items-center gap-1\">\n                  <span\n                    class=\"inline-block transition-transform\"\n                    aria-hidden=\"true\"\n                    >▴</span\n                  >\n                  collapse\n                </span>\n              </button>\n            `\n          : null}\n      </div>\n    </div>\n  `;\n};\n\nconst renderEvidenceLine = (item = {}, onOpenFile, changedPathsSet) => {\n  if (item?.path && item?.snippet) {\n    const isOutdated = changedPathsSet.has(item.path);\n    return html`<${SnippetBlock}\n      item=${item}\n      onOpenFile=${onOpenFile}\n      isOutdated=${isOutdated}\n    />`;\n  }\n  if (item?.path)\n    return renderPathLink(item.path, onOpenFile, {\n      startLine: item.startLine,\n      endLine: item.endLine,\n    });\n  if (item?.text) return item.text;\n  return JSON.stringify(item);\n};\n\nexport const DoctorFindingsList = ({\n  cards = [],\n  busyCardId = 0,\n  onAskAgentFix = () => {},\n  onUpdateStatus = () => {},\n  onOpenFile = () => {},\n  changedPaths = [],\n  showRunMeta = false,\n  hideEmptyState = false,\n}) => {\n  const changedPathsSet = new Set(changedPaths);\n  return html`\n    <div class=\"space-y-4\">\n      ${cards.length\n        ? html`\n            <div class=\"space-y-3\">\n              ${cards.map(\n                (card) => html`\n                  <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n                    <div class=\"space-y-2\">\n                      <div class=\"flex flex-wrap items-start justify-between gap-3\">\n                        <div class=\"space-y-2 min-w-0\">\n                          <div class=\"flex flex-wrap items-center gap-2\">\n                            <${Badge} tone=${getDoctorPriorityTone(card.priority)}>\n                              ${card.priority}\n                            </${Badge}>\n                            <h3 class=\"text-sm font-semibold text-bright\">\n                              ${card.title}\n                            </h3>\n                          </div>\n                          <div class=\"flex flex-wrap items-center gap-2\">\n                            <${Badge} tone=${getDoctorCategoryTone(card.category)}>\n                              ${formatDoctorCategory(card.category)}\n                            </${Badge}>\n                            ${\n                              showRunMeta\n                                ? html`\n                                    <span class=\"text-xs text-fg-dim\"\n                                      >Run #${card.runId}</span\n                                    >\n                                  `\n                                : null\n                            }\n                          </div>\n                        </div>\n                      </div>\n                      ${\n                        card.summary\n                          ? html`<p\n                              class=\"text-xs text-body leading-5 pt-1\"\n                            >\n                              ${card.summary}\n                            </p>`\n                          : null\n                      }\n                    </div>\n                    <details class=\"group rounded-lg border border-border bg-field\">\n                      <summary class=\"list-none cursor-pointer px-3 py-2.5 text-xs text-fg-muted group-open:border-b group-open:border-border\">\n                        <span class=\"inline-flex items-center gap-2\">\n                          <span\n                            class=\"inline-block text-fg-muted transition-transform duration-200 group-open:rotate-90\"\n                            aria-hidden=\"true\"\n                            >▸</span\n                          >\n                          <span>Show recommendation and details</span>\n                        </span>\n                      </summary>\n                      <div class=\"p-3 space-y-3\">\n                        <div>\n                          <div class=\"ac-small-heading\">\n                            Recommendation\n                          </div>\n                          <p class=\"text-xs text-body mt-1 leading-5\">\n                            ${card.recommendation}\n                          </p>\n                        </div>\n                        ${\n                          Array.isArray(card.targetPaths) &&\n                          card.targetPaths.length\n                            ? html`\n                                <div>\n                                  <div class=\"ac-small-heading\">\n                                    Target paths\n                                  </div>\n                                  <div class=\"mt-1 flex flex-wrap gap-1.5\">\n                                    ${card.targetPaths.map((rawItem) => {\n                                      const resolved =\n                                        resolveTargetPath(rawItem);\n                                      if (!resolved) return null;\n                                      const label = formatPathLabel(\n                                        resolved.path,\n                                        resolved.startLine,\n                                        resolved.endLine,\n                                      );\n                                      return html`\n                                        <button\n                                          type=\"button\"\n                                          class=\"text-[11px] px-2 py-1 rounded-md bg-field border border-border font-mono text-body hover:text-white hover:border-fg-muted cursor-pointer transition-colors\"\n                                          onClick=${(e) => {\n                                            e.preventDefault();\n                                            onOpenFile(\n                                              String(resolved.path || \"\"),\n                                              buildLineOptions(\n                                                resolved.startLine,\n                                                resolved.endLine,\n                                              ),\n                                            );\n                                          }}\n                                        >\n                                          ${label}\n                                        </button>\n                                      `;\n                                    })}\n                                  </div>\n                                </div>\n                              `\n                            : null\n                        }\n                        ${\n                          Array.isArray(card.evidence) && card.evidence.length\n                            ? html`\n                                <div>\n                                  <div class=\"ac-small-heading\">Evidence</div>\n                                  <div class=\"mt-1 space-y-2\">\n                                    ${card.evidence.map(\n                                      (item) => html`\n                                        <div class=\"text-xs text-fg-muted\">\n                                          ${renderEvidenceLine(\n                                            item,\n                                            onOpenFile,\n                                            changedPathsSet,\n                                          )}\n                                        </div>\n                                      `,\n                                    )}\n                                  </div>\n                                </div>\n                              `\n                            : null\n                        }\n                      </div>\n                    </details>\n                    <div class=\"flex flex-wrap gap-2\">\n                      <${ActionButton}\n                        onClick=${() => onAskAgentFix(card)}\n                        loading=${busyCardId === card.id}\n                        tone=\"primary\"\n                        idleLabel=\"Ask agent to fix\"\n                        loadingLabel=\"Sending...\"\n                      />\n                      ${\n                        card.status !== \"fixed\"\n                          ? html`\n                              <${ActionButton}\n                                onClick=${() => onUpdateStatus(card, \"fixed\")}\n                                tone=\"secondary\"\n                                idleLabel=\"Mark fixed\"\n                              />\n                            `\n                          : html`\n                              <${ActionButton}\n                                onClick=${() => onUpdateStatus(card, \"open\")}\n                                tone=\"secondary\"\n                                idleLabel=\"Reopen\"\n                              />\n                            `\n                      }\n                      ${\n                        card.status !== \"dismissed\"\n                          ? html`\n                              <${ActionButton}\n                                onClick=${() =>\n                                  onUpdateStatus(card, \"dismissed\")}\n                                tone=\"ghost\"\n                                idleLabel=\"Dismiss\"\n                              />\n                            `\n                          : html`\n                              <${ActionButton}\n                                onClick=${() => onUpdateStatus(card, \"open\")}\n                                tone=\"ghost\"\n                                idleLabel=\"Restore\"\n                              />\n                            `\n                      }\n                    </div>\n                  </div>\n                `,\n              )}\n            </div>\n          `\n        : hideEmptyState\n          ? null\n          : html`\n              <div class=\"ac-surface-inset rounded-xl p-4 space-y-1.5\">\n                <p class=\"text-xs text-body leading-5\">\n                  No findings currently for this selection.\n                </p>\n              </div>\n            `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/doctor/fix-card-modal.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { sendDoctorCardFix, updateDoctorCardStatus } from \"../../lib/api.js\";\nimport { showToast } from \"../toast.js\";\nimport { AgentSendModal } from \"../agent-send-modal.js\";\n\nconst html = htm.bind(h);\n\nexport const DoctorFixCardModal = ({\n  visible = false,\n  card = null,\n  onClose = () => {},\n  onComplete = () => {},\n}) => {\n  const handleSend = async ({ selectedSession, message }) => {\n    if (!card?.id) return false;\n    try {\n      await sendDoctorCardFix({\n        cardId: card.id,\n        sessionId: selectedSession?.sessionId || \"\",\n        replyChannel: selectedSession?.replyChannel || \"\",\n        replyTo: selectedSession?.replyTo || \"\",\n        prompt: message,\n      });\n      try {\n        await updateDoctorCardStatus({ cardId: card.id, status: \"fixed\" });\n        showToast(\n          \"Doctor fix request sent and finding marked fixed\",\n          \"success\",\n        );\n      } catch (statusError) {\n        showToast(\n          statusError.message ||\n            \"Doctor fix request sent, but could not mark the finding fixed\",\n          \"warning\",\n        );\n      }\n      await onComplete();\n      return true;\n    } catch (error) {\n      showToast(error.message || \"Could not send Doctor fix request\", \"error\");\n      return false;\n    }\n  };\n\n  return html`\n    <${AgentSendModal}\n      visible=${visible}\n      title=\"Ask agent to fix\"\n      messageLabel=\"Instructions\"\n      initialMessage=${String(card?.fixPrompt || \"\")}\n      resetKey=${String(card?.id || \"\")}\n      submitLabel=\"Send fix request\"\n      loadingLabel=\"Sending...\"\n      onClose=${onClose}\n      onSubmit=${handleSend}\n    />\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/doctor/general-warning.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { getDoctorWarningMessage, shouldShowDoctorWarning } from \"./helpers.js\";\n\nconst html = htm.bind(h);\n\nexport const GeneralDoctorWarning = ({\n  doctorStatus = null,\n  dismissedUntilMs = 0,\n  onOpenDoctor = () => {},\n  onDismiss = () => {},\n}) => {\n  if (!shouldShowDoctorWarning(doctorStatus, dismissedUntilMs)) return null;\n  return html`\n    <div class=\"bg-yellow-500/10 border border-yellow-500/35 rounded-xl p-4\">\n      <div class=\"flex flex-col gap-3\">\n        <div class=\"space-y-1\">\n          <h2 class=\"font-semibold text-sm text-status-warning\">Drift Doctor</h2>\n          <p class=\"text-xs text-status-warning/80\">${getDoctorWarningMessage(doctorStatus)}</p>\n        </div>\n        <div class=\"flex flex-wrap gap-2\">\n          <${ActionButton}\n            onClick=${onDismiss}\n            tone=\"secondary\"\n            idleLabel=\"Dismiss for 1 week\"\n          />\n          <${ActionButton}\n            onClick=${onOpenDoctor}\n            tone=\"warning\"\n            idleLabel=\"Open Drift Doctor\"\n          />\n        </div>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/doctor/helpers.js",
    "content": "export const getDoctorPriorityTone = (priority = \"\") => {\n  const normalized = String(priority || \"\")\n    .trim()\n    .toUpperCase();\n  if (normalized === \"P0\") return \"danger\";\n  if (normalized === \"P1\") return \"warning\";\n  return \"neutral\";\n};\n\nexport const getDoctorStatusTone = (status = \"\") => {\n  const normalized = String(status || \"\")\n    .trim()\n    .toLowerCase();\n  if (normalized === \"fixed\") return \"success\";\n  if (normalized === \"dismissed\") return \"neutral\";\n  return \"warning\";\n};\n\nexport const getDoctorCategoryTone = (category = \"\") => {\n  const normalized = String(category || \"\")\n    .trim()\n    .toLowerCase()\n    .replace(/[_-]+/g, \" \");\n  if (normalized === \"token efficiency\") return \"info\";\n  if (normalized === \"redundancy\") return \"accent\";\n  if (normalized === \"mixed concerns\") return \"cyan\";\n  if (normalized === \"workspace state\") return \"secondary\";\n  return \"info\";\n};\n\nexport const formatDoctorCategory = (category = \"\") => {\n  const normalized = String(category || \"\")\n    .trim()\n    .replace(/[_-]+/g, \" \");\n  if (!normalized) return \"Workspace\";\n  return normalized.replace(/\\b\\w/g, (character) => character.toUpperCase());\n};\n\nexport const buildDoctorPriorityCounts = (cards = []) =>\n  cards.reduce(\n    (totals, card) => {\n      const priority = String(card?.priority || \"\")\n        .trim()\n        .toUpperCase();\n      if (priority === \"P0\" || priority === \"P1\" || priority === \"P2\") {\n        totals[priority] += 1;\n      }\n      return totals;\n    },\n    { P0: 0, P1: 0, P2: 0 },\n  );\n\nexport const groupDoctorCardsByStatus = (cards = []) =>\n  cards.reduce(\n    (groups, card) => {\n      const status = String(card?.status || \"open\")\n        .trim()\n        .toLowerCase();\n      if (status === \"fixed\") {\n        groups.fixed.push(card);\n        return groups;\n      }\n      if (status === \"dismissed\") {\n        groups.dismissed.push(card);\n        return groups;\n      }\n      groups.open.push(card);\n      return groups;\n    },\n    { open: [], dismissed: [], fixed: [] },\n  );\n\nexport const shouldShowDoctorWarning = (\n  doctorStatus = null,\n  dismissedUntilMs = 0,\n) => {\n  if (!doctorStatus || doctorStatus.runInProgress) return false;\n  if (doctorStatus.needsInitialRun || !doctorStatus.stale) return false;\n  if (!doctorStatus.changeSummary?.hasMeaningfulChanges) return false;\n  return Number(dismissedUntilMs || 0) <= Date.now();\n};\n\nexport const getDoctorWarningMessage = (doctorStatus = null) => {\n  if (!doctorStatus) return \"\";\n  const changedFilesCount = Number(\n    doctorStatus.changeSummary?.changedFilesCount || 0,\n  );\n  if (changedFilesCount > 0) {\n    return `Drift Doctor has not been run in the last week and ${changedFilesCount} file${changedFilesCount === 1 ? \"\" : \"s\"} changed since the last review.`;\n  }\n  return \"Doctor has not been run in the last week.\";\n};\n\nexport const formatDoctorCharCount = (value = 0) =>\n  `${Number(value || 0).toLocaleString()} chars`;\n\nconst isManagedBootstrapContextPath = (filePath = \"\") =>\n  String(filePath || \"\").startsWith(\"hooks/bootstrap/\");\n\nexport const getDoctorBootstrapTruncationItems = (doctorStatus = null) => {\n  const bootstrapContext = doctorStatus?.bootstrapContext;\n  const truncatedFiles = (bootstrapContext?.activeTruncatedFiles || []).filter(\n    (file) => !isManagedBootstrapContextPath(file?.path),\n  );\n  const nearLimitFiles = (bootstrapContext?.activeNearLimitFiles || []).filter(\n    (file) => !isManagedBootstrapContextPath(file?.path),\n  );\n  return [\n    ...truncatedFiles.map((file) => ({\n      path: file.path,\n      size: formatDoctorCharCount(file.rawChars),\n      statusText: `-${Number(\n        Math.max(\n          0,\n          Number(file.rawChars || 0) - Number(file.injectedChars || 0),\n        ),\n      ).toLocaleString()} cut`,\n      statusTone: \"danger\",\n    })),\n    ...nearLimitFiles.map((file) => ({\n      path: file.path,\n      size: formatDoctorCharCount(file.rawChars),\n      statusText: \"Near limit\",\n      statusTone: \"warning\",\n    })),\n  ];\n};\n\nexport const hasDoctorBootstrapWarnings = (doctorStatus = null) =>\n  getDoctorBootstrapTruncationItems(doctorStatus).length > 0;\n\nexport const getDoctorBootstrapWarningTitle = (doctorStatus = null) => {\n  const items = getDoctorBootstrapTruncationItems(doctorStatus);\n  if (!items.length) return \"\";\n  const hasTruncatedItems = items.some((item) => item.statusTone === \"danger\");\n  const hasNearLimitItems = items.some((item) => item.statusTone === \"warning\");\n  if (hasTruncatedItems && hasNearLimitItems) {\n    return \"Some of your main files are being truncated or nearing the limit:\";\n  }\n  if (hasNearLimitItems) {\n    return items.length === 1\n      ? \"One of your main files is nearing the limit:\"\n      : \"Some of your main files are nearing the limit:\";\n  }\n  return items.length === 1\n    ? \"One of your main files is being truncated:\"\n    : \"Some of your main files are being truncated:\";\n};\n\nexport const getDoctorChangeLabel = (changeSummary = null) => {\n  const changedFilesCount = Number(changeSummary?.changedFilesCount || 0);\n  if (changedFilesCount === 0) return \"No changes since last run\";\n  return `${changedFilesCount} change${changedFilesCount === 1 ? \"\" : \"s\"} since last run`;\n};\n\nexport const getDoctorRunPillDetail = (run = null) => {\n  if (!run || typeof run !== \"object\") return \"\";\n  if (run.status === \"running\") return \"Running\";\n  if (run.status === \"failed\") return \"Failed\";\n  if ((run.cardCount || 0) === 0) return \"No findings\";\n  return `${run.cardCount || 0} finding${run.cardCount === 1 ? \"\" : \"s\"}`;\n};\n\nexport const buildDoctorRunMarkers = (run = null) => {\n  if (!run || typeof run !== \"object\") return [];\n  if (run.status === \"running\") {\n    return [{ tone: \"cyan\", count: 0, label: \"Running\" }];\n  }\n  if (run.status === \"failed\") {\n    return [{ tone: \"neutral\", count: 0, label: \"Failed\" }];\n  }\n  if ((run.cardCount || 0) === 0) {\n    return [{ tone: \"success\", count: 0, label: \"No findings\" }];\n  }\n  const highPriority = [];\n  if (Number(run?.priorityCounts?.P0 || 0) > 0) {\n    highPriority.push({ tone: \"danger\", count: 0, label: \"P0\" });\n  }\n  if (Number(run?.priorityCounts?.P1 || 0) > 0) {\n    highPriority.push({ tone: \"warning\", count: 0, label: \"P1\" });\n  }\n  if (highPriority.length > 0) return highPriority.slice(0, 2);\n  return [{ tone: \"neutral\", count: 0, label: \"P2\" }];\n};\n\nexport const buildDoctorStatusFilterOptions = () => [\n  { value: \"open\", label: \"Open\" },\n  { value: \"dismissed\", label: \"Dismissed\" },\n  { value: \"fixed\", label: \"Fixed\" },\n];\n"
  },
  {
    "path": "lib/public/js/components/doctor/index.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport {\n  fetchDoctorCards,\n  fetchDoctorStatus,\n  fetchDoctorRuns,\n  startDoctorRun,\n  updateDoctorCardStatus,\n} from \"../../lib/api.js\";\nimport { formatLocaleDateTime } from \"../../lib/format.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { showToast } from \"../toast.js\";\nimport { DoctorSummaryCards } from \"./summary-cards.js\";\nimport { DoctorFindingsList } from \"./findings-list.js\";\nimport { DoctorFixCardModal } from \"./fix-card-modal.js\";\nimport {\n  buildDoctorRunMarkers,\n  buildDoctorStatusFilterOptions,\n  getDoctorBootstrapTruncationItems,\n  getDoctorBootstrapWarningTitle,\n  getDoctorChangeLabel,\n  getDoctorRunPillDetail,\n  hasDoctorBootstrapWarnings,\n  shouldShowDoctorWarning,\n} from \"./helpers.js\";\n\nconst html = htm.bind(h);\n\nconst kIdlePollMs = 15000;\nconst kActivePollMs = 2000;\n\nconst DoctorEmptyStateIcon = () => html`\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    class=\"h-12 w-12 text-cyan-400\"\n  >\n    <path\n      d=\"M8 20V14H16V20H19V4H5V20H8ZM10 20H14V16H10V20ZM21 20H23V22H1V20H3V3C3 2.44772 3.44772 2 4 2H20C20.5523 2 21 2.44772 21 3V20ZM11 8V6H13V8H15V10H13V12H11V10H9V8H11Z\"\n    ></path>\n  </svg>\n`;\n\nexport const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {\n  const statusPoll = usePolling(fetchDoctorStatus, kIdlePollMs, {\n    enabled: isActive,\n  });\n  const doctorStatus = statusPoll.data?.status || null;\n  const runPollIntervalMs = doctorStatus?.runInProgress\n    ? kActivePollMs\n    : kIdlePollMs;\n  const runsPoll = usePolling(() => fetchDoctorRuns(10), runPollIntervalMs, {\n    enabled: isActive,\n  });\n  const [selectedRunFilter, setSelectedRunFilter] = useState(\"all\");\n  const [selectedStatusFilter, setSelectedStatusFilter] = useState(\"open\");\n  const [busyCardId, setBusyCardId] = useState(0);\n  const [fixCard, setFixCard] = useState(null);\n  const [pendingRunSelectionId, setPendingRunSelectionId] = useState(\"\");\n\n  const runs = runsPoll.data?.runs || [];\n  const activeRunId = String(doctorStatus?.activeRunId || \"\");\n  const selectedRunId = String(selectedRunFilter || \"\");\n  const shouldRenderPendingRunTab =\n    selectedRunId !== \"\" &&\n    selectedRunId !== \"all\" &&\n    !runs.some((run) => String(run.id || \"\") === selectedRunId) &&\n    (pendingRunSelectionId === selectedRunId ||\n      (doctorStatus?.runInProgress && activeRunId === selectedRunId));\n  const pendingRun = shouldRenderPendingRunTab\n    ? {\n        id: Number(selectedRunId || 0),\n        status: \"running\",\n        summary: \"\",\n        priorityCounts: { P0: 0, P1: 0, P2: 0 },\n        statusCounts: { open: 0, dismissed: 0, fixed: 0 },\n      }\n    : null;\n  const displayRuns = pendingRun ? [pendingRun, ...runs] : runs;\n  const selectedRunIsActiveRun =\n    selectedRunFilter !== \"all\" &&\n    !!activeRunId &&\n    String(selectedRunFilter || \"\") === activeRunId;\n  const selectedRun =\n    selectedRunFilter === \"all\"\n      ? null\n      : displayRuns.find(\n          (run) => String(run.id || \"\") === String(selectedRunFilter || \"\"),\n        ) || null;\n  const cardsPoll = usePolling(\n    () => fetchDoctorCards({ runId: selectedRunFilter || \"all\" }),\n    doctorStatus?.runInProgress || selectedRun?.status === \"running\"\n      ? kActivePollMs\n      : kIdlePollMs,\n    { enabled: isActive },\n  );\n  const allCards = cardsPoll.data?.cards || [];\n\n  useEffect(() => {\n    if (!isActive) return;\n    statusPoll.refresh();\n    runsPoll.refresh();\n  }, [isActive]);\n\n  useEffect(() => {\n    if (!runs.length) {\n      if (pendingRunSelectionId && selectedRunId === pendingRunSelectionId)\n        return;\n      if (selectedRunIsActiveRun && doctorStatus?.runInProgress) return;\n      if (selectedRunFilter !== \"all\") setSelectedRunFilter(\"all\");\n      return;\n    }\n    if (selectedRunFilter === \"all\") return;\n    const hasSelectedRun = runs.some(\n      (run) => String(run.id || \"\") === String(selectedRunFilter || \"\"),\n    );\n    if (hasSelectedRun) return;\n    if (selectedRunIsActiveRun && doctorStatus?.runInProgress) return;\n    setSelectedRunFilter(\"all\");\n  }, [\n    runs,\n    selectedRunId,\n    selectedRunFilter,\n    selectedRunIsActiveRun,\n    pendingRunSelectionId,\n    doctorStatus?.runInProgress,\n  ]);\n\n  useEffect(() => {\n    if (!pendingRunSelectionId) return;\n    if (selectedRunFilter !== pendingRunSelectionId) {\n      setSelectedRunFilter(pendingRunSelectionId);\n      return;\n    }\n    const hasPendingRun = runs.some(\n      (run) => String(run.id || \"\") === String(pendingRunSelectionId || \"\"),\n    );\n    const activePendingRun =\n      !!activeRunId &&\n      activeRunId === pendingRunSelectionId &&\n      !!doctorStatus?.runInProgress;\n    if (!hasPendingRun && !activePendingRun) return;\n    setPendingRunSelectionId(\"\");\n  }, [\n    activeRunId,\n    doctorStatus?.runInProgress,\n    pendingRunSelectionId,\n    runs,\n    selectedRunFilter,\n  ]);\n\n  useEffect(() => {\n    cardsPoll.refresh();\n  }, [selectedRunFilter]);\n\n  const selectedRunIsInProgress =\n    selectedRun?.status === \"running\" ||\n    (selectedRunIsActiveRun && doctorStatus?.runInProgress);\n  const selectedRunSummary = useMemo(\n    () => (selectedRunIsInProgress ? \"\" : selectedRun?.summary || \"\"),\n    [selectedRun, selectedRunIsInProgress],\n  );\n  const statusFilterOptions = useMemo(\n    () => buildDoctorStatusFilterOptions(),\n    [],\n  );\n  const changeLabel = useMemo(\n    () => getDoctorChangeLabel(doctorStatus?.changeSummary || null),\n    [doctorStatus],\n  );\n  const canRunDoctor = useMemo(() => {\n    if (doctorStatus?.runInProgress) return true;\n    if (doctorStatus?.needsInitialRun) return true;\n    return Number(doctorStatus?.changeSummary?.changedFilesCount || 0) > 0;\n  }, [doctorStatus]);\n  const runDoctorDisabledReason = canRunDoctor\n    ? \"\"\n    : \"No workspace changes since the last completed Drift Doctor run.\";\n  const showDoctorStaleBanner = useMemo(\n    () => shouldShowDoctorWarning(doctorStatus, 0),\n    [doctorStatus],\n  );\n  const showBootstrapTruncationBanner = useMemo(\n    () => hasDoctorBootstrapWarnings(doctorStatus),\n    [doctorStatus],\n  );\n  const bootstrapTruncationMessage = useMemo(\n    () => getDoctorBootstrapWarningTitle(doctorStatus),\n    [doctorStatus],\n  );\n  const bootstrapTruncationItems = useMemo(\n    () => getDoctorBootstrapTruncationItems(doctorStatus),\n    [doctorStatus],\n  );\n  const hasCompletedDoctorRun = !!doctorStatus?.lastRunAt;\n  const hasRuns = runs.length > 0;\n  const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null;\n  const hasLoadedCards = cardsPoll.data !== null || cardsPoll.error !== null;\n  const showInitialLoadingState =\n    !hasLoadedRuns || (hasRuns && !hasLoadedCards);\n  const cards = useMemo(() => {\n    if (selectedStatusFilter === \"all\") return allCards;\n    return allCards.filter(\n      (card) =>\n        String(card?.status || \"open\")\n          .trim()\n          .toLowerCase() === selectedStatusFilter,\n    );\n  }, [allCards, selectedStatusFilter]);\n  const openCards = useMemo(\n    () =>\n      allCards.filter(\n        (card) =>\n          String(card?.status || \"open\")\n            .trim()\n            .toLowerCase() === \"open\",\n      ),\n    [allCards],\n  );\n  const visibleRuns = useMemo(() => displayRuns.slice(0, 2), [displayRuns]);\n  const overflowRuns = useMemo(() => displayRuns.slice(2), [displayRuns]);\n  const selectedOverflowRunValue = useMemo(() => {\n    if (selectedRunFilter === \"all\") return \"\";\n    return overflowRuns.some(\n      (run) => String(run.id || \"\") === String(selectedRunFilter || \"\"),\n    )\n      ? String(selectedRunFilter || \"\")\n      : \"\";\n  }, [overflowRuns, selectedRunFilter]);\n\n  const getRunTabClassName = (selected = false) =>\n    [\n      \"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors\",\n      selected\n        ? \"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]\"\n        : \"border-border bg-field text-body hover:border-fg-muted hover:text-bright\",\n    ].join(\" \");\n\n  const getRunMarkerClassName = (tone = \"neutral\") => {\n    if (tone === \"success\") return \"bg-green-400\";\n    if (tone === \"warning\") return \"bg-yellow-400\";\n    if (tone === \"danger\") return \"bg-red-400\";\n    if (tone === \"cyan\") return \"ac-status-dot ac-status-dot--info\";\n    return \"bg-gray-500\";\n  };\n  const showRunLayout =\n    !showInitialLoadingState &&\n    (runs.length > 0 ||\n      !!pendingRunSelectionId ||\n      !!activeRunId ||\n      !!doctorStatus?.runInProgress);\n\n  const handleRunDoctor = async () => {\n    try {\n      const result = await startDoctorRun();\n      showToast(\n        result?.reusedPreviousRun\n          ? \"No workspace changes since the last scan; reused previous findings\"\n          : \"Doctor run started\",\n        \"success\",\n      );\n      if (result?.runId) {\n        const runId = String(result.runId);\n        setPendingRunSelectionId(runId);\n        setSelectedRunFilter(runId);\n      }\n      statusPoll.refresh();\n      runsPoll.refresh();\n      cardsPoll.refresh();\n      setTimeout(statusPoll.refresh, 1200);\n      setTimeout(runsPoll.refresh, 1200);\n      setTimeout(cardsPoll.refresh, 1200);\n    } catch (error) {\n      showToast(error.message || \"Could not start Doctor run\", \"error\");\n    }\n  };\n\n  const handleUpdateStatus = async (card, status) => {\n    if (!card?.id || busyCardId) return;\n    try {\n      setBusyCardId(card.id);\n      await updateDoctorCardStatus({ cardId: card.id, status });\n      showToast(\"Doctor card updated\", \"success\");\n      await cardsPoll.refresh();\n      await runsPoll.refresh();\n      await statusPoll.refresh();\n    } catch (error) {\n      showToast(error.message || \"Could not update Doctor card\", \"error\");\n    } finally {\n      setBusyCardId(0);\n    }\n  };\n\n  return html`\n    <div class=\"space-y-4\">\n      ${showRunLayout\n        ? html`\n            <${PageHeader}\n              title=\"Drift Doctor\"\n              actions=${html`\n                <${ActionButton}\n                  onClick=${handleRunDoctor}\n                  disabled=${!canRunDoctor}\n                  loading=${!!doctorStatus?.runInProgress}\n                  idleLabel=\"Run Drift Doctor\"\n                  loadingLabel=\"Running...\"\n                  title=${runDoctorDisabledReason}\n                />\n              `}\n            />\n          `\n        : null}\n      ${showInitialLoadingState\n        ? html`\n            <div class=\"bg-surface border border-border rounded-xl p-5\">\n              <div class=\"flex items-center gap-3 text-sm text-fg-muted\">\n                <${LoadingSpinner} className=\"h-4 w-4\" />\n                <span>Loading Drift Doctor...</span>\n              </div>\n            </div>\n          `\n        : null}\n      ${!showInitialLoadingState && hasRuns\n        ? html`\n            <div class=\"space-y-3\">\n              <${DoctorSummaryCards} cards=${openCards} />\n              <div class=\"space-y-3\">\n                ${hasCompletedDoctorRun\n                  ? html`\n                      <div\n                        class=\"bg-surface border border-border rounded-xl p-4 flex flex-wrap items-center justify-between gap-3\"\n                      >\n                        <span class=\"text-xs text-fg-muted\">\n                          Last run ·${\" \"}\n                          <span class=\"text-body\">\n                            ${formatLocaleDateTime(doctorStatus?.lastRunAt, {\n                              fallback: \"Never\",\n                            })}\n                          </span>\n                        </span>\n                        <span class=\"text-xs text-fg-muted\">\n                          ${changeLabel}\n                        </span>\n                      </div>\n                      ${showBootstrapTruncationBanner\n                        ? html`\n                            <div\n                              class=\"bg-surface border border-border rounded-xl p-4 space-y-3\"\n                            >\n                              <div class=\"text-xs text-fg-muted\">\n                                ⚠️ ${bootstrapTruncationMessage}\n                              </div>\n                              <div class=\"space-y-2\">\n                                ${bootstrapTruncationItems.map(\n                                  (item) => html`\n                                    <div\n                                      class=\"flex items-center justify-between gap-3 text-xs\"\n                                    >\n                                      <button\n                                        type=\"button\"\n                                        class=\"font-mono text-body ac-tip-link hover:underline text-left cursor-pointer\"\n                                        onClick=${() => onOpenFile(String(item.path || \"\"))}\n                                      >\n                                        ${item.path}\n                                      </button>\n                                      <span\n                                        class=\"flex items-center gap-3 whitespace-nowrap\"\n                                      >\n                                        <span class=\"text-fg-muted\">\n                                          ${item.size}\n                                        </span>\n                                        <span\n                                          class=${item.statusTone === \"warning\"\n                                            ? \"text-status-warning\"\n                                            : \"text-status-error\"}\n                                        >\n                                          ${item.statusText}\n                                        </span>\n                                      </span>\n                                    </div>\n                                  `,\n                                )}\n                              </div>\n                              <div class=\"border-t border-border\"></div>\n                              <p class=\"text-xs text-fg-muted leading-5\">\n                                Truncated files become partially hidden from\n                                your agent and could cause drift.\n                              </p>\n                            </div>\n                          `\n                        : null}\n                    `\n                  : null}\n                ${showDoctorStaleBanner\n                  ? html`\n                      <div\n                        class=\"text-xs text-status-warning bg-yellow-500/10 border border-yellow-500/35 rounded-lg px-3 py-2\"\n                      >\n                        Doctor should be run again because the latest completed\n                        run is older than one week and the workspace has\n                        changed.\n                      </div>\n                    `\n                  : null}\n              </div>\n            </div>\n          `\n        : null}\n      ${showRunLayout\n        ? html`\n            <div class=\"space-y-4 pt-2\">\n              <div class=\"flex flex-wrap items-center justify-between gap-3\">\n                <h2 class=\"font-semibold text-base\">Findings</h2>\n              </div>\n              <div class=\"flex flex-wrap items-center gap-2\">\n                <button\n                  type=\"button\"\n                  class=${getRunTabClassName(selectedRunFilter === \"all\")}\n                  onClick=${() => setSelectedRunFilter(\"all\")}\n                >\n                  <span class=\"font-medium\">All runs</span>\n                </button>\n                ${visibleRuns.map((run) => {\n                  const selected =\n                    String(selectedRunFilter || \"\") === String(run.id || \"\");\n                  const markers = buildDoctorRunMarkers(run);\n                  return html`\n                    <button\n                      key=${run.id}\n                      type=\"button\"\n                      class=${getRunTabClassName(selected)}\n                      onClick=${() =>\n                        setSelectedRunFilter(String(run.id || \"\"))}\n                    >\n                      <span class=\"font-medium\">Run #${run.id}</span>\n                      <span class=\"inline-flex items-center gap-1\">\n                        ${markers.map(\n                          (marker) => html`\n                            <span\n                              class=\"inline-flex items-center\"\n                              title=${marker.label}\n                            >\n                              <span\n                                class=${getRunMarkerClassName(\n                                  marker.tone,\n                                ).startsWith(\"ac-status-dot\")\n                                  ? getRunMarkerClassName(marker.tone)\n                                  : `h-2 w-2 rounded-full ${getRunMarkerClassName(marker.tone)}`}\n                              ></span>\n                            </span>\n                          `,\n                        )}\n                      </span>\n                    </button>\n                  `;\n                })}\n                ${overflowRuns.length\n                  ? html`\n                      <label\n                        class=\"flex items-center gap-2 text-xs text-fg-muted\"\n                      >\n                        <select\n                          value=${selectedOverflowRunValue}\n                          onChange=${(event) => {\n                            const nextValue = String(\n                              event.currentTarget?.value || \"\",\n                            );\n                            if (!nextValue) return;\n                            setSelectedRunFilter(nextValue);\n                          }}\n                          class=\"bg-field border border-border rounded-full px-3 py-1.5 text-xs text-body focus:border-fg-muted\"\n                        >\n                          <option value=\"\">More runs</option>\n                          ${overflowRuns.map(\n                            (run) => html`\n                              <option value=${String(run.id || \"\")}>\n                                Run #${run.id} · ${getDoctorRunPillDetail(run)}\n                              </option>\n                            `,\n                          )}\n                        </select>\n                      </label>\n                    `\n                  : null}\n                <label class=\"flex items-center gap-2 text-xs text-fg-muted\">\n                  <select\n                    value=${selectedStatusFilter}\n                    onChange=${(event) =>\n                      setSelectedStatusFilter(\n                        String(event.currentTarget?.value || \"open\"),\n                      )}\n                    class=\"bg-field border border-border rounded-full px-3 py-1.5 text-xs text-body focus:border-fg-muted\"\n                  >\n                    ${statusFilterOptions.map(\n                      (option) => html`\n                        <option value=${option.value}>${option.label}</option>\n                      `,\n                    )}\n                  </select>\n                </label>\n              </div>\n              ${selectedRunSummary\n                ? html`\n                    <div class=\"ac-surface-inset rounded-xl p-4 space-y-1.5\">\n                      <div\n                        class=\"text-[11px] uppercase tracking-wide text-fg-muted\"\n                      >\n                        ${selectedRun?.id\n                          ? `Run #${selectedRun.id} summary`\n                          : \"Run summary\"}\n                      </div>\n                      <p class=\"text-xs text-body leading-5\">\n                        ${selectedRunSummary}\n                      </p>\n                    </div>\n                  `\n                : null}\n              ${selectedRunIsInProgress\n                ? html`\n                    <div class=\"ac-surface-inset rounded-xl p-4\">\n                      <div class=\"flex items-center gap-2 text-xs leading-5 text-fg-muted\">\n                        <${LoadingSpinner} className=\"h-3.5 w-3.5\" />\n                        <span>\n                          Run in progress. Findings will appear when analysis\n                          completes.\n                        </span>\n                      </div>\n                    </div>\n                  `\n                : null}\n              <div>\n                <${DoctorFindingsList}\n                  cards=${cards}\n                  busyCardId=${busyCardId}\n                  onAskAgentFix=${setFixCard}\n                  onUpdateStatus=${handleUpdateStatus}\n                  onOpenFile=${onOpenFile}\n                  changedPaths=${doctorStatus?.changeSummary?.changedPaths ||\n                  []}\n                  showRunMeta=${selectedRunFilter === \"all\"}\n                  hideEmptyState=${selectedRunIsInProgress}\n                />\n              </div>\n            </div>\n          `\n        : null}\n      ${!showInitialLoadingState && !showRunLayout\n        ? html`\n            <div\n              class=\"bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center\"\n            >\n              <div class=\"max-w-md w-full flex flex-col items-center gap-4\">\n                <${DoctorEmptyStateIcon} />\n                <div class=\"space-y-2\">\n                  <h2 class=\"font-semibold text-lg text-bright\">\n                    Workspace health review\n                  </h2>\n                  <p class=\"text-xs text-fg-muted leading-5\">\n                    Drift Doctor scans the workspace for guidance drift,\n                    misplaced instructions, redundant docs, and cleanup\n                    opportunities.\n                  </p>\n                </div>\n                <div class=\"flex flex-col items-center gap-2 mt-8\">\n                  <${ActionButton}\n                    onClick=${handleRunDoctor}\n                    disabled=${!canRunDoctor}\n                    loading=${!!doctorStatus?.runInProgress}\n                    size=\"lg\"\n                    idleLabel=\"Run Drift Doctor\"\n                    loadingLabel=\"Running...\"\n                    title=${runDoctorDisabledReason}\n                  />\n                  <p class=\"text-xs text-fg-muted leading-5 mt-10\">\n                    Runs on your main agent and consumes tokens. No\n                    changes will be made without your approval.\n                  </p>\n                </div>\n              </div>\n            </div>\n          `\n        : null}\n      <${DoctorFixCardModal}\n        visible=${!!fixCard}\n        card=${fixCard}\n        onClose=${() => setFixCard(null)}\n        onComplete=${async () => {\n          await statusPoll.refresh();\n          await runsPoll.refresh();\n          await cardsPoll.refresh();\n        }}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/doctor/summary-cards.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { SummaryStatCard } from \"../summary-stat-card.js\";\nimport { buildDoctorPriorityCounts } from \"./helpers.js\";\n\nconst html = htm.bind(h);\n\nexport const DoctorSummaryCards = ({ cards = [] }) => {\n  const counts = buildDoctorPriorityCounts(cards);\n  return html`\n    <div class=\"grid grid-cols-1 md:grid-cols-4 gap-3\">\n      <${SummaryStatCard} title=\"Open Findings\" value=${cards.length} />\n      <${SummaryStatCard} title=\"P0\" value=${counts.P0} toneClassName=\"text-status-error-muted\" />\n      <${SummaryStatCard} title=\"P1\" value=${counts.P1} toneClassName=\"text-status-warning-muted\" />\n      <${SummaryStatCard} title=\"P2\" value=${counts.P2} toneClassName=\"text-body\" />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/envars.js",
    "content": "import { h } from \"preact\";\nimport {\n  useState,\n  useEffect,\n  useCallback,\n  useRef,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport { fetchEnvVars, saveEnvVars } from \"../lib/api.js\";\nimport { useCachedFetch } from \"../hooks/use-cached-fetch.js\";\nimport { showToast } from \"./toast.js\";\nimport { SecretInput } from \"./secret-input.js\";\nimport { PageHeader } from \"./page-header.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport { PopActions } from \"./pop-actions.js\";\nimport { PaneShell } from \"./pane-shell.js\";\nimport {\n  Brain2LineIcon,\n  ChatVoiceLineIcon,\n  ChevronDownIcon,\n  ImageAiLineIcon,\n  TextToSpeechLineIcon,\n} from \"./icons.js\";\nimport { Tooltip } from \"./tooltip.js\";\nconst html = htm.bind(h);\n\nconst kGroupLabels = {\n  ai: \"AI Provider Keys\",\n  github: \"GitHub\",\n  channels: \"Channels\",\n  tools: \"Tools\",\n  custom: \"Custom\",\n};\n\nconst kGroupOrder = [\"ai\", \"github\", \"channels\", \"tools\", \"custom\"];\nconst kDefaultVisibleAiKeys = new Set([\"OPENAI_API_KEY\", \"GEMINI_API_KEY\"]);\nconst kFeatureIconByName = {\n  Embeddings: {\n    Icon: Brain2LineIcon,\n    label: \"Memory embeddings\",\n  },\n  Image: {\n    Icon: ImageAiLineIcon,\n    label: \"Image generation\",\n  },\n  TTS: {\n    Icon: TextToSpeechLineIcon,\n    label: \"Text to speech\",\n  },\n  STT: {\n    Icon: ChatVoiceLineIcon,\n    label: \"Speech to text\",\n  },\n};\nconst normalizeEnvVarKey = (raw) =>\n  raw\n    .trim()\n    .toUpperCase()\n    .replace(/[^A-Z0-9_]/g, \"_\");\nconst kManagedChannelTokenPattern =\n  /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/;\nconst stripSurroundingQuotes = (raw) => {\n  const value = String(raw || \"\").trim();\n  if (value.length < 2) return value;\n  const startsWithDouble = value.startsWith('\"');\n  const endsWithDouble = value.endsWith('\"');\n  if (startsWithDouble && endsWithDouble) return value.slice(1, -1);\n  const startsWithSingle = value.startsWith(\"'\");\n  const endsWithSingle = value.endsWith(\"'\");\n  if (startsWithSingle && endsWithSingle) return value.slice(1, -1);\n  return value;\n};\nconst isManagedChannelTokenKey = (key = \"\") =>\n  kManagedChannelTokenPattern.test(\n    String(key || \"\")\n      .trim()\n      .toUpperCase(),\n  );\nconst getVarsSignature = (items) =>\n  JSON.stringify(\n    (items || [])\n      .map((v) => ({\n        key: String(v?.key || \"\"),\n        value: String(v?.value || \"\"),\n      }))\n      .sort((a, b) => a.key.localeCompare(b.key)),\n  );\n\nconst sortCustomVarsAlphabetically = (items) => {\n  const list = Array.isArray(items) ? [...items] : [];\n  const customSorted = list\n    .filter((item) => (item?.group || \"custom\") === \"custom\")\n    .sort((a, b) => String(a?.key || \"\").localeCompare(String(b?.key || \"\")));\n  let customIdx = 0;\n  return list.map((item) => {\n    if ((item?.group || \"custom\") !== \"custom\") return item;\n    const next = customSorted[customIdx];\n    customIdx += 1;\n    return next;\n  });\n};\n\nconst kHintByKey = {\n  ANTHROPIC_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://console.anthropic.com\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >console.anthropic.com</a\n    >`,\n  ANTHROPIC_TOKEN: html`from\n    <code class=\"text-xs bg-field px-1 rounded\">claude setup-token</code>`,\n  OPENAI_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://platform.openai.com\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >platform.openai.com</a\n    >`,\n  GEMINI_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://aistudio.google.com\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >aistudio.google.com</a\n    >`,\n  ELEVENLABS_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://elevenlabs.io\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >elevenlabs.io</a\n    >${\" \"} · ${\" \"}\n    <code class=\"text-xs bg-field px-1 rounded\">XI_API_KEY</code> also\n    supported`,\n  GITHUB_WORKSPACE_REPO: html`use\n    <code class=\"text-xs bg-field px-1 rounded\">owner/repo</code> or\n    <code class=\"text-xs bg-field px-1 rounded\"\n      >https://github.com/owner/repo</code\n    >`,\n  TELEGRAM_BOT_TOKEN: html`from${\" \"}\n    <a\n      href=\"https://t.me/BotFather\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >@BotFather</a\n    >\n    ·\n    <a\n      href=\"https://docs.openclaw.ai/channels/telegram\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >full guide</a\n    >`,\n  DISCORD_BOT_TOKEN: html`from${\" \"}\n    <a\n      href=\"https://discord.com/developers/applications\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >developer portal</a\n    >\n    ·\n    <a\n      href=\"https://docs.openclaw.ai/channels/discord\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >full guide</a\n    >`,\n  MISTRAL_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://console.mistral.ai\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >console.mistral.ai</a\n    >`,\n  VOYAGE_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://dash.voyageai.com\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >dash.voyageai.com</a\n    >`,\n  GROQ_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://console.groq.com\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >console.groq.com</a\n    >`,\n  DEEPGRAM_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://console.deepgram.com\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >console.deepgram.com</a\n    >`,\n  BRAVE_API_KEY: html`from${\" \"}\n    <a\n      href=\"https://brave.com/search/api/\"\n      target=\"_blank\"\n      class=\"hover:underline\"\n      style=\"color: var(--accent-link)\"\n      >brave.com/search/api</a\n    >${\" \"} — free tier available`,\n};\n\nconst getHintContent = (envVar) => kHintByKey[envVar.key] || envVar.hint || \"\";\n\nconst getVisibleFeatureIcons = (envVar) =>\n  (Array.isArray(envVar?.features) ? envVar.features : []).filter(\n    (feature) => !!kFeatureIconByName[feature],\n  );\n\nconst splitAiVars = (items) => {\n  const visible = [];\n  const hidden = [];\n  (items || []).forEach((item) => {\n    const hasValue = !!String(item?.value || \"\").trim();\n    if (kDefaultVisibleAiKeys.has(item?.key) || hasValue) {\n      visible.push(item);\n      return;\n    }\n    hidden.push(item);\n  });\n  return { visible, hidden };\n};\n\nconst FeatureIcon = ({ feature }) => {\n  const entry = kFeatureIconByName[feature];\n  if (!entry) return null;\n  const { Icon, label } = entry;\n  return html`\n    <${Tooltip} text=${label} widthClass=\"w-auto\" tooltipClassName=\"whitespace-nowrap\">\n      <span\n        class=\"inline-flex items-center justify-center text-fg-muted hover:text-body focus-within:text-body\"\n        tabindex=\"0\"\n        aria-label=${label}\n      >\n        <${Icon} className=\"w-3.5 h-3.5\" />\n      </span>\n    </${Tooltip}>\n  `;\n};\n\nconst EnvRow = ({ envVar, onChange, onDelete, disabled }) => {\n  const hint = getHintContent(envVar);\n  const featureIcons = getVisibleFeatureIcons(envVar);\n\n  return html`\n    <div class=\"flex items-start gap-4 px-4 py-3\">\n      <div class=\"shrink-0\" style=\"width: 200px\">\n        <div class=\"flex items-center gap-2 pt-1.5\">\n          <span\n            class=\"inline-block w-1.5 h-1.5 rounded-full shrink-0 ${envVar.value\n              ? \"bg-green-500\"\n              : \"bg-gray-600\"}\"\n          />\n          <code class=\"text-sm truncate\">${envVar.key}</code>\n        </div>\n        ${featureIcons.length > 0\n          ? html`\n              <div class=\"flex items-center gap-2 mt-1 pl-3.5\">\n                ${featureIcons.map(\n                  (feature) =>\n                    html`<${FeatureIcon} key=${feature} feature=${feature} />`,\n                )}\n              </div>\n            `\n          : null}\n      </div>\n      <div class=\"flex-1 min-w-0\">\n        <div class=\"flex items-center gap-1\">\n          <${SecretInput}\n            value=${envVar.value}\n            onInput=${(e) => onChange(envVar.key, e.target.value)}\n            placeholder=${envVar.value ? \"\" : \"not set\"}\n            isSecret=${!!envVar.value}\n            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\"\n            disabled=${disabled}\n          />\n          ${envVar.group === \"custom\"\n            ? html`<button\n                onclick=${() => onDelete(envVar.key)}\n                class=\"text-fg-dim hover:text-status-error-muted px-1 text-xs shrink-0\"\n                title=\"Delete\"\n              >\n                ✕\n              </button>`\n            : null}\n        </div>\n        ${hint ? html`<p class=\"text-xs text-fg-dim mt-1\">${hint}</p>` : null}\n      </div>\n    </div>\n  `;\n};\n\nexport const Envars = ({ onRestartRequired = () => {} }) => {\n  const [vars, setVars] = useState([]);\n  const [reservedKeys, setReservedKeys] = useState(() => new Set());\n  const [pendingCustomKeys, setPendingCustomKeys] = useState([]);\n  const [secretMaskEpoch, setSecretMaskEpoch] = useState(0);\n  const [dirty, setDirty] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [showAllAiKeys, setShowAllAiKeys] = useState(false);\n  const [newKey, setNewKey] = useState(\"\");\n  const baselineSignatureRef = useRef(\"[]\");\n  const {\n    data: envPayload,\n    error: envPayloadError,\n    loading: envPayloadLoading,\n    refresh: refreshEnvPayload,\n  } = useCachedFetch(\"/api/env\", fetchEnvVars, {\n    maxAgeMs: 30000,\n  });\n\n  const applyEnvPayload = useCallback(\n    (data) => {\n      if (!data) return;\n      const nextVars = sortCustomVarsAlphabetically(data.vars || []);\n      baselineSignatureRef.current = getVarsSignature(nextVars);\n      setVars(nextVars);\n      setPendingCustomKeys([]);\n      setReservedKeys(new Set(data.reservedKeys || []));\n      if (data.restartRequired) {\n        onRestartRequired(true);\n      }\n    },\n    [onRestartRequired],\n  );\n\n  const load = useCallback(async () => {\n    try {\n      const data = await refreshEnvPayload({ force: true });\n      applyEnvPayload(data);\n    } catch (err) {\n      console.error(\"Failed to load env vars:\", err);\n    }\n  }, [applyEnvPayload, refreshEnvPayload]);\n\n  useEffect(() => {\n    if (!envPayload) return;\n    if (dirty || saving) return;\n    applyEnvPayload(envPayload);\n  }, [applyEnvPayload, dirty, envPayload, saving]);\n\n  useEffect(() => {\n    if (!envPayloadError) return;\n    console.error(\"Failed to load env vars:\", envPayloadError);\n  }, [envPayloadError]);\n\n  useEffect(() => {\n    setDirty(getVarsSignature(vars) !== baselineSignatureRef.current);\n  }, [vars]);\n\n  const handleChange = (key, value) => {\n    setVars((prev) => prev.map((v) => (v.key === key ? { ...v, value } : v)));\n  };\n\n  const handleDelete = (key) => {\n    setVars((prev) => prev.filter((v) => v.key !== key));\n    setPendingCustomKeys((prev) =>\n      prev.filter((pendingKey) => pendingKey !== key),\n    );\n  };\n\n  const handleSave = async () => {\n    if (saving) return;\n    setSaving(true);\n    try {\n      const toSave = vars\n        .filter((v) => v.editable && !isManagedChannelTokenKey(v?.key))\n        .map((v) => ({ key: v.key, value: v.value }));\n      const result = await saveEnvVars(toSave);\n      const needsRestart = !!result?.restartRequired;\n      if (needsRestart) onRestartRequired(true);\n      showToast(\n        needsRestart\n          ? \"Environment variables saved. Restart gateway to apply.\"\n          : \"Environment variables saved\",\n        \"success\",\n      );\n      // Force-refresh /api/env so stale cached payload cannot overwrite newly\n      // saved values with older state right after save.\n      const latestPayload = await refreshEnvPayload({ force: true });\n      applyEnvPayload(latestPayload);\n      setSecretMaskEpoch((prev) => prev + 1);\n      setDirty(false);\n    } catch (err) {\n      showToast(\"Failed to save: \" + err.message, \"error\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const [newVal, setNewVal] = useState(\"\");\n\n  const parsePaste = (input) => {\n    const lines = input\n      .split(\"\\n\")\n      .map((l) => l.trim())\n      .filter(Boolean)\n      .filter((l) => !l.startsWith(\"#\"));\n    const pairs = [];\n    for (const line of lines) {\n      const eqIdx = line.indexOf(\"=\");\n      if (eqIdx > 0)\n        pairs.push({\n          key: line.slice(0, eqIdx).trim(),\n          value: stripSurroundingQuotes(line.slice(eqIdx + 1)),\n        });\n    }\n    return pairs;\n  };\n\n  const addVars = (pairs) => {\n    let added = 0;\n    const blocked = [];\n    const managedChannelKeys = [];\n    const addedCustomKeys = [];\n    setVars((prev) => {\n      const next = [...prev];\n      for (const { key: rawKey, value } of pairs) {\n        const key = normalizeEnvVarKey(rawKey);\n        if (!key) continue;\n        if (isManagedChannelTokenKey(key)) {\n          managedChannelKeys.push(key);\n          continue;\n        }\n        if (reservedKeys.has(key)) {\n          blocked.push(key);\n          continue;\n        }\n        const existing = next.find((v) => v.key === key);\n        if (existing) {\n          existing.value = value;\n        } else {\n          next.push({\n            key,\n            value,\n            label: key,\n            group: \"custom\",\n            hint: \"\",\n            source: \"env_file\",\n            editable: true,\n          });\n          addedCustomKeys.push(key);\n        }\n        added++;\n      }\n      return next;\n    });\n    if (addedCustomKeys.length) {\n      setPendingCustomKeys((prev) => [...prev, ...addedCustomKeys]);\n    }\n    return { added, blocked, managedChannelKeys };\n  };\n\n  const handlePaste = (e, fallbackField) => {\n    const text = (e.clipboardData || window.clipboardData).getData(\"text\");\n    const pairs = parsePaste(text);\n    if (pairs.length > 1) {\n      e.preventDefault();\n      const { added, blocked, managedChannelKeys } = addVars(pairs);\n      setNewKey(\"\");\n      setNewVal(\"\");\n      if (blocked.length) {\n        const uniqueBlocked = Array.from(new Set(blocked));\n        showToast(\n          `Reserved vars can't be added: ${uniqueBlocked.join(\", \")}`,\n          \"error\",\n        );\n      }\n      if (managedChannelKeys.length) {\n        const uniqueManagedKeys = Array.from(new Set(managedChannelKeys));\n        showToast(\n          `Channel tokens are managed from Channels: ${uniqueManagedKeys.join(\", \")}`,\n          \"error\",\n        );\n      }\n      if (added) {\n        showToast(\n          `Added ${added} variable${added !== 1 ? \"s\" : \"\"}`,\n          \"success\",\n        );\n      }\n      return;\n    }\n    if (pairs.length === 1) {\n      e.preventDefault();\n      setNewKey(pairs[0].key);\n      setNewVal(pairs[0].value);\n      return;\n    }\n  };\n\n  const handleKeyInput = (raw) => {\n    const pairs = parsePaste(raw);\n    if (pairs.length === 1) {\n      setNewKey(pairs[0].key);\n      setNewVal(pairs[0].value);\n      return;\n    }\n    setNewKey(raw);\n  };\n\n  const handleValInput = (raw) => {\n    const pairs = parsePaste(raw);\n    if (pairs.length === 1) {\n      setNewKey(pairs[0].key);\n      setNewVal(pairs[0].value);\n      return;\n    }\n    setNewVal(raw);\n  };\n\n  const handleAddVar = () => {\n    const key = normalizeEnvVarKey(newKey);\n    if (!key) return;\n    if (isManagedChannelTokenKey(key)) {\n      showToast(`Channel tokens are managed from Channels: ${key}`, \"error\");\n      return;\n    }\n    if (reservedKeys.has(key)) {\n      showToast(`Reserved var can't be added: ${key}`, \"error\");\n      return;\n    }\n    addVars([{ key, value: newVal }]);\n    setNewKey(\"\");\n    setNewVal(\"\");\n  };\n\n  const visibleVars = vars.filter((v) => !isManagedChannelTokenKey(v?.key));\n\n  // Group vars\n  const grouped = {};\n  for (const v of visibleVars) {\n    const g = v.group || \"custom\";\n    if (!grouped[g]) grouped[g] = [];\n    grouped[g].push(v);\n  }\n  if (grouped.custom?.length) {\n    const pending = new Set(pendingCustomKeys);\n    const nonPending = grouped.custom\n      .filter((item) => !pending.has(item.key))\n      .sort((a, b) => String(a?.key || \"\").localeCompare(String(b?.key || \"\")));\n    const pendingAtBottom = grouped.custom.filter((item) =>\n      pending.has(item.key),\n    );\n    grouped.custom = [...nonPending, ...pendingAtBottom];\n  }\n  const aiSplit = splitAiVars(grouped.ai || []);\n  const renderEnvRows = (items) =>\n    items.map(\n      (v) =>\n        html`<${EnvRow}\n          key=${`${secretMaskEpoch}:${v.key}`}\n          envVar=${v}\n          onChange=${handleChange}\n          onDelete=${handleDelete}\n          disabled=${saving}\n        />`,\n    );\n  const renderGroupCard = (groupKey) => {\n    const items = grouped[groupKey] || [];\n    if (!items.length) return null;\n    if (groupKey === \"ai\") {\n      const { visible, hidden } = aiSplit;\n      const expanded = showAllAiKeys && hidden.length > 0;\n      return html`\n        <div class=\"bg-surface border border-border rounded-xl overflow-hidden\">\n          <h3 class=\"card-label text-xs px-4 pt-3 pb-2\">\n            ${kGroupLabels[groupKey] || groupKey}\n          </h3>\n          <div class=\"divide-y divide-border\">${renderEnvRows(visible)}</div>\n          ${hidden.length > 0\n            ? html`\n                <div class=\"border-t border-border px-4 py-2\">\n                  <button\n                    type=\"button\"\n                    onclick=${() => setShowAllAiKeys((prev) => !prev)}\n                    class=\"inline-flex items-center gap-1.5 text-xs text-fg-muted hover:text-body\"\n                  >\n                    <${ChevronDownIcon}\n                      className=${`transition-transform ${expanded ? \"rotate-180\" : \"\"}`}\n                    />\n                    ${expanded ? \"Show fewer\" : `Show more (${hidden.length})`}\n                  </button>\n                </div>\n              `\n            : null}\n          ${expanded\n            ? html`<div class=\"divide-y divide-border border-t border-border\">\n                ${renderEnvRows(hidden)}\n              </div>`\n            : null}\n        </div>\n      `;\n    }\n    return html`\n      <div class=\"bg-surface border border-border rounded-xl overflow-hidden\">\n        <h3 class=\"card-label text-xs px-4 pt-3 pb-2\">\n          ${kGroupLabels[groupKey] || groupKey}\n        </h3>\n        <div class=\"divide-y divide-border\">${renderEnvRows(items)}</div>\n      </div>\n    `;\n  };\n\n  if (envPayloadLoading && !vars.length) {\n    return html`\n      <${PaneShell}\n        header=${html`<${PageHeader} title=\"Envars\" />`}\n      >\n        <div class=\"bg-surface border border-border rounded-xl p-4 text-sm text-fg-muted\">\n          Loading environment variables...\n        </div>\n      </${PaneShell}>\n    `;\n  }\n\n  return html`\n    <${PaneShell}\n      header=${html`\n        <${PageHeader}\n          title=\"Envars\"\n          actions=${html`\n            <${PopActions} visible=${dirty}>\n              <${ActionButton}\n                onClick=${load}\n                disabled=${saving}\n                tone=\"secondary\"\n                size=\"sm\"\n                idleLabel=\"Cancel\"\n                className=\"text-xs\"\n              />\n              <${ActionButton}\n                onClick=${handleSave}\n                disabled=${saving}\n                loading=${saving}\n                loadingMode=\"inline\"\n                tone=\"primary\"\n                size=\"sm\"\n                idleLabel=\"Save changes\"\n                loadingLabel=\"Saving…\"\n                className=\"text-xs\"\n              />\n            </${PopActions}>\n          `}\n        />\n      `}\n    >\n      ${kGroupOrder\n        .filter((g) => grouped[g]?.length)\n        .map((g) => renderGroupCard(g))}\n\n      <div\n        class=\"bg-surface border border-border rounded-xl overflow-hidden\"\n      >\n        <div class=\"flex items-center justify-between px-4 pt-3 pb-2\">\n          <h3 class=\"card-label text-xs\">Add Variable</h3>\n          <span class=\"text-xs\" style=\"color: var(--text-dim)\"\n            >Paste KEY=VALUE or multiple lines</span\n          >\n        </div>\n        <div\n          class=\"flex items-start gap-4 px-4 py-3 border-t border-border\"\n        >\n          <div class=\"shrink-0\" style=\"width: 200px\">\n            <input\n              type=\"text\"\n              value=${newKey}\n              placeholder=\"KEY\"\n              onInput=${(e) => handleKeyInput(e.target.value)}\n              onPaste=${(e) => handlePaste(e, \"key\")}\n              onKeyDown=${(e) => e.key === \"Enter\" && handleAddVar()}\n              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\"\n            />\n          </div>\n          <div class=\"flex-1 flex gap-2\">\n            <input\n              type=\"text\"\n              value=${newVal}\n              placeholder=\"value\"\n              onInput=${(e) => handleValInput(e.target.value)}\n              onPaste=${(e) => handlePaste(e, \"val\")}\n              onKeyDown=${(e) => e.key === \"Enter\" && handleAddVar()}\n              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\"\n            />\n            <button\n              onclick=${handleAddVar}\n              class=\"text-xs px-3 py-1.5 rounded-lg border border-border text-fg-muted hover:text-body hover:border-fg-muted shrink-0\"\n            >\n              + Add\n            </button>\n          </div>\n        </div>\n      </div>\n    </${PaneShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/features.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { fetchEnvVars } from \"../lib/api.js\";\nimport { useCachedFetch } from \"../hooks/use-cached-fetch.js\";\nimport { Badge } from \"./badge.js\";\nimport {\n  kFeatureDefs,\n  kProviderAuthFields,\n  kProviderLabels,\n} from \"../lib/model-config.js\";\n\nconst html = htm.bind(h);\n\nconst getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || \"\";\n\nconst resolveFeatureStatus = (feature, envVars) => {\n  for (const provider of feature.providers) {\n    const fields = kProviderAuthFields[provider] || [];\n    const hasKey = fields.some((f) => !!getKeyVal(envVars, f.key));\n    if (hasKey) return { active: true, provider };\n  }\n  return { active: false, provider: null };\n};\n\nexport const Features = ({ onSwitchTab }) => {\n  const { data, loading } = useCachedFetch(\"/api/env\", fetchEnvVars, {\n    maxAgeMs: 30000,\n  });\n  const envVars = Array.isArray(data?.vars) ? data.vars : [];\n  const loaded = !loading;\n\n  if (!loaded) return null;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <h2 class=\"card-label mb-3\">Features</h2>\n      <div class=\"space-y-2\">\n        ${kFeatureDefs.map((feature) => {\n          const status = resolveFeatureStatus(feature, envVars);\n          return html`\n            <div class=\"flex justify-between items-center py-1.5\">\n              <span class=\"text-sm text-body\">${feature.label}</span>\n              ${status.active\n                ? html`\n                    <span class=\"flex items-center gap-2\">\n                      <span class=\"text-xs text-fg-muted\">\n                        ${kProviderLabels[status.provider] || status.provider}\n                      </span>\n                      <${Badge} tone=\"success\">Enabled</${Badge}>\n                    </span>\n                  `\n                : html`\n                    <span class=\"flex items-center gap-2\">\n                      <a\n                        href=\"#\"\n                        onclick=${(e) => {\n                          e.preventDefault();\n                          onSwitchTab?.(\"envars\");\n                        }}\n                        class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost\"\n                      >Add provider</a>\n                      <${Badge} tone=${feature.hasDefault ? \"neutral\" : \"danger\"}>Disabled</${Badge}>\n                    </span>\n                  `}\n            </div>\n          `;\n        })}\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/file-tree.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  fetchBrowseTree,\n  deleteBrowseFile,\n  createBrowseFile,\n  createBrowseFolder,\n  moveBrowsePath,\n  downloadBrowseFile,\n} from \"../lib/api.js\";\nimport {\n  kDraftIndexChangedEventName,\n  readStoredDraftPaths,\n} from \"../lib/browse-draft-state.js\";\nimport {\n  kLockedBrowsePaths,\n  kProtectedBrowsePaths,\n  matchesBrowsePolicyPath,\n  normalizeBrowsePolicyPath,\n} from \"../lib/browse-file-policies.js\";\nimport { collectAncestorFolderPaths } from \"../lib/file-tree-utils.js\";\nimport {\n  MarkdownFillIcon,\n  JavascriptFillIcon,\n  File3LineIcon,\n  FileMusicLineIcon,\n  Image2FillIcon,\n  TerminalFillIcon,\n  BracesLineIcon,\n  FileCodeLineIcon,\n  Database2LineIcon,\n  HashtagIcon,\n  LockLineIcon,\n  FileAddLineIcon,\n  FolderAddLineIcon,\n  DeleteBinLineIcon,\n  DownloadLineIcon,\n  FileCopyLineIcon,\n} from \"./icons.js\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\nimport { ConfirmDialog } from \"./confirm-dialog.js\";\nimport { showToast } from \"./toast.js\";\nimport { copyTextToClipboard } from \"../lib/clipboard.js\";\n\nconst html = htm.bind(h);\nconst kTreeIndentPx = 9;\nconst kFolderBasePaddingPx = 10;\nconst kFileBasePaddingPx = 14;\nconst kTreeRefreshIntervalMs = 5000;\nimport { kExpandedFoldersStorageKey } from \"../lib/storage-keys.js\";\n\nconst readStoredExpandedPaths = () => {\n  try {\n    const rawValue = window.localStorage.getItem(kExpandedFoldersStorageKey);\n    if (!rawValue) return null;\n    const parsedValue = JSON.parse(rawValue);\n    if (!Array.isArray(parsedValue)) return null;\n    return new Set(parsedValue.map((entry) => String(entry)));\n  } catch {\n    return null;\n  }\n};\n\nconst collectFolderPaths = (node, folderPaths) => {\n  if (!node || node.type !== \"folder\") return;\n  if (node.path) folderPaths.add(node.path);\n  (node.children || []).forEach((childNode) =>\n    collectFolderPaths(childNode, folderPaths),\n  );\n};\n\nconst collectFilePaths = (node, filePaths) => {\n  if (!node) return;\n  if (node.type === \"file\") {\n    if (node.path) filePaths.push(node.path);\n    return;\n  }\n  (node.children || []).forEach((childNode) =>\n    collectFilePaths(childNode, filePaths),\n  );\n};\n\nconst removeTreePath = (node, targetPath) => {\n  if (!node) return null;\n  const safeTargetPath = String(targetPath || \"\").trim();\n  if (!safeTargetPath) return node;\n  const nodePath = String(node.path || \"\").trim();\n  if (nodePath === safeTargetPath) return null;\n  if (node.type !== \"folder\") return node;\n  const nextChildren = (node.children || [])\n    .map((childNode) => removeTreePath(childNode, safeTargetPath))\n    .filter(Boolean);\n  if (nextChildren.length === (node.children || []).length) return node;\n  return {\n    ...node,\n    children: nextChildren,\n  };\n};\n\nconst filterTreeNode = (node, normalizedQuery) => {\n  if (!node) return null;\n  const query = String(normalizedQuery || \"\")\n    .trim()\n    .toLowerCase();\n  if (!query) return node;\n  const nodeName = String(node.name || \"\").toLowerCase();\n  const nodePath = String(node.path || \"\").toLowerCase();\n  const isDirectMatch = nodeName.includes(query) || nodePath.includes(query);\n  if (node.type === \"file\") {\n    return isDirectMatch ? node : null;\n  }\n  const filteredChildren = (node.children || [])\n    .map((childNode) => filterTreeNode(childNode, query))\n    .filter(Boolean);\n  if (!isDirectMatch && filteredChildren.length === 0) return null;\n  return {\n    ...node,\n    children: filteredChildren,\n  };\n};\n\nconst getFileIconMeta = (fileName) => {\n  const normalizedName = String(fileName || \"\").toLowerCase();\n  const normalizedNameWithoutBakSuffix = normalizedName.replace(/(\\.bak)+$/i, \"\");\n  if (normalizedNameWithoutBakSuffix.endsWith(\".md\")) {\n    return {\n      icon: MarkdownFillIcon,\n      className: \"file-icon file-icon-md\",\n    };\n  }\n  if (\n    normalizedNameWithoutBakSuffix.endsWith(\".js\") ||\n    normalizedNameWithoutBakSuffix.endsWith(\".mjs\")\n  ) {\n    return {\n      icon: JavascriptFillIcon,\n      className: \"file-icon file-icon-js\",\n    };\n  }\n  if (\n    normalizedNameWithoutBakSuffix.endsWith(\".json\") ||\n    normalizedNameWithoutBakSuffix.endsWith(\".jsonl\")\n  ) {\n    return {\n      icon: BracesLineIcon,\n      className: \"file-icon file-icon-json\",\n    };\n  }\n  if (\n    normalizedNameWithoutBakSuffix.endsWith(\".css\") ||\n    normalizedNameWithoutBakSuffix.endsWith(\".scss\")\n  ) {\n    return {\n      icon: HashtagIcon,\n      className: \"file-icon file-icon-css\",\n    };\n  }\n  if (/\\.(html?)$/i.test(normalizedNameWithoutBakSuffix)) {\n    return {\n      icon: FileCodeLineIcon,\n      className: \"file-icon file-icon-html\",\n    };\n  }\n  if (\n    /\\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(\n      normalizedNameWithoutBakSuffix,\n    )\n  ) {\n    return {\n      icon: Image2FillIcon,\n      className: \"file-icon file-icon-image\",\n    };\n  }\n  if (\n    /\\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)$/i.test(\n      normalizedNameWithoutBakSuffix,\n    )\n  ) {\n    return {\n      icon: FileMusicLineIcon,\n      className: \"file-icon file-icon-audio\",\n    };\n  }\n  if (\n    /\\.(sh|bash|zsh|command)$/i.test(normalizedNameWithoutBakSuffix) ||\n    [\n      \".bashrc\",\n      \".zshrc\",\n      \".profile\",\n      \".bash_profile\",\n      \".zprofile\",\n      \".zshenv\",\n    ].includes(normalizedNameWithoutBakSuffix)\n  ) {\n    return {\n      icon: TerminalFillIcon,\n      className: \"file-icon file-icon-shell\",\n    };\n  }\n  if (\n    /\\.(db|sqlite|sqlite3|db3|sdb|sqlitedb|duckdb|mdb|accdb)$/i.test(\n      normalizedNameWithoutBakSuffix,\n    )\n  ) {\n    return {\n      icon: Database2LineIcon,\n      className: \"file-icon file-icon-db\",\n    };\n  }\n  return {\n    icon: File3LineIcon,\n    className: \"file-icon file-icon-generic\",\n  };\n};\n\nconst TreeContextMenu = ({\n  x,\n  y,\n  targetPath,\n  targetType,\n  isLocked,\n  onNewFile,\n  onNewFolder,\n  onCopyPath,\n  onDownload,\n  onDelete,\n  onClose,\n}) => {\n  const menuRef = useRef(null);\n\n  useEffect(() => {\n    const handleClick = (e) => {\n      if (menuRef.current && !menuRef.current.contains(e.target)) onClose();\n    };\n    const handleKey = (e) => {\n      if (e.key === \"Escape\") onClose();\n    };\n    const handleScroll = () => onClose();\n    window.addEventListener(\"mousedown\", handleClick);\n    window.addEventListener(\"keydown\", handleKey);\n    window.addEventListener(\"scroll\", handleScroll, true);\n    return () => {\n      window.removeEventListener(\"mousedown\", handleClick);\n      window.removeEventListener(\"keydown\", handleKey);\n      window.removeEventListener(\"scroll\", handleScroll, true);\n    };\n  }, [onClose]);\n\n  const isFolder = targetType === \"folder\";\n  const isFile = targetType === \"file\";\n  const isRoot = targetType === \"root\";\n  const contextFolder = isFolder ? targetPath : \"\";\n  const canCreate = !isLocked && (isFolder || isRoot);\n  const canCopyPath = Boolean((isFolder || isFile) && targetPath);\n  const canDownload = isFile && targetPath;\n  const canDelete = !isLocked && (isFolder || isFile) && targetPath;\n\n  return html`\n    <div\n      ref=${menuRef}\n      class=\"tree-context-menu\"\n      style=${{ top: `${y}px`, left: `${x}px` }}\n    >\n      ${canCreate\n        ? html`\n            <button\n              class=\"tree-context-menu-item\"\n              onclick=${() => { onNewFile(contextFolder); onClose(); }}\n            >\n              <${FileAddLineIcon} className=\"tree-context-menu-icon\" />\n              <span>New File</span>\n            </button>\n            <button\n              class=\"tree-context-menu-item\"\n              onclick=${() => { onNewFolder(contextFolder); onClose(); }}\n            >\n              <${FolderAddLineIcon} className=\"tree-context-menu-icon\" />\n              <span>New Folder</span>\n            </button>\n          `\n        : null}\n      ${canCopyPath || canDownload || canDelete\n        ? html`\n            ${canCreate\n              ? html`<div class=\"tree-context-menu-sep\"></div>`\n              : null}\n            ${canCopyPath\n              ? html`\n                  <button\n                    class=\"tree-context-menu-item\"\n                    onclick=${() => { onCopyPath(targetPath); onClose(); }}\n                  >\n                    <${FileCopyLineIcon} className=\"tree-context-menu-icon\" />\n                    <span>Copy Path</span>\n                  </button>\n                `\n              : null}\n            ${canDownload\n              ? html`\n                  <button\n                    class=\"tree-context-menu-item\"\n                    onclick=${() => { onDownload(targetPath); onClose(); }}\n                  >\n                    <${DownloadLineIcon} className=\"tree-context-menu-icon\" />\n                    <span>Download</span>\n                  </button>\n                `\n              : null}\n            ${canDelete\n              ? html`\n                  <button\n                    class=\"tree-context-menu-item\"\n                    onclick=${() => { onDelete(targetPath); onClose(); }}\n                  >\n                    <${DeleteBinLineIcon} className=\"tree-context-menu-icon\" />\n                    <span>Delete</span>\n                  </button>\n                `\n              : null}\n          `\n        : null}\n      ${isLocked\n        ? html`\n            <div class=\"tree-context-menu-item is-disabled\">\n              <${LockLineIcon} className=\"tree-context-menu-icon\" />\n              <span>Managed by AlphaClaw</span>\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n\nconst CreationInput = ({ type, depth, onConfirm, onCancel }) => {\n  const inputRef = useRef(null);\n  const [value, setValue] = useState(\"\");\n  const submittedRef = useRef(false);\n\n  useEffect(() => {\n    inputRef.current?.focus();\n  }, []);\n\n  const submit = () => {\n    const name = value.trim();\n    if (!name || submittedRef.current) {\n      onCancel();\n      return;\n    }\n    submittedRef.current = true;\n    onConfirm(name);\n  };\n\n  const IconComponent = type === \"folder\" ? FolderAddLineIcon : FileAddLineIcon;\n  return html`\n    <li class=\"tree-item\">\n      <div\n        class=\"tree-create-row\"\n        style=${{ paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px` }}\n      >\n        <${IconComponent} className=\"tree-create-icon\" />\n        <input\n          ref=${inputRef}\n          class=\"tree-create-input\"\n          type=\"text\"\n          value=${value}\n          onInput=${(e) => setValue(e.target.value)}\n          onKeyDown=${(e) => {\n            if (e.key === \"Enter\") {\n              e.preventDefault();\n              submit();\n            }\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              onCancel();\n            }\n          }}\n          onBlur=${submit}\n          placeholder=${type === \"folder\" ? \"folder name\" : \"file name\"}\n          autocomplete=\"off\"\n          spellcheck=${false}\n        />\n      </div>\n    </li>\n  `;\n};\n\nconst TreeNode = ({\n  node,\n  depth = 0,\n  expandedPaths,\n  onSetFolderExpanded,\n  onSelectFolder,\n  onRequestDelete,\n  onSelectFile,\n  onContextMenu,\n  onDragDrop,\n  selectedPath = \"\",\n  draftPaths,\n  isSearchActive = false,\n  searchActivePath = \"\",\n  creatingInFolder = \"\",\n  creatingType = \"\",\n  onCreationConfirm,\n  onCreationCancel,\n  dragSourcePath = \"\",\n}) => {\n  if (!node) return null;\n  if (node.type === \"file\") {\n    const isActive = selectedPath === node.path;\n    const isSearchActiveNode = searchActivePath === node.path;\n    const hasDraft = draftPaths.has(node.path || \"\");\n    const isLocked = matchesBrowsePolicyPath(\n      kLockedBrowsePaths,\n      normalizeBrowsePolicyPath(node.path || \"\"),\n    );\n    const fileIconMeta = getFileIconMeta(node.name);\n    const FileTypeIcon = fileIconMeta.icon;\n    return html`\n      <li class=\"tree-item${dragSourcePath === node.path ? \" is-dragging\" : \"\"}\">\n        <a\n          class=${`${isActive ? \"active\" : \"\"} ${isSearchActiveNode && !isActive ? \"soft-active\" : \"\"}`.trim()}\n          onclick=${() => onSelectFile(node.path)}\n          oncontextmenu=${(e) => {\n            e.preventDefault();\n            e.stopPropagation();\n            onContextMenu({ x: e.clientX, y: e.clientY, targetPath: node.path, targetType: \"file\", isLocked });\n          }}\n          draggable=${!isLocked}\n          onDragStart=${(e) => {\n            if (isLocked) { e.preventDefault(); return; }\n            e.dataTransfer.setData(\"text/plain\", node.path);\n            e.dataTransfer.effectAllowed = \"move\";\n            onDragDrop(\"start\", node.path);\n          }}\n          onDragEnd=${() => onDragDrop(\"end\", \"\")}\n          onKeyDown=${(event) => {\n            const isDeleteKey =\n              event.key === \"Delete\" || event.key === \"Backspace\";\n            if (!isDeleteKey || !isActive) return;\n            event.preventDefault();\n            onRequestDelete(node.path);\n          }}\n          tabindex=\"0\"\n          role=\"button\"\n          style=${{\n            paddingLeft: `${kFileBasePaddingPx + depth * kTreeIndentPx}px`,\n          }}\n          title=${node.path || node.name}\n        >\n          <${FileTypeIcon} className=${fileIconMeta.className} />\n          <span class=\"tree-label\">${node.name}</span>\n          ${isLocked\n            ? html`<${LockLineIcon}\n                className=\"tree-lock-icon\"\n                title=\"Managed by AlphaClaw\"\n              />`\n            : hasDraft\n              ? html`<span class=\"tree-draft-dot\" aria-hidden=\"true\"></span>`\n              : null}\n        </a>\n      </li>\n    `;\n  }\n\n  const folderPath = node.path || \"\";\n  const isCollapsed = isSearchActive ? false : !expandedPaths.has(folderPath);\n  const isFolderActive = selectedPath === folderPath;\n  const isFolderLocked = folderPath && matchesBrowsePolicyPath(\n    kLockedBrowsePaths,\n    normalizeBrowsePolicyPath(folderPath),\n  );\n  const [isDropTarget, setIsDropTarget] = useState(false);\n  const dropCounterRef = useRef(0);\n  return html`\n    <li class=\"tree-item${dragSourcePath === folderPath ? \" is-dragging\" : \"\"}\">\n      <div\n        class=${`tree-folder ${isCollapsed ? \"collapsed\" : \"\"} ${isFolderActive ? \"active\" : \"\"} ${isDropTarget ? \"is-drop-target\" : \"\"}`.trim()}\n        onclick=${() => {\n          if (!folderPath) return;\n          onSetFolderExpanded(folderPath, isCollapsed);\n          onSelectFolder(folderPath);\n        }}\n        oncontextmenu=${(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          onContextMenu({ x: e.clientX, y: e.clientY, targetPath: folderPath, targetType: \"folder\", isLocked: isFolderLocked });\n        }}\n        draggable=${!!folderPath && !isFolderLocked}\n        onDragStart=${(e) => {\n          if (!folderPath || isFolderLocked) { e.preventDefault(); return; }\n          e.dataTransfer.setData(\"text/plain\", folderPath);\n          e.dataTransfer.effectAllowed = \"move\";\n          onDragDrop(\"start\", folderPath);\n        }}\n        onDragEnd=${() => { setIsDropTarget(false); dropCounterRef.current = 0; onDragDrop(\"end\", \"\"); }}\n        onDragOver=${(e) => {\n          if (isFolderLocked) return;\n          e.preventDefault();\n          e.dataTransfer.dropEffect = \"move\";\n        }}\n        onDragEnter=${(e) => {\n          if (isFolderLocked) return;\n          e.preventDefault();\n          dropCounterRef.current += 1;\n          if (dropCounterRef.current === 1) setIsDropTarget(true);\n        }}\n        onDragLeave=${() => {\n          dropCounterRef.current -= 1;\n          if (dropCounterRef.current <= 0) { dropCounterRef.current = 0; setIsDropTarget(false); }\n        }}\n        onDrop=${(e) => {\n          if (isFolderLocked) return;\n          e.preventDefault();\n          e.stopPropagation();\n          setIsDropTarget(false);\n          dropCounterRef.current = 0;\n          const sourcePath = e.dataTransfer.getData(\"text/plain\");\n          if (sourcePath && sourcePath !== folderPath) {\n            onDragDrop(\"drop\", sourcePath, folderPath);\n          }\n        }}\n        style=${{\n          paddingLeft: `${kFolderBasePaddingPx + depth * kTreeIndentPx}px`,\n        }}\n        title=${folderPath || node.name}\n      >\n        <button\n          type=\"button\"\n          class=\"tree-folder-toggle\"\n          aria-label=${`${isCollapsed ? \"Expand\" : \"Collapse\"} ${node.name || \"folder\"}`}\n          aria-expanded=${isCollapsed ? \"false\" : \"true\"}\n          onclick=${(event) => {\n            event.preventDefault();\n            event.stopPropagation();\n            if (!folderPath) return;\n            onSetFolderExpanded(folderPath, isCollapsed);\n          }}\n        >\n          <span class=\"arrow\">▼</span>\n        </button>\n        <span class=\"tree-label\">${node.name}</span>\n        ${isFolderLocked\n          ? html`<${LockLineIcon}\n              className=\"tree-lock-icon\"\n              title=\"Managed by AlphaClaw\"\n            />`\n          : null}\n      </div>\n      <ul class=${`tree-children ${isCollapsed ? \"hidden\" : \"\"}`}>\n        ${creatingInFolder === folderPath && creatingType === \"folder\"\n          ? html`\n              <${CreationInput}\n                key=\"__creation__\"\n                type=\"folder\"\n                depth=${depth + 1}\n                onConfirm=${onCreationConfirm}\n                onCancel=${onCreationCancel}\n              />\n            `\n          : null}\n        ${(node.children || []).filter((c) => c.type === \"folder\").map(\n          (childNode) => html`\n            <${TreeNode}\n              key=${childNode.path || `${folderPath}/${childNode.name}`}\n              node=${childNode}\n              depth=${depth + 1}\n              expandedPaths=${expandedPaths}\n              onSetFolderExpanded=${onSetFolderExpanded}\n              onSelectFolder=${onSelectFolder}\n              onRequestDelete=${onRequestDelete}\n              onSelectFile=${onSelectFile}\n              onContextMenu=${onContextMenu}\n              onDragDrop=${onDragDrop}\n              selectedPath=${selectedPath}\n              draftPaths=${draftPaths}\n              isSearchActive=${isSearchActive}\n              searchActivePath=${searchActivePath}\n              creatingInFolder=${creatingInFolder}\n              creatingType=${creatingType}\n              onCreationConfirm=${onCreationConfirm}\n              onCreationCancel=${onCreationCancel}\n              dragSourcePath=${dragSourcePath}\n            />\n          `,\n        )}\n        ${creatingInFolder === folderPath && creatingType === \"file\"\n          ? html`\n              <${CreationInput}\n                key=\"__creation__\"\n                type=\"file\"\n                depth=${depth + 1}\n                onConfirm=${onCreationConfirm}\n                onCancel=${onCreationCancel}\n              />\n            `\n          : null}\n        ${(node.children || []).filter((c) => c.type !== \"folder\").map(\n          (childNode) => html`\n            <${TreeNode}\n              key=${childNode.path || `${folderPath}/${childNode.name}`}\n              node=${childNode}\n              depth=${depth + 1}\n              expandedPaths=${expandedPaths}\n              onSetFolderExpanded=${onSetFolderExpanded}\n              onSelectFolder=${onSelectFolder}\n              onRequestDelete=${onRequestDelete}\n              onSelectFile=${onSelectFile}\n              onContextMenu=${onContextMenu}\n              onDragDrop=${onDragDrop}\n              selectedPath=${selectedPath}\n              draftPaths=${draftPaths}\n              isSearchActive=${isSearchActive}\n              searchActivePath=${searchActivePath}\n              creatingInFolder=${creatingInFolder}\n              creatingType=${creatingType}\n              onCreationConfirm=${onCreationConfirm}\n              onCreationCancel=${onCreationCancel}\n              dragSourcePath=${dragSourcePath}\n            />\n          `,\n        )}\n      </ul>\n    </li>\n  `;\n};\n\nexport const FileTree = ({\n  onSelectFile = () => {},\n  selectedPath = \"\",\n  onPreviewFile = () => {},\n  isActive = true,\n}) => {\n  const [treeRoot, setTreeRoot] = useState(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(\"\");\n  const [expandedPaths, setExpandedPaths] = useState(readStoredExpandedPaths);\n  const [draftPaths, setDraftPaths] = useState(readStoredDraftPaths);\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const [searchActivePath, setSearchActivePath] = useState(\"\");\n  const [deleteTargetPath, setDeleteTargetPath] = useState(\"\");\n  const [deletingFile, setDeletingFile] = useState(false);\n  const [creatingInFolder, setCreatingInFolder] = useState(\"\");\n  const [creatingType, setCreatingType] = useState(\"\");\n  const [contextMenu, setContextMenu] = useState(null);\n  const [dragSourcePath, setDragSourcePath] = useState(\"\");\n  const [selectedFolder, setSelectedFolder] = useState(\"\");\n  const effectiveSelectedPath = selectedFolder || selectedPath;\n  const searchInputRef = useRef(null);\n  const treeSignatureRef = useRef(\"\");\n\n  const loadTree = useCallback(async ({ showLoading = false } = {}) => {\n    if (showLoading) setLoading(true);\n    if (showLoading) setError(\"\");\n    try {\n      const data = await fetchBrowseTree();\n      const nextRoot = data.root || null;\n      const nextSignature = JSON.stringify(nextRoot || {});\n      if (treeSignatureRef.current !== nextSignature) {\n        treeSignatureRef.current = nextSignature;\n        setTreeRoot(nextRoot);\n      }\n      setExpandedPaths((previousPaths) =>\n        previousPaths instanceof Set ? previousPaths : new Set(),\n      );\n      if (showLoading) setError(\"\");\n    } catch (loadError) {\n      if (showLoading) {\n        setError(loadError.message || \"Could not load file tree\");\n      }\n    } finally {\n      if (showLoading) setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    loadTree({ showLoading: true });\n  }, [loadTree]);\n\n  useEffect(() => {\n    if (!isActive) return () => {};\n    const refreshTree = () => {\n      loadTree({ showLoading: false });\n    };\n    const handleFileDeleted = (event) => {\n      const deletedPath = String(event?.detail?.path || \"\").trim();\n      if (!deletedPath) return;\n      setTreeRoot((previousRoot) => removeTreePath(previousRoot, deletedPath));\n    };\n    refreshTree();\n    const refreshInterval = window.setInterval(\n      refreshTree,\n      kTreeRefreshIntervalMs,\n    );\n    window.addEventListener(\"alphaclaw:browse-file-saved\", refreshTree);\n    window.addEventListener(\"alphaclaw:browse-tree-refresh\", refreshTree);\n    window.addEventListener(\"alphaclaw:browse-file-deleted\", handleFileDeleted);\n    return () => {\n      window.clearInterval(refreshInterval);\n      window.removeEventListener(\"alphaclaw:browse-file-saved\", refreshTree);\n      window.removeEventListener(\"alphaclaw:browse-tree-refresh\", refreshTree);\n      window.removeEventListener(\"alphaclaw:browse-file-deleted\", handleFileDeleted);\n    };\n  }, [isActive, loadTree]);\n\n  const normalizedSearchQuery = String(searchQuery || \"\")\n    .trim()\n    .toLowerCase();\n  const rootChildren = useMemo(() => {\n    const children = treeRoot?.children || [];\n    if (!normalizedSearchQuery) return children;\n    return children\n      .map((node) => filterTreeNode(node, normalizedSearchQuery))\n      .filter(Boolean);\n  }, [treeRoot, normalizedSearchQuery]);\n  const safeExpandedPaths =\n    expandedPaths instanceof Set ? expandedPaths : new Set();\n  const isSearchActive = normalizedSearchQuery.length > 0;\n  const filteredFilePaths = useMemo(() => {\n    const filePaths = [];\n    rootChildren.forEach((node) => collectFilePaths(node, filePaths));\n    return filePaths;\n  }, [rootChildren]);\n  const allTreeFilePaths = useMemo(() => {\n    const filePaths = [];\n    (treeRoot?.children || []).forEach((node) => collectFilePaths(node, filePaths));\n    return new Set(filePaths);\n  }, [treeRoot]);\n  const folderPaths = useMemo(() => {\n    const nextFolderPaths = new Set();\n    rootChildren.forEach((node) => collectFolderPaths(node, nextFolderPaths));\n    return nextFolderPaths;\n  }, [rootChildren]);\n\n  useEffect(() => {\n    if (!(expandedPaths instanceof Set)) return;\n    try {\n      window.localStorage.setItem(\n        kExpandedFoldersStorageKey,\n        JSON.stringify(Array.from(expandedPaths)),\n      );\n    } catch {}\n  }, [expandedPaths]);\n\n  useEffect(() => {\n    if (selectedPath) setSelectedFolder(\"\");\n  }, [selectedPath]);\n\n  useEffect(() => {\n    if (!selectedPath) return;\n    const ancestorFolderPaths = collectAncestorFolderPaths(selectedPath);\n    if (!ancestorFolderPaths.length) return;\n    setExpandedPaths((previousPaths) => {\n      if (!(previousPaths instanceof Set)) return previousPaths;\n      let didChange = false;\n      const nextPaths = new Set(previousPaths);\n      ancestorFolderPaths.forEach((ancestorPath) => {\n        if (!nextPaths.has(ancestorPath)) {\n          nextPaths.add(ancestorPath);\n          didChange = true;\n        }\n      });\n      return didChange ? nextPaths : previousPaths;\n    });\n  }, [selectedPath]);\n\n  useEffect(() => {\n    const handleDraftIndexChanged = (event) => {\n      const eventPaths = event?.detail?.paths;\n      if (Array.isArray(eventPaths)) {\n        setDraftPaths(\n          new Set(\n            eventPaths\n              .map((entry) => String(entry || \"\").trim())\n              .filter(Boolean),\n          ),\n        );\n        return;\n      }\n      setDraftPaths(readStoredDraftPaths());\n    };\n    window.addEventListener(\n      kDraftIndexChangedEventName,\n      handleDraftIndexChanged,\n    );\n    window.addEventListener(\"storage\", handleDraftIndexChanged);\n    return () => {\n      window.removeEventListener(\n        kDraftIndexChangedEventName,\n        handleDraftIndexChanged,\n      );\n      window.removeEventListener(\"storage\", handleDraftIndexChanged);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!isActive) return () => {};\n    const handleGlobalSearchShortcut = (event) => {\n      if (event.key !== \"/\") return;\n      if (event.metaKey || event.ctrlKey || event.altKey) return;\n      const target = event.target;\n      const tagName = String(target?.tagName || \"\").toLowerCase();\n      const isTypingTarget =\n        tagName === \"input\" ||\n        tagName === \"textarea\" ||\n        tagName === \"select\" ||\n        target?.isContentEditable;\n      if (isTypingTarget && target !== searchInputRef.current) return;\n      event.preventDefault();\n      searchInputRef.current?.focus();\n      searchInputRef.current?.select();\n    };\n    window.addEventListener(\"keydown\", handleGlobalSearchShortcut);\n    return () => {\n      window.removeEventListener(\"keydown\", handleGlobalSearchShortcut);\n    };\n  }, [isActive]);\n\n  useEffect(() => {\n    if (!isSearchActive) {\n      setSearchActivePath(\"\");\n      onPreviewFile(\"\");\n      return;\n    }\n    if (searchActivePath && filteredFilePaths.includes(searchActivePath))\n      return;\n    setSearchActivePath(\"\");\n    onPreviewFile(\"\");\n  }, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]);\n\n  const setFolderExpanded = (folderPath, nextExpanded) => {\n    setExpandedPaths((previousPaths) => {\n      const nextPaths =\n        previousPaths instanceof Set ? new Set(previousPaths) : new Set();\n      if (nextExpanded === true) {\n        nextPaths.add(folderPath);\n        return nextPaths;\n      }\n      if (nextExpanded === false) {\n        nextPaths.delete(folderPath);\n        return nextPaths;\n      }\n      if (nextPaths.has(folderPath)) nextPaths.delete(folderPath);\n      else nextPaths.add(folderPath);\n      return nextPaths;\n    });\n  };\n\n  const handleSelectFile = useCallback((filePath, options) => {\n    setSelectedFolder(\"\");\n    onSelectFile(filePath, options);\n  }, [onSelectFile]);\n\n  const selectFolder = (folderPath) => {\n    setSelectedFolder(folderPath);\n  };\n\n  const requestDelete = (targetPath) => {\n    const normalizedTargetPath = normalizeBrowsePolicyPath(targetPath);\n    if (!normalizedTargetPath) return;\n    if (\n      matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedTargetPath) ||\n      matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedTargetPath)\n    ) {\n      showToast(\"Protected or locked paths cannot be deleted\", \"warning\");\n      return;\n    }\n    setDeleteTargetPath(targetPath);\n  };\n\n  const deleteTargetIsFolder = folderPaths.has(deleteTargetPath);\n\n  const confirmDelete = async () => {\n    if (!deleteTargetPath || deletingFile) return;\n    setDeletingFile(true);\n    try {\n      await deleteBrowseFile(deleteTargetPath);\n      window.dispatchEvent(\n        new CustomEvent(\"alphaclaw:browse-file-saved\", {\n          detail: { path: deleteTargetPath },\n        }),\n      );\n      window.dispatchEvent(\n        new CustomEvent(\"alphaclaw:browse-file-deleted\", {\n          detail: { path: deleteTargetPath },\n        }),\n      );\n      setTreeRoot((previousRoot) =>\n        removeTreePath(previousRoot, deleteTargetPath),\n      );\n      window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-tree-refresh\"));\n      handleSelectFile(\"\");\n      showToast(deleteTargetIsFolder ? \"Folder deleted\" : \"File deleted\", \"success\");\n      setDeleteTargetPath(\"\");\n    } catch (deleteError) {\n      showToast(deleteError.message || \"Could not delete\", \"error\");\n    } finally {\n      setDeletingFile(false);\n    }\n  };\n\n  const getCreateFolder = (explicitFolder) => {\n    if (explicitFolder !== undefined) return explicitFolder;\n    if (!effectiveSelectedPath) return \"\";\n    if (folderPaths.has(effectiveSelectedPath)) return effectiveSelectedPath;\n    const lastSlash = effectiveSelectedPath.lastIndexOf(\"/\");\n    return lastSlash > 0 ? effectiveSelectedPath.slice(0, lastSlash) : \"\";\n  };\n\n  const requestCreate = (folderPath, type) => {\n    const target = getCreateFolder(folderPath);\n    if (target && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizeBrowsePolicyPath(target))) {\n      showToast(\"Cannot create inside a locked folder\", \"warning\");\n      return;\n    }\n    setCreatingInFolder(target);\n    setCreatingType(type);\n    if (target) {\n      setExpandedPaths((prev) => {\n        const next = prev instanceof Set ? new Set(prev) : new Set();\n        next.add(target);\n        return next;\n      });\n    }\n  };\n\n  const requestCreateFromToolbar = (type) => {\n    requestCreate(getCreateFolder(), type);\n  };\n\n  const cancelCreate = () => {\n    setCreatingInFolder(\"\");\n    setCreatingType(\"\");\n  };\n\n  const confirmCreate = async (name) => {\n    const folder = creatingInFolder;\n    const type = creatingType;\n    cancelCreate();\n    const fullPath = folder ? `${folder}/${name}` : name;\n    try {\n      if (type === \"folder\") {\n        await createBrowseFolder(fullPath);\n        showToast(\"Folder created\", \"success\");\n      } else {\n        await createBrowseFile(fullPath);\n        showToast(\"File created\", \"success\");\n      }\n      window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-tree-refresh\"));\n      if (folder) {\n        setExpandedPaths((prev) => {\n          const next = prev instanceof Set ? new Set(prev) : new Set();\n          next.add(folder);\n          return next;\n        });\n      }\n      if (type === \"file\") {\n        handleSelectFile(fullPath);\n      }\n    } catch (createError) {\n      showToast(createError.message || `Could not create ${type}`, \"error\");\n    }\n  };\n\n  const openContextMenu = (menu) => {\n    setContextMenu(menu);\n  };\n\n  const closeContextMenu = () => {\n    setContextMenu(null);\n  };\n\n  const requestDownload = async (targetPath) => {\n    try {\n      await downloadBrowseFile(targetPath);\n      showToast(\"Download started\", \"success\");\n    } catch (downloadError) {\n      showToast(downloadError.message || \"Could not download file\", \"error\");\n    }\n  };\n\n  const copyPath = async (targetPath) => {\n    const copied = await copyTextToClipboard(targetPath);\n    if (copied) {\n      showToast(\"Path copied\", \"success\");\n      return;\n    }\n    showToast(\"Could not copy path\", \"error\");\n  };\n\n  const handleDragDrop = async (action, sourcePath, targetFolder) => {\n    if (action === \"start\") {\n      setDragSourcePath(sourcePath);\n      return;\n    }\n    if (action === \"end\") {\n      setDragSourcePath(\"\");\n      return;\n    }\n    if (action === \"drop\") {\n      setDragSourcePath(\"\");\n      const basename = sourcePath.split(\"/\").pop();\n      if (!basename) return;\n      const destination = targetFolder ? `${targetFolder}/${basename}` : basename;\n      if (sourcePath === destination) return;\n      try {\n        await moveBrowsePath(sourcePath, destination);\n        showToast(`Moved to ${targetFolder || \"root\"}`, \"success\");\n        window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-tree-refresh\"));\n        if (selectedPath === sourcePath) {\n          handleSelectFile(destination);\n        }\n      } catch (moveError) {\n        showToast(moveError.message || \"Could not move\", \"error\");\n      }\n    }\n  };\n\n  const updateSearchQuery = (nextQuery) => {\n    setSearchQuery(nextQuery);\n  };\n\n  const clearSearch = () => {\n    setSearchQuery(\"\");\n    setSearchActivePath(\"\");\n    onPreviewFile(\"\");\n  };\n\n  const moveSearchSelection = (direction) => {\n    if (!filteredFilePaths.length) return;\n    const currentIndex = filteredFilePaths.indexOf(searchActivePath);\n    const delta = direction === \"up\" ? -1 : 1;\n    const baseIndex =\n      currentIndex === -1 ? (direction === \"up\" ? 0 : -1) : currentIndex;\n    const nextIndex =\n      (baseIndex + delta + filteredFilePaths.length) % filteredFilePaths.length;\n    const nextPath = filteredFilePaths[nextIndex];\n    setSearchActivePath(nextPath);\n    onPreviewFile(nextPath);\n  };\n\n  const commitSearchSelection = () => {\n    const [singlePath = \"\"] = filteredFilePaths;\n    const targetPath =\n      searchActivePath || (filteredFilePaths.length === 1 ? singlePath : \"\");\n    if (!targetPath) return;\n    handleSelectFile(targetPath);\n    clearSearch();\n  };\n\n  const onSearchKeyDown = (event) => {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      moveSearchSelection(\"down\");\n      return;\n    }\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      moveSearchSelection(\"up\");\n      return;\n    }\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      commitSearchSelection();\n      return;\n    }\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      clearSearch();\n    }\n  };\n\n  if (loading) {\n    return html`\n      <div class=\"file-tree-wrap file-tree-wrap-loading\">\n        <div class=\"file-tree-state file-tree-state-loading\">\n          <${LoadingSpinner} className=\"h-5 w-5 text-fg-muted\" />\n        </div>\n      </div>\n    `;\n  }\n  if (error) {\n    return html`<div class=\"file-tree-state file-tree-state-error\">\n      ${error}\n    </div>`;\n  }\n  if (!rootChildren.length && !creatingType) {\n    return html`\n      <div class=\"file-tree-wrap\">\n        <div class=\"file-tree-search\">\n          <input\n            class=\"file-tree-search-input\"\n            type=\"text\"\n            ref=${searchInputRef}\n            value=${searchQuery}\n            onInput=${(event) => updateSearchQuery(event.target.value)}\n            onKeyDown=${onSearchKeyDown}\n            placeholder=\"Search files...\"\n            autocomplete=\"off\"\n            spellcheck=${false}\n          />\n          <span class=\"file-tree-search-actions\">\n            <button\n              type=\"button\"\n              class=\"tree-folder-action\"\n              title=\"New file\"\n              onclick=${() => requestCreateFromToolbar(\"file\")}\n            >\n              <${FileAddLineIcon} className=\"tree-folder-action-icon\" />\n            </button>\n            <button\n              type=\"button\"\n              class=\"tree-folder-action\"\n              title=\"New folder\"\n              onclick=${() => requestCreateFromToolbar(\"folder\")}\n            >\n              <${FolderAddLineIcon} className=\"tree-folder-action-icon\" />\n            </button>\n          </span>\n        </div>\n        <div class=\"file-tree-state\">\n          ${isSearchActive ? \"No matching files.\" : \"No files found.\"}\n        </div>\n      </div>\n    `;\n  }\n\n  return html`\n    <div class=\"file-tree-wrap\">\n      <div class=\"file-tree-search\">\n        <input\n          class=\"file-tree-search-input\"\n          type=\"text\"\n          ref=${searchInputRef}\n          value=${searchQuery}\n          onInput=${(event) => updateSearchQuery(event.target.value)}\n          onKeyDown=${onSearchKeyDown}\n          placeholder=\"Search files...\"\n          autocomplete=\"off\"\n          spellcheck=${false}\n        />\n        <span class=\"file-tree-search-actions\">\n          <button\n            type=\"button\"\n            class=\"tree-folder-action\"\n            title=\"New file\"\n            onclick=${() => requestCreateFromToolbar(\"file\")}\n          >\n            <${FileAddLineIcon} className=\"tree-folder-action-icon\" />\n          </button>\n          <button\n            type=\"button\"\n            class=\"tree-folder-action\"\n            title=\"New folder\"\n            onclick=${() => requestCreateFromToolbar(\"folder\")}\n          >\n            <${FolderAddLineIcon} className=\"tree-folder-action-icon\" />\n          </button>\n        </span>\n      </div>\n      <div class=\"file-tree-scroll\">\n      <ul\n        class=\"file-tree\"\n        oncontextmenu=${(e) => {\n          e.preventDefault();\n          openContextMenu({ x: e.clientX, y: e.clientY, targetPath: \"\", targetType: \"root\" });\n        }}\n        onDragOver=${(e) => { e.preventDefault(); e.dataTransfer.dropEffect = \"move\"; }}\n        onDrop=${(e) => {\n          e.preventDefault();\n          const sourcePath = e.dataTransfer.getData(\"text/plain\");\n          if (sourcePath) handleDragDrop(\"drop\", sourcePath, \"\");\n        }}\n      >\n        ${creatingInFolder === \"\" && creatingType === \"folder\"\n          ? html`\n              <${CreationInput}\n                key=\"__root-creation__\"\n                type=\"folder\"\n                depth=${0}\n                onConfirm=${confirmCreate}\n                onCancel=${cancelCreate}\n              />\n            `\n          : null}\n        ${rootChildren.filter((n) => n.type === \"folder\").map(\n          (node) => html`\n            <${TreeNode}\n              key=${node.path || node.name}\n              node=${node}\n              expandedPaths=${safeExpandedPaths}\n              onSetFolderExpanded=${setFolderExpanded}\n              onSelectFolder=${selectFolder}\n              onRequestDelete=${requestDelete}\n              onSelectFile=${handleSelectFile}\n              onContextMenu=${openContextMenu}\n              onDragDrop=${handleDragDrop}\n              selectedPath=${effectiveSelectedPath}\n              draftPaths=${draftPaths}\n              isSearchActive=${isSearchActive}\n              searchActivePath=${searchActivePath}\n              creatingInFolder=${creatingInFolder}\n              creatingType=${creatingType}\n              onCreationConfirm=${confirmCreate}\n              onCreationCancel=${cancelCreate}\n              dragSourcePath=${dragSourcePath}\n            />\n          `,\n        )}\n        ${creatingInFolder === \"\" && creatingType === \"file\"\n          ? html`\n              <${CreationInput}\n                key=\"__root-creation__\"\n                type=\"file\"\n                depth=${0}\n                onConfirm=${confirmCreate}\n                onCancel=${cancelCreate}\n              />\n            `\n          : null}\n        ${rootChildren.filter((n) => n.type !== \"folder\").map(\n          (node) => html`\n            <${TreeNode}\n              key=${node.path || node.name}\n              node=${node}\n              expandedPaths=${safeExpandedPaths}\n              onSetFolderExpanded=${setFolderExpanded}\n              onSelectFolder=${selectFolder}\n              onRequestDelete=${requestDelete}\n              onSelectFile=${handleSelectFile}\n              onContextMenu=${openContextMenu}\n              onDragDrop=${handleDragDrop}\n              selectedPath=${effectiveSelectedPath}\n              draftPaths=${draftPaths}\n              isSearchActive=${isSearchActive}\n              searchActivePath=${searchActivePath}\n              creatingInFolder=${creatingInFolder}\n              creatingType=${creatingType}\n              onCreationConfirm=${confirmCreate}\n              onCreationCancel=${cancelCreate}\n              dragSourcePath=${dragSourcePath}\n            />\n          `,\n        )}\n      </ul>\n      </div>\n      ${contextMenu\n        ? html`\n            <${TreeContextMenu}\n              x=${contextMenu.x}\n              y=${contextMenu.y}\n              targetPath=${contextMenu.targetPath}\n              targetType=${contextMenu.targetType}\n              isLocked=${!!contextMenu.isLocked}\n              onNewFile=${(folder) => requestCreate(folder, \"file\")}\n              onNewFolder=${(folder) => requestCreate(folder, \"folder\")}\n              onCopyPath=${copyPath}\n              onDownload=${requestDownload}\n              onDelete=${requestDelete}\n              onClose=${closeContextMenu}\n            />\n          `\n        : null}\n      <${ConfirmDialog}\n        visible=${!!deleteTargetPath}\n        title=${deleteTargetIsFolder ? \"Delete folder?\" : \"Delete file?\"}\n        message=${deleteTargetIsFolder\n          ? `Delete folder ${deleteTargetPath || \"this folder\"} and all its contents?`\n          : `Delete ${deleteTargetPath || \"this file\"}? This can be restored from diff view before sync.`}\n        confirmLabel=\"Delete\"\n        confirmLoadingLabel=\"Deleting...\"\n        cancelLabel=\"Cancel\"\n        confirmTone=\"warning\"\n        confirmLoading=${deletingFile}\n        confirmDisabled=${deletingFile}\n        onCancel=${() => {\n          if (deletingFile) return;\n          setDeleteTargetPath(\"\");\n        }}\n        onConfirm=${confirmDelete}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/constants.js",
    "content": "export {\n  kFileViewerModeStorageKey,\n  kEditorSelectionStorageKey,\n} from \"../../lib/storage-keys.js\";\nexport const kLoadingIndicatorDelayMs = 1000;\nexport const kFileRefreshIntervalMs = 5000;\nexport const kSqlitePageSize = 50;\nexport const kLargeFileSimpleEditorCharThreshold = 250000;\nexport const kLargeFileSimpleEditorLineThreshold = 5000;\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/diff-viewer.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\n\nconst html = htm.bind(h);\n\nconst getDiffLineClass = (line) => {\n  if (line.startsWith(\"+\") && !line.startsWith(\"+++\")) return \"is-added\";\n  if (line.startsWith(\"-\") && !line.startsWith(\"---\")) return \"is-removed\";\n  if (line.startsWith(\"@@\")) return \"is-hunk\";\n  if (\n    line.startsWith(\"diff \") ||\n    line.startsWith(\"index \") ||\n    line.startsWith(\"--- \") ||\n    line.startsWith(\"+++ \")\n  ) {\n    return \"is-header\";\n  }\n  return \"\";\n};\n\nexport const DiffViewer = ({ diffLoading, diffError, diffContent }) => html`\n  <div class=\"file-viewer-diff-shell\">\n    ${diffLoading\n      ? html`\n          <div class=\"file-viewer-loading-shell\">\n            <${LoadingSpinner} className=\"h-4 w-4\" />\n          </div>\n        `\n      : diffError\n        ? html`<div class=\"file-viewer-state file-viewer-state-error\">${diffError}</div>`\n        : html`\n            <pre class=\"file-viewer-diff-pre\">\n${(diffContent || \"\").split(\"\\n\").map((line, lineIndex) => html`\n                <div\n                  key=${`${lineIndex}:${line.slice(0, 20)}`}\n                  class=${`file-viewer-diff-line ${getDiffLineClass(line)}`.trim()}\n                >\n                  ${line || \" \"}\n                </div>\n              `)}\n          </pre\n            >\n          `}\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/editor-surface.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst EditorTextarea = ({\n  overlay = false,\n  editorTextareaRef,\n  renderContent,\n  handleContentInput,\n  handleEditorKeyDown,\n  handleEditorScroll,\n  handleEditorSelectionChange,\n  isEditBlocked,\n  isPreviewOnly,\n  textareaWrap = \"soft\",\n}) => html`\n  <textarea\n    class=${overlay ? \"file-viewer-editor file-viewer-editor-overlay\" : \"file-viewer-editor\"}\n    ref=${editorTextareaRef}\n    value=${renderContent}\n    onInput=${handleContentInput}\n    onKeyDown=${handleEditorKeyDown}\n    onScroll=${handleEditorScroll}\n    onSelect=${handleEditorSelectionChange}\n    onKeyUp=${handleEditorSelectionChange}\n    onClick=${handleEditorSelectionChange}\n    disabled=${isEditBlocked || isPreviewOnly}\n    readonly=${isEditBlocked || isPreviewOnly}\n    spellcheck=${false}\n    autocorrect=\"off\"\n    autocapitalize=\"off\"\n    autocomplete=\"off\"\n    data-gramm=\"false\"\n    data-gramm_editor=\"false\"\n    data-enable-grammarly=\"false\"\n    wrap=${textareaWrap}\n  ></textarea>\n`;\n\nexport const EditorSurface = ({\n  editorShellClassName = \"file-viewer-editor-shell\",\n  editorShellAriaHidden,\n  editorLineNumbers,\n  editorLineNumbersRef,\n  editorLineNumberRowRefs,\n  shouldUseHighlightedEditor,\n  highlightedEditorLines,\n  editorHighlightRef,\n  editorHighlightLineRefs,\n  editorTextareaRef,\n  renderContent,\n  handleContentInput,\n  handleEditorKeyDown,\n  handleEditorScroll,\n  handleEditorSelectionChange,\n  isEditBlocked,\n  isPreviewOnly,\n  textareaWrap = \"soft\",\n}) => html`\n  <div class=${editorShellClassName} aria-hidden=${editorShellAriaHidden}>\n    <div class=\"file-viewer-editor-line-num-col\" ref=${editorLineNumbersRef}>\n      ${editorLineNumbers.map(\n        (lineNumber) => html`\n          <div\n            class=\"file-viewer-editor-line-num\"\n            key=${lineNumber}\n            data-line-row\n            ref=${(element) => {\n              editorLineNumberRowRefs.current[lineNumber - 1] = element;\n            }}\n          >\n            ${lineNumber}\n          </div>\n        `,\n      )}\n    </div>\n    ${shouldUseHighlightedEditor\n      ? html`\n          <div class=\"file-viewer-editor-stack\">\n            <div class=\"file-viewer-editor-highlight\" ref=${editorHighlightRef}>\n              ${highlightedEditorLines.map(\n                (line) => html`\n                  <div\n                    class=\"file-viewer-editor-highlight-line\"\n                    key=${line.lineNumber}\n                    ref=${(element) => {\n                      editorHighlightLineRefs.current[line.lineNumber - 1] = element;\n                    }}\n                  >\n                    <span\n                      class=\"file-viewer-editor-highlight-line-content\"\n                      dangerouslySetInnerHTML=${{\n                        __html: line.html,\n                      }}\n                    ></span>\n                  </div>\n                `,\n              )}\n            </div>\n            <${EditorTextarea}\n              overlay=${true}\n              editorTextareaRef=${editorTextareaRef}\n              renderContent=${renderContent}\n              handleContentInput=${handleContentInput}\n              handleEditorKeyDown=${handleEditorKeyDown}\n              handleEditorScroll=${handleEditorScroll}\n              handleEditorSelectionChange=${handleEditorSelectionChange}\n              isEditBlocked=${isEditBlocked}\n              isPreviewOnly=${isPreviewOnly}\n              textareaWrap=${textareaWrap}\n            />\n          </div>\n        `\n      : html`\n          <${EditorTextarea}\n            overlay=${false}\n            editorTextareaRef=${editorTextareaRef}\n            renderContent=${renderContent}\n            handleContentInput=${handleContentInput}\n            handleEditorKeyDown=${handleEditorKeyDown}\n            handleEditorScroll=${handleEditorScroll}\n            handleEditorSelectionChange=${handleEditorSelectionChange}\n            isEditBlocked=${isEditBlocked}\n            isPreviewOnly=${isPreviewOnly}\n            textareaWrap=${textareaWrap}\n          />\n        `}\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/frontmatter-panel.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { formatFrontmatterValue } from \"../../lib/syntax-highlighters/index.js\";\n\nconst html = htm.bind(h);\n\nexport const FrontmatterPanel = ({\n  isMarkdownFile,\n  parsedFrontmatter,\n  frontmatterCollapsed,\n  setFrontmatterCollapsed,\n}) => {\n  if (!isMarkdownFile || parsedFrontmatter.entries.length <= 0) return null;\n\n  return html`\n    <div class=\"frontmatter-box\">\n      <button\n        type=\"button\"\n        class=\"frontmatter-title\"\n        onclick=${() => setFrontmatterCollapsed((collapsed) => !collapsed)}\n      >\n        <span\n          class=${`frontmatter-chevron ${frontmatterCollapsed ? \"\" : \"open\"}`}\n          aria-hidden=\"true\"\n        >\n          <svg viewBox=\"0 0 20 20\" focusable=\"false\">\n            <path d=\"M7 4l6 6-6 6\" />\n          </svg>\n        </span>\n        <span>frontmatter</span>\n      </button>\n      ${!frontmatterCollapsed\n        ? html`\n            <div class=\"frontmatter-grid\">\n              ${parsedFrontmatter.entries.map((entry) => {\n                const formattedValue = formatFrontmatterValue(entry.rawValue);\n                const isMultilineValue = formattedValue.includes(\"\\n\");\n                return html`\n                  <div class=\"frontmatter-row\" key=${entry.key}>\n                    <div class=\"frontmatter-key\">${entry.key}</div>\n                    ${isMultilineValue\n                      ? html`\n                          <pre class=\"frontmatter-value frontmatter-value-pre\">\n${formattedValue}</pre\n                          >\n                        `\n                      : html`<div class=\"frontmatter-value\">${formattedValue}</div>`}\n                  </div>\n                `;\n              })}\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/index.js",
    "content": "import { h } from \"preact\";\nimport { useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\nimport { ConfirmDialog } from \"../confirm-dialog.js\";\nimport { SqliteViewer } from \"./sqlite-viewer.js\";\nimport { FileViewerToolbar } from \"./toolbar.js\";\nimport { FileViewerStatusBanners } from \"./status-banners.js\";\nimport { FrontmatterPanel } from \"./frontmatter-panel.js\";\nimport { DiffViewer } from \"./diff-viewer.js\";\nimport { MediaPreview } from \"./media-preview.js\";\nimport { EditorSurface } from \"./editor-surface.js\";\nimport { MarkdownSplitView } from \"./markdown-split-view.js\";\nimport { kSqlitePageSize } from \"./constants.js\";\nimport { useFileViewer } from \"./use-file-viewer.js\";\n\nconst html = htm.bind(h);\n\nexport const FileViewer = ({\n  filePath = \"\",\n  isPreviewOnly = false,\n  browseView = \"edit\",\n  lineTarget = 0,\n  lineEndTarget = 0,\n  onRequestEdit = () => {},\n  onRequestClearSelection = () => {},\n}) => {\n  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);\n  const { state, derived, refs, actions, context } = useFileViewer({\n    filePath,\n    isPreviewOnly,\n    browseView,\n    lineTarget,\n    lineEndTarget,\n    onRequestClearSelection,\n    onRequestEdit,\n  });\n\n  if (!state.hasSelectedPath || state.isFolderPath) {\n    return html`\n      <div class=\"file-viewer-empty\">\n        <div class=\"file-viewer-empty-mark\">[ ]</div>\n        <div class=\"file-viewer-empty-title\">Browse and edit files<br />Syncs to git</div>\n      </div>\n    `;\n  }\n\n  return html`\n    <div class=\"file-viewer\">\n      <${FileViewerToolbar}\n        pathSegments=${derived.pathSegments}\n        isDirty=${derived.isDirty}\n        isPreviewOnly=${state.isPreviewOnly}\n        isDiffView=${state.isDiffView}\n        isMarkdownFile=${state.isMarkdownFile}\n        viewMode=${state.viewMode}\n        handleChangeViewMode=${actions.handleChangeViewMode}\n        handleSave=${actions.handleSave}\n        handleDiscard=${actions.handleDiscard}\n        loading=${state.loading}\n        canEditFile=${derived.canEditFile}\n        isEditBlocked=${derived.isEditBlocked}\n        isImageFile=${state.isImageFile}\n        isAudioFile=${state.isAudioFile}\n        isSqliteFile=${state.isSqliteFile}\n        saving=${state.saving}\n        deleting=${state.deleting}\n        restoring=${state.restoring}\n        canDeleteFile=${derived.canDeleteFile}\n        isDeleteBlocked=${derived.isDeleteBlocked}\n        isProtectedFile=${derived.isProtectedFile}\n        canRestoreDeletedDiff=${state.isDiffView && !!state.diffStatus?.isDeleted}\n        onRequestDelete=${() => setDeleteConfirmOpen(true)}\n        onRequestRestore=${actions.handleRestore}\n      />\n      <${FileViewerStatusBanners}\n        isDiffView=${state.isDiffView}\n        onRequestEdit=${onRequestEdit}\n        normalizedPath=${context.normalizedPath}\n        isDeletedDiff=${!!state.diffStatus?.isDeleted}\n        isLockedFile=${derived.isLockedFile}\n        isProtectedFile=${derived.isProtectedFile}\n        isProtectedLocked=${derived.isProtectedLocked}\n        handleEditProtectedFile=${actions.handleEditProtectedFile}\n      />\n      ${!state.isDiffView\n        ? html`\n            <${FrontmatterPanel}\n              isMarkdownFile=${state.isMarkdownFile}\n              parsedFrontmatter=${derived.parsedFrontmatter}\n              frontmatterCollapsed=${state.frontmatterCollapsed}\n              setFrontmatterCollapsed=${actions.setFrontmatterCollapsed}\n            />\n          `\n        : null}\n      ${state.loading\n        ? html`\n            <div class=\"file-viewer-loading-shell\">\n              ${state.showDelayedLoadingSpinner\n                ? html`<${LoadingSpinner} className=\"h-4 w-4\" />`\n                : null}\n            </div>\n          `\n        : state.error\n          ? html`<div class=\"file-viewer-state file-viewer-state-error\">${state.error}</div>`\n          : state.isImageFile || state.isAudioFile\n              ? html`\n                  <${MediaPreview}\n                    isImageFile=${state.isImageFile}\n                    imageDataUrl=${state.imageDataUrl}\n                    pathSegments=${derived.pathSegments}\n                    isAudioFile=${state.isAudioFile}\n                    audioDataUrl=${state.audioDataUrl}\n                  />\n                `\n              : state.isSqliteFile\n                ? html`\n                    <${SqliteViewer}\n                      sqliteSummary=${state.sqliteSummary}\n                      sqliteSelectedTable=${state.sqliteSelectedTable}\n                      setSqliteSelectedTable=${actions.setSqliteSelectedTable}\n                      sqliteTableOffset=${state.sqliteTableOffset}\n                      setSqliteTableOffset=${actions.setSqliteTableOffset}\n                      sqliteTableLoading=${state.sqliteTableLoading}\n                      sqliteTableError=${state.sqliteTableError}\n                      sqliteTableData=${state.sqliteTableData}\n                      kSqlitePageSize=${kSqlitePageSize}\n                    />\n                  `\n                : state.isDiffView\n                  ? html`\n                      <${DiffViewer}\n                        diffLoading=${state.diffLoading}\n                        diffError=${state.diffError}\n                        diffContent=${state.diffContent}\n                      />\n                    `\n                  : html`\n                      ${state.isMarkdownFile\n                        ? html`\n                            <${MarkdownSplitView}\n                              viewMode=${state.viewMode}\n                              previewRef=${refs.previewRef}\n                              handlePreviewScroll=${actions.handlePreviewScroll}\n                              previewHtml=${state.previewHtml}\n                              editorLineNumbers=${derived.editorLineNumbers}\n                              editorLineNumbersRef=${refs.editorLineNumbersRef}\n                              editorLineNumberRowRefs=${refs.editorLineNumberRowRefs}\n                              shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor}\n                              highlightedEditorLines=${derived.highlightedEditorLines}\n                              editorHighlightRef=${refs.editorHighlightRef}\n                              editorHighlightLineRefs=${refs.editorHighlightLineRefs}\n                              editorTextareaRef=${refs.editorTextareaRef}\n                              renderContent=${state.renderContent}\n                              handleContentInput=${actions.handleContentInput}\n                              handleEditorKeyDown=${actions.handleEditorKeyDown}\n                              handleEditorScroll=${actions.handleEditorScroll}\n                              handleEditorSelectionChange=${actions.handleEditorSelectionChange}\n                              isEditBlocked=${derived.isEditBlocked}\n                              isPreviewOnly=${state.isPreviewOnly}\n                            />\n                          `\n                        : html`\n                            <${EditorSurface}\n                              editorLineNumbers=${derived.editorLineNumbers}\n                              editorLineNumbersRef=${refs.editorLineNumbersRef}\n                              editorLineNumberRowRefs=${refs.editorLineNumberRowRefs}\n                              shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor}\n                              highlightedEditorLines=${derived.highlightedEditorLines}\n                              editorHighlightRef=${refs.editorHighlightRef}\n                              editorHighlightLineRefs=${refs.editorHighlightLineRefs}\n                              editorTextareaRef=${refs.editorTextareaRef}\n                              renderContent=${state.renderContent}\n                              handleContentInput=${actions.handleContentInput}\n                              handleEditorKeyDown=${actions.handleEditorKeyDown}\n                              handleEditorScroll=${actions.handleEditorScroll}\n                              handleEditorSelectionChange=${actions.handleEditorSelectionChange}\n                              isEditBlocked=${derived.isEditBlocked}\n                              isPreviewOnly=${state.isPreviewOnly}\n                            />\n                          `}\n                    `}\n      <${ConfirmDialog}\n        visible=${deleteConfirmOpen}\n        title=\"Delete file?\"\n        message=${`Delete ${context.normalizedPath || \"this file\"}? This can be restored from diff view before sync.`}\n        confirmLabel=\"Delete\"\n        confirmLoadingLabel=\"Deleting...\"\n        cancelLabel=\"Cancel\"\n        confirmTone=\"warning\"\n        confirmLoading=${state.deleting}\n        confirmDisabled=${!derived.canDeleteFile || state.deleting}\n        onCancel=${() => {\n          if (state.deleting) return;\n          setDeleteConfirmOpen(false);\n        }}\n        onConfirm=${async () => {\n          await actions.handleDelete();\n          setDeleteConfirmOpen(false);\n        }}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/markdown-split-view.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { EditorSurface } from \"./editor-surface.js\";\n\nconst html = htm.bind(h);\n\nexport const MarkdownSplitView = ({\n  viewMode,\n  previewRef,\n  handlePreviewScroll,\n  previewHtml,\n  editorLineNumbers,\n  editorLineNumbersRef,\n  editorLineNumberRowRefs,\n  shouldUseHighlightedEditor,\n  highlightedEditorLines,\n  editorHighlightRef,\n  editorHighlightLineRefs,\n  editorTextareaRef,\n  renderContent,\n  handleContentInput,\n  handleEditorKeyDown,\n  handleEditorScroll,\n  handleEditorSelectionChange,\n  isEditBlocked,\n  isPreviewOnly,\n}) => html`\n  <div\n    class=${`file-viewer-preview ${viewMode === \"preview\" ? \"\" : \"file-viewer-pane-hidden\"}`}\n    ref=${previewRef}\n    onscroll=${handlePreviewScroll}\n    aria-hidden=${viewMode === \"preview\" ? \"false\" : \"true\"}\n    dangerouslySetInnerHTML=${{ __html: previewHtml }}\n  ></div>\n  <${EditorSurface}\n    editorShellClassName=${`file-viewer-editor-shell ${viewMode === \"edit\" ? \"\" : \"file-viewer-pane-hidden\"}`}\n    editorShellAriaHidden=${viewMode === \"edit\" ? \"false\" : \"true\"}\n    editorLineNumbers=${editorLineNumbers}\n    editorLineNumbersRef=${editorLineNumbersRef}\n    editorLineNumberRowRefs=${editorLineNumberRowRefs}\n    shouldUseHighlightedEditor=${shouldUseHighlightedEditor}\n    highlightedEditorLines=${highlightedEditorLines}\n    editorHighlightRef=${editorHighlightRef}\n    editorHighlightLineRefs=${editorHighlightLineRefs}\n    editorTextareaRef=${editorTextareaRef}\n    renderContent=${renderContent}\n    handleContentInput=${handleContentInput}\n    handleEditorKeyDown=${handleEditorKeyDown}\n    handleEditorScroll=${handleEditorScroll}\n    handleEditorSelectionChange=${handleEditorSelectionChange}\n    isEditBlocked=${isEditBlocked}\n    isPreviewOnly=${isPreviewOnly}\n  />\n`;\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/media-preview.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const MediaPreview = ({\n  isImageFile,\n  imageDataUrl,\n  pathSegments,\n  isAudioFile,\n  audioDataUrl,\n}) => {\n  if (isImageFile) {\n    return html`\n      <div class=\"file-viewer-image-shell\">\n        ${imageDataUrl\n          ? html`\n              <img\n                src=${imageDataUrl}\n                alt=${pathSegments[pathSegments.length - 1] || \"Selected image\"}\n                class=\"file-viewer-image\"\n              />\n            `\n          : html`<div class=\"file-viewer-state\">Could not render image preview.</div>`}\n      </div>\n    `;\n  }\n\n  if (isAudioFile) {\n    return html`\n      <div class=\"file-viewer-audio-shell\">\n        ${audioDataUrl\n          ? html`\n              <audio class=\"file-viewer-audio-player\" controls preload=\"metadata\" src=${audioDataUrl}>\n                Your browser does not support audio playback.\n              </audio>\n            `\n          : html`<div class=\"file-viewer-state\">Could not render audio preview.</div>`}\n      </div>\n    `;\n  }\n\n  return null;\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/scroll-sync.js",
    "content": "import { useRef } from \"preact/hooks\";\n\nexport const getScrollRatio = (element) => {\n  if (!element) return 0;\n  const maxScrollTop = element.scrollHeight - element.clientHeight;\n  if (maxScrollTop <= 0) return 0;\n  return element.scrollTop / maxScrollTop;\n};\n\nexport const setScrollByRatio = (element, ratio) => {\n  if (!element) return;\n  const maxScrollTop = element.scrollHeight - element.clientHeight;\n  if (maxScrollTop <= 0) {\n    element.scrollTop = 0;\n    return;\n  }\n  const clampedRatio = Math.max(0, Math.min(1, ratio));\n  element.scrollTop = maxScrollTop * clampedRatio;\n};\n\nexport const useScrollSync = ({\n  viewMode,\n  setViewMode,\n  previewRef,\n  editorTextareaRef,\n  editorLineNumbersRef,\n  editorHighlightRef,\n}) => {\n  const viewScrollRatioRef = useRef(0);\n  const isSyncingScrollRef = useRef(false);\n\n  const handleEditorScroll = (event) => {\n    if (isSyncingScrollRef.current) return;\n    const nextScrollTop = event.currentTarget.scrollTop;\n    const nextRatio = getScrollRatio(event.currentTarget);\n    viewScrollRatioRef.current = nextRatio;\n    if (!editorLineNumbersRef.current) return;\n    editorLineNumbersRef.current.scrollTop = nextScrollTop;\n    if (editorHighlightRef.current) {\n      editorHighlightRef.current.scrollTop = nextScrollTop;\n      editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft;\n    }\n    if (previewRef.current) {\n      isSyncingScrollRef.current = true;\n      setScrollByRatio(previewRef.current, nextRatio);\n      window.requestAnimationFrame(() => {\n        isSyncingScrollRef.current = false;\n      });\n    }\n  };\n\n  const handlePreviewScroll = (event) => {\n    if (isSyncingScrollRef.current) return;\n    const nextRatio = getScrollRatio(event.currentTarget);\n    viewScrollRatioRef.current = nextRatio;\n    isSyncingScrollRef.current = true;\n    setScrollByRatio(editorTextareaRef.current, nextRatio);\n    setScrollByRatio(editorLineNumbersRef.current, nextRatio);\n    setScrollByRatio(editorHighlightRef.current, nextRatio);\n    window.requestAnimationFrame(() => {\n      isSyncingScrollRef.current = false;\n    });\n  };\n\n  const handleChangeViewMode = (nextMode) => {\n    if (nextMode === viewMode) return;\n    const nextRatio =\n      viewMode === \"preview\"\n        ? getScrollRatio(previewRef.current)\n        : getScrollRatio(editorTextareaRef.current);\n    viewScrollRatioRef.current = nextRatio;\n    setViewMode(nextMode);\n    window.requestAnimationFrame(() => {\n      isSyncingScrollRef.current = true;\n      if (nextMode === \"preview\") {\n        setScrollByRatio(previewRef.current, nextRatio);\n      } else {\n        setScrollByRatio(editorTextareaRef.current, nextRatio);\n        setScrollByRatio(editorLineNumbersRef.current, nextRatio);\n        setScrollByRatio(editorHighlightRef.current, nextRatio);\n      }\n      window.requestAnimationFrame(() => {\n        isSyncingScrollRef.current = false;\n      });\n    });\n  };\n\n  return {\n    viewScrollRatioRef,\n    isSyncingScrollRef,\n    handleEditorScroll,\n    handlePreviewScroll,\n    handleChangeViewMode,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/sqlite-viewer.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\n\nconst html = htm.bind(h);\n\nexport const SqliteViewer = ({\n  sqliteSummary,\n  sqliteSelectedTable,\n  setSqliteSelectedTable,\n  sqliteTableOffset,\n  setSqliteTableOffset,\n  sqliteTableLoading,\n  sqliteTableError,\n  sqliteTableData,\n  kSqlitePageSize,\n}) => {\n  const sqliteRows = Array.isArray(sqliteTableData?.rows) ? sqliteTableData.rows : [];\n  const sqliteColumns =\n    Array.isArray(sqliteTableData?.columns) && sqliteTableData.columns.length\n      ? sqliteTableData.columns\n      : (sqliteSummary?.objects || []).find(\n          (entry) => entry?.name === sqliteSelectedTable,\n        )?.columns || [];\n  const sqliteTotalRows = Number(sqliteTableData?.totalRows || 0);\n  const sqliteCanGoPrev = sqliteTableOffset > 0;\n  const sqliteCanGoNext = sqliteTableOffset + kSqlitePageSize < sqliteTotalRows;\n\n  return html`\n    <div class=\"file-viewer-sqlite-shell\">\n      ${sqliteSummary?.objects?.length\n        ? html`\n            <div class=\"file-viewer-sqlite-layout\">\n              <div class=\"file-viewer-sqlite-list\">\n                ${sqliteSummary.objects.map(\n                  (entry) => html`\n                    <button\n                      type=\"button\"\n                      class=${`file-viewer-sqlite-card ${sqliteSelectedTable === entry.name ? \"is-active\" : \"\"}`}\n                      onclick=${() => {\n                        if (!entry?.name || sqliteSelectedTable === entry.name) return;\n                        setSqliteSelectedTable(entry.name);\n                        setSqliteTableOffset(0);\n                      }}\n                    >\n                      <div class=\"file-viewer-sqlite-title\">\n                        <span>${entry.name}</span>\n                        <span class=\"file-viewer-sqlite-type\">${entry.type}</span>\n                      </div>\n                    </button>\n                  `,\n                )}\n              </div>\n              <div class=\"file-viewer-sqlite-table-shell\">\n                ${sqliteSelectedTable\n                  ? html`\n                      <div class=\"file-viewer-sqlite-table-header\">\n                        <span class=\"file-viewer-sqlite-table-name\">\n                          ${sqliteSelectedTable}\n                        </span>\n                        <div class=\"file-viewer-sqlite-table-nav\">\n                          <button\n                            type=\"button\"\n                            class=\"ac-btn-secondary text-xs px-2 py-1 rounded-md\"\n                            disabled=${!sqliteCanGoPrev}\n                            onclick=${() =>\n                              setSqliteTableOffset((previousOffset) =>\n                                Math.max(0, previousOffset - kSqlitePageSize),\n                              )}\n                          >\n                            Prev\n                          </button>\n                          <button\n                            type=\"button\"\n                            class=\"ac-btn-secondary text-xs px-2 py-1 rounded-md\"\n                            disabled=${!sqliteCanGoNext}\n                            onclick=${() =>\n                              setSqliteTableOffset(\n                                (previousOffset) => previousOffset + kSqlitePageSize,\n                              )}\n                          >\n                            Next\n                          </button>\n                        </div>\n                      </div>\n                      <div class=\"file-viewer-sqlite-table-meta\">\n                        ${sqliteTotalRows\n                          ? `${Math.min(sqliteTableOffset + 1, sqliteTotalRows)}-${Math.min(sqliteTableOffset + kSqlitePageSize, sqliteTotalRows)} of ${sqliteTotalRows} rows`\n                          : \"No rows\"}\n                      </div>\n                      ${sqliteTableLoading\n                        ? html`\n                            <div class=\"file-viewer-loading-shell\">\n                              <${LoadingSpinner} className=\"h-4 w-4\" />\n                            </div>\n                          `\n                        : sqliteTableError\n                          ? html`\n                              <div class=\"file-viewer-state file-viewer-state-error\">\n                                ${sqliteTableError}\n                              </div>\n                            `\n                          : html`\n                              <div class=\"file-viewer-sqlite-table-wrap\">\n                                <table class=\"file-viewer-sqlite-table\">\n                                  <thead>\n                                    <tr>\n                                      ${sqliteColumns.map(\n                                        (column) => html`<th>${column.name}</th>`,\n                                      )}\n                                    </tr>\n                                  </thead>\n                                  <tbody>\n                                    ${sqliteRows.length\n                                      ? sqliteRows.map(\n                                          (row, rowIndex) => html`\n                                            <tr key=${rowIndex}>\n                                              ${sqliteColumns.map((column) => {\n                                                const cellValue = row?.[column.name];\n                                                const displayValue =\n                                                  cellValue === null\n                                                    ? \"NULL\"\n                                                    : typeof cellValue === \"object\"\n                                                      ? JSON.stringify(cellValue)\n                                                      : String(cellValue ?? \"\");\n                                                return html`\n                                                  <td title=${displayValue}>\n                                                    ${displayValue}\n                                                  </td>\n                                                `;\n                                              })}\n                                            </tr>\n                                          `,\n                                        )\n                                      : html`\n                                          <tr>\n                                            <td colspan=${Math.max(1, sqliteColumns.length)}>\n                                              <span class=\"file-viewer-sqlite-table-empty\">\n                                                No rows\n                                              </span>\n                                            </td>\n                                          </tr>\n                                        `}\n                                  </tbody>\n                                </table>\n                              </div>\n                            `}\n                    `\n                  : html`\n                      <div class=\"file-viewer-state\">Select a table to view rows.</div>\n                    `}\n              </div>\n            </div>\n            <div class=\"file-viewer-sqlite-footer\">\n              ${sqliteSummary.truncated\n                ? `Showing ${sqliteSummary.objects.length} of ${sqliteSummary.totalObjects} tables/views`\n                : `${sqliteSummary.totalObjects} tables/views`}\n            </div>\n          `\n        : html`\n            <div class=\"file-viewer-state\">\n              SQLite database loaded, but no tables/views were found.\n            </div>\n          `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/status-banners.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { LockLineIcon } from \"../icons.js\";\n\nconst html = htm.bind(h);\n\nexport const FileViewerStatusBanners = ({\n  isDiffView,\n  onRequestEdit,\n  normalizedPath,\n  isDeletedDiff = false,\n  isLockedFile,\n  isProtectedFile,\n  isProtectedLocked,\n  handleEditProtectedFile,\n}) => html`\n  ${isDiffView\n    ? html`\n        <div class=\"file-viewer-protected-banner file-viewer-diff-banner\">\n          <div class=\"file-viewer-protected-banner-text\">Viewing unsynced changes</div>\n          ${!isDeletedDiff\n            ? html`\n                <${ActionButton}\n                  onClick=${() => onRequestEdit(normalizedPath)}\n                  tone=\"secondary\"\n                  size=\"sm\"\n                  idleLabel=\"View file\"\n                />\n              `\n            : null}\n        </div>\n      `\n    : null}\n  ${!isDiffView && isLockedFile\n    ? html`\n        <div class=\"file-viewer-protected-banner is-locked\">\n          <${LockLineIcon} className=\"file-viewer-protected-banner-icon\" />\n          <div class=\"file-viewer-protected-banner-text\">\n            This file is managed by AlphaClaw and cannot be edited.\n          </div>\n        </div>\n      `\n    : null}\n  ${!isDiffView && isProtectedFile\n    ? html`\n        <div class=\"file-viewer-protected-banner\">\n          <div class=\"file-viewer-protected-banner-text\">\n            Protected file. Changes may break workspace behavior.\n          </div>\n          ${isProtectedLocked\n            ? html`\n                <${ActionButton}\n                  onClick=${handleEditProtectedFile}\n                  tone=\"warning\"\n                  size=\"sm\"\n                  idleLabel=\"Edit anyway\"\n                />\n              `\n            : null}\n        </div>\n      `\n    : null}\n`;\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/storage.js",
    "content": "import {\n  kEditorSelectionStorageKey,\n  kFileViewerModeStorageKey,\n} from \"./constants.js\";\n\nexport const readStoredFileViewerMode = () => {\n  try {\n    const storedMode = String(\n      window.localStorage.getItem(kFileViewerModeStorageKey) || \"\",\n    ).trim();\n    return storedMode === \"preview\" ? \"preview\" : \"edit\";\n  } catch {\n    return \"edit\";\n  }\n};\n\nexport const readEditorSelectionStorageMap = () => {\n  try {\n    const rawStorageValue = window.localStorage.getItem(kEditorSelectionStorageKey);\n    if (!rawStorageValue) return {};\n    const parsedStorageValue = JSON.parse(rawStorageValue);\n    if (!parsedStorageValue || typeof parsedStorageValue !== \"object\") return {};\n    return parsedStorageValue;\n  } catch {\n    return {};\n  }\n};\n\nexport const readStoredEditorSelection = (filePath) => {\n  const safePath = String(filePath || \"\").trim();\n  if (!safePath) return null;\n  const storageMap = readEditorSelectionStorageMap();\n  const selection = storageMap[safePath];\n  if (!selection || typeof selection !== \"object\") return null;\n  return {\n    start: selection.start,\n    end: selection.end,\n  };\n};\n\nexport const writeStoredEditorSelection = (filePath, selection) => {\n  const safePath = String(filePath || \"\").trim();\n  if (!safePath || !selection || typeof selection !== \"object\") return;\n  try {\n    const nextStorageValue = readEditorSelectionStorageMap();\n    nextStorageValue[safePath] = {\n      start: selection.start,\n      end: selection.end,\n    };\n    window.localStorage.setItem(\n      kEditorSelectionStorageKey,\n      JSON.stringify(nextStorageValue),\n    );\n  } catch {}\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/toolbar.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport { DeleteBinLineIcon, RestartLineIcon, SaveFillIcon } from \"../icons.js\";\n\nconst html = htm.bind(h);\n\nexport const FileViewerToolbar = ({\n  pathSegments,\n  isDirty,\n  isPreviewOnly,\n  isDiffView,\n  isMarkdownFile,\n  viewMode,\n  handleChangeViewMode,\n  handleSave,\n  handleDiscard,\n  loading,\n  canEditFile,\n  isEditBlocked,\n  isImageFile,\n  isAudioFile,\n  isSqliteFile,\n  saving,\n  deleting,\n  restoring,\n  canDeleteFile,\n  isDeleteBlocked,\n  isProtectedFile,\n  canRestoreDeletedDiff,\n  onRequestDelete,\n  onRequestRestore,\n}) => html`\n  <div class=\"file-viewer-tabbar\">\n    <div class=\"file-viewer-tab active\">\n      <span class=\"file-icon\">f</span>\n      <span class=\"file-viewer-breadcrumb\">\n        ${pathSegments.map(\n          (segment, index) => html`\n            <span class=\"file-viewer-breadcrumb-item\">\n              <span\n                class=${index === pathSegments.length - 1 ? \"is-current\" : \"\"}\n              >\n                ${segment}\n              </span>\n              ${index < pathSegments.length - 1 &&\n              html`<span class=\"file-viewer-sep\">></span>`}\n            </span>\n          `,\n        )}\n      </span>\n      ${isDirty\n        ? html`<span class=\"file-viewer-dirty-dot\" aria-hidden=\"true\"></span>`\n        : null}\n    </div>\n    <div class=\"file-viewer-tabbar-spacer\"></div>\n    ${isPreviewOnly\n      ? html`<div class=\"file-viewer-preview-pill\">Preview</div>`\n      : null}\n    ${!isDiffView &&\n    isMarkdownFile &&\n    html`\n      <${SegmentedControl}\n        className=\"mr-2.5\"\n        options=${[\n          { label: \"edit\", value: \"edit\" },\n          { label: \"preview\", value: \"preview\" },\n        ]}\n        value=${viewMode}\n        onChange=${handleChangeViewMode}\n      />\n    `}\n    ${!isDiffView\n      ? !isImageFile && !isAudioFile && !isSqliteFile\n        ? html`\n            ${!isProtectedFile\n              ? html`\n                  <${ActionButton}\n                    onClick=${onRequestDelete}\n                    disabled=${!canDeleteFile || deleting}\n                    tone=\"secondary\"\n                    size=\"sm\"\n                    iconOnly=${true}\n                    idleLabel=\"\"\n                    idleIcon=${DeleteBinLineIcon}\n                    idleIconClassName=\"file-viewer-icon-action-icon\"\n                    className=\"file-viewer-save-action\"\n                    title=${isDeleteBlocked\n                      ? \"Locked files cannot be deleted\"\n                      : \"Delete file\"}\n                    ariaLabel=\"Delete file\"\n                  />\n                `\n              : null}\n            ${isDirty\n              ? html`\n                  <${ActionButton}\n                    onClick=${handleDiscard}\n                    disabled=${loading ||\n                    !canEditFile ||\n                    isEditBlocked ||\n                    deleting ||\n                    saving}\n                    tone=\"secondary\"\n                    size=\"sm\"\n                    idleLabel=\"Discard changes\"\n                    className=\"file-viewer-save-action\"\n                  />\n                `\n              : null}\n            <${ActionButton}\n              onClick=${handleSave}\n              disabled=${loading || !isDirty || !canEditFile || isEditBlocked}\n              loading=${saving}\n              tone=${isDirty ? \"primary\" : \"secondary\"}\n              size=\"sm\"\n              idleLabel=\"Save\"\n              loadingLabel=\"Saving...\"\n              idleIcon=${SaveFillIcon}\n              idleIconClassName=\"file-viewer-save-icon\"\n              className=\"file-viewer-save-action\"\n            />\n          `\n        : null\n      : null}\n    ${isDiffView && canRestoreDeletedDiff\n      ? html`\n          <${ActionButton}\n            onClick=${onRequestRestore}\n            disabled=${restoring}\n            loading=${restoring}\n            tone=\"secondary\"\n            size=\"sm\"\n            idleLabel=\"Restore\"\n            loadingLabel=\"Restoring...\"\n            idleIcon=${RestartLineIcon}\n            idleIconClassName=\"file-viewer-save-icon\"\n            className=\"file-viewer-save-action\"\n          />\n        `\n      : null}\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-editor-line-number-sync.js",
    "content": "import { useCallback, useEffect } from \"preact/hooks\";\n\nexport const useEditorLineNumberSync = ({\n  enabled = false,\n  syncKey = \"\",\n  editorLineNumberRowRefs,\n  editorHighlightLineRefs,\n}) => {\n  const syncEditorLineNumberHeights = useCallback(() => {\n    if (!enabled) return;\n    const numberRows = editorLineNumberRowRefs?.current || [];\n    const highlightRows = editorHighlightLineRefs?.current || [];\n    const rowCount = Math.min(numberRows.length, highlightRows.length);\n    for (let index = 0; index < rowCount; index += 1) {\n      const numberRow = numberRows[index];\n      const highlightRow = highlightRows[index];\n      if (!numberRow || !highlightRow) continue;\n      numberRow.style.height = `${highlightRow.offsetHeight}px`;\n    }\n  }, [editorHighlightLineRefs, editorLineNumberRowRefs, enabled]);\n\n  useEffect(() => {\n    syncEditorLineNumberHeights();\n  }, [syncEditorLineNumberHeights, syncKey]);\n\n  useEffect(() => {\n    if (!enabled) return () => {};\n    const onResize = () => syncEditorLineNumberHeights();\n    window.addEventListener(\"resize\", onResize);\n    return () => window.removeEventListener(\"resize\", onResize);\n  }, [enabled, syncEditorLineNumberHeights]);\n\n  return {\n    syncEditorLineNumberHeights,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-editor-selection-restore.js",
    "content": "import { useEffect, useRef } from \"preact/hooks\";\nimport { readStoredEditorSelection } from \"./storage.js\";\nimport { clampSelectionIndex } from \"./utils.js\";\nimport { getScrollRatio } from \"./scroll-sync.js\";\n\nconst getCharOffsetForLine = (text, lineNumber) => {\n  const lines = String(text || \"\").split(\"\\n\");\n  const targetIndex = Math.max(0, Math.min(lineNumber - 1, lines.length - 1));\n  let offset = 0;\n  for (let i = 0; i < targetIndex; i += 1) offset += lines[i].length + 1;\n  return offset;\n};\n\nconst scrollEditorToLine = ({\n  lineIndex,\n  textareaElement,\n  editorLineNumbersRef,\n  editorHighlightRef,\n  viewScrollRatioRef,\n}) => {\n  const computedStyle = window.getComputedStyle(textareaElement);\n  const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || \"\");\n  const lineHeight =\n    Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20;\n  const nextScrollTop = Math.max(\n    0,\n    lineIndex * lineHeight - textareaElement.clientHeight * 0.4,\n  );\n  textareaElement.scrollTop = nextScrollTop;\n  if (editorLineNumbersRef.current) {\n    editorLineNumbersRef.current.scrollTop = nextScrollTop;\n  }\n  if (editorHighlightRef.current) {\n    editorHighlightRef.current.scrollTop = nextScrollTop;\n  }\n  viewScrollRatioRef.current = getScrollRatio(textareaElement);\n};\n\nconst clearLineHighlights = (lineNumbersContainer) => {\n  if (!lineNumbersContainer) return;\n  const highlighted = lineNumbersContainer.querySelectorAll(\".line-highlight-flash\");\n  for (const row of highlighted) row.classList.remove(\"line-highlight-flash\");\n};\n\nconst highlightLineRange = (lineNumbersContainer, startIndex, endIndex) => {\n  if (!lineNumbersContainer) return;\n  clearLineHighlights(lineNumbersContainer);\n  const rows = lineNumbersContainer.querySelectorAll(\"[data-line-row]\");\n  const safeEnd = Math.min(endIndex, rows.length - 1);\n  for (let i = startIndex; i <= safeEnd; i += 1) {\n    const row = rows[i];\n    if (row) row.classList.add(\"line-highlight-flash\");\n  }\n};\n\nexport const useEditorSelectionRestore = ({\n  canEditFile,\n  isEditBlocked,\n  loading,\n  hasSelectedPath,\n  normalizedPath,\n  loadedFilePathRef,\n  restoredSelectionPathRef,\n  viewMode,\n  content,\n  lineTarget = 0,\n  lineEndTarget = 0,\n  editorTextareaRef,\n  editorLineNumbersRef,\n  editorHighlightRef,\n  viewScrollRatioRef,\n}) => {\n  const appliedLineTargetRef = useRef(\"\");\n\n  useEffect(() => {\n    if (lineTarget && lineTarget >= 1) return;\n    if (!appliedLineTargetRef.current) return;\n    clearLineHighlights(editorLineNumbersRef.current);\n    appliedLineTargetRef.current = \"\";\n  }, [lineTarget, normalizedPath, editorLineNumbersRef]);\n\n  useEffect(() => {\n    if (isEditBlocked || !canEditFile || loading || !hasSelectedPath) return () => {};\n    if (loadedFilePathRef.current !== normalizedPath) return () => {};\n    if (viewMode !== \"edit\") return () => {};\n    if (!lineTarget || lineTarget < 1) return () => {};\n    const effectiveEnd = lineEndTarget && lineEndTarget >= lineTarget ? lineEndTarget : lineTarget;\n    const lineKey = `${normalizedPath}:${lineTarget}-${effectiveEnd}`;\n    if (appliedLineTargetRef.current === lineKey) return () => {};\n    let frameId = 0;\n    let attempts = 0;\n    const applyLineTarget = () => {\n      const textareaElement = editorTextareaRef.current;\n      if (!textareaElement) {\n        attempts += 1;\n        if (attempts < 6) frameId = window.requestAnimationFrame(applyLineTarget);\n        return;\n      }\n      const safeContent = String(content || \"\");\n      const charOffset = getCharOffsetForLine(safeContent, lineTarget);\n      textareaElement.setSelectionRange(charOffset, charOffset);\n      const startIndex = lineTarget - 1;\n      const endIndex = effectiveEnd - 1;\n      window.requestAnimationFrame(() => {\n        const nextTextareaElement = editorTextareaRef.current;\n        if (!nextTextareaElement) return;\n        scrollEditorToLine({\n          lineIndex: startIndex,\n          textareaElement: nextTextareaElement,\n          editorLineNumbersRef,\n          editorHighlightRef,\n          viewScrollRatioRef,\n        });\n        highlightLineRange(editorLineNumbersRef.current, startIndex, endIndex);\n      });\n      appliedLineTargetRef.current = lineKey;\n      restoredSelectionPathRef.current = normalizedPath;\n    };\n    frameId = window.requestAnimationFrame(applyLineTarget);\n    return () => {\n      if (frameId) window.cancelAnimationFrame(frameId);\n    };\n  }, [\n    canEditFile,\n    isEditBlocked,\n    loading,\n    hasSelectedPath,\n    normalizedPath,\n    content,\n    viewMode,\n    lineTarget,\n    lineEndTarget,\n    loadedFilePathRef,\n    restoredSelectionPathRef,\n    editorTextareaRef,\n    editorLineNumbersRef,\n    editorHighlightRef,\n    viewScrollRatioRef,\n  ]);\n\n  useEffect(() => {\n    if (isEditBlocked) {\n      restoredSelectionPathRef.current = \"\";\n      return () => {};\n    }\n    if (!canEditFile || loading || !hasSelectedPath) return () => {};\n    if (loadedFilePathRef.current !== normalizedPath) return () => {};\n    if (restoredSelectionPathRef.current === normalizedPath) return () => {};\n    if (viewMode !== \"edit\") return () => {};\n    if (lineTarget && lineTarget >= 1) return () => {};\n    const storedSelection = readStoredEditorSelection(normalizedPath);\n    if (!storedSelection) {\n      restoredSelectionPathRef.current = normalizedPath;\n      return () => {};\n    }\n    let frameId = 0;\n    let attempts = 0;\n    const restoreSelection = () => {\n      const textareaElement = editorTextareaRef.current;\n      if (!textareaElement) {\n        attempts += 1;\n        if (attempts < 6) frameId = window.requestAnimationFrame(restoreSelection);\n        return;\n      }\n      const maxIndex = String(content || \"\").length;\n      const start = clampSelectionIndex(storedSelection.start, maxIndex);\n      const end = clampSelectionIndex(storedSelection.end, maxIndex);\n      textareaElement.focus();\n      textareaElement.setSelectionRange(start, Math.max(start, end));\n      window.requestAnimationFrame(() => {\n        const nextTextareaElement = editorTextareaRef.current;\n        if (!nextTextareaElement) return;\n        const safeContent = String(content || \"\");\n        const safeStart = clampSelectionIndex(start, safeContent.length);\n        const lineIndex = safeContent.slice(0, safeStart).split(\"\\n\").length - 1;\n        scrollEditorToLine({\n          lineIndex,\n          textareaElement: nextTextareaElement,\n          editorLineNumbersRef,\n          editorHighlightRef,\n          viewScrollRatioRef,\n        });\n      });\n      restoredSelectionPathRef.current = normalizedPath;\n    };\n    frameId = window.requestAnimationFrame(restoreSelection);\n    return () => {\n      if (frameId) window.cancelAnimationFrame(frameId);\n    };\n  }, [\n    canEditFile,\n    isEditBlocked,\n    loading,\n    hasSelectedPath,\n    normalizedPath,\n    content,\n    viewMode,\n    lineTarget,\n    loadedFilePathRef,\n    restoredSelectionPathRef,\n    editorTextareaRef,\n    editorLineNumbersRef,\n    editorHighlightRef,\n    viewScrollRatioRef,\n  ]);\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-file-diff.js",
    "content": "import { useEffect, useState } from \"preact/hooks\";\nimport { fetchBrowseFileDiff } from \"../../lib/api.js\";\n\nexport const useFileDiff = ({\n  hasSelectedPath,\n  isDiffView,\n  isPreviewOnly,\n  normalizedPath,\n}) => {\n  const [diffLoading, setDiffLoading] = useState(false);\n  const [diffError, setDiffError] = useState(\"\");\n  const [diffContent, setDiffContent] = useState(\"\");\n  const [diffStatus, setDiffStatus] = useState({\n    statusKind: \"\",\n    isDeleted: false,\n  });\n\n  useEffect(() => {\n    let active = true;\n    if (!hasSelectedPath || !isDiffView || isPreviewOnly) {\n      setDiffLoading(false);\n      setDiffError(\"\");\n      setDiffContent(\"\");\n      setDiffStatus({ statusKind: \"\", isDeleted: false });\n      return () => {\n        active = false;\n      };\n    }\n    const loadDiff = async () => {\n      setDiffLoading(true);\n      setDiffError(\"\");\n      try {\n        const data = await fetchBrowseFileDiff(normalizedPath);\n        if (!active) return;\n        setDiffContent(String(data?.content || \"\"));\n        setDiffStatus({\n          statusKind: String(data?.statusKind || \"\"),\n          isDeleted: !!data?.isDeleted,\n        });\n      } catch (nextError) {\n        if (!active) return;\n        setDiffError(nextError.message || \"Could not load diff\");\n        setDiffStatus({ statusKind: \"\", isDeleted: false });\n      } finally {\n        if (active) setDiffLoading(false);\n      }\n    };\n    loadDiff();\n    return () => {\n      active = false;\n    };\n  }, [hasSelectedPath, isDiffView, isPreviewOnly, normalizedPath]);\n\n  return {\n    diffLoading,\n    diffError,\n    diffContent,\n    diffStatus,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-file-loader.js",
    "content": "import { useEffect, useRef } from \"preact/hooks\";\nimport { fetchBrowseSqliteTable, fetchFileContent } from \"../../lib/api.js\";\nimport {\n  clearStoredFileDraft,\n  readStoredFileDraft,\n  updateDraftIndex,\n} from \"../../lib/browse-draft-state.js\";\nimport { showToast } from \"../toast.js\";\nimport { kFileRefreshIntervalMs, kSqlitePageSize } from \"./constants.js\";\n\nexport const useFileLoader = ({\n  hasSelectedPath,\n  normalizedPath,\n  isDiffView,\n  isSqliteFile,\n  sqliteSelectedTable,\n  sqliteTableOffset,\n  canEditFile,\n  isFolderPath,\n  loading,\n  saving,\n  initialContent,\n  isDirty,\n  setContent,\n  setInitialContent,\n  setFileKind,\n  setImageDataUrl,\n  setAudioDataUrl,\n  setSqliteSummary,\n  setSqliteSelectedTable,\n  setSqliteTableOffset,\n  setSqliteTableLoading,\n  setSqliteTableError,\n  setSqliteTableData,\n  setError,\n  setIsFolderPath,\n  setExternalChangeNoticeShown,\n  externalChangeNoticeShown,\n  viewScrollRatioRef,\n  setLoading,\n}) => {\n  const loadedFilePathRef = useRef(\"\");\n  const restoredSelectionPathRef = useRef(\"\");\n  const fileRefreshInFlightRef = useRef(false);\n\n  useEffect(() => {\n    let active = true;\n    loadedFilePathRef.current = \"\";\n    restoredSelectionPathRef.current = \"\";\n    if (!hasSelectedPath) {\n      setContent(\"\");\n      setInitialContent(\"\");\n      setFileKind(\"text\");\n      setImageDataUrl(\"\");\n      setAudioDataUrl(\"\");\n      setSqliteSummary(null);\n      setSqliteSelectedTable(\"\");\n      setSqliteTableOffset(0);\n      setSqliteTableLoading(false);\n      setSqliteTableError(\"\");\n      setSqliteTableData(null);\n      setError(\"\");\n      setIsFolderPath(false);\n      viewScrollRatioRef.current = 0;\n      loadedFilePathRef.current = \"\";\n      return () => {\n        active = false;\n      };\n    }\n    // Clear previous file state immediately so large content from the last\n    // file is never rendered/parses under the next file's syntax mode.\n    setContent(\"\");\n    setInitialContent(\"\");\n    setImageDataUrl(\"\");\n    setAudioDataUrl(\"\");\n    setSqliteSummary(null);\n    setSqliteSelectedTable(\"\");\n    setSqliteTableOffset(0);\n    setSqliteTableLoading(false);\n    setSqliteTableError(\"\");\n    setSqliteTableData(null);\n    setFileKind(\"text\");\n    setError(\"\");\n    setIsFolderPath(false);\n    setExternalChangeNoticeShown(false);\n    viewScrollRatioRef.current = 0;\n    if (isDiffView) {\n      setLoading(false);\n      loadedFilePathRef.current = normalizedPath;\n      restoredSelectionPathRef.current = \"\";\n      return () => {\n        active = false;\n      };\n    }\n\n    const loadFile = async () => {\n      setLoading(true);\n      setError(\"\");\n      setIsFolderPath(false);\n      try {\n        const data = await fetchFileContent(normalizedPath);\n        if (!active) return;\n        const nextFileKind =\n          data?.kind === \"image\"\n            ? \"image\"\n            : data?.kind === \"audio\"\n              ? \"audio\"\n              : data?.kind === \"sqlite\"\n                ? \"sqlite\"\n                : \"text\";\n        setFileKind(nextFileKind);\n        if (nextFileKind === \"image\") {\n          setImageDataUrl(String(data?.imageDataUrl || \"\"));\n          setAudioDataUrl(\"\");\n          setSqliteSummary(null);\n          setSqliteSelectedTable(\"\");\n          setSqliteTableOffset(0);\n          setSqliteTableLoading(false);\n          setSqliteTableError(\"\");\n          setSqliteTableData(null);\n          setContent(\"\");\n          setInitialContent(\"\");\n          setExternalChangeNoticeShown(false);\n          viewScrollRatioRef.current = 0;\n          loadedFilePathRef.current = normalizedPath;\n          restoredSelectionPathRef.current = \"\";\n          return;\n        }\n        if (nextFileKind === \"audio\") {\n          setAudioDataUrl(String(data?.audioDataUrl || \"\"));\n          setImageDataUrl(\"\");\n          setSqliteSummary(null);\n          setSqliteSelectedTable(\"\");\n          setSqliteTableOffset(0);\n          setSqliteTableLoading(false);\n          setSqliteTableError(\"\");\n          setSqliteTableData(null);\n          setContent(\"\");\n          setInitialContent(\"\");\n          setExternalChangeNoticeShown(false);\n          viewScrollRatioRef.current = 0;\n          loadedFilePathRef.current = normalizedPath;\n          restoredSelectionPathRef.current = \"\";\n          return;\n        }\n        if (nextFileKind === \"sqlite\") {\n          const nextSqliteSummary = data?.sqliteSummary || null;\n          const nextObjects = nextSqliteSummary?.objects || [];\n          const defaultTable =\n            nextObjects.find((entry) => entry?.type === \"table\")?.name ||\n            nextObjects[0]?.name ||\n            \"\";\n          setSqliteSummary(nextSqliteSummary);\n          setSqliteSelectedTable(defaultTable);\n          setSqliteTableOffset(0);\n          setSqliteTableLoading(false);\n          setSqliteTableError(\"\");\n          setSqliteTableData(null);\n          setImageDataUrl(\"\");\n          setAudioDataUrl(\"\");\n          setContent(\"\");\n          setInitialContent(\"\");\n          setExternalChangeNoticeShown(false);\n          viewScrollRatioRef.current = 0;\n          loadedFilePathRef.current = normalizedPath;\n          restoredSelectionPathRef.current = \"\";\n          return;\n        }\n        setImageDataUrl(\"\");\n        setAudioDataUrl(\"\");\n        setSqliteSummary(null);\n        setSqliteSelectedTable(\"\");\n        setSqliteTableOffset(0);\n        setSqliteTableLoading(false);\n        setSqliteTableError(\"\");\n        setSqliteTableData(null);\n        const nextContent = data.content || \"\";\n        const draftContent = readStoredFileDraft(normalizedPath);\n        setContent(draftContent || nextContent);\n        updateDraftIndex(normalizedPath, Boolean(draftContent && draftContent !== nextContent), {\n          dispatchEvent: (event) => window.dispatchEvent(event),\n        });\n        setInitialContent(nextContent);\n        setExternalChangeNoticeShown(false);\n        viewScrollRatioRef.current = 0;\n        loadedFilePathRef.current = normalizedPath;\n        restoredSelectionPathRef.current = \"\";\n      } catch (loadError) {\n        if (!active) return;\n        setFileKind(\"text\");\n        setImageDataUrl(\"\");\n        setAudioDataUrl(\"\");\n        setSqliteSummary(null);\n        setSqliteSelectedTable(\"\");\n        setSqliteTableOffset(0);\n        setSqliteTableLoading(false);\n        setSqliteTableError(\"\");\n        setSqliteTableData(null);\n        const message = loadError.message || \"Could not load file\";\n        if (/path is not a file/i.test(message)) {\n          setContent(\"\");\n          setInitialContent(\"\");\n          setIsFolderPath(true);\n          setError(\"\");\n          loadedFilePathRef.current = normalizedPath;\n          restoredSelectionPathRef.current = \"\";\n          return;\n        }\n        setError(message);\n      } finally {\n        if (active) setLoading(false);\n      }\n    };\n    loadFile();\n    return () => {\n      active = false;\n    };\n  }, [hasSelectedPath, normalizedPath, isDiffView]);\n\n  useEffect(() => {\n    if (!isSqliteFile || !normalizedPath || !sqliteSelectedTable) {\n      setSqliteTableData(null);\n      setSqliteTableError(\"\");\n      setSqliteTableLoading(false);\n      return () => {};\n    }\n    let active = true;\n    const loadSqliteTable = async () => {\n      setSqliteTableLoading(true);\n      setSqliteTableError(\"\");\n      try {\n        const tableData = await fetchBrowseSqliteTable({\n          filePath: normalizedPath,\n          table: sqliteSelectedTable,\n          limit: kSqlitePageSize,\n          offset: sqliteTableOffset,\n        });\n        if (!active) return;\n        setSqliteTableData(tableData);\n      } catch (nextError) {\n        if (!active) return;\n        setSqliteTableError(nextError.message || \"Could not load sqlite table\");\n      } finally {\n        if (active) setSqliteTableLoading(false);\n      }\n    };\n    loadSqliteTable();\n    return () => {\n      active = false;\n    };\n  }, [isSqliteFile, normalizedPath, sqliteSelectedTable, sqliteTableOffset]);\n\n  useEffect(() => {\n    if (!hasSelectedPath || isFolderPath || !canEditFile || isDiffView) return () => {};\n    const refreshFromDisk = async () => {\n      if (loading || saving) return;\n      if (fileRefreshInFlightRef.current) return;\n      fileRefreshInFlightRef.current = true;\n      try {\n        const data = await fetchFileContent(normalizedPath);\n        const diskContent = data.content || \"\";\n        if (diskContent === initialContent) {\n          setExternalChangeNoticeShown(false);\n          return;\n        }\n        // Auto-refresh only when editor has no unsaved work.\n        if (!isDirty) {\n          setContent(diskContent);\n          setInitialContent(diskContent);\n          clearStoredFileDraft(normalizedPath);\n          updateDraftIndex(normalizedPath, false, {\n            dispatchEvent: (event) => window.dispatchEvent(event),\n          });\n          setExternalChangeNoticeShown(false);\n          window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-tree-refresh\"));\n          return;\n        }\n        if (!externalChangeNoticeShown) {\n          showToast(\n            \"This file changed on disk. Save to overwrite or reload by re-opening.\",\n            \"error\",\n          );\n          setExternalChangeNoticeShown(true);\n        }\n      } catch {\n        // Ignore transient refresh errors to avoid interrupting editing.\n      } finally {\n        fileRefreshInFlightRef.current = false;\n      }\n    };\n    const intervalId = window.setInterval(refreshFromDisk, kFileRefreshIntervalMs);\n    return () => {\n      window.clearInterval(intervalId);\n    };\n  }, [\n    hasSelectedPath,\n    isFolderPath,\n    canEditFile,\n    isDiffView,\n    loading,\n    saving,\n    normalizedPath,\n    initialContent,\n    isDirty,\n    externalChangeNoticeShown,\n  ]);\n\n  return {\n    loadedFilePathRef,\n    restoredSelectionPathRef,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js",
    "content": "import { useEffect } from \"preact/hooks\";\nimport {\n  clearStoredFileDraft,\n  updateDraftIndex,\n  writeStoredFileDraft,\n} from \"../../lib/browse-draft-state.js\";\n\nexport const useFileViewerDraftSync = ({\n  loadedFilePathRef,\n  normalizedPath,\n  canEditFile,\n  hasSelectedPath,\n  loading,\n  content,\n  initialContent,\n}) => {\n  useEffect(() => {\n    if (loadedFilePathRef.current !== normalizedPath) return;\n    if (!canEditFile || !hasSelectedPath || loading) return;\n    if (content === initialContent) {\n      clearStoredFileDraft(normalizedPath);\n      updateDraftIndex(normalizedPath, false, {\n        dispatchEvent: (event) => window.dispatchEvent(event),\n      });\n      return;\n    }\n    writeStoredFileDraft(normalizedPath, content);\n    updateDraftIndex(normalizedPath, true, {\n      dispatchEvent: (event) => window.dispatchEvent(event),\n    });\n  }, [canEditFile, hasSelectedPath, loading, content, initialContent, normalizedPath]);\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js",
    "content": "import { useEffect } from \"preact/hooks\";\n\nexport const useFileViewerHotkeys = ({\n  canEditFile,\n  isPreviewOnly,\n  isDiffView,\n  viewMode,\n  handleSave,\n}) => {\n  useEffect(() => {\n    const handleKeyDown = (event) => {\n      const isSaveShortcut =\n        (event.metaKey || event.ctrlKey) &&\n        !event.shiftKey &&\n        !event.altKey &&\n        String(event.key || \"\").toLowerCase() === \"s\";\n      if (!isSaveShortcut) return;\n      if (!canEditFile || isPreviewOnly || isDiffView || viewMode !== \"edit\") return;\n      event.preventDefault();\n      void handleSave();\n    };\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [canEditFile, isPreviewOnly, isDiffView, viewMode, handleSave]);\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/use-file-viewer.js",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport { marked } from \"marked\";\nimport { deleteBrowseFile, restoreBrowseFile, saveFileContent } from \"../../lib/api.js\";\nimport {\n  getFileSyntaxKind,\n  highlightEditorLines,\n  parseFrontmatter,\n} from \"../../lib/syntax-highlighters/index.js\";\nimport {\n  clearStoredFileDraft,\n  updateDraftIndex,\n  writeStoredFileDraft,\n} from \"../../lib/browse-draft-state.js\";\nimport {\n  kLockedBrowsePaths,\n  kProtectedBrowsePaths,\n  matchesBrowsePolicyPath,\n  normalizeBrowsePolicyPath,\n} from \"../../lib/browse-file-policies.js\";\nimport { showToast } from \"../toast.js\";\nimport {\n  kFileViewerModeStorageKey,\n  kLargeFileSimpleEditorCharThreshold,\n  kLargeFileSimpleEditorLineThreshold,\n  kLoadingIndicatorDelayMs,\n} from \"./constants.js\";\nimport { readStoredFileViewerMode, writeStoredEditorSelection } from \"./storage.js\";\nimport { countTextLines, parsePathSegments, shouldUseSimpleEditorMode } from \"./utils.js\";\nimport { useScrollSync } from \"./scroll-sync.js\";\nimport { useFileLoader } from \"./use-file-loader.js\";\nimport { useFileDiff } from \"./use-file-diff.js\";\nimport { useFileViewerDraftSync } from \"./use-file-viewer-draft-sync.js\";\nimport { useFileViewerHotkeys } from \"./use-file-viewer-hotkeys.js\";\nimport { useEditorSelectionRestore } from \"./use-editor-selection-restore.js\";\nimport { useEditorLineNumberSync } from \"./use-editor-line-number-sync.js\";\n\nexport const useFileViewer = ({\n  filePath = \"\",\n  isPreviewOnly = false,\n  browseView = \"edit\",\n  lineTarget = 0,\n  lineEndTarget = 0,\n  onRequestClearSelection = () => {},\n  onRequestEdit = () => {},\n}) => {\n  const normalizedPath = String(filePath || \"\").trim();\n  const normalizedPolicyPath = normalizeBrowsePolicyPath(normalizedPath);\n  const [content, setContent] = useState(\"\");\n  const [initialContent, setInitialContent] = useState(\"\");\n  const [fileKind, setFileKind] = useState(\"text\");\n  const [imageDataUrl, setImageDataUrl] = useState(\"\");\n  const [audioDataUrl, setAudioDataUrl] = useState(\"\");\n  const [sqliteSummary, setSqliteSummary] = useState(null);\n  const [sqliteSelectedTable, setSqliteSelectedTable] = useState(\"\");\n  const [sqliteTableOffset, setSqliteTableOffset] = useState(0);\n  const [sqliteTableLoading, setSqliteTableLoading] = useState(false);\n  const [sqliteTableError, setSqliteTableError] = useState(\"\");\n  const [sqliteTableData, setSqliteTableData] = useState(null);\n  const [viewMode, setViewMode] = useState(readStoredFileViewerMode);\n  const [loading, setLoading] = useState(false);\n  const [showDelayedLoadingSpinner, setShowDelayedLoadingSpinner] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [deleting, setDeleting] = useState(false);\n  const [restoring, setRestoring] = useState(false);\n  const [error, setError] = useState(\"\");\n  const [isFolderPath, setIsFolderPath] = useState(false);\n  const [frontmatterCollapsed, setFrontmatterCollapsed] = useState(false);\n  const [externalChangeNoticeShown, setExternalChangeNoticeShown] = useState(false);\n  const [protectedEditBypassPaths, setProtectedEditBypassPaths] = useState(() => new Set());\n  const editorLineNumbersRef = useRef(null);\n  const editorHighlightRef = useRef(null);\n  const editorTextareaRef = useRef(null);\n  const previewRef = useRef(null);\n  const editorLineNumberRowRefs = useRef([]);\n  const editorHighlightLineRefs = useRef([]);\n\n  const hasSelectedPath = normalizedPath.length > 0;\n  const isImageFile = fileKind === \"image\";\n  const isAudioFile = fileKind === \"audio\";\n  const isSqliteFile = fileKind === \"sqlite\";\n  const canEditFile =\n    hasSelectedPath && !isFolderPath && !isPreviewOnly && !isImageFile && !isAudioFile && !isSqliteFile;\n  const isDiffView = String(browseView || \"edit\") === \"diff\";\n\n  const { viewScrollRatioRef, handleEditorScroll, handlePreviewScroll, handleChangeViewMode } =\n    useScrollSync({\n      viewMode,\n      setViewMode,\n      previewRef,\n      editorTextareaRef,\n      editorLineNumbersRef,\n      editorHighlightRef,\n    });\n\n  const { loadedFilePathRef, restoredSelectionPathRef } = useFileLoader({\n    hasSelectedPath,\n    normalizedPath,\n    isDiffView,\n    isSqliteFile,\n    sqliteSelectedTable,\n    sqliteTableOffset,\n    canEditFile,\n    isFolderPath,\n    loading,\n    saving,\n    initialContent,\n    isDirty: canEditFile && content !== initialContent,\n    setLoading,\n    setContent,\n    setInitialContent,\n    setFileKind,\n    setImageDataUrl,\n    setAudioDataUrl,\n    setSqliteSummary,\n    setSqliteSelectedTable,\n    setSqliteTableOffset,\n    setSqliteTableLoading,\n    setSqliteTableError,\n    setSqliteTableData,\n    setError,\n    setIsFolderPath,\n    setExternalChangeNoticeShown,\n    externalChangeNoticeShown,\n    viewScrollRatioRef,\n  });\n\n  const { diffLoading, diffError, diffContent, diffStatus } = useFileDiff({\n    hasSelectedPath,\n    isDiffView,\n    isPreviewOnly,\n    normalizedPath,\n  });\n\n  const pathSegments = useMemo(() => parsePathSegments(normalizedPath), [normalizedPath]);\n  const isCurrentFileLoaded = loadedFilePathRef.current === normalizedPath;\n  const renderContent = isCurrentFileLoaded ? content : \"\";\n  const renderInitialContent = isCurrentFileLoaded ? initialContent : \"\";\n  const isDirty = canEditFile && renderContent !== renderInitialContent;\n  const isLockedFile =\n    canEditFile && matchesBrowsePolicyPath(kLockedBrowsePaths, normalizedPolicyPath);\n  const isProtectedFile =\n    canEditFile &&\n    !isLockedFile &&\n    matchesBrowsePolicyPath(kProtectedBrowsePaths, normalizedPolicyPath);\n  const isProtectedLocked = isProtectedFile && !protectedEditBypassPaths.has(normalizedPolicyPath);\n  const isEditBlocked = isLockedFile || isProtectedLocked;\n  const isDeleteBlocked = isLockedFile || isProtectedFile;\n  const canDeleteFile =\n    hasSelectedPath &&\n    !isFolderPath &&\n    !isPreviewOnly &&\n    !isDiffView &&\n    !deleting &&\n    !saving &&\n    !isDeleteBlocked;\n  const syntaxKind = useMemo(() => getFileSyntaxKind(normalizedPath), [normalizedPath]);\n  const isMarkdownFile = syntaxKind === \"markdown\";\n  const editorLineCount = useMemo(() => countTextLines(renderContent), [renderContent]);\n  const useSimpleEditor = useMemo(\n    () =>\n      shouldUseSimpleEditorMode({\n        contentLength: renderContent.length,\n        lineCount: editorLineCount,\n        charThreshold: kLargeFileSimpleEditorCharThreshold,\n        lineThreshold: kLargeFileSimpleEditorLineThreshold,\n      }),\n    [renderContent, editorLineCount],\n  );\n  const shouldUseHighlightedEditor = syntaxKind !== \"plain\" && !useSimpleEditor;\n  const shouldRenderLineNumbers = !useSimpleEditor;\n  const parsedFrontmatter = useMemo(\n    () => (isMarkdownFile ? parseFrontmatter(renderContent) : { entries: [], body: renderContent }),\n    [renderContent, isMarkdownFile],\n  );\n  const highlightedEditorLines = useMemo(\n    () => (shouldUseHighlightedEditor ? highlightEditorLines(renderContent, syntaxKind) : []),\n    [renderContent, shouldUseHighlightedEditor, syntaxKind],\n  );\n  const editorLineNumbers = useMemo(() => {\n    if (!shouldRenderLineNumbers) return [];\n    return Array.from({ length: editorLineCount }, (_, index) => index + 1);\n  }, [editorLineCount, shouldRenderLineNumbers]);\n  const previewHtml = useMemo(\n    () =>\n      isMarkdownFile\n        ? marked.parse(parsedFrontmatter.body || \"\", {\n            gfm: true,\n            breaks: true,\n          })\n        : \"\",\n    [parsedFrontmatter.body, isMarkdownFile],\n  );\n\n  useEditorLineNumberSync({\n    enabled: shouldUseHighlightedEditor && viewMode === \"edit\",\n    syncKey: `${normalizedPath}:${renderContent.length}:${highlightedEditorLines.length}`,\n    editorLineNumberRowRefs,\n    editorHighlightLineRefs,\n  });\n\n  useEffect(() => {\n    if (!isMarkdownFile && viewMode !== \"edit\") {\n      setViewMode(\"edit\");\n    }\n  }, [isMarkdownFile, viewMode]);\n\n  useEffect(() => {\n    setProtectedEditBypassPaths(new Set());\n  }, [normalizedPath]);\n\n  useEffect(() => {\n    try {\n      window.localStorage.setItem(kFileViewerModeStorageKey, viewMode);\n    } catch {}\n  }, [viewMode]);\n\n  useEffect(() => {\n    if (!loading) {\n      setShowDelayedLoadingSpinner(false);\n      return () => {};\n    }\n    const timer = window.setTimeout(() => {\n      setShowDelayedLoadingSpinner(true);\n    }, kLoadingIndicatorDelayMs);\n    return () => window.clearTimeout(timer);\n  }, [loading]);\n\n  useFileViewerDraftSync({\n    loadedFilePathRef,\n    normalizedPath,\n    canEditFile,\n    hasSelectedPath,\n    loading,\n    content,\n    initialContent,\n  });\n\n  useEditorSelectionRestore({\n    canEditFile,\n    isEditBlocked,\n    loading,\n    hasSelectedPath,\n    normalizedPath,\n    loadedFilePathRef,\n    restoredSelectionPathRef,\n    viewMode,\n    content,\n    lineTarget,\n    lineEndTarget,\n    editorTextareaRef,\n    editorLineNumbersRef,\n    editorHighlightRef,\n    viewScrollRatioRef,\n  });\n\n  const handleSave = useCallback(async () => {\n    if (!canEditFile || saving || !isDirty || isEditBlocked) return;\n    setSaving(true);\n    setError(\"\");\n    try {\n      await saveFileContent(normalizedPath, content);\n      setInitialContent(content);\n      setExternalChangeNoticeShown(false);\n      clearStoredFileDraft(normalizedPath);\n      updateDraftIndex(normalizedPath, false, {\n        dispatchEvent: (event) => window.dispatchEvent(event),\n      });\n      window.dispatchEvent(\n        new CustomEvent(\"alphaclaw:browse-file-saved\", {\n          detail: { path: normalizedPath },\n        }),\n      );\n    } catch (saveError) {\n      const message = saveError.message || \"Could not save file\";\n      showToast(message, \"error\");\n    } finally {\n      setSaving(false);\n    }\n  }, [canEditFile, saving, isDirty, isEditBlocked, normalizedPath, content]);\n\n  const handleDelete = useCallback(async () => {\n    if (!canDeleteFile) return;\n    setDeleting(true);\n    setError(\"\");\n    try {\n      const data = await deleteBrowseFile(normalizedPath);\n      const deletedPath = String(data?.path || normalizedPath);\n      setExternalChangeNoticeShown(false);\n      clearStoredFileDraft(normalizedPath);\n      updateDraftIndex(normalizedPath, false, {\n        dispatchEvent: (event) => window.dispatchEvent(event),\n      });\n      window.dispatchEvent(\n        new CustomEvent(\"alphaclaw:browse-file-saved\", {\n          detail: { path: deletedPath },\n        }),\n      );\n      window.dispatchEvent(\n        new CustomEvent(\"alphaclaw:browse-file-deleted\", {\n          detail: { path: deletedPath },\n        }),\n      );\n      window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-tree-refresh\"));\n      showToast(\"File deleted\", \"success\");\n      onRequestClearSelection();\n    } catch (deleteError) {\n      const message = deleteError.message || \"Could not delete file\";\n      setError(message);\n      if (/path is not a file/i.test(message)) {\n        showToast(\"Only files can be deleted\", \"warning\");\n        onRequestClearSelection();\n      } else {\n        showToast(message, \"error\");\n      }\n    } finally {\n      setDeleting(false);\n    }\n  }, [canDeleteFile, normalizedPath, onRequestClearSelection]);\n\n  const handleRestore = useCallback(async () => {\n    if (!isDiffView || !diffStatus?.isDeleted || restoring) return;\n    setRestoring(true);\n    try {\n      const data = await restoreBrowseFile(normalizedPath);\n      const restoredPath = String(data?.path || normalizedPath);\n      window.dispatchEvent(\n        new CustomEvent(\"alphaclaw:browse-file-saved\", {\n          detail: { path: restoredPath },\n        }),\n      );\n      window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-tree-refresh\"));\n      showToast(\"File restored\", \"success\");\n      onRequestEdit(restoredPath);\n    } catch (restoreError) {\n      showToast(restoreError.message || \"Could not restore file\", \"error\");\n    } finally {\n      setRestoring(false);\n    }\n  }, [\n    diffStatus?.isDeleted,\n    isDiffView,\n    normalizedPath,\n    onRequestEdit,\n    restoring,\n  ]);\n\n  useFileViewerHotkeys({\n    canEditFile,\n    isPreviewOnly,\n    isDiffView,\n    viewMode,\n    handleSave,\n  });\n\n  const handleEditProtectedFile = () => {\n    if (!normalizedPolicyPath) return;\n    setProtectedEditBypassPaths((previousPaths) => {\n      const nextPaths = new Set(previousPaths);\n      nextPaths.add(normalizedPolicyPath);\n      return nextPaths;\n    });\n    window.requestAnimationFrame(() => {\n      window.requestAnimationFrame(() => {\n        const textareaElement = editorTextareaRef.current;\n        if (!textareaElement) return;\n        if (textareaElement.disabled || textareaElement.readOnly) return;\n        textareaElement.focus();\n      });\n    });\n  };\n\n  const persistDraftForContent = useCallback(\n    (nextContent, selection = null) => {\n      if (!hasSelectedPath || !canEditFile) return;\n      if (selection) {\n        writeStoredEditorSelection(normalizedPath, selection);\n      }\n      writeStoredFileDraft(normalizedPath, nextContent);\n      updateDraftIndex(normalizedPath, nextContent !== initialContent, {\n        dispatchEvent: (event) => window.dispatchEvent(event),\n      });\n    },\n    [hasSelectedPath, canEditFile, normalizedPath, initialContent],\n  );\n\n  const handleContentInput = (event) => {\n    if (isEditBlocked || isPreviewOnly) return;\n    const nextContent = event.target.value;\n    setContent(nextContent);\n    persistDraftForContent(nextContent, {\n      start: event.target.selectionStart,\n      end: event.target.selectionEnd,\n    });\n  };\n\n  const handleEditorKeyDown = (event) => {\n    if (event.key !== \"Tab\") return;\n    if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;\n    if (isEditBlocked || isPreviewOnly || !canEditFile) return;\n    const textareaElement = event.currentTarget;\n    if (!textareaElement) return;\n    event.preventDefault();\n    const start = Number(textareaElement.selectionStart || 0);\n    const end = Number(textareaElement.selectionEnd || 0);\n    textareaElement.setRangeText(\"  \", start, end, \"end\");\n    const nextContent = textareaElement.value;\n    setContent(nextContent);\n    persistDraftForContent(nextContent, {\n      start: textareaElement.selectionStart,\n      end: textareaElement.selectionEnd,\n    });\n  };\n\n  const handleDiscard = () => {\n    if (!canEditFile || !isDirty || saving || deleting) return;\n    setContent(initialContent);\n    clearStoredFileDraft(normalizedPath);\n    updateDraftIndex(normalizedPath, false, {\n      dispatchEvent: (event) => window.dispatchEvent(event),\n    });\n    showToast(\"Changes discarded\", \"info\");\n  };\n\n  const handleEditorSelectionChange = () => {\n    if (!hasSelectedPath || !canEditFile || loading) return;\n    const textareaElement = editorTextareaRef.current;\n    if (!textareaElement) return;\n    writeStoredEditorSelection(normalizedPath, {\n      start: textareaElement.selectionStart,\n      end: textareaElement.selectionEnd,\n    });\n  };\n\n  return {\n    state: {\n      hasSelectedPath,\n      isPreviewOnly,\n      loading,\n      saving,\n      deleting,\n      restoring,\n      showDelayedLoadingSpinner,\n      error,\n      isFolderPath,\n      isImageFile,\n      imageDataUrl,\n      isAudioFile,\n      audioDataUrl,\n      isSqliteFile,\n      sqliteSummary,\n      sqliteSelectedTable,\n      sqliteTableOffset,\n      sqliteTableLoading,\n      sqliteTableError,\n      sqliteTableData,\n      isDiffView,\n      diffLoading,\n      diffError,\n      diffContent,\n      diffStatus,\n      isMarkdownFile,\n      frontmatterCollapsed,\n      previewHtml,\n      viewMode,\n      renderContent,\n    },\n    derived: {\n      pathSegments,\n      isDirty,\n      canEditFile,\n      canDeleteFile,\n      isDeleteBlocked,\n      isEditBlocked,\n      isLockedFile,\n      isProtectedFile,\n      isProtectedLocked,\n      shouldUseHighlightedEditor,\n      shouldRenderLineNumbers,\n      parsedFrontmatter,\n      highlightedEditorLines,\n      editorLineNumbers,\n    },\n    refs: {\n      previewRef,\n      editorLineNumbersRef,\n      editorLineNumberRowRefs,\n      editorHighlightRef,\n      editorHighlightLineRefs,\n      editorTextareaRef,\n    },\n    actions: {\n      setFrontmatterCollapsed,\n      setSqliteSelectedTable,\n      setSqliteTableOffset,\n      handleChangeViewMode,\n      handleSave,\n      handleDiscard,\n      handleDelete,\n      handleRestore,\n      handleEditProtectedFile,\n      handleContentInput,\n      handleEditorKeyDown,\n      handleEditorScroll,\n      handlePreviewScroll,\n      handleEditorSelectionChange,\n    },\n    context: {\n      normalizedPath,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/file-viewer/utils.js",
    "content": "export const parsePathSegments = (inputPath) =>\n  String(inputPath || \"\")\n    .split(\"/\")\n    .map((part) => part.trim())\n    .filter(Boolean);\n\nexport const clampSelectionIndex = (value, maxValue) => {\n  const numericValue = Number.parseInt(String(value ?? \"\"), 10);\n  if (!Number.isFinite(numericValue)) return 0;\n  return Math.max(0, Math.min(maxValue, numericValue));\n};\n\nexport const countTextLines = (content) => {\n  const text = String(content || \"\");\n  if (!text) return 1;\n  return text.split(/\\r\\n|\\r|\\n/).length;\n};\n\nexport const shouldUseSimpleEditorMode = ({\n  contentLength = 0,\n  lineCount = 1,\n  charThreshold = 250000,\n  lineThreshold = 5000,\n}) =>\n  Number(contentLength) > Number(charThreshold) ||\n  Number(lineCount) > Number(lineThreshold);\n"
  },
  {
    "path": "lib/public/js/components/gateway.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { UpdateActionButton } from \"./update-action-button.js\";\nconst html = htm.bind(h);\n\nconst formatDuration = (ms) => {\n  const safeMs = Number(ms || 0);\n  if (!Number.isFinite(safeMs) || safeMs <= 0) return \"0s\";\n  const totalSeconds = Math.floor(safeMs / 1000);\n  const days = Math.floor(totalSeconds / 86400);\n  const hours = Math.floor(totalSeconds / 3600);\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  const seconds = totalSeconds % 60;\n  if (days > 0) return `${days}d ${hours % 24}h ${minutes}m ${seconds}s`;\n  if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;\n  if (minutes > 0) return `${minutes}m ${seconds}s`;\n  return `${seconds}s`;\n};\n\nexport const Gateway = ({\n  status,\n  restarting = false,\n  onRestart,\n  watchdogStatus = null,\n  onOpenWatchdog,\n  onRepair,\n  repairing = false,\n}) => {\n  const [nowMs, setNowMs] = useState(() => Date.now());\n  const isRunning = status === \"running\" && !restarting;\n  const dotClass = isRunning\n    ? \"ac-status-dot ac-status-dot--healthy\"\n    : \"w-2 h-2 rounded-full bg-yellow-500 animate-pulse\";\n  const watchdogHealth =\n    watchdogStatus?.lifecycle === \"crash_loop\"\n      ? \"crash_loop\"\n      : watchdogStatus?.health;\n  const watchdogDotClass =\n    watchdogHealth === \"healthy\"\n      ? \"ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset\"\n      : watchdogHealth === \"degraded\"\n        ? \"bg-yellow-500\"\n        : watchdogHealth === \"unhealthy\" || watchdogHealth === \"crash_loop\"\n          ? \"bg-red-500\"\n          : \"bg-gray-500\";\n  const watchdogLabel =\n    watchdogHealth === \"unknown\" ? \"initializing\" : watchdogHealth || \"unknown\";\n  const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress;\n  const showInspectButton = watchdogHealth === \"degraded\" && !!onOpenWatchdog;\n  const showRepairButton =\n    isRepairInProgress ||\n    (watchdogStatus?.health === \"degraded\" && !onOpenWatchdog) ||\n    watchdogStatus?.lifecycle === \"crash_loop\" ||\n    watchdogStatus?.health === \"unhealthy\" ||\n    watchdogStatus?.health === \"crashed\";\n  const liveUptimeMs = useMemo(() => {\n    const startedAtMs = watchdogStatus?.uptimeStartedAt\n      ? Date.parse(watchdogStatus.uptimeStartedAt)\n      : null;\n    if (Number.isFinite(startedAtMs)) {\n      return Math.max(0, nowMs - startedAtMs);\n    }\n    return watchdogStatus?.uptimeMs || 0;\n  }, [watchdogStatus?.uptimeStartedAt, watchdogStatus?.uptimeMs, nowMs]);\n\n  useEffect(() => {\n    const id = setInterval(() => {\n      setNowMs(Date.now());\n    }, 1000);\n    return () => clearInterval(id);\n  }, []);\n\n  return html` <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <div class=\"space-y-2\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <div class=\"min-w-0 flex items-center gap-2 text-sm\">\n          <span class=${dotClass}></span>\n          <span class=\"font-semibold\">Gateway:</span>\n          <span class=\"text-fg-muted\"\n            >${restarting ? \"restarting...\" : status || \"checking...\"}</span\n          >\n        </div>\n        <div class=\"flex items-center gap-3 shrink-0\">\n          ${!restarting && isRunning\n            ? html`\n                <span class=\"text-xs text-fg-muted whitespace-nowrap\"\n                  >Uptime: ${formatDuration(liveUptimeMs)}</span\n                >\n              `\n            : null}\n          <${UpdateActionButton}\n            onClick=${onRestart}\n            disabled=${!status}\n            loading=${restarting}\n            warning=${false}\n            idleLabel=\"Restart\"\n            loadingLabel=\"On it...\"\n          />\n        </div>\n      </div>\n      <div class=\"flex items-center justify-between gap-3\">\n        ${onOpenWatchdog\n          ? html`\n              <button\n                class=\"inline-flex items-center gap-2 text-sm hover:opacity-90\"\n                onclick=${onOpenWatchdog}\n                title=\"Open Watchdog tab\"\n              >\n                <span\n                  class=${watchdogDotClass.startsWith(\"ac-status-dot\")\n                    ? watchdogDotClass\n                    : `w-2 h-2 rounded-full ${watchdogDotClass}`}\n                ></span>\n                <span class=\"font-semibold\">Watchdog:</span>\n                <span class=\"text-fg-muted\">${watchdogLabel}</span>\n              </button>\n            `\n          : html`\n              <div class=\"inline-flex items-center gap-2 text-sm\">\n                <span\n                  class=${watchdogDotClass.startsWith(\"ac-status-dot\")\n                    ? watchdogDotClass\n                    : `w-2 h-2 rounded-full ${watchdogDotClass}`}\n                ></span>\n                <span class=\"font-semibold\">Watchdog:</span>\n                <span class=\"text-fg-muted\">${watchdogLabel}</span>\n              </div>\n            `}\n        ${onRepair\n          ? html`\n              <div class=\"shrink-0 w-32 flex justify-end\">\n                ${showInspectButton\n                  ? html`\n                      <${UpdateActionButton}\n                        onClick=${onOpenWatchdog}\n                        warning=${false}\n                        idleLabel=\"Inspect\"\n                        loadingLabel=\"Inspect\"\n                        className=\"w-full justify-center\"\n                      />\n                    `\n                  : showRepairButton\n                    ? html`\n                        <${UpdateActionButton}\n                          onClick=${onRepair}\n                          loading=${isRepairInProgress}\n                          warning=${true}\n                          idleLabel=\"Repair\"\n                          loadingLabel=\"Repairing...\"\n                          className=\"w-full justify-center\"\n                        />\n                      `\n                    : html`<span\n                        class=\"inline-flex h-7 w-full\"\n                        aria-hidden=\"true\"\n                      ></span>`}\n              </div>\n            `\n          : null}\n      </div>\n    </div>\n  </div>`;\n};\n"
  },
  {
    "path": "lib/public/js/components/general/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Gateway } from \"../gateway.js\";\nimport { Channels } from \"../channels.js\";\nimport { ChannelOperationsPanel } from \"../channel-operations-panel.js\";\nimport { Pairings } from \"../pairings.js\";\nimport { DevicePairings } from \"../device-pairings.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { Google } from \"../google/index.js\";\nimport { Features } from \"../features.js\";\nimport { GeneralDoctorWarning } from \"../doctor/general-warning.js\";\nimport { ChevronDownIcon } from \"../icons.js\";\nimport { UpdateActionButton } from \"../update-action-button.js\";\nimport { useGeneralTab } from \"./use-general-tab.js\";\n\nconst html = htm.bind(h);\n\nconst openWhatsAppQrModal = () => {\n  if (typeof window === \"undefined\") return;\n  window.dispatchEvent(new CustomEvent(\"alphaclaw:open-whatsapp-qr\"));\n};\n\nexport const GeneralTab = ({\n  statusData = null,\n  watchdogData = null,\n  doctorStatusData = null,\n  agents = [],\n  doctorWarningDismissedUntilMs = 0,\n  onRefreshStatuses = () => {},\n  onSwitchTab = () => {},\n  onNavigate = () => {},\n  onOpenGmailWebhook = () => {},\n  isActive = false,\n  restartingGateway = false,\n  onRestartGateway = () => {},\n  restartSignal = 0,\n  onRestartRequired = () => {},\n  onDismissDoctorWarning = () => {},\n}) => {\n  const { state, actions } = useGeneralTab({\n    statusData,\n    watchdogData,\n    doctorStatusData,\n    onRefreshStatuses,\n    isActive,\n    restartSignal,\n  });\n  const whatsappStatus = state.channels?.whatsapp || null;\n  const whatsappAccounts =\n    whatsappStatus?.accounts && typeof whatsappStatus.accounts === \"object\"\n      ? whatsappStatus.accounts\n      : {};\n  const hasWhatsAppAwaitingPairing =\n    Object.keys(whatsappAccounts).length > 0\n      ? Object.values(whatsappAccounts).some(\n          (account) => account && account.status !== \"paired\",\n        )\n      : String(whatsappStatus?.status || \"\").trim() === \"configured\";\n  const showWhatsAppPairingCard =\n    state.hasUnpaired &&\n    !state.pairingStatusRefreshing &&\n    Array.isArray(state.pending) &&\n    state.pending.length === 0 &&\n    hasWhatsAppAwaitingPairing;\n  const showPairings =\n    state.hasUnpaired ||\n    (Array.isArray(state.pending) && state.pending.length > 0) ||\n    state.pairingStatusRefreshing;\n\n  return html`\n    <div class=\"space-y-4\">\n      <${Gateway}\n        status=${state.gatewayStatus}\n        restarting=${restartingGateway}\n        onRestart=${onRestartGateway}\n        watchdogStatus=${state.watchdogStatus}\n        onOpenWatchdog=${() => onSwitchTab(\"watchdog\")}\n        onRepair=${actions.handleWatchdogRepair}\n        repairing=${state.repairingWatchdog}\n      />\n      <${GeneralDoctorWarning}\n        doctorStatus=${state.doctorStatus}\n        dismissedUntilMs=${doctorWarningDismissedUntilMs}\n        onOpenDoctor=${() => onSwitchTab(\"doctor\")}\n        onDismiss=${onDismissDoctorWarning}\n      />\n      <${ChannelOperationsPanel}\n        channelsSection=${html`\n          <${Channels}\n            channels=${state.channels}\n            agents=${agents}\n            onNavigate=${onNavigate}\n            onRefreshStatuses=${onRefreshStatuses}\n            onRestartGateway=${onRestartGateway}\n          />\n        `}\n        pairingsSection=${html`\n          ${showWhatsAppPairingCard\n            ? html`\n                <div class=\"bg-surface border border-border rounded-xl p-4\">\n                  <h2 class=\"card-label mb-3\">Pending Pairings</h2>\n                  <div class=\"text-center py-4 space-y-3\">\n                    <img\n                      src=\"/assets/icons/whatsapp.svg\"\n                      alt=\"\"\n                      class=\"w-10 h-10 mx-auto\"\n                      aria-hidden=\"true\"\n                    />\n                    <p class=\"text-body text-sm font-medium\">WhatsApp needs to be linked</p>\n                    <p class=\"text-fg-dim text-xs\">Scan the QR code to finish pairing this channel.</p>\n                    <${ActionButton}\n                      onClick=${openWhatsAppQrModal}\n                      tone=\"primary\"\n                      size=\"sm\"\n                      idleLabel=\"Open QR Code\"\n                    />\n                  </div>\n                </div>\n              `\n            : html`\n                <${Pairings}\n                  pending=${state.pending}\n                  channels=${state.channels}\n                  visible=${showPairings}\n                  pollingInFlight=${state.pairingsPolling}\n                  statusRefreshing=${state.pairingStatusRefreshing}\n                  onApprove=${actions.handleApprove}\n                  onReject=${actions.handleReject}\n                />\n              `}\n        `}\n      />\n      <${Features} onSwitchTab=${onSwitchTab} />\n      <${Google}\n        gatewayStatus=${state.gatewayStatus}\n        onRestartRequired=${onRestartRequired}\n        onOpenGmailWebhook=${onOpenGmailWebhook}\n      />\n\n      ${state.repo &&\n      html`\n        <div class=\"bg-surface border border-border rounded-xl p-4\">\n          <div class=\"flex items-center justify-between gap-3\">\n            <div class=\"flex items-center gap-2 min-w-0\">\n              <svg\n                class=\"w-4 h-4 text-fg-muted\"\n                viewBox=\"0 0 16 16\"\n                fill=\"currentColor\"\n              >\n                <path\n                  d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\"\n                />\n              </svg>\n              <a\n                href=\"https://github.com/${state.repo}\"\n                target=\"_blank\"\n                class=\"text-sm text-fg-muted hover:text-body transition-colors truncate\"\n                >${state.repo}</a\n              >\n            </div>\n            <div class=\"flex items-center gap-2 shrink-0\">\n              <span class=\"text-xs text-fg-muted\">Auto-sync</span>\n              <div class=\"relative\">\n                <select\n                  value=${state.syncCronChoice}\n                  onchange=${(event) =>\n                    actions.handleSyncCronChoiceChange(event.target.value)}\n                  disabled=${state.savingSyncCron}\n                  class=\"appearance-none bg-field border border-border rounded-lg pl-2.5 pr-9 py-1.5 text-xs text-body ${state.savingSyncCron\n                    ? \"opacity-50 cursor-not-allowed\"\n                    : \"\"}\"\n                  title=${state.syncCron?.installed === false\n                    ? \"Not Installed Yet\"\n                    : state.syncCronStatusText}\n                >\n                  <option value=\"disabled\">Disabled</option>\n                  <option value=\"*/30 * * * *\">Every 30 min</option>\n                  <option value=\"0 * * * *\">Hourly</option>\n                  <option value=\"0 0 * * *\">Daily</option>\n                </select>\n                <${ChevronDownIcon}\n                  className=\"pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted\"\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n      `}\n\n      <div class=\"bg-surface border border-border rounded-xl p-4\">\n        <div class=\"flex items-center justify-between\">\n          <div>\n            <h2 class=\"font-semibold text-sm\">OpenClaw Gateway Dashboard</h2>\n          </div>\n          <${UpdateActionButton}\n            onClick=${actions.handleOpenDashboard}\n            loading=${state.dashboardLoading}\n            warning=${false}\n            idleLabel=\"Open\"\n            loadingLabel=\"Opening...\"\n          />\n        </div>\n        <${DevicePairings}\n          pending=${state.devicePending}\n          onApprove=${actions.handleDeviceApprove}\n          onReject=${actions.handleDeviceReject}\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/general/use-general-tab.js",
    "content": "import { useEffect, useRef, useState } from \"preact/hooks\";\nimport {\n  approveDevice,\n  approvePairing,\n  fetchDashboardUrl,\n  fetchDevicePairings,\n  fetchPairings,\n  rejectDevice,\n  rejectPairing,\n  triggerWatchdogRepair,\n  updateSyncCron,\n} from \"../../lib/api.js\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport { showToast } from \"../toast.js\";\nimport { ALL_CHANNELS } from \"../channels.js\";\n\nconst kDefaultSyncCronSchedule = \"0 * * * *\";\n\nexport const useGeneralTab = ({\n  statusData = null,\n  watchdogData = null,\n  doctorStatusData = null,\n  onRefreshStatuses = () => {},\n  isActive = false,\n  restartSignal = 0,\n} = {}) => {\n  const [dashboardLoading, setDashboardLoading] = useState(false);\n  const [repairingWatchdog, setRepairingWatchdog] = useState(false);\n  const [syncCronEnabled, setSyncCronEnabled] = useState(true);\n  const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule);\n  const [savingSyncCron, setSavingSyncCron] = useState(false);\n  const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule);\n  const [pairingStatusRefreshing, setPairingStatusRefreshing] = useState(false);\n  const [devicePollingEnabled, setDevicePollingEnabled] = useState(false);\n  const [cliAutoApproveComplete, setCliAutoApproveComplete] = useState(false);\n  const pairingRefreshTimerRef = useRef(null);\n\n  const status = statusData;\n  const watchdogStatus = watchdogData;\n  const doctorStatus = doctorStatusData;\n  const gatewayStatus = status?.gateway ?? null;\n  const channels = status?.channels ?? null;\n  const repo = status?.repo || null;\n  const syncCron = status?.syncCron || null;\n  const openclawVersion = status?.openclawVersion || null;\n\n  const hasUnpaired = ALL_CHANNELS.some((channel) => {\n    const info = channels?.[channel];\n    if (!info) return false;\n    const accounts =\n      info.accounts && typeof info.accounts === \"object\" ? info.accounts : {};\n    if (Object.keys(accounts).length > 0) {\n      return Object.values(accounts).some(\n        (acc) => acc && acc.status !== \"paired\",\n      );\n    }\n    return info.status !== \"paired\";\n  });\n  const hasConfiguredPairingChannel = ALL_CHANNELS.some((channel) =>\n    Boolean(channels?.[channel]),\n  );\n\n  const pairingsPoll = usePolling(\n    async () => {\n      const data = await fetchPairings();\n      return data.pending || [];\n    },\n    3000,\n    {\n      enabled: hasConfiguredPairingChannel && gatewayStatus === \"running\",\n      cacheKey: \"/api/pairings\",\n      dedupeInFlight: true,\n    },\n  );\n  const pending = pairingsPoll.data || [];\n  const shouldPollDevices =\n    gatewayStatus === \"running\" && (devicePollingEnabled || !cliAutoApproveComplete);\n\n  const devicePoll = usePolling(\n    async () => {\n      const data = await fetchDevicePairings();\n      setCliAutoApproveComplete(data?.cliAutoApproveComplete === true);\n      return data.pending || [];\n    },\n    5000,\n    {\n      enabled: shouldPollDevices,\n      cacheKey: \"/api/devices\",\n    },\n  );\n  const devicePending = devicePoll.data || [];\n\n  useEffect(() => {\n    if (!restartSignal || !isActive) return;\n    onRefreshStatuses();\n    pairingsPoll.refresh({ force: true });\n    if (shouldPollDevices) {\n      devicePoll.refresh();\n    }\n    const t1 = setTimeout(() => {\n      onRefreshStatuses();\n      pairingsPoll.refresh();\n      if (shouldPollDevices) {\n        devicePoll.refresh();\n      }\n    }, 1200);\n    const t2 = setTimeout(() => {\n      onRefreshStatuses();\n      pairingsPoll.refresh();\n      if (shouldPollDevices) {\n        devicePoll.refresh();\n      }\n    }, 3500);\n    return () => {\n      clearTimeout(t1);\n      clearTimeout(t2);\n    };\n  }, [\n    devicePoll.refresh,\n    isActive,\n    onRefreshStatuses,\n    pairingsPoll.refresh,\n    restartSignal,\n    devicePollingEnabled,\n    shouldPollDevices,\n  ]);\n\n  useEffect(() => {\n    if (!syncCron) return;\n    setSyncCronEnabled(syncCron.enabled !== false);\n    setSyncCronSchedule(syncCron.schedule || kDefaultSyncCronSchedule);\n    setSyncCronChoice(\n      syncCron.enabled === false ? \"disabled\" : syncCron.schedule || kDefaultSyncCronSchedule,\n    );\n  }, [syncCron?.enabled, syncCron?.schedule]);\n\n  useEffect(\n    () => () => {\n      if (pairingRefreshTimerRef.current) {\n        clearTimeout(pairingRefreshTimerRef.current);\n      }\n    },\n    [],\n  );\n\n  const refreshAfterPairingAction = () => {\n    setPairingStatusRefreshing(true);\n    if (pairingRefreshTimerRef.current) {\n      clearTimeout(pairingRefreshTimerRef.current);\n    }\n    pairingRefreshTimerRef.current = setTimeout(() => {\n      setPairingStatusRefreshing(false);\n      pairingRefreshTimerRef.current = null;\n    }, 2800);\n    onRefreshStatuses();\n    pairingsPoll.refresh({ force: true });\n    setTimeout(() => {\n      onRefreshStatuses();\n      pairingsPoll.refresh();\n    }, 700);\n    setTimeout(() => {\n      onRefreshStatuses();\n      pairingsPoll.refresh();\n    }, 1800);\n  };\n\n  const saveSyncCronSettings = async ({\n    enabled = syncCronEnabled,\n    schedule = syncCronSchedule,\n  } = {}) => {\n    if (savingSyncCron) return;\n    setSavingSyncCron(true);\n    try {\n      const data = await updateSyncCron({ enabled, schedule });\n      if (!data.ok) {\n        throw new Error(data.error || \"Could not save sync settings\");\n      }\n      showToast(\"Sync schedule updated\", \"success\");\n      onRefreshStatuses();\n    } catch (err) {\n      showToast(err.message || \"Could not save sync settings\", \"error\");\n    } finally {\n      setSavingSyncCron(false);\n    }\n  };\n\n  const handleSyncCronChoiceChange = async (nextChoice) => {\n    setSyncCronChoice(nextChoice);\n    const nextEnabled = nextChoice !== \"disabled\";\n    const nextSchedule = nextEnabled ? nextChoice : syncCronSchedule;\n    setSyncCronEnabled(nextEnabled);\n    setSyncCronSchedule(nextSchedule);\n    await saveSyncCronSettings({\n      enabled: nextEnabled,\n      schedule: nextSchedule,\n    });\n  };\n\n  const handleApprove = async (id, channel, accountId = \"\") => {\n    try {\n      const result = await approvePairing(id, channel, accountId);\n      if (!result.ok) throw new Error(result.error || \"Could not approve pairing\");\n      refreshAfterPairingAction();\n    } catch (err) {\n      showToast(err.message || \"Could not approve pairing\", \"error\");\n      throw err;\n    }\n  };\n\n  const handleReject = async (id, channel, accountId = \"\") => {\n    try {\n      await rejectPairing(id, channel, accountId);\n      refreshAfterPairingAction();\n    } catch (err) {\n      showToast(err.message || \"Could not reject pairing\", \"error\");\n      throw err;\n    }\n  };\n\n  const handleDeviceApprove = async (id) => {\n    try {\n      await approveDevice(id);\n      showToast(\"Device pairing approved\", \"success\");\n      setTimeout(devicePoll.refresh, 500);\n      setTimeout(devicePoll.refresh, 2000);\n    } catch (err) {\n      showToast(err.message || \"Could not approve device pairing\", \"error\");\n      throw err;\n    }\n  };\n\n  const handleDeviceReject = async (id) => {\n    try {\n      await rejectDevice(id);\n      showToast(\"Device pairing rejected\", \"info\");\n      setTimeout(devicePoll.refresh, 500);\n      setTimeout(devicePoll.refresh, 2000);\n    } catch (err) {\n      showToast(err.message || \"Could not reject device pairing\", \"error\");\n      throw err;\n    }\n  };\n\n  const handleWatchdogRepair = async () => {\n    if (repairingWatchdog) return;\n    setRepairingWatchdog(true);\n    try {\n      const data = await triggerWatchdogRepair();\n      if (!data.ok) throw new Error(data.error || \"Repair failed\");\n      showToast(\"Repair triggered\", \"success\");\n      setTimeout(() => {\n        onRefreshStatuses();\n      }, 800);\n    } catch (err) {\n      showToast(err.message || \"Could not run repair\", \"error\");\n    } finally {\n      setRepairingWatchdog(false);\n    }\n  };\n\n  const handleOpenDashboard = async () => {\n    if (dashboardLoading) return;\n    setDevicePollingEnabled(true);\n    setDashboardLoading(true);\n    try {\n      const data = await fetchDashboardUrl();\n      if (data.needsAuth) {\n        showToast(\n          \"OpenClaw dashboard token is missing from the AlphaClaw server environment\",\n          \"warning\",\n        );\n      }\n      window.open(data.url || \"/openclaw\", \"_blank\");\n    } catch (err) {\n      showToast(err.message || \"Could not open OpenClaw dashboard\", \"error\");\n      window.open(\"/openclaw\", \"_blank\");\n    } finally {\n      setDashboardLoading(false);\n    }\n  };\n\n  return {\n    state: {\n      channels,\n      dashboardLoading,\n      devicePending,\n      doctorStatus,\n      gatewayStatus,\n      hasUnpaired,\n      openclawVersion,\n      pending,\n      pairingsPolling: pairingsPoll.isPolling,\n      pairingStatusRefreshing,\n      repairingWatchdog,\n      repo,\n      savingSyncCron,\n      syncCron,\n      syncCronChoice,\n      syncCronEnabled,\n      syncCronSchedule,\n      syncCronStatusText: syncCronEnabled ? \"Enabled\" : \"Disabled\",\n      watchdogStatus,\n    },\n    actions: {\n      handleApprove,\n      handleDeviceApprove,\n      handleDeviceReject,\n      handleOpenDashboard,\n      handleReject,\n      handleSyncCronChoiceChange,\n      handleWatchdogRepair,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/global-restart-banner.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { UpdateActionButton } from \"./update-action-button.js\";\nimport { CloseIcon } from \"./icons.js\";\n\nconst html = htm.bind(h);\n\nexport const GlobalRestartBanner = ({\n  visible = false,\n  restarting = false,\n  onRestart,\n  onDismiss = () => {},\n}) => {\n  if (!visible) return null;\n  return html`\n    <div class=\"global-restart-banner\">\n      <div class=\"global-restart-banner__content\">\n        <p class=\"global-restart-banner__text\">\n          Gateway restart required to apply pending configuration changes.\n        </p>\n        <div class=\"global-restart-banner__actions\">\n          <${UpdateActionButton}\n            onClick=${onRestart}\n            disabled=${restarting}\n            loading=${restarting}\n            warning=${true}\n            idleLabel=\"Restart Gateway\"\n            loadingLabel=\"Restarting...\"\n            className=\"global-restart-banner__button\"\n          />\n          <button\n            type=\"button\"\n            onclick=${onDismiss}\n            class=\"global-restart-banner__dismiss ac-btn-ghost\"\n            aria-label=\"Dismiss restart banner\"\n            title=\"Dismiss\"\n          >\n            <${CloseIcon} className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/google/account-row.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Badge } from \"../badge.js\";\nimport { ScopePicker } from \"../scope-picker.js\";\nimport { GmailWatchToggle } from \"./gmail-watch-toggle.js\";\n\nconst html = htm.bind(h);\n\nconst scopeListsEqual = (a = [], b = []) =>\n  a.length === b.length && a.every((scope) => b.includes(scope));\n\nexport const GoogleAccountRow = ({\n  account,\n  personal = false,\n  expanded,\n  onToggleExpanded,\n  scopes = [],\n  savedScopes = [],\n  apiStatus = {},\n  checkingApis = false,\n  onToggleScope,\n  onCheckApis,\n  onUpdatePermissions,\n  onEditCredentials,\n  onDisconnect,\n  gmailWatchStatus = null,\n  gmailWatchBusy = false,\n  onEnableGmailWatch,\n  onDisableGmailWatch,\n  onOpenGmailSetup,\n  onOpenGmailWebhook,\n}) => {\n  const scopesChanged = !scopeListsEqual(scopes, savedScopes);\n  return html`\n    <div class=\"border border-border rounded-lg bg-field overflow-visible\">\n      <button\n        type=\"button\"\n        onclick=${() => onToggleExpanded?.(account.id)}\n        class=\"w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-field\"\n      >\n        <div class=\"min-w-0\">\n          <div class=\"text-sm font-medium truncate\">${account.email}</div>\n        </div>\n        <div class=\"flex items-center gap-2\">\n          ${personal ? html`<${Badge} tone=\"neutral\">Personal</${Badge}>` : null}\n          <${Badge} tone=${account.authenticated ? \"success\" : \"warning\"}>\n            ${account.authenticated ? \"Connected\" : \"Awaiting sign-in\"}\n          </${Badge}>\n          <span class=\"text-xs text-fg-muted\">${expanded ? \"▾\" : \"▸\"}</span>\n        </div>\n      </button>\n      ${expanded\n        ? html`\n            <div class=\"px-3 pb-3 space-y-3 border-t border-border\">\n              <div class=\"flex justify-between items-center pt-3\">\n                <span class=\"text-sm text-fg-muted\">Select permissions</span>\n                ${account.authenticated\n                  ? html`<button\n                      type=\"button\"\n                      onclick=${() => onCheckApis?.(account.id)}\n                      disabled=${checkingApis}\n                      class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost disabled:opacity-50 disabled:cursor-not-allowed\"\n                    >\n                      ${checkingApis ? \"Checking APIs...\" : \"↻ Check APIs\"}\n                    </button>`\n                  : null}\n              </div>\n              <${ScopePicker}\n                scopes=${scopes}\n                onToggle=${(scope) => onToggleScope?.(account.id, scope)}\n                apiStatus=${account.authenticated ? apiStatus : {}}\n                loading=${account.authenticated && checkingApis}\n              />\n              ${account.authenticated\n                ? html`\n                    <div class=\"-mx-3 mt-4 mb-2 border-y border-border\">\n                      <div class=\"px-3 py-3 space-y-2\">\n                        <div class=\"flex justify-between items-center gap-2\">\n                          <span class=\"text-sm text-fg-muted\">Incoming events</span>\n                          <button\n                            type=\"button\"\n                            onclick=${() => onOpenGmailSetup?.(account.id)}\n                            class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost\"\n                          >\n                            Configure\n                          </button>\n                        </div>\n                        <${GmailWatchToggle}\n                          account=${account}\n                          watchStatus=${gmailWatchStatus}\n                          busy=${gmailWatchBusy}\n                          onEnable=${() => onEnableGmailWatch?.(account.id)}\n                          onDisable=${() => onDisableGmailWatch?.(account.id)}\n                          onOpenWebhook=${() => onOpenGmailWebhook?.()}\n                        />\n                      </div>\n                    </div>\n                  `\n                : null}\n              <div class=\"pt-1 space-y-2 sm:space-y-0 sm:flex sm:justify-between sm:items-center\">\n                <div class=\"grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center\">\n                  <button\n                    type=\"button\"\n                    onclick=${() => onUpdatePermissions?.(account.id)}\n                    disabled=${account.authenticated && !scopesChanged}\n                    class=\"w-full sm:w-auto text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan disabled:opacity-50 disabled:cursor-not-allowed\"\n                  >\n                    ${account.authenticated ? \"Update Permissions\" : \"Sign in with Google\"}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onclick=${() => onEditCredentials?.(account.id)}\n                    class=\"w-full sm:w-auto text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary\"\n                  >\n                    Edit Credentials\n                  </button>\n                </div>\n                <button\n                  type=\"button\"\n                  onclick=${() => onDisconnect?.(account.id)}\n                  class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost w-full sm:w-auto\"\n                >\n                  Disconnect\n                </button>\n              </div>\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/google/add-account-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { ModalShell } from \"../modal-shell.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { CloseIcon } from \"../icons.js\";\n\nconst html = htm.bind(h);\n\nexport const AddGoogleAccountModal = ({\n  visible,\n  onClose,\n  onSubmit,\n  loading = false,\n  defaultEmail = \"\",\n  title = \"Add Company Account\",\n}) => {\n  const [email, setEmail] = useState(\"\");\n  const [error, setError] = useState(\"\");\n\n  useEffect(() => {\n    if (!visible) return;\n    setEmail(String(defaultEmail || \"\"));\n    setError(\"\");\n  }, [visible, defaultEmail]);\n\n  if (!visible) return null;\n\n  const submit = async () => {\n    setError(\"\");\n    const nextEmail = String(email || \"\").trim();\n    if (!nextEmail) {\n      setError(\"Email is required\");\n      return;\n    }\n    await onSubmit?.({\n      email: nextEmail,\n      setError,\n    });\n  };\n\n  return html`<${ModalShell}\n    visible=${visible}\n    onClose=${onClose}\n    closeOnOverlayClick=${false}\n    panelClassName=\"bg-modal border border-border rounded-xl p-6 max-w-md w-full space-y-4\"\n  >\n    <${PageHeader}\n      title=${title}\n      actions=${html`\n        <button\n          type=\"button\"\n          onclick=${onClose}\n          class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n          aria-label=\"Close modal\"\n        >\n          <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n        </button>\n      `}\n    />\n    <div class=\"space-y-3\">\n      <div>\n        <label class=\"text-sm text-fg-muted block mb-1\"\n          >Email (Google account to authorize)</label\n        >\n        <p class=\"text-xs text-fg-muted mb-2\">\n          This adds another account to the same company workspace. Only one company workspace is supported.\n        </p>\n        <input\n          type=\"email\"\n          value=${email}\n          onInput=${(e) => setEmail(e.target.value)}\n          placeholder=\"you@company.com\"\n          class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-fg-muted\"\n        />\n      </div>\n      ${error ? html`<div class=\"text-status-error-muted text-xs\">${error}</div>` : null}\n    </div>\n    <div class=\"pt-2\">\n      <${ActionButton}\n        onClick=${submit}\n        disabled=${loading}\n        loading=${loading}\n        tone=\"primary\"\n        size=\"lg\"\n        idleLabel=\"Add Account\"\n        loadingLabel=\"Saving...\"\n        className=\"w-full px-4 py-2 rounded-lg text-sm\"\n      />\n    </div>\n  </${ModalShell}>`;\n};\n"
  },
  {
    "path": "lib/public/js/components/google/gmail-setup-wizard.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ModalShell } from \"../modal-shell.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { CloseIcon } from \"../icons.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { SessionSelectField } from \"../session-select-field.js\";\nimport { sendAgentMessage } from \"../../lib/api.js\";\nimport { showToast } from \"../toast.js\";\nimport { useAgentSessions } from \"../../hooks/useAgentSessions.js\";\nimport {\n  kNoDestinationSessionValue,\n  useDestinationSessionSelection,\n} from \"../../hooks/use-destination-session-selection.js\";\nimport {\n  getSessionDisplayLabel,\n  kDestinationSessionFilter,\n} from \"../../lib/session-keys.js\";\n\nconst html = htm.bind(h);\n\nconst copyText = async (value) => {\n  const text = String(value || \"\");\n  if (!text) return false;\n  try {\n    if (navigator?.clipboard?.writeText) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n  } catch {}\n  try {\n    const element = document.createElement(\"textarea\");\n    element.value = text;\n    element.setAttribute(\"readonly\", \"\");\n    element.style.position = \"fixed\";\n    element.style.opacity = \"0\";\n    document.body.appendChild(element);\n    element.select();\n    document.execCommand(\"copy\");\n    document.body.removeChild(element);\n    return true;\n  } catch {\n    return false;\n  }\n};\n\nconst kSetupStepTitles = [\n  \"Install + Authenticate gcloud\",\n  \"Enable APIs\",\n  \"Create Topic + IAM\",\n  \"Create Push Subscription\",\n  \"Build with your Agent\",\n];\nconst kTutorialStepTitles = kSetupStepTitles.slice(0, 3);\nconst kNoSessionSelectedValue = kNoDestinationSessionValue;\n\nconst renderCommandBlock = (command = \"\", onCopy = () => {}) => html`\n  <div class=\"rounded-lg border border-border bg-field p-3\">\n    <pre\n      class=\"pt-1 pl-2 text-[11px] leading-5 whitespace-pre-wrap break-all font-mono text-body\"\n    >\n${command}</pre\n    >\n    <div class=\"pt-3\">\n      <button\n        type=\"button\"\n        onclick=${onCopy}\n        class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost\"\n      >\n        Copy\n      </button>\n    </div>\n  </div>\n`;\n\nexport const GmailSetupWizard = ({\n  visible = false,\n  account = null,\n  clientConfig = null,\n  saving = false,\n  onClose = () => {},\n  onSaveSetup = async () => {},\n  onFinish = async () => {},\n}) => {\n  const [step, setStep] = useState(0);\n  const [projectIdInput, setProjectIdInput] = useState(\"\");\n  const [editingProjectId, setEditingProjectId] = useState(false);\n  const [localError, setLocalError] = useState(\"\");\n  const [projectIdResolved, setProjectIdResolved] = useState(false);\n  const [watchEnabled, setWatchEnabled] = useState(false);\n  const [sendingToAgent, setSendingToAgent] = useState(false);\n  const [agentMessageSent, setAgentMessageSent] = useState(false);\n  const [existingWebhookAtOpen, setExistingWebhookAtOpen] = useState(false);\n\n  const {\n    selectedSessionKey,\n    setSelectedSessionKey,\n    loading: loadingAgentSessions,\n    error: agentSessionsError,\n  } = useAgentSessions({\n    enabled: visible,\n    filter: kDestinationSessionFilter,\n  });\n  const {\n    sessions: selectableAgentSessions,\n    destinationSessionKey,\n    setDestinationSessionKey,\n    selectedDestination,\n  } = useDestinationSessionSelection({\n    enabled: visible,\n    resetKey: String(account?.id || \"\"),\n  });\n\n  useEffect(() => {\n    if (!visible) return;\n    setStep(0);\n    setLocalError(\"\");\n    setProjectIdInput(\"\");\n    setEditingProjectId(false);\n    setProjectIdResolved(false);\n    setWatchEnabled(false);\n    setSendingToAgent(false);\n    setAgentMessageSent(false);\n    setExistingWebhookAtOpen(Boolean(clientConfig?.webhookExists));\n  }, [visible, account?.id]);\n\n  const commands = clientConfig?.commands || null;\n  const hasProjectIdFromConfig = Boolean(\n    String(clientConfig?.projectId || \"\").trim() || commands,\n  );\n  const needsProjectId =\n    editingProjectId || (!hasProjectIdFromConfig && !projectIdResolved);\n  const detectedProjectId =\n    String(projectIdInput || \"\").trim() ||\n    String(clientConfig?.projectId || \"\").trim() ||\n    \"<project-id>\";\n  const hasExistingWebhookSetup = existingWebhookAtOpen;\n  const stepTitles = hasExistingWebhookSetup ? kTutorialStepTitles : kSetupStepTitles;\n  const totalSteps = stepTitles.length;\n  const client =\n    String(account?.client || clientConfig?.client || \"default\").trim() ||\n    \"default\";\n\n  const canAdvance = useMemo(() => {\n    if (needsProjectId) {\n      return String(projectIdInput || \"\").trim().length > 0;\n    }\n    return true;\n  }, [needsProjectId, projectIdInput]);\n\n  const handleCopy = useCallback(async (value) => {\n    const ok = await copyText(value);\n    if (ok) {\n      showToast(\"Copied to clipboard\", \"success\");\n      return;\n    }\n    showToast(\"Could not copy text\", \"error\");\n  }, []);\n\n  const handleChangeProjectId = useCallback(() => {\n    setLocalError(\"\");\n    setProjectIdInput(String(clientConfig?.projectId || \"\").trim());\n    setProjectIdResolved(false);\n    setEditingProjectId(true);\n  }, [clientConfig?.projectId]);\n\n  const handleFinish = async () => {\n    try {\n      setLocalError(\"\");\n      await onFinish({\n        client,\n        projectId: String(projectIdInput || \"\").trim(),\n        destination: selectedDestination,\n      });\n      setWatchEnabled(true);\n      setStep((prev) => Math.min(prev + 1, totalSteps - 1));\n    } catch (err) {\n      setLocalError(err.message || \"Could not finish setup\");\n    }\n  };\n\n  const handleNext = async () => {\n    if (saving) return;\n    if (needsProjectId) {\n      if (!canAdvance) return;\n      setLocalError(\"\");\n      try {\n        await onSaveSetup({\n          client,\n          projectId: String(projectIdInput || \"\").trim(),\n        });\n        setEditingProjectId(false);\n        setProjectIdResolved(true);\n      } catch (err) {\n        setLocalError(err.message || \"Could not save project id\");\n        return;\n      }\n      return;\n    }\n    setStep((prev) => Math.min(prev + 1, totalSteps - 1));\n  };\n\n  const handleSendToAgent = async () => {\n    if (sendingToAgent || agentMessageSent) return;\n    try {\n      setSendingToAgent(true);\n      const accountEmail =\n        String(account?.email || \"this account\").trim() || \"this account\";\n      const message =\n        `I just enabled Gmail watch for \"${accountEmail}\", set up the webhook, ` +\n        `and created the transform file. Help me set up what I want to do ` +\n        `with incoming email.`;\n      await sendAgentMessage({\n        message,\n        sessionKey: selectedSessionKey,\n      });\n      setAgentMessageSent(true);\n      showToast(\"Message sent to your agent\", \"success\");\n    } catch (err) {\n      showToast(err.message || \"Could not send message to agent\", \"error\");\n    } finally {\n      setSendingToAgent(false);\n    }\n  };\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      closeOnOverlayClick=${false}\n      closeOnEscape=${false}\n      panelClassName=\"relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4\"\n    >\n      <button\n        type=\"button\"\n        onclick=${onClose}\n        class=\"absolute top-6 right-6 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n        aria-label=\"Close modal\"\n      >\n        <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n      </button>\n      <div class=\"text-xs text-fg-muted\">Gmail Pub / Sub Setup</div>\n      <div class=\"flex items-center gap-1\">\n        ${stepTitles.map(\n          (title, idx) => html`\n            <div\n              class=${`h-1 flex-1 rounded-full transition-colors ${idx <= step ? \"bg-accent\" : \"bg-border\"}`}\n              style=${idx <= step ? \"background: var(--accent)\" : \"\"}\n              title=${title}\n            ></div>\n          `,\n        )}\n      </div>\n      <${PageHeader}\n        title=${`Step ${step + 1} of ${totalSteps}: ${stepTitles[step]}`}\n        actions=${null}\n      />\n      ${localError ? html`<div class=\"text-xs text-status-error-muted\">${localError}</div>` : null}\n      ${\n        needsProjectId\n          ? html`\n              <div\n                class=\"rounded-lg border border-border bg-field p-3 space-y-2\"\n              >\n                <div class=\"text-sm\">\n                  ${editingProjectId\n                    ? \"Change project ID\"\n                    : \"Project ID required\"}\n                </div>\n                <div class=\"text-xs text-fg-muted\">\n                  Find it in the${\" \"}\n                  <a\n                    href=\"https://console.cloud.google.com/home/dashboard\"\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                    class=\"ac-tip-link\"\n                  >\n                    Google Cloud Console Project Selector\n                  </a>\n                </div>\n                <input\n                  type=\"text\"\n                  value=${projectIdInput}\n                  oninput=${(event) => setProjectIdInput(event.target.value)}\n                  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\"\n                  placeholder=\"my-gcp-project\"\n                />\n              </div>\n            `\n          : null\n      }\n      ${\n        !needsProjectId && step === 0\n          ? html`\n              <div class=\"space-y-1\">\n                <div class=\"text-xs text-fg-muted\">\n                  Using project <code>${detectedProjectId}</code>.\n                </div>\n                <div class=\"text-xs text-fg-muted\">\n                  If <code>gcloud</code> is not installed on your computer,\n                  follow the official install guide:${\" \"}\n                  <a\n                    href=\"https://docs.cloud.google.com/sdk/docs/install-sdk\"\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                    class=\"ac-tip-link\"\n                  >\n                    Google Cloud SDK install docs\n                  </a>\n                </div>\n              </div>\n              ${renderCommandBlock(\n                `gcloud auth login\\n` +\n                  `gcloud config set project ${detectedProjectId}`,\n                () =>\n                  handleCopy(\n                    `gcloud auth login\\n` +\n                      `gcloud config set project ${detectedProjectId}`,\n                  ),\n              )}\n            `\n          : null\n      }\n      ${\n        !needsProjectId && step === 1\n          ? renderCommandBlock(commands?.enableApis || \"\", () =>\n              handleCopy(commands?.enableApis || \"\"),\n            )\n          : null\n      }\n      ${\n        !needsProjectId && step === 2\n          ? html`\n              ${renderCommandBlock(\n                `${commands?.createTopic || \"\"}\\n\\n${commands?.grantPublisher || \"\"}`.trim(),\n                () =>\n                  handleCopy(\n                    `${commands?.createTopic || \"\"}\\n\\n${commands?.grantPublisher || \"\"}`.trim(),\n                  ),\n              )}\n            `\n          : null\n      }\n      ${\n        !hasExistingWebhookSetup && !needsProjectId && step === 3\n          ? html`\n              ${renderCommandBlock(commands?.createSubscription || \"\", () =>\n                handleCopy(commands?.createSubscription || \"\"),\n              )}\n              <div\n                class=\"rounded-lg border border-border bg-field p-3 space-y-2\"\n              >\n                <${SessionSelectField}\n                  label=\"Deliver to\"\n                  sessions=${selectableAgentSessions}\n                  selectedSessionKey=${destinationSessionKey}\n                  onChangeSessionKey=${setDestinationSessionKey}\n                  disabled=${hasExistingWebhookSetup ||\n                  loadingAgentSessions ||\n                  saving}\n                  loading=${loadingAgentSessions}\n                  error=${agentSessionsError}\n                  allowNone=${true}\n                  noneValue=${kNoSessionSelectedValue}\n                  noneLabel=\"Default\"\n                  loadingLabel=\"Loading sessions...\"\n                  helperText=${hasExistingWebhookSetup\n                    ? \"This Gmail webhook has already been created. To edit delivery routing, ask your agent.\"\n                    : null}\n                  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\"\n                  helperClassName=\"text-xs text-fg-muted\"\n                  statusClassName=\"text-[11px] text-fg-muted\"\n                  errorClassName=\"text-[11px] text-status-error-muted\"\n                />\n              </div>\n            `\n          : null\n      }\n      ${\n        !hasExistingWebhookSetup && step === 4\n          ? html`\n              <div\n                class=\"rounded-lg border border-border bg-field p-3 space-y-3\"\n              >\n                <div class=\"pt-1 space-y-1\">\n                  <div class=\"text-sm\">Continue with your agent</div>\n                  <div class=\"text-xs text-fg-muted\">\n                    Tell your OpenClaw agent about what you want to build with\n                    incoming email to continue the setup.\n                  </div>\n                  <div class=\"pt-2 space-y-2\">\n                    <div class=\"text-[11px] text-fg-muted\">\n                      Send this to session\n                    </div>\n                    <div class=\"flex items-center gap-2\">\n                      <select\n                        value=${selectedSessionKey || kNoSessionSelectedValue}\n                        oninput=${(event) => {\n                          const nextValue = String(event.target.value || \"\");\n                          setSelectedSessionKey(\n                            nextValue === kNoSessionSelectedValue\n                              ? \"\"\n                              : nextValue,\n                          );\n                        }}\n                        disabled=${loadingAgentSessions ||\n                        sendingToAgent ||\n                        agentMessageSent}\n                        class=\"flex-1 min-w-0 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\"\n                      >\n                        ${!selectedSessionKey\n                          ? html`<option value=${kNoSessionSelectedValue}>\n                              Select a session...\n                            </option>`\n                          : null}\n                        ${selectableAgentSessions.map(\n                          (sessionRow) => html`\n                            <option value=${sessionRow.key}>\n                              ${getSessionDisplayLabel(sessionRow) ||\n                              sessionRow.key}\n                            </option>\n                          `,\n                        )}\n                      </select>\n                      <${ActionButton}\n                        onClick=${handleSendToAgent}\n                        disabled=${!selectedSessionKey || agentMessageSent}\n                        loading=${sendingToAgent}\n                        idleLabel=${agentMessageSent ? \"Sent\" : \"Send to Agent\"}\n                        loadingLabel=\"Sending...\"\n                        tone=\"primary\"\n                        size=\"sm\"\n                        className=\"h-[34px] px-3\"\n                      />\n                    </div>\n                    ${loadingAgentSessions\n                      ? html`<div class=\"text-[11px] text-fg-muted\">\n                          Loading sessions...\n                        </div>`\n                      : null}\n                    ${agentSessionsError\n                      ? html`<div class=\"text-[11px] text-status-error-muted\">\n                          ${agentSessionsError}\n                        </div>`\n                      : null}\n                  </div>\n                </div>\n              </div>\n            `\n          : null\n      }\n      <div class=\"grid grid-cols-2 gap-2 pt-2\">\n        ${\n          step === 0\n            ? html`${!needsProjectId\n                ? html`<button\n                    type=\"button\"\n                    onclick=${handleChangeProjectId}\n                    class=\"justify-self-start text-xs px-2 py-1 rounded-lg ac-btn-ghost\"\n                  >\n                    Change project ID\n                  </button>`\n                : html`<div></div>`}`\n            : html`<${ActionButton}\n                onClick=${() => setStep((prev) => Math.max(prev - 1, 0))}\n                disabled=${saving}\n                idleLabel=\"Back\"\n                tone=\"secondary\"\n                size=\"md\"\n                className=\"w-full justify-center\"\n              />`\n        }\n        ${\n          !hasExistingWebhookSetup && step === totalSteps - 2\n            ? html`<${ActionButton}\n                onClick=${handleFinish}\n                disabled=${false}\n                loading=${saving}\n                idleLabel=\"Enable watch\"\n                loadingLabel=\"Enabling...\"\n                tone=\"primary\"\n                size=\"md\"\n                className=\"w-full justify-center\"\n              />`\n            : step < totalSteps - 1\n            ? html`<${ActionButton}\n                onClick=${handleNext}\n                disabled=${saving || (needsProjectId && !canAdvance)}\n                idleLabel=\"Next\"\n                tone=\"primary\"\n                size=\"md\"\n                className=\"w-full justify-center\"\n              />`\n            : html`<${ActionButton}\n                onClick=${onClose}\n                disabled=${saving || sendingToAgent}\n                idleLabel=\"Done\"\n                tone=\"secondary\"\n                size=\"md\"\n                className=\"w-full justify-center\"\n              />`\n        }\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/google/gmail-watch-toggle.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Badge } from \"../badge.js\";\nimport { ToggleSwitch } from \"../toggle-switch.js\";\nimport { InfoTooltip } from \"../info-tooltip.js\";\n\nconst html = htm.bind(h);\n\nconst resolveWatchState = ({ watchStatus, busy = false }) => {\n  if (busy) {\n    const label = watchStatus?.enabled ? \"Stopping\" : \"Starting\";\n    return { label, tone: \"warning\" };\n  }\n  if (!watchStatus?.enabled) return { label: \"Stopped\", tone: \"neutral\" };\n  if (watchStatus.enabled && !watchStatus.running)\n    return { label: \"Error\", tone: \"danger\" };\n  return { label: \"Watching\", tone: \"success\" };\n};\n\nexport const GmailWatchToggle = ({\n  account,\n  watchStatus = null,\n  busy = false,\n  onEnable = () => {},\n  onDisable = () => {},\n  onOpenWebhook = () => {},\n}) => {\n  const hasGmailReadScope = Array.isArray(account?.activeScopes)\n    ? account.activeScopes.includes(\"gmail:read\")\n    : Array.isArray(account?.services)\n      ? account.services.includes(\"gmail:read\")\n      : false;\n  if (!hasGmailReadScope) {\n    return html`\n      <div class=\"bg-field rounded-lg px-3 py-2\">\n        <div class=\"text-xs text-fg-muted\">\n          Gmail watch requires <code>gmail:read</code>. Add it in permissions\n          above, then update permissions.\n        </div>\n      </div>\n    `;\n  }\n\n  const state = resolveWatchState({ watchStatus, busy });\n  const enabled = Boolean(watchStatus?.enabled);\n  return html`\n    <div\n      class=\"flex items-center justify-between bg-field border border-transparent rounded-lg px-3 py-2 cursor-pointer hover:bg-field hover:border-white/20 transition-colors\"\n      role=\"button\"\n      tabindex=\"0\"\n      onClick=${() => onOpenWebhook?.()}\n      onKeyDown=${(event) => {\n        if (event.key !== \"Enter\" && event.key !== \" \") return;\n        event.preventDefault();\n        onOpenWebhook?.();\n      }}\n    >\n      <div class=\"flex items-center gap-1.5 text-sm\">\n        <span>🔔 Gmail</span>\n        <${InfoTooltip}\n          text=\"Watches this inbox for new email events and routes them to your agent via the Gmail hook.\"\n          widthClass=\"w-72\"\n        />\n      </div>\n      <div\n        class=\"flex items-center gap-2\"\n        onClick=${(event) => event.stopPropagation()}\n        onKeyDown=${(event) => event.stopPropagation()}\n      >\n        <${Badge} tone=${state.tone}>${state.label}</${Badge}>\n        <${ToggleSwitch}\n          checked=${enabled}\n          disabled=${busy}\n          label=\"\"\n          onChange=${(nextChecked) => {\n            if (busy) return;\n            if (nextChecked) onEnable?.();\n            else onDisable?.();\n          }}\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/google/index.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  checkGoogleApis,\n  disconnectGoogle,\n  fetchGoogleCredentials,\n  saveGoogleAccount,\n} from \"../../lib/api.js\";\nimport { getDefaultScopes, toggleScopeLogic } from \"../scope-picker.js\";\nimport { CredentialsModal } from \"../credentials-modal.js\";\nimport { ConfirmDialog } from \"../confirm-dialog.js\";\nimport { showToast } from \"../toast.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"../overflow-menu.js\";\nimport { GoogleAccountRow } from \"./account-row.js\";\nimport { AddGoogleAccountModal } from \"./add-account-modal.js\";\nimport { useGoogleAccounts } from \"./use-google-accounts.js\";\nimport { useGmailWatch } from \"./use-gmail-watch.js\";\nimport { GmailSetupWizard } from \"./gmail-setup-wizard.js\";\n\nconst html = htm.bind(h);\n\nconst hasScopesChanged = (nextScopes = [], savedScopes = []) =>\n  nextScopes.length !== savedScopes.length ||\n  nextScopes.some((scope) => !savedScopes.includes(scope));\n\nconst isPersonalAccount = (account = {}) => Boolean(account.personal);\n\nconst kGoogleIconPath = \"/assets/icons/google_icon.svg\";\n\nexport const Google = ({\n  gatewayStatus,\n  onRestartRequired = () => {},\n  onOpenGmailWebhook = () => {},\n}) => {\n  const { accounts, loading, hasCompanyCredentials, refreshAccounts } =\n    useGoogleAccounts({ gatewayStatus });\n  const [expandedAccountId, setExpandedAccountId] = useState(\"\");\n  const [scopesByAccountId, setScopesByAccountId] = useState({});\n  const [savedScopesByAccountId, setSavedScopesByAccountId] = useState({});\n  const [apiStatusByAccountId, setApiStatusByAccountId] = useState({});\n  const [checkingByAccountId, setCheckingByAccountId] = useState({});\n  const [addMenuOpen, setAddMenuOpen] = useState(false);\n  const [credentialsModalState, setCredentialsModalState] = useState({\n    visible: false,\n    accountId: \"\",\n    client: \"default\",\n    personal: false,\n    title: \"Connect Google Workspace\",\n    submitLabel: \"Connect Google\",\n    defaultInstrType: \"workspace\",\n    initialValues: {},\n  });\n  const [addCompanyModalOpen, setAddCompanyModalOpen] = useState(false);\n  const [savingAddCompany, setSavingAddCompany] = useState(false);\n  const [disconnectAccountId, setDisconnectAccountId] = useState(\"\");\n  const [gmailWizardState, setGmailWizardState] = useState({\n    visible: false,\n    accountId: \"\",\n  });\n  const {\n    loading: gmailLoading,\n    watchByAccountId,\n    clientConfigByClient,\n    busyByAccountId,\n    savingClient,\n    refresh: refreshGmailWatch,\n    saveClientSetup,\n    startWatchForAccount,\n    stopWatchForAccount,\n  } = useGmailWatch({ gatewayStatus, accounts });\n\n  const hasPersonalAccount = useMemo(\n    () => accounts.some((account) => isPersonalAccount(account)),\n    [accounts],\n  );\n  const hasCompanyAccount = useMemo(\n    () => accounts.some((account) => !isPersonalAccount(account)),\n    [accounts],\n  );\n\n  const getAccountById = useCallback(\n    (accountId) => accounts.find((account) => account.id === accountId) || null,\n    [accounts],\n  );\n\n  const ensureScopesForAccount = useCallback((account) => {\n    const nextScopes =\n      Array.isArray(account.activeScopes) && account.activeScopes.length\n        ? account.activeScopes\n        : Array.isArray(account.services) && account.services.length\n          ? account.services\n          : getDefaultScopes();\n    setSavedScopesByAccountId((prev) => ({\n      ...prev,\n      [account.id]: [...nextScopes],\n    }));\n    setScopesByAccountId((prev) => {\n      const current = prev[account.id];\n      if (!current || !hasScopesChanged(current, nextScopes)) {\n        return { ...prev, [account.id]: [...nextScopes] };\n      }\n      return prev;\n    });\n  }, []);\n\n  useEffect(() => {\n    if (!accounts.length) {\n      setExpandedAccountId(\"\");\n      return;\n    }\n    const firstAwaitingSignInId =\n      accounts.find((account) => !account.authenticated)?.id || \"\";\n    setExpandedAccountId((previousId) => {\n      if (previousId && accounts.some((account) => account.id === previousId)) {\n        return previousId;\n      }\n      return firstAwaitingSignInId;\n    });\n    accounts.forEach((account) => ensureScopesForAccount(account));\n  }, [accounts, ensureScopesForAccount]);\n\n  const startAuth = useCallback(\n    (accountId) => {\n      const account = getAccountById(accountId);\n      if (!account) return;\n      const scopes =\n        scopesByAccountId[accountId] ||\n        account.activeScopes ||\n        getDefaultScopes();\n      if (!scopes.length) {\n        window.alert(\"Select at least one service\");\n        return;\n      }\n      const authUrl =\n        `/auth/google/start?accountId=${encodeURIComponent(accountId)}` +\n        `&services=${encodeURIComponent(scopes.join(\",\"))}&_ts=${Date.now()}`;\n      const popup = window.open(\n        authUrl,\n        `google-auth-${accountId}`,\n        \"popup=yes,width=500,height=700\",\n      );\n      if (!popup || popup.closed) window.location.href = authUrl;\n    },\n    [getAccountById, scopesByAccountId],\n  );\n\n  const handleToggleScope = (accountId, scope) => {\n    setScopesByAccountId((prev) => ({\n      ...prev,\n      [accountId]: toggleScopeLogic(prev[accountId] || [], scope),\n    }));\n  };\n\n  const handleCheckApis = useCallback(async (accountId) => {\n    setApiStatusByAccountId((prev) => {\n      const next = { ...prev };\n      delete next[accountId];\n      return next;\n    });\n    setCheckingByAccountId({ [accountId]: true });\n    try {\n      const data = await checkGoogleApis(accountId);\n      if (data.results) {\n        setApiStatusByAccountId((prev) => ({\n          ...prev,\n          [accountId]: data.results,\n        }));\n      }\n    } finally {\n      setCheckingByAccountId((prev) => {\n        if (!prev[accountId]) return prev;\n        const next = { ...prev };\n        delete next[accountId];\n        return next;\n      });\n    }\n  }, []);\n\n  useEffect(() => {\n    const handler = async (event) => {\n      if (event.data?.google === \"success\") {\n        showToast(\"✓ Google account connected\", \"success\");\n        const accountId = String(event.data?.accountId || \"\").trim();\n        setApiStatusByAccountId({});\n        await refreshAccounts();\n        await refreshGmailWatch();\n        if (accountId) {\n          await handleCheckApis(accountId);\n        }\n      } else if (event.data?.google === \"error\") {\n        showToast(\n          `✗ Google auth failed: ${event.data.message || \"unknown\"}`,\n          \"error\",\n        );\n      }\n    };\n    window.addEventListener(\"message\", handler);\n    return () => window.removeEventListener(\"message\", handler);\n  }, [handleCheckApis, refreshAccounts, refreshGmailWatch]);\n\n  useEffect(() => {\n    if (!expandedAccountId) return;\n    const account = getAccountById(expandedAccountId);\n    if (!account?.authenticated) return;\n    if (checkingByAccountId[expandedAccountId]) return;\n    if (apiStatusByAccountId[expandedAccountId]) return;\n    handleCheckApis(expandedAccountId);\n  }, [\n    accounts,\n    apiStatusByAccountId,\n    checkingByAccountId,\n    expandedAccountId,\n    getAccountById,\n    handleCheckApis,\n  ]);\n\n  const handleDisconnect = async (accountId) => {\n    const data = await disconnectGoogle(accountId);\n    if (!data.ok) {\n      showToast(`Failed to disconnect: ${data.error || \"unknown\"}`, \"error\");\n      return;\n    }\n    showToast(\"Google account disconnected\", \"success\");\n    setApiStatusByAccountId((prev) => {\n      const next = { ...prev };\n      delete next[accountId];\n      return next;\n    });\n    await refreshAccounts();\n    await refreshGmailWatch();\n  };\n\n  const openCredentialsModal = ({\n    accountId = \"\",\n    client = \"default\",\n    personal = false,\n    title = \"Connect Google Workspace\",\n    submitLabel = \"Connect Google\",\n    defaultInstrType = personal ? \"personal\" : \"workspace\",\n    initialValues = {},\n  }) => {\n    setCredentialsModalState({\n      visible: true,\n      accountId,\n      client,\n      personal,\n      title,\n      submitLabel,\n      defaultInstrType,\n      initialValues,\n    });\n  };\n\n  const closeCredentialsModal = () => {\n    setCredentialsModalState((prev) => ({ ...prev, visible: false }));\n  };\n\n  const handleCredentialsSaved = async (account) => {\n    if (account?.id) {\n      setExpandedAccountId(account.id);\n    }\n    await refreshAccounts();\n    if (account?.id) startAuth(account.id);\n  };\n\n  const handleAddCompanyAccount = async ({ email, setError }) => {\n    setSavingAddCompany(true);\n    try {\n      const data = await saveGoogleAccount({\n        email,\n        client: \"default\",\n        personal: false,\n        services: getDefaultScopes(),\n      });\n      if (!data.ok) {\n        setError?.(data.error || \"Could not add account\");\n        return;\n      }\n      setAddCompanyModalOpen(false);\n      if (data.accountId) {\n        setExpandedAccountId(data.accountId);\n      }\n      await refreshAccounts();\n      if (data.accountId) startAuth(data.accountId);\n    } finally {\n      setSavingAddCompany(false);\n    }\n  };\n\n  const handleAddCompanyClick = () => {\n    setAddMenuOpen(false);\n    if (hasCompanyAccount && hasCompanyCredentials) {\n      setAddCompanyModalOpen(true);\n      return;\n    }\n    openCredentialsModal({\n      client: \"default\",\n      personal: false,\n      title: \"Add Company Account\",\n      submitLabel: \"Save Credentials\",\n      defaultInstrType: \"workspace\",\n    });\n  };\n\n  const handleAddPersonalClick = () => {\n    setAddMenuOpen(false);\n    openCredentialsModal({\n      client: \"personal\",\n      personal: true,\n      title: \"Add Personal Account\",\n      submitLabel: \"Save Credentials\",\n      defaultInstrType: \"personal\",\n    });\n  };\n\n  const handleEditCredentials = async (accountId) => {\n    const account = getAccountById(accountId);\n    if (!account) return;\n    const personal = isPersonalAccount(account);\n    const client = personal ? \"personal\" : account.client || \"default\";\n    let credentialValues = {};\n    try {\n      const credentialResponse = await fetchGoogleCredentials({\n        accountId: account.id,\n        client,\n      });\n      if (credentialResponse?.ok) {\n        credentialValues = {\n          clientId: String(credentialResponse.clientId || \"\"),\n          clientSecret: String(credentialResponse.clientSecret || \"\"),\n        };\n      }\n    } catch {\n      showToast(\"Could not load saved client credentials\", \"warning\");\n    }\n    openCredentialsModal({\n      accountId: account.id,\n      client,\n      personal,\n      title: `Edit Credentials (${account.email})`,\n      submitLabel: \"Save Credentials\",\n      defaultInstrType: personal ? \"personal\" : \"workspace\",\n      initialValues: {\n        email: account.email,\n        ...credentialValues,\n      },\n    });\n  };\n\n  const openGmailSetupWizard = (accountId) => {\n    setGmailWizardState({\n      visible: true,\n      accountId: String(accountId || \"\"),\n    });\n  };\n\n  const closeGmailSetupWizard = () => {\n    setGmailWizardState({\n      visible: false,\n      accountId: \"\",\n    });\n  };\n\n  const handleEnableGmailWatch = async (accountId) => {\n    const account = getAccountById(accountId);\n    if (!account) return;\n    const client = String(account.client || \"default\").trim() || \"default\";\n    const clientConfig = clientConfigByClient.get(client);\n    if (!clientConfig?.configured || !clientConfig?.webhookExists) {\n      openGmailSetupWizard(accountId);\n      return;\n    }\n    try {\n      const result = await startWatchForAccount(accountId);\n      if (result?.restartRequired) {\n        onRestartRequired(true);\n      }\n      showToast(\"Gmail watch enabled\", \"success\");\n    } catch (err) {\n      showToast(err.message || \"Could not enable Gmail watch\", \"error\");\n    }\n  };\n\n  const handleDisableGmailWatch = async (accountId) => {\n    try {\n      await stopWatchForAccount(accountId);\n      showToast(\"Gmail watch disabled\", \"info\");\n    } catch (err) {\n      showToast(err.message || \"Could not disable Gmail watch\", \"error\");\n    }\n  };\n\n  const handleFinishGmailSetupWizard = async ({\n    client,\n    projectId,\n    destination = null,\n  }) => {\n    const accountId = String(gmailWizardState.accountId || \"\").trim();\n    if (!accountId) return;\n    await saveClientSetup({\n      client,\n      projectId,\n      regeneratePushToken: false,\n    });\n    await startWatchForAccount(accountId, { destination });\n    showToast(\"Gmail setup complete and watch enabled\", \"success\");\n  };\n\n  const renderEmptyState = () => html`\n    <div class=\"text-center space-y-2 pt-3\">\n      <div class=\"rounded-lg border border-border bg-field px-3 py-5\">\n        <div class=\"flex flex-col items-center justify-center gap-3\">\n          <img\n            src=${kGoogleIconPath}\n            alt=\"Google logo\"\n            class=\"h-5 w-5 shrink-0\"\n            loading=\"lazy\"\n            decoding=\"async\"\n          />\n          <p class=\"text-xs text-fg-muted\">\n            Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and\n            Meet.\n          </p>\n        </div>\n      </div>\n      <div class=\"grid grid-cols-1 sm:grid-cols-2 gap-2 pt-2\">\n        <${ActionButton}\n          onClick=${handleAddCompanyClick}\n          tone=\"primary\"\n          size=\"sm\"\n          idleLabel=\"Add Company Account\"\n          className=\"w-full font-medium\"\n        />\n        <${ActionButton}\n          onClick=${handleAddPersonalClick}\n          tone=\"secondary\"\n          size=\"sm\"\n          idleLabel=\"Add Personal Account\"\n          className=\"w-full font-medium\"\n        />\n      </div>\n    </div>\n  `;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <h2 class=\"card-label\">Google Accounts</h2>\n        ${accounts.length\n          ? html`\n              <div class=\"relative\">\n                <${OverflowMenu}\n                  open=${addMenuOpen}\n                  ariaLabel=\"Add Google account\"\n                  title=\"Add Google account\"\n                  onClose=${() => setAddMenuOpen(false)}\n                  onToggle=${() => setAddMenuOpen((prev) => !prev)}\n                  renderTrigger=${({ onToggle, ariaLabel, title }) => html`\n                    <${ActionButton}\n                      onClick=${onToggle}\n                      tone=\"subtle\"\n                      size=\"sm\"\n                      idleLabel=\"+ Add Account\"\n                      ariaLabel=${ariaLabel}\n                      title=${title}\n                    />\n                  `}\n                >\n                  <${OverflowMenuItem} onClick=${handleAddCompanyClick}>\n                    Company account\n                  </${OverflowMenuItem}>\n                  ${!hasPersonalAccount\n                    ? html`\n                        <${OverflowMenuItem} onClick=${handleAddPersonalClick}>\n                          Personal account\n                        </${OverflowMenuItem}>\n                      `\n                    : null}\n                </${OverflowMenu}>\n              </div>\n            `\n          : null}\n      </div>\n      ${loading\n        ? html`<div class=\"text-fg-muted text-sm text-center py-2\">\n            Loading...\n          </div>`\n        : accounts.length\n          ? html`\n              <div class=\"space-y-2 mt-3\">\n                ${accounts.map(\n                  (account) =>\n                    html`<${GoogleAccountRow}\n                      key=${account.id}\n                      account=${account}\n                      personal=${isPersonalAccount(account)}\n                      expanded=${expandedAccountId === account.id}\n                      onToggleExpanded=${(accountId) =>\n                        setExpandedAccountId((prev) =>\n                          prev === accountId ? \"\" : accountId,\n                        )}\n                      scopes=${scopesByAccountId[account.id] ||\n                      account.activeScopes ||\n                      getDefaultScopes()}\n                      savedScopes=${savedScopesByAccountId[account.id] ||\n                      account.activeScopes ||\n                      getDefaultScopes()}\n                      apiStatus=${apiStatusByAccountId[account.id] || {}}\n                      checkingApis=${expandedAccountId === account.id &&\n                      Boolean(checkingByAccountId[account.id])}\n                      onToggleScope=${handleToggleScope}\n                      onCheckApis=${handleCheckApis}\n                      onUpdatePermissions=${(accountId) => startAuth(accountId)}\n                      onEditCredentials=${handleEditCredentials}\n                      onDisconnect=${(accountId) =>\n                        setDisconnectAccountId(accountId)}\n                      gmailWatchStatus=${watchByAccountId.get(account.id) ||\n                      null}\n                      gmailWatchBusy=${Boolean(busyByAccountId[account.id])}\n                      onEnableGmailWatch=${handleEnableGmailWatch}\n                      onDisableGmailWatch=${handleDisableGmailWatch}\n                      onOpenGmailSetup=${openGmailSetupWizard}\n                      onOpenGmailWebhook=${onOpenGmailWebhook}\n                    />`,\n                )}\n              </div>\n            `\n          : renderEmptyState()}\n    </div>\n\n    <${CredentialsModal}\n      visible=${credentialsModalState.visible}\n      onClose=${closeCredentialsModal}\n      onSaved=${handleCredentialsSaved}\n      title=${credentialsModalState.title}\n      submitLabel=${credentialsModalState.submitLabel}\n      defaultInstrType=${credentialsModalState.defaultInstrType}\n      client=${credentialsModalState.client}\n      personal=${credentialsModalState.personal}\n      accountId=${credentialsModalState.accountId}\n      initialValues=${credentialsModalState.initialValues}\n    />\n\n    <${AddGoogleAccountModal}\n      visible=${addCompanyModalOpen}\n      onClose=${() => setAddCompanyModalOpen(false)}\n      onSubmit=${handleAddCompanyAccount}\n      loading=${savingAddCompany}\n      title=\"Add Company Account\"\n    />\n\n    <${GmailSetupWizard}\n      visible=${gmailWizardState.visible}\n      account=${getAccountById(gmailWizardState.accountId)}\n      clientConfig=${clientConfigByClient.get(\n        String(\n          getAccountById(gmailWizardState.accountId)?.client || \"default\",\n        ).trim() || \"default\",\n      ) || null}\n      saving=${savingClient || gmailLoading}\n      onClose=${closeGmailSetupWizard}\n      onSaveSetup=${saveClientSetup}\n      onFinish=${handleFinishGmailSetupWizard}\n    />\n\n    <${ConfirmDialog}\n      visible=${Boolean(disconnectAccountId)}\n      title=\"Disconnect Google account?\"\n      message=\"Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect.\"\n      confirmLabel=\"Disconnect\"\n      cancelLabel=\"Cancel\"\n      onCancel=${() => setDisconnectAccountId(\"\")}\n      onConfirm=${async () => {\n        const accountId = disconnectAccountId;\n        setDisconnectAccountId(\"\");\n        await handleDisconnect(accountId);\n      }}\n    />\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/google/use-gmail-watch.js",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"preact/hooks\";\nimport {\n  fetchGmailConfig,\n  renewGmailWatch,\n  saveGmailConfig,\n  startGmailWatch,\n  stopGmailWatch,\n} from \"../../lib/api.js\";\nimport { useCachedFetch } from \"../../hooks/use-cached-fetch.js\";\n\nexport const useGmailWatch = ({ gatewayStatus, accounts = [] }) => {\n  const [busyByAccountId, setBusyByAccountId] = useState({});\n  const [savingClient, setSavingClient] = useState(false);\n  const accountSignature = useMemo(\n    () =>\n      accounts\n        .map((entry) => String(entry?.id || \"\").trim())\n        .filter(Boolean)\n        .sort()\n        .join(\"|\"),\n    [accounts],\n  );\n  const {\n    data: config,\n    loading,\n    refresh: refreshCachedConfig,\n  } = useCachedFetch(\"/api/gmail/config\", fetchGmailConfig, {\n    enabled: gatewayStatus === \"running\",\n    maxAgeMs: 30000,\n  });\n\n  const refresh = useCallback(async () => {\n    return refreshCachedConfig({ force: true });\n  }, [refreshCachedConfig]);\n\n  useEffect(() => {\n    if (gatewayStatus !== \"running\") return;\n    if (!accounts.length) return;\n    refresh().catch(() => {});\n  }, [accountSignature, accounts.length, gatewayStatus, refresh]);\n\n  const watchByAccountId = useMemo(() => {\n    const map = new Map();\n    for (const entry of config?.accounts || []) {\n      map.set(String(entry.accountId || \"\"), entry);\n    }\n    return map;\n  }, [config]);\n\n  const clientConfigByClient = useMemo(() => {\n    const map = new Map();\n    for (const clientConfig of config?.clients || []) {\n      map.set(String(clientConfig.client || \"default\"), clientConfig);\n    }\n    return map;\n  }, [config]);\n\n  const setBusy = (accountId, busy) => {\n    setBusyByAccountId((prev) => {\n      const key = String(accountId || \"\");\n      if (!key) return prev;\n      if (busy) return { ...prev, [key]: true };\n      if (!prev[key]) return prev;\n      const next = { ...prev };\n      delete next[key];\n      return next;\n    });\n  };\n\n  const startWatchForAccount = useCallback(async (accountId, { destination = null } = {}) => {\n    const key = String(accountId || \"\");\n    setBusy(key, true);\n    try {\n      const data = await startGmailWatch(key, { destination });\n      await refresh();\n      return data;\n    } finally {\n      setBusy(key, false);\n    }\n  }, [refresh]);\n\n  const stopWatchForAccount = useCallback(async (accountId) => {\n    const key = String(accountId || \"\");\n    setBusy(key, true);\n    try {\n      await stopGmailWatch(key);\n      await refresh();\n    } finally {\n      setBusy(key, false);\n    }\n  }, [refresh]);\n\n  const renewForAccount = useCallback(async (accountId = \"\") => {\n    const key = String(accountId || \"\");\n    if (key) setBusy(key, true);\n    try {\n      await renewGmailWatch({ accountId: key, force: true });\n      await refresh();\n    } finally {\n      if (key) setBusy(key, false);\n    }\n  }, [refresh]);\n\n  const saveClientSetup = useCallback(async ({\n    client = \"default\",\n    projectId = \"\",\n    regeneratePushToken = false,\n  } = {}) => {\n    setSavingClient(true);\n    try {\n      const data = await saveGmailConfig({\n        client,\n        projectId,\n        regeneratePushToken,\n      });\n      await refresh();\n      return data;\n    } catch (err) {\n      const message = String(err?.message || \"\");\n      if (message.toLowerCase().includes(\"not found\")) {\n        throw new Error(\n          \"Gmail watch API route not found. Restart AlphaClaw so /api/gmail routes are loaded.\",\n        );\n      }\n      throw err;\n    } finally {\n      setSavingClient(false);\n    }\n  }, [refresh]);\n\n  return {\n    loading,\n    config,\n    watchByAccountId,\n    clientConfigByClient,\n    busyByAccountId,\n    savingClient,\n    refresh,\n    saveClientSetup,\n    startWatchForAccount,\n    stopWatchForAccount,\n    renewForAccount,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/google/use-google-accounts.js",
    "content": "import { useCallback, useEffect, useMemo, useRef } from \"preact/hooks\";\nimport { fetchGoogleAccounts } from \"../../lib/api.js\";\nimport { useCachedFetch } from \"../../hooks/use-cached-fetch.js\";\n\nexport const useGoogleAccounts = ({ gatewayStatus }) => {\n  const hasRefreshedAfterGatewayRunningRef = useRef(false);\n  const { data, loading, refresh } = useCachedFetch(\n    \"/api/google/accounts\",\n    fetchGoogleAccounts,\n    { maxAgeMs: 30000 },\n  );\n\n  const accounts = useMemo(\n    () => (Array.isArray(data?.accounts) ? data.accounts : []),\n    [data?.accounts],\n  );\n  const hasCompanyCredentials = Boolean(data?.hasCompanyCredentials);\n  const hasPersonalCredentials = Boolean(data?.hasPersonalCredentials);\n\n  const refreshAccounts = useCallback(async () => {\n    return refresh({ force: true });\n  }, [refresh]);\n\n  useEffect(() => {\n    if (gatewayStatus !== \"running\") {\n      hasRefreshedAfterGatewayRunningRef.current = false;\n      return;\n    }\n    if (hasRefreshedAfterGatewayRunningRef.current) return;\n    hasRefreshedAfterGatewayRunningRef.current = true;\n    refreshAccounts().catch(() => {});\n  }, [gatewayStatus, refreshAccounts]);\n\n  return {\n    accounts,\n    loading,\n    hasCompanyCredentials,\n    hasPersonalCredentials,\n    refreshAccounts,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/icons.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const ChevronDownIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    width=\"12\"\n    height=\"12\"\n    viewBox=\"0 0 20 20\"\n    fill=\"none\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M5 7.5L10 12.5L15 7.5\"\n      stroke=\"currentColor\"\n      stroke-width=\"1.8\"\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n    />\n  </svg>\n`;\n\nexport const CloseIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    width=\"16\"\n    height=\"16\"\n    viewBox=\"0 0 16 16\"\n    fill=\"none\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M4 4L12 12M12 4L4 12\"\n      stroke=\"currentColor\"\n      stroke-width=\"1.5\"\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n    />\n  </svg>\n`;\n\nexport const AddLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z\" />\n  </svg>\n`;\n\nexport const More2FillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M5 10C3.89543 10 3 10.8954 3 12C3 13.1046 3.89543 14 5 14C6.10457 14 7 13.1046 7 12C7 10.8954 6.10457 10 5 10ZM12 10C10.8954 10 10 10.8954 10 12C10 13.1046 10.8954 14 12 14C13.1046 14 14 13.1046 14 12C14 10.8954 13.1046 10 12 10ZM19 10C17.8954 10 17 10.8954 17 12C17 13.1046 17.8954 14 19 14C20.1046 14 21 13.1046 21 12C21 10.8954 20.1046 10 19 10Z\"\n    />\n  </svg>\n`;\n\nexport const HomeLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M13 19H19V9.97815L12 4.53371L5 9.97815V19H11V13H13V19ZM21 20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V9.48907C3 9.18048 3.14247 8.88917 3.38606 8.69972L11.3861 2.47749C11.7472 2.19663 12.2528 2.19663 12.6139 2.47749L20.6139 8.69972C20.8575 8.88917 21 9.18048 21 9.48907V20Z\"\n    />\n  </svg>\n`;\n\nexport const FolderLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M4 5V19H20V7H11.5858L9.58579 5H4ZM12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z\"\n    />\n  </svg>\n`;\n\nexport const RobotLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z\"\n    />\n  </svg>\n`;\n\nexport const MarkdownFillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM7 15.5V11.5L9 13.5L11 11.5V15.5H13V8.5H11L9 10.5L7 8.5H5V15.5H7ZM18 12.5V8.5H16V12.5H14L17 15.5L20 12.5H18Z\"\n    />\n  </svg>\n`;\n\nexport const File3LineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9Z\"\n    />\n  </svg>\n`;\n\nexport const JavascriptFillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M6 3C4.34315 3 3 4.34315 3 6V18C3 19.6569 4.34315 21 6 21H18C19.6569 21 21 19.6569 21 18V6C21 4.34315 19.6569 3 18 3H6ZM13.3344 16.055C14.0531 16.6343 14.7717 16.9203 15.4904 16.913C15.9304 16.913 16.2677 16.8323 16.5024 16.671C16.7297 16.517 16.8434 16.297 16.8434 16.011C16.8434 15.7177 16.7297 15.4683 16.5024 15.263C16.2677 15.0577 15.8241 14.8523 15.1714 14.647C14.3867 14.4197 13.7817 14.1263 13.3564 13.767C12.9384 13.4077 12.7257 12.9053 12.7184 12.26C12.7184 11.6513 12.9824 11.1417 13.5104 10.731C14.0237 10.3203 14.6801 10.115 15.4794 10.115C16.5941 10.115 17.4887 10.3863 18.1634 10.929L17.3934 12.128C17.1221 11.9153 16.8104 11.7613 16.4584 11.666C16.1064 11.556 15.7911 11.501 15.5124 11.501C15.1311 11.501 14.8267 11.5707 14.5994 11.71C14.3721 11.8493 14.2584 12.0327 14.2584 12.26C14.2584 12.5093 14.3977 12.722 14.6764 12.898C14.9551 13.0667 15.4317 13.2537 16.1064 13.459C16.9204 13.701 17.4997 14.0237 17.8444 14.427C18.1891 14.8303 18.3614 15.3437 18.3614 15.967C18.3614 16.605 18.1157 17.155 17.6244 17.617C17.1404 18.0717 16.4364 18.31 15.5124 18.332C14.3024 18.332 13.2904 17.969 12.4764 17.243L13.3344 16.055ZM7.80405 16.693C8.03872 16.8397 8.32105 16.913 8.65105 16.913C8.99572 16.913 9.28172 16.814 9.50905 16.616C9.73639 16.4107 9.85005 16.055 9.85005 15.549V10.247H11.3351V15.835C11.3131 16.7003 11.0637 17.3237 10.5871 17.705C10.3157 17.9323 10.0187 18.0937 9.69605 18.189C9.37339 18.2843 9.06172 18.332 8.76105 18.332C8.21105 18.332 7.72339 18.2367 7.29805 18.046C6.84339 17.8407 6.46205 17.4777 6.15405 16.957L7.18805 16.11C7.37872 16.3667 7.58405 16.561 7.80405 16.693Z\"\n    />\n  </svg>\n`;\n\nexport const Image2FillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M5 11.1005L7 9.1005L12.5 14.6005L16 11.1005L19 14.1005V5H5V11.1005ZM4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM15.5 10C14.6716 10 14 9.32843 14 8.5C14 7.67157 14.6716 7 15.5 7C16.3284 7 17 7.67157 17 8.5C17 9.32843 16.3284 10 15.5 10Z\"\n    />\n  </svg>\n`;\n\nexport const ImageAiLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M20.7134 8.12811L20.4668 8.69379C20.2864 9.10792 19.7136 9.10792 19.5331 8.69379L19.2866 8.12811C18.8471 7.11947 18.0555 6.31641 17.0677 5.87708L16.308 5.53922C15.8973 5.35653 15.8973 4.75881 16.308 4.57612L17.0252 4.25714C18.0384 3.80651 18.8442 2.97373 19.2761 1.93083L19.5293 1.31953C19.7058 0.893489 20.2942 0.893489 20.4706 1.31953L20.7238 1.93083C21.1558 2.97373 21.9616 3.80651 22.9748 4.25714L23.6919 4.57612C24.1027 4.75881 24.1027 5.35653 23.6919 5.53922L22.9323 5.87708C21.9445 6.31641 21.1529 7.11947 20.7134 8.12811ZM2.9918 3H14V5H4V19L14 9L20 15V11H22V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z\"\n    />\n  </svg>\n`;\n\nexport const Brain2LineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M7 6C7 6.23676 7.04072 6.46184 7.11469 6.66999C7.22686 6.98559 7.17357 7.33638 6.97276 7.60444C6.77194 7.8725 6.45026 8.02222 6.11585 8.00327C6.0776 8.0011 6.03898 8 6 8C4.89543 8 4 8.89543 4 10C4 10.5129 4.19174 10.9786 4.50903 11.3331C4.84885 11.7128 4.84885 12.2872 4.50903 12.6669C4.19174 13.0214 4 13.4871 4 14C4 14.8842 4.57447 15.6369 5.37327 15.9001C5.84924 16.057 6.1356 16.5419 6.04308 17.0345C6.01489 17.1846 6 17.3401 6 17.5C6 18.8807 7.11929 20 8.5 20C9.75862 20 10.8015 19.069 10.9746 17.8583C10.9806 17.8165 10.9891 17.7756 11 17.7358V6C11 4.89543 10.1046 4 9 4C7.89543 4 7 4.89543 7 6ZM13 17.7358C13.0109 17.7756 13.0194 17.8165 13.0254 17.8583C13.1985 19.069 14.2414 20 15.5 20C16.8807 20 18 18.8807 18 17.5C18 17.3401 17.9851 17.1846 17.9569 17.0345C17.8644 16.5419 18.1508 16.057 18.6267 15.9001C19.4255 15.6369 20 14.8842 20 14C20 13.4871 19.8083 13.0214 19.491 12.6669C19.1511 12.2872 19.1511 11.7128 19.491 11.3331C19.8083 10.9786 20 10.5129 20 10C20 8.89543 19.1046 8 18 8C17.961 8 17.9224 8.0011 17.8841 8.00327C17.5497 8.02222 17.2281 7.8725 17.0272 7.60444C16.8264 7.33638 16.7731 6.98559 16.8853 6.66999C16.9593 6.46184 17 6.23676 17 6C17 4.89543 16.1046 4 15 4C13.8954 4 13 4.89543 13 6V17.7358ZM9 2C10.1947 2 11.2671 2.52376 12 3.35418C12.7329 2.52376 13.8053 2 15 2C17.2091 2 19 3.79086 19 6C19 6.04198 18.9994 6.08382 18.9981 6.12552C20.7243 6.56889 22 8.13546 22 10C22 10.728 21.8049 11.4116 21.4646 12C21.8049 12.5884 22 13.272 22 14C22 15.4817 21.1949 16.7734 19.9999 17.4646L20 17.5C20 19.9853 17.9853 22 15.5 22C14.0859 22 12.8248 21.3481 12 20.3285C11.1752 21.3481 9.91405 22 8.5 22C6.01472 22 4 19.9853 4 17.5L4.00014 17.4646C2.80512 16.7734 2 15.4817 2 14C2 13.272 2.19513 12.5884 2.53536 12C2.19513 11.4116 2 10.728 2 10C2 8.13546 3.27573 6.56889 5.00194 6.12552C5.00065 6.08382 5 6.04198 5 6C5 3.79086 6.79086 2 9 2Z\"\n    />\n  </svg>\n`;\n\nexport const TextToSpeechLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M14.5 5H6C4.89543 5 4 5.89543 4 7V17C4 18.1046 4.89543 19 6 19H18C19.1046 19 20 18.1046 20 17V14.5H22V17C22 19.2091 20.2091 21 18 21H6C3.79086 21 2 19.2091 2 17V7C2 4.79086 3.79086 3 6 3H14.5V5ZM14 11H11V17H9V11H6V9H14V11ZM20.6572 1.34277C22.1049 2.79049 23 4.79086 23 7C23 9.20914 22.1049 11.2095 20.6572 12.6572L19.2422 11.2422C20.328 10.1564 21 8.65685 21 7C21 5.34315 20.328 3.8436 19.2422 2.75781L20.6572 1.34277ZM17.8281 4.17188C18.552 4.89573 19 5.89543 19 7C19 8.10457 18.552 9.10427 17.8281 9.82812L16.4141 8.41406C16.776 8.05213 17 7.55228 17 7C17 6.44772 16.776 5.94787 16.4141 5.58594L17.8281 4.17188Z\"\n    />\n  </svg>\n`;\n\nexport const ChatVoiceLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22H2L4.92893 19.0711C3.11929 17.2614 2 14.7614 2 12ZM6.82843 20H12C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 14.1524 4.85124 16.1649 6.34315 17.6569L7.75736 19.0711L6.82843 20ZM11 6H13V18H11V6ZM7 9H9V15H7V9ZM15 9H17V15H15V9Z\"\n    />\n  </svg>\n`;\n\nexport const Chat4LineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M5.76282 17H20V5H4V18.3851L5.76282 17ZM6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455Z\"\n    />\n  </svg>\n`;\n\nexport const FileMusicLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M16 8V10H13V14.5C13 15.8807 11.8807 17 10.5 17C9.11929 17 8 15.8807 8 14.5C8 13.1193 9.11929 12 10.5 12C10.6712 12 10.8384 12.0172 11 12.05V8H15V4H5V20H19V8H16ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z\"\n    />\n  </svg>\n`;\n\nexport const TerminalFillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z\"\n    />\n  </svg>\n`;\n\nexport const BracesLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V10.1C6 10.9858 5.42408 11.7372 4.62623 12C5.42408 12.2628 6 13.0142 6 13.9V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V13.9C18 13.0142 18.5759 12.2628 19.3738 12C18.5759 11.7372 18 10.9858 18 10.1V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z\"\n    />\n  </svg>\n`;\n\nexport const FileCodeLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM17.6569 12L14.1213 15.5355L12.7071 14.1213L14.8284 12L12.7071 9.87868L14.1213 8.46447L17.6569 12ZM6.34315 12L9.87868 8.46447L11.2929 9.87868L9.17157 12L11.2929 14.1213L9.87868 15.5355L6.34315 12Z\"\n    />\n  </svg>\n`;\n\nexport const Database2LineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z\"\n    />\n  </svg>\n`;\n\nexport const HashtagIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M7.78428 14L8.2047 10H4V8H8.41491L8.94043 3H10.9514L10.4259 8H14.4149L14.9404 3H16.9514L16.4259 8H20V10H16.2157L15.7953 14H20V16H15.5851L15.0596 21H13.0486L13.5741 16H9.58509L9.05957 21H7.04855L7.57407 16H4V14H7.78428ZM9.7953 14H13.7843L14.2047 10H10.2157L9.7953 14Z\"\n    />\n  </svg>\n`;\n\nexport const BarChartLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M3 12H5V21H3V12ZM19 8H21V21H19V8ZM11 2H13V21H11V2Z\" />\n  </svg>\n`;\n\nexport const SignalTowerLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M6.11629 20.0868L7.1308 18.348C5.2271 16.8856 4 14.5861 4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 14.5861 18.7729 16.8856 16.8692 18.348L17.8837 20.0868C20.3786 18.2684 22 15.3236 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 15.3236 3.62137 18.2684 6.11629 20.0868ZM8.14965 16.6018C6.83562 15.5012 6 13.8482 6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12C18 13.8482 17.1644 15.5012 15.8503 16.6018L14.8203 14.8365C15.549 14.112 16 13.1087 16 12C16 9.79086 14.2091 8 12 8C9.79086 8 8 9.79086 8 12C8 13.1087 8.45105 14.112 9.17965 14.8365L8.14965 16.6018ZM11 13H13V22H11V13Z\" />\n  </svg>\n`;\n\nexport const GitBranchLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M7.10508 15.2101C8.21506 15.6501 9 16.7334 9 18C9 19.6569 7.65685 21 6 21C4.34315 21 3 19.6569 3 18C3 16.6938 3.83481 15.5825 5 15.1707V8.82929C3.83481 8.41746 3 7.30622 3 6C3 4.34315 4.34315 3 6 3C7.65685 3 9 4.34315 9 6C9 7.30622 8.16519 8.41746 7 8.82929V11.9996C7.83566 11.3719 8.87439 11 10 11H14C15.3835 11 16.5482 10.0635 16.8949 8.78991C15.7849 8.34988 15 7.26661 15 6C15 4.34315 16.3431 3 18 3C19.6569 3 21 4.34315 21 6C21 7.3332 20.1303 8.46329 18.9274 8.85392C18.5222 11.2085 16.4703 13 14 13H10C8.61653 13 7.45179 13.9365 7.10508 15.2101ZM6 17C5.44772 17 5 17.4477 5 18C5 18.5523 5.44772 19 6 19C6.55228 19 7 18.5523 7 18C7 17.4477 6.55228 17 6 17ZM6 5C5.44772 5 5 5.44772 5 6C5 6.55228 5.44772 7 6 7C6.55228 7 7 6.55228 7 6C7 5.44772 6.55228 5 6 5ZM18 5C17.4477 5 17 5.44772 17 6C17 6.55228 17.4477 7 18 7C18.5523 7 19 6.55228 19 6C19 5.44772 18.5523 5 18 5Z\"\n    />\n  </svg>\n`;\n\nexport const GithubFillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z\"\n    />\n  </svg>\n`;\n\nexport const SaveFillIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M18 21V13H6V21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3H17L21 7V20C21 20.5523 20.5523 21 20 21H18ZM16 21H8V15H16V21Z\"\n    />\n  </svg>\n`;\n\nexport const LockLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M19 10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H5V9C5 5.13401 8.13401 2 12 2C15.866 2 19 5.13401 19 9V10ZM5 12V20H19V12H5ZM11 14H13V18H11V14ZM17 10V9C17 6.23858 14.7614 4 12 4C9.23858 4 7 6.23858 7 9V10H17Z\"\n    />\n  </svg>\n`;\n\nexport const DeleteBinLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z\"\n    />\n  </svg>\n`;\n\nexport const FileAddLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11 11V8H13V11H16V13H13V16H11V13H8V11H11Z\"\n    />\n  </svg>\n`;\n\nexport const FolderAddLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5ZM4 5V19H20V7H11.5858L9.58579 5H4ZM11 12V9H13V12H16V14H13V17H11V14H8V12H11Z\"\n    />\n  </svg>\n`;\n\nexport const DownloadLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M3 19H21V21H3V19ZM13 13.1716L19.0711 7.1005L20.4853 8.51472L12 17L3.51472 8.51472L4.92893 7.1005L11 13.1716V2H13V13.1716Z\"\n    />\n  </svg>\n`;\n\nexport const FileCopyLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z\"\n    />\n  </svg>\n`;\n\nexport const RestartLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z\"\n    />\n  </svg>\n`;\n\nexport const ErrorWarningLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path\n      d=\"M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z\"\n    />\n  </svg>\n`;\n\nexport const AlarmLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M12.0001 22.0001C7.02956 22.0001 3.00012 17.9707 3.00012 13.0001C3.00012 8.02956 7.02956 4.00012 12.0001 4.00012C16.9707 4.00012 21.0001 8.02956 21.0001 13.0001C21.0001 17.9707 16.9707 22.0001 12.0001 22.0001ZM12.0001 20.0001C15.8661 20.0001 19.0001 16.8661 19.0001 13.0001C19.0001 9.13412 15.8661 6.00012 12.0001 6.00012C8.13412 6.00012 5.00012 9.13412 5.00012 13.0001C5.00012 16.8661 8.13412 20.0001 12.0001 20.0001ZM13.0001 13.0001H16.0001V15.0001H11.0001V8.00012H13.0001V13.0001ZM1.74707 6.2826L5.2826 2.74707L6.69682 4.16128L3.16128 7.69682L1.74707 6.2826ZM18.7176 2.74707L22.2532 6.2826L20.839 7.69682L17.3034 4.16128L18.7176 2.74707Z\" />\n  </svg>\n`;\n\nexport const HospitalLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M8 20V14H16V20H19V4H5V20H8ZM10 20H14V16H10V20ZM21 20H23V22H1V20H3V3C3 2.44772 3.44772 2 4 2H20C20.5523 2 21 2.44772 21 3V20ZM11 8V6H13V8H15V10H13V12H11V10H9V8H11Z\" />\n  </svg>\n`;\n\nexport const PulseLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M9 7.53861L15 21.5386L18.6594 13H23V11H17.3406L15 16.4614L9 2.46143L5.3406 11H1V13H6.6594L9 7.53861Z\" />\n  </svg>\n`;\n\nexport const EyeLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z\" />\n  </svg>\n`;\n\nexport const SunIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    stroke-width=\"2\"\n    stroke-linecap=\"round\"\n    stroke-linejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <circle cx=\"12\" cy=\"12\" r=\"5\" />\n    <line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\" />\n    <line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\" />\n    <line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\" />\n    <line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\" />\n    <line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\" />\n    <line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\" />\n    <line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\" />\n    <line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\" />\n  </svg>\n`;\n\nexport const MoonIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    stroke-width=\"2\"\n    stroke-linecap=\"round\"\n    stroke-linejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\" />\n  </svg>\n`;\n\nexport const FullscreenLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z\" />\n  </svg>\n`;\n\nexport const ComputerLineIcon = ({ className = \"\" }) => html`\n  <svg\n    class=${className}\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z\" />\n  </svg>\n`;\n"
  },
  {
    "path": "lib/public/js/components/info-tooltip.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Tooltip } from \"./tooltip.js\";\n\nconst html = htm.bind(h);\n\nexport const InfoTooltip = ({ text = \"\", widthClass = \"w-64\" }) => html`\n  <${Tooltip} text=${text} widthClass=${widthClass}>\n    <span\n      class=\"inline-flex h-4 w-4 items-center justify-center rounded-full border border-fg-muted text-[10px] text-fg-muted cursor-default select-none\"\n      aria-label=${text}\n      >?</span\n    >\n  </${Tooltip}>\n`;\n"
  },
  {
    "path": "lib/public/js/components/loading-spinner.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const LoadingSpinner = ({\n  className = \"h-4 w-4\",\n  ariaHidden = true,\n  style = \"\",\n}) => html`\n  <svg\n    class=${`ac-spinner ${className}`.trim()}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    aria-hidden=${ariaHidden ? \"true\" : \"false\"}\n    style=${style}\n  >\n    <circle\n      class=\"opacity-25\"\n      cx=\"12\"\n      cy=\"12\"\n      r=\"10\"\n      stroke=\"currentColor\"\n      stroke-width=\"4\"\n    />\n    <path\n      class=\"opacity-75\"\n      fill=\"currentColor\"\n      d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"\n    />\n  </svg>\n`;\n"
  },
  {
    "path": "lib/public/js/components/modal-shell.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef } from \"preact/hooks\";\nimport { createPortal } from \"preact/compat\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const ModalShell = ({\n  visible = false,\n  onClose = () => {},\n  closeOnOverlayClick = true,\n  closeOnEscape = true,\n  panelClassName = \"bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3\",\n  children = null,\n}) => {\n  const overlayPointerDownRef = useRef(false);\n\n  useEffect(() => {\n    if (!visible || !closeOnEscape) return;\n\n    const handleKeydown = (event) => {\n      if (event.key === \"Escape\") onClose?.();\n    };\n\n    window.addEventListener(\"keydown\", handleKeydown);\n    return () => window.removeEventListener(\"keydown\", handleKeydown);\n  }, [visible, closeOnEscape, onClose]);\n\n  if (!visible) return null;\n\n  return createPortal(\n    html`\n      <div\n        class=\"fixed inset-0 bg-overlay flex items-start justify-center overflow-y-auto p-4 sm:items-center z-50\"\n        onpointerdown=${(event) => {\n          overlayPointerDownRef.current = event.target === event.currentTarget;\n        }}\n        onpointerup=${(event) => {\n          const shouldClose =\n            closeOnOverlayClick &&\n            overlayPointerDownRef.current &&\n            event.target === event.currentTarget;\n          overlayPointerDownRef.current = false;\n          if (shouldClose) onClose?.();\n        }}\n        onpointercancel=${() => {\n          overlayPointerDownRef.current = false;\n        }}\n      >\n        <div class=${panelClassName}>${children}</div>\n      </div>\n    `,\n    document.body,\n  );\n};\n"
  },
  {
    "path": "lib/public/js/components/models-tab/index.js",
    "content": "import { h } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { PageHeader } from \"../page-header.js\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { PopActions } from \"../pop-actions.js\";\nimport { PaneShell } from \"../pane-shell.js\";\nimport { Badge } from \"../badge.js\";\nimport { useModels } from \"./use-models.js\";\nimport {\n  buildProviderHasAuth,\n  buildSyntheticModelEntry,\n  getModelCatalogProvider,\n  getModelsTabAuthProvider,\n  getProviderSortIndex,\n  SearchableModelPicker,\n} from \"./model-picker.js\";\nimport { ProviderAuthCard } from \"./provider-auth-card.js\";\nimport {\n  getFeaturedModels,\n  kProviderOrder,\n} from \"../../lib/model-config.js\";\n\nconst html = htm.bind(h);\n\nconst deriveRequiredProviders = (configuredModels) => {\n  const providers = new Set();\n  for (const modelKey of Object.keys(configuredModels)) {\n    const provider = getModelsTabAuthProvider(modelKey);\n    if (provider) providers.add(provider);\n  }\n  return [...providers];\n};\n\nconst kProviderDisplayOrder = [\n  \"anthropic\",\n  \"openai\",\n  \"openai-codex\",\n  ...kProviderOrder.filter((provider) => ![\"anthropic\", \"openai\"].includes(provider)),\n];\n\nexport const Models = ({ onRestartRequired = () => {}, agentId, embedded = false }) => {\n  const {\n    catalog,\n    primary,\n    configuredModels,\n    authProfiles,\n    authOrder,\n    codexStatus,\n    loading,\n    saving,\n    ready,\n    error,\n    isDirty,\n    addModel,\n    removeModel,\n    setPrimaryModel,\n    editProfile,\n    editAuthOrder,\n    getProfileValue,\n    getEffectiveOrder,\n    cancelChanges,\n    saveAll,\n    refreshCodexStatus,\n  } = useModels(agentId);\n\n  const configuredKeys = useMemo(\n    () => new Set(Object.keys(configuredModels)),\n    [configuredModels],\n  );\n\n  const featuredModels = useMemo(() => getFeaturedModels(catalog), [catalog]);\n  const popularPickerModels = useMemo(\n    () => featuredModels.filter((model) => !configuredKeys.has(model.key)),\n    [featuredModels, configuredKeys],\n  );\n\n  const pickerModels = useMemo(() => {\n    return [...catalog]\n      .filter((model) => !configuredKeys.has(model.key))\n      .sort((a, b) => {\n        const providerCompare =\n          getProviderSortIndex(getModelCatalogProvider(a)) -\n          getProviderSortIndex(getModelCatalogProvider(b));\n        if (providerCompare !== 0) return providerCompare;\n        return String(a.label || a.key).localeCompare(String(b.label || b.key));\n      });\n  }, [catalog, configuredKeys]);\n\n  const requiredProviders = useMemo(\n    () => deriveRequiredProviders(configuredModels),\n    [configuredModels],\n  );\n\n  const sortedProviders = useMemo(() => {\n    const ordered = [];\n    for (const p of kProviderDisplayOrder) {\n      if (requiredProviders.includes(p)) ordered.push(p);\n    }\n    for (const p of requiredProviders) {\n      if (!ordered.includes(p)) ordered.push(p);\n    }\n    return ordered;\n  }, [requiredProviders]);\n\n  const providerHasAuth = useMemo(\n    () => buildProviderHasAuth({ authProfiles, codexStatus }),\n    [authProfiles, codexStatus],\n  );\n\n  const configuredModelEntries = useMemo(\n    () =>\n      Object.keys(configuredModels).map((key) => {\n        const catalogEntry =\n          catalog.find((m) => m.key === key) || buildSyntheticModelEntry(key);\n        const provider = getModelsTabAuthProvider(key);\n        const hasAuth = !!providerHasAuth[provider];\n        return {\n          key,\n          label: catalogEntry?.label || key,\n          provider: catalogEntry?.provider || provider,\n          isPrimary: key === primary,\n          hasAuth,\n        };\n      }),\n    [configuredModels, catalog, primary, providerHasAuth],\n  );\n\n  const headerActions = html`\n    <${PopActions} visible=${isDirty}>\n      <${ActionButton}\n        onClick=${cancelChanges}\n        disabled=${saving}\n        tone=\"secondary\"\n        size=\"sm\"\n        idleLabel=\"Cancel\"\n        className=\"text-xs\"\n      />\n      <${ActionButton}\n        onClick=${saveAll}\n        disabled=${saving}\n        loading=${saving}\n        loadingMode=\"inline\"\n        tone=\"primary\"\n        size=\"sm\"\n        idleLabel=\"Save changes\"\n        loadingLabel=\"Saving…\"\n        className=\"text-xs\"\n      />\n    </${PopActions}>\n  `;\n\n  if (!ready) {\n    const loadingBody = html`\n      <div class=\"bg-surface border border-border rounded-xl p-4\">\n        <div class=\"flex items-center gap-2 text-sm text-fg-muted\">\n          <${LoadingSpinner} className=\"h-4 w-4\" />\n          Loading model settings...\n        </div>\n      </div>\n    `;\n    if (embedded) return loadingBody;\n    return html`\n      <${PaneShell}\n        header=${html`<${PageHeader} title=\"Models\" />`}\n      >\n        ${loadingBody}\n      </${PaneShell}>\n    `;\n  }\n\n  const bodyContent = html`\n    <!-- Configured Models -->\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <h2 class=\"card-label\">Available Models</h2>\n\n      ${configuredModelEntries.length === 0\n        ? html`<p class=\"text-xs text-fg-muted\">\n            No models configured. Add a model below.\n          </p>`\n        : html`\n            <div class=\"space-y-1\">\n              ${configuredModelEntries.map(\n                (entry) => html`\n                  <div\n                    class=\"flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-surface\"\n                  >\n                    <div class=\"flex items-center gap-2 min-w-0\">\n                      <span class=\"text-sm text-body truncate\"\n                        >${entry.label}</span\n                      >\n                      ${entry.isPrimary\n                        ? html`<${Badge} tone=\"cyan\">Primary</${Badge}>`\n                        : entry.hasAuth\n                          ? html`\n                              <button\n                                onclick=${() => setPrimaryModel(entry.key)}\n                                class=\"text-xs px-2 py-0.5 rounded-full text-fg-muted hover:text-body hover:bg-surface\"\n                              >\n                                Set primary\n                              </button>\n                            `\n                          : html`<${Badge} tone=\"warning\">Needs auth</${Badge}>`}\n                    </div>\n                    <button\n                      onclick=${() => removeModel(entry.key)}\n                      class=\"text-xs text-fg-dim hover:text-status-error-muted shrink-0 px-1\"\n                    >\n                      Remove\n                    </button>\n                  </div>\n                `,\n              )}\n            </div>\n          `}\n\n      <div class=\"space-y-2\">\n        <${SearchableModelPicker}\n          options=${pickerModels}\n          popularModels=${popularPickerModels}\n          configuredOptions=${configuredModelEntries}\n          placeholder=\"Add model...\"\n          onSelect=${(modelKey) => {\n            addModel(modelKey);\n            if (!primary) setPrimaryModel(modelKey);\n          }}\n        />\n      </div>\n\n      ${loading\n        ? html`<p class=\"text-xs text-fg-dim\">\n            Loading model catalog...\n          </p>`\n        : error\n          ? html`<p class=\"text-xs text-fg-dim\">${error}</p>`\n          : null}\n    </div>\n\n    <!-- Provider Auth -->\n    ${sortedProviders.length > 0\n      ? html`\n          <div class=\"space-y-3\">\n            <h2 class=\"font-semibold text-base\">\n              Provider Authentication\n            </h2>\n            ${sortedProviders.map(\n              (provider) => html`\n                <${ProviderAuthCard}\n                  provider=${provider}\n                  authProfiles=${authProfiles}\n                  authOrder=${authOrder}\n                  codexStatus=${codexStatus}\n                  onEditProfile=${editProfile}\n                  onEditAuthOrder=${editAuthOrder}\n                  getProfileValue=${getProfileValue}\n                  getEffectiveOrder=${getEffectiveOrder}\n                  onRefreshCodex=${refreshCodexStatus}\n                />\n              `,\n            )}\n          </div>\n        `\n      : null}\n  `;\n\n  if (embedded) {\n    return html`\n      <div class=\"space-y-4\">\n        <div class=\"flex items-center justify-end gap-2\">\n          ${headerActions}\n        </div>\n        ${bodyContent}\n      </div>\n    `;\n  }\n\n  return html`\n    <${PaneShell}\n      header=${html`<${PageHeader} title=\"Models\" actions=${headerActions} />`}\n    >\n      ${bodyContent}\n    </${PaneShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/models-tab/model-picker.js",
    "content": "import { h } from \"preact\";\nimport { useState, useMemo, useRef, useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  getModelProvider,\n  getAuthProviderFromModelProvider,\n  kProviderLabels,\n  kProviderOrder,\n} from \"../../lib/model-config.js\";\n\nconst html = htm.bind(h);\n\nconst kProviderDisplayOrder = [\n  \"anthropic\",\n  \"openai\",\n  \"openai-codex\",\n  ...kProviderOrder.filter((provider) => ![\"anthropic\", \"openai\"].includes(provider)),\n];\n\nexport const getModelsTabAuthProvider = (modelKey) => {\n  const provider = getModelProvider(modelKey);\n  if (provider === \"openai-codex\") return \"openai-codex\";\n  return getAuthProviderFromModelProvider(provider);\n};\n\nexport const getModelCatalogProvider = (model) =>\n  String(model?.provider || getModelProvider(model?.key)).trim();\n\nexport const getProviderSortIndex = (provider) => {\n  const index = kProviderDisplayOrder.indexOf(provider);\n  return index >= 0 ? index : Number.MAX_SAFE_INTEGER;\n};\n\nconst formatProviderSectionLabel = (provider) =>\n  String(kProviderLabels[provider] || provider).toUpperCase();\n\nconst normalizeSearch = (value) => String(value || \"\").trim().toLowerCase();\n\nconst titleCaseToken = (value) => {\n  const normalized = String(value || \"\").trim().toLowerCase();\n  if (!normalized) return \"\";\n  return normalized.charAt(0).toUpperCase() + normalized.slice(1);\n};\n\nconst formatAnthropicModelLabel = (modelId) => {\n  const match = /^claude-(opus|sonnet|haiku)-(\\d+)[-.](\\d+)$/i.exec(\n    String(modelId || \"\").trim(),\n  );\n  if (!match) return \"\";\n  return `Claude ${titleCaseToken(match[1])} ${match[2]}.${match[3]}`;\n};\n\nexport const buildSyntheticModelEntry = (modelKey) => {\n  const key = String(modelKey || \"\").trim();\n  const provider = getModelProvider(key);\n  const modelId = key.includes(\"/\") ? key.split(\"/\").slice(1).join(\"/\") : key;\n  const anthropicLabel =\n    provider === \"anthropic\" ? formatAnthropicModelLabel(modelId) : \"\";\n  return {\n    key,\n    provider,\n    label: anthropicLabel || key,\n  };\n};\n\nexport const getModelDisplayLabel = (model) => model?.featuredLabel || model?.label || model?.key;\n\nconst buildModelSearchText = (model) =>\n  [\n    model?.featuredLabel || \"\",\n    model?.label || \"\",\n    model?.key || \"\",\n    model?.provider || getModelProvider(model?.key),\n  ]\n    .join(\" \")\n    .toLowerCase();\n\nexport const buildProviderHasAuth = ({\n  authProfiles = [],\n  codexStatus = { connected: false },\n} = {}) => {\n  const result = {};\n  for (const profile of authProfiles) {\n    if (profile?.key || profile?.token || profile?.access) {\n      result[profile.provider] = true;\n    }\n  }\n  if (codexStatus?.connected) {\n    result[\"openai-codex\"] = true;\n  }\n  return result;\n};\n\nexport const SearchableModelPicker = ({\n  options = [],\n  popularModels = [],\n  configuredOptions = [],\n  placeholder = \"Add model...\",\n  onSelect = () => {},\n  disabled = false,\n}) => {\n  const [query, setQuery] = useState(\"\");\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef(null);\n  const normalizedQuery = normalizeSearch(query);\n  const filteredOptions = useMemo(\n    () =>\n      normalizedQuery\n        ? options.filter((option) =>\n            buildModelSearchText(option).includes(normalizedQuery),\n          )\n        : options,\n    [options, normalizedQuery],\n  );\n  const matchingConfiguredOptions = useMemo(\n    () =>\n      normalizedQuery\n        ? configuredOptions.filter((option) =>\n            buildModelSearchText(option).includes(normalizedQuery),\n          )\n        : [],\n    [configuredOptions, normalizedQuery],\n  );\n  const groupedOptions = useMemo(() => {\n    const groups = [];\n    const showPopularGroup = !normalizedQuery;\n    const visibleOptionKeys = new Set(filteredOptions.map((option) => option.key));\n    const visiblePopularModels = popularModels.filter((model) =>\n      visibleOptionKeys.has(model.key),\n    );\n    if (showPopularGroup && visiblePopularModels.length > 0) {\n      groups.push({\n        provider: \"popular\",\n        label: \"POPULAR\",\n        options: visiblePopularModels,\n      });\n    }\n    for (const option of filteredOptions) {\n      const provider = getModelCatalogProvider(option);\n      const label = formatProviderSectionLabel(provider);\n      const currentGroup = groups[groups.length - 1];\n      if (!currentGroup || currentGroup.provider !== provider) {\n        groups.push({ provider, label, options: [option] });\n        continue;\n      }\n      currentGroup.options.push(option);\n    }\n    return groups;\n  }, [filteredOptions, popularModels, normalizedQuery]);\n\n  useEffect(() => {\n    const handleOutsidePointer = (event) => {\n      if (!rootRef.current?.contains(event.target)) {\n        setOpen(false);\n      }\n    };\n    document.addEventListener(\"mousedown\", handleOutsidePointer);\n    return () => document.removeEventListener(\"mousedown\", handleOutsidePointer);\n  }, []);\n\n  const handleSelect = (modelKey) => {\n    if (!modelKey || disabled) return;\n    onSelect(modelKey);\n    setQuery(\"\");\n    setOpen(false);\n  };\n\n  const handleKeyDown = (event) => {\n    const firstVisibleOption = groupedOptions[0]?.options?.[0];\n    if (event.key === \"Escape\") {\n      setOpen(false);\n      return;\n    }\n    if (event.key === \"Enter\" && firstVisibleOption?.key) {\n      event.preventDefault();\n      handleSelect(firstVisibleOption.key);\n    }\n  };\n\n  return html`\n    <div class=\"relative\" ref=${rootRef}>\n      <input\n        type=\"text\"\n        value=${query}\n        placeholder=${placeholder}\n        disabled=${disabled}\n        onFocus=${() => {\n          if (disabled) return;\n          setOpen(true);\n        }}\n        onInput=${(event) => {\n          setQuery(event.target.value);\n          setOpen(true);\n        }}\n        onKeyDown=${handleKeyDown}\n        class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted disabled:opacity-50 disabled:cursor-not-allowed\"\n      />\n      ${open && !disabled\n        ? html`\n            <div\n              class=\"absolute left-0 right-0 top-full mt-2 z-20 bg-modal border border-border rounded-xl shadow-2xl overflow-hidden\"\n            >\n              <div class=\"max-h-80 overflow-y-auto\">\n                ${filteredOptions.length > 0\n                  ? groupedOptions.map(\n                      (group, index) => html`\n                        <div key=${group.provider}>\n                          <div\n                            class=${`sticky top-0 z-10 h-[22px] px-3 text-[12px] font-semibold tracking-wide text-fg-muted bg-[#151922] border-b border-border flex items-center ${\n                              index > 0 ? \"border-t border-border\" : \"\"\n                            }`}\n                          >\n                            ${group.label}\n                          </div>\n                          ${group.options.map(\n                            (model) => html`\n                              <button\n                                key=${model.key}\n                                type=\"button\"\n                                onMouseDown=${(event) => event.preventDefault()}\n                                onClick=${() => handleSelect(model.key)}\n                                class=\"w-full text-left px-3 py-2 hover:bg-surface border-b border-border last:border-b-0\"\n                              >\n                                <div class=\"flex flex-col gap-1\">\n                                  <div class=\"text-sm text-body\">\n                                    ${getModelDisplayLabel(model)}\n                                  </div>\n                                  <div class=\"text-xs text-fg-muted font-mono\">\n                                    ${model.key}\n                                  </div>\n                                </div>\n                              </button>\n                            `,\n                          )}\n                        </div>\n                      `,\n                    )\n                  : matchingConfiguredOptions.length > 0\n                    ? html`\n                        <div class=\"px-3 py-3 text-xs text-fg-muted\">\n                          ${matchingConfiguredOptions.length === 1\n                            ? html`\n                                Already added above:\n                                <span class=\"text-body\">\n                                  ${getModelDisplayLabel(\n                                    matchingConfiguredOptions[0],\n                                  )}\n                                </span>\n                              `\n                            : `${matchingConfiguredOptions.length} matching models are already added above.`}\n                        </div>\n                      `\n                  : html`\n                      <div class=\"px-3 py-3 text-xs text-fg-muted\">\n                        No models match that search.\n                      </div>\n                    `}\n              </div>\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/models-tab/provider-auth-card.js",
    "content": "import { h } from \"preact\";\nimport { useState, useRef, useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { Badge } from \"../badge.js\";\nimport { SecretInput } from \"../secret-input.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { exchangeCodexOAuth, disconnectCodex } from \"../../lib/api.js\";\nimport {\n  isCodexAuthCallbackMessage,\n  openCodexAuthWindow,\n} from \"../../lib/codex-oauth-window.js\";\nimport { showToast } from \"../toast.js\";\nimport {\n  kProviderAuthFields,\n  kProviderLabels,\n} from \"../../lib/model-config.js\";\n\nconst html = htm.bind(h);\n\nconst kProviderMeta = {\n  anthropic: {\n    label: \"Anthropic\",\n    modes: [\n      {\n        id: \"api_key\",\n        label: \"API Key\",\n        profileSuffix: \"default\",\n        placeholder: \"sk-ant-api03-...\",\n        url: \"https://console.anthropic.com\",\n        field: \"key\",\n      },\n      {\n        id: \"token\",\n        label: \"Setup Token\",\n        profileSuffix: \"manual\",\n        placeholder: \"sk-ant-oat01-...\",\n        hint: \"From claude setup-token (uses your Claude subscription)\",\n        field: \"token\",\n      },\n    ],\n  },\n  openai: {\n    label: \"OpenAI\",\n    modes: [\n      {\n        id: \"api_key\",\n        label: \"API Key\",\n        profileSuffix: \"default\",\n        placeholder: \"sk-...\",\n        url: \"https://platform.openai.com\",\n        field: \"key\",\n      },\n    ],\n  },\n  \"openai-codex\": {\n    label: \"OpenAI Codex\",\n    modes: [{ id: \"oauth\", label: \"Codex OAuth\", isCodexOauth: true }],\n  },\n  google: {\n    label: \"Gemini\",\n    modes: [\n      {\n        id: \"api_key\",\n        label: \"API Key\",\n        profileSuffix: \"default\",\n        placeholder: \"AI...\",\n        url: \"https://aistudio.google.com\",\n        field: \"key\",\n      },\n    ],\n  },\n};\n\nconst kDefaultMode = {\n  id: \"api_key\",\n  label: \"API Key\",\n  profileSuffix: \"default\",\n  placeholder: \"...\",\n  field: \"key\",\n};\n\nconst buildDefaultProviderModes = (provider) => {\n  const fields = kProviderAuthFields[provider] || [];\n  if (fields.length === 0) return [kDefaultMode];\n  return fields.map((fieldDef) => ({\n    id: \"api_key\",\n    label: fieldDef.label || \"API Key\",\n    profileSuffix: \"default\",\n    placeholder: fieldDef.placeholder || \"...\",\n    hint: fieldDef.hint,\n    url: fieldDef.url,\n    field: \"key\",\n  }));\n};\n\nconst getProviderMeta = (provider) =>\n  kProviderMeta[provider] || {\n    label: kProviderLabels[provider] || provider,\n    modes: buildDefaultProviderModes(provider),\n  };\n\nconst resolveProfileId = (mode, provider) => {\n  const p = mode.provider || provider;\n  return `${p}:${mode.profileSuffix || \"default\"}`;\n};\n\nconst getCredentialValue = (value) =>\n  String(value?.key || value?.token || value?.access || \"\").trim();\n\nconst CodexOAuthSection = ({ codexStatus, onRefreshCodex }) => {\n  const [authStarted, setAuthStarted] = useState(false);\n  const [authWaiting, setAuthWaiting] = useState(false);\n  const [manualInput, setManualInput] = useState(\"\");\n  const [exchanging, setExchanging] = useState(false);\n  const exchangeInFlightRef = useRef(false);\n  const popupPollRef = useRef(null);\n\n  useEffect(\n    () => () => {\n      if (popupPollRef.current) clearInterval(popupPollRef.current);\n    },\n    [],\n  );\n\n  const submitAuthInput = async (input) => {\n    const normalizedInput = String(input || \"\").trim();\n    if (!normalizedInput || exchangeInFlightRef.current) return;\n    exchangeInFlightRef.current = true;\n    setManualInput(normalizedInput);\n    setExchanging(true);\n    try {\n      const result = await exchangeCodexOAuth(normalizedInput);\n      if (!result.ok)\n        throw new Error(result.error || \"Codex OAuth exchange failed\");\n      setManualInput(\"\");\n      showToast(\"Codex connected\", \"success\");\n      setAuthStarted(false);\n      setAuthWaiting(false);\n      await onRefreshCodex();\n    } catch (err) {\n      setAuthWaiting(false);\n      showToast(err.message || \"Codex OAuth exchange failed\", \"error\");\n    } finally {\n      exchangeInFlightRef.current = false;\n      setExchanging(false);\n    }\n  };\n\n  useEffect(() => {\n    const onMessage = async (e) => {\n      if (e.data?.codex === \"success\") {\n        showToast(\"Codex connected\", \"success\");\n        setAuthStarted(false);\n        setAuthWaiting(false);\n        await onRefreshCodex();\n      } else if (isCodexAuthCallbackMessage(e.data)) {\n        await submitAuthInput(e.data.input);\n      } else if (e.data?.codex === \"error\") {\n        showToast(\n          `Codex auth failed: ${e.data.message || \"unknown error\"}`,\n          \"error\",\n        );\n      }\n    };\n    window.addEventListener(\"message\", onMessage);\n    return () => window.removeEventListener(\"message\", onMessage);\n  }, [onRefreshCodex, submitAuthInput]);\n\n  const startAuth = () => {\n    setAuthStarted(true);\n    setAuthWaiting(true);\n    const popup = openCodexAuthWindow();\n    if (!popup || popup.closed) {\n      setAuthWaiting(false);\n      return;\n    }\n    if (popupPollRef.current) clearInterval(popupPollRef.current);\n    popupPollRef.current = setInterval(() => {\n      if (popup.closed) {\n        clearInterval(popupPollRef.current);\n        popupPollRef.current = null;\n        setAuthWaiting(false);\n      }\n    }, 500);\n  };\n\n  const completeAuth = async () => {\n    await submitAuthInput(manualInput);\n  };\n\n  const handleDisconnect = async () => {\n    const result = await disconnectCodex();\n    if (!result.ok) {\n      showToast(result.error || \"Failed to disconnect Codex\", \"error\");\n      return;\n    }\n    showToast(\"Codex disconnected\", \"success\");\n    setAuthStarted(false);\n    setAuthWaiting(false);\n    setManualInput(\"\");\n    await onRefreshCodex();\n  };\n\n  return html`\n    <div class=\"space-y-2\">\n      <div class=\"flex items-center justify-between\">\n        <span class=\"text-xs text-fg-muted\">Codex OAuth</span>\n        ${codexStatus.connected\n          ? html`<${Badge} tone=\"success\">Connected</${Badge}>`\n          : html`<${Badge} tone=\"warning\">Not connected</${Badge}>`}\n      </div>\n      ${authStarted\n        ? html`\n            <div class=\"flex items-center justify-between gap-2\">\n              <p class=\"text-xs text-fg-muted\">\n                ${authWaiting\n                  ? \"Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't.\"\n                  : \"Paste the redirect URL from your browser to finish connecting.\"}\n              </p>\n              <button\n                onclick=${startAuth}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0\"\n              >\n                Restart\n              </button>\n            </div>\n          `\n        : codexStatus.connected\n        ? html`\n            <div class=\"flex gap-2\">\n              <button\n                onclick=${startAuth}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary\"\n              >\n                Reconnect\n              </button>\n              <button\n                onclick=${handleDisconnect}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost\"\n              >\n                Disconnect\n              </button>\n            </div>\n          `\n        : html`\n            <button\n              onclick=${startAuth}\n              class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan\"\n            >\n              Connect Codex OAuth\n            </button>\n          `}\n      ${authStarted\n          ? html`\n            <p class=\"text-xs text-fg-muted\">\n              After login, copy the full redirect URL (starts with\n              <code class=\"text-xs bg-field px-1 rounded\"\n                >http://localhost:1455/auth/callback</code\n              >) and paste it here.\n            </p>\n            <input\n              type=\"text\"\n              value=${manualInput}\n              onInput=${(e) => setManualInput(e.target.value)}\n              placeholder=\"http://localhost:1455/auth/callback?code=...&state=...\"\n              class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted\"\n            />\n            <${ActionButton}\n              onClick=${completeAuth}\n              disabled=${!manualInput.trim() || exchanging}\n              loading=${exchanging}\n              tone=\"primary\"\n              size=\"sm\"\n              idleLabel=\"Complete Codex OAuth\"\n              loadingLabel=\"Completing...\"\n              className=\"text-xs font-medium px-3 py-1.5\"\n            />\n          `\n        : null}\n    </div>\n  `;\n};\n\nexport const ProviderAuthCard = ({\n  provider,\n  authProfiles,\n  authOrder,\n  codexStatus,\n  onEditProfile,\n  onEditAuthOrder,\n  getProfileValue,\n  getEffectiveOrder,\n  onRefreshCodex,\n}) => {\n  const meta = getProviderMeta(provider);\n  const credentialModes = meta.modes.filter((m) => !m.isCodexOauth);\n  const hasMultipleModes = credentialModes.length > 1;\n  const showsInlineOauthStatus = meta.modes.some((m) => m.isCodexOauth);\n\n  const effectiveOrder = getEffectiveOrder(provider);\n  const activeProfileId = effectiveOrder?.[0] || null;\n  const savedOrder = authOrder[provider] || null;\n\n  const hasUnsavedProfileChanges = credentialModes.some((mode) => {\n    const profileId = resolveProfileId(mode, provider);\n    const savedValue = authProfiles.find((p) => p.id === profileId) || null;\n    const draftValue = getProfileValue(profileId);\n    return getCredentialValue(draftValue) !== getCredentialValue(savedValue);\n  });\n\n  const hasUnsavedOrderChanges =\n    JSON.stringify(effectiveOrder || null) !== JSON.stringify(savedOrder);\n  const hasUnsavedChanges = hasUnsavedProfileChanges || hasUnsavedOrderChanges;\n\n  const isConnected =\n    credentialModes.some((mode) => {\n      const profileId = resolveProfileId(mode, provider);\n      const val = getProfileValue(profileId);\n      return !!(val?.key || val?.token || val?.access);\n    }) || (provider === \"openai-codex\" && !!codexStatus?.connected);\n\n  const handleSetActive = (mode) => {\n    const profileId = resolveProfileId(mode, provider);\n    const allIds = credentialModes.map((m) => resolveProfileId(m, provider));\n    const ordered = [profileId, ...allIds.filter((id) => id !== profileId)];\n    onEditAuthOrder(provider, ordered);\n  };\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between\">\n        <h3 class=\"card-label\">${meta.label}</h3>\n        ${showsInlineOauthStatus && credentialModes.length === 0\n          ? null\n          : hasUnsavedChanges\n            ? html`<${Badge} tone=\"warning\">Unsaved</${Badge}>`\n            : isConnected\n            ? html`<${Badge} tone=\"success\">Connected</${Badge}>`\n            : html`<${Badge} tone=\"warning\">Not configured</${Badge}>`}\n      </div>\n      ${credentialModes.map((mode) => {\n        const profileId = resolveProfileId(mode, provider);\n        const profileProvider = mode.provider || provider;\n        const currentValue = getProfileValue(profileId);\n        const fieldValue = currentValue?.[mode.field] || \"\";\n        const isActive =\n          !hasMultipleModes ||\n          activeProfileId === profileId ||\n          (!activeProfileId && mode === credentialModes[0]);\n\n        return html`\n          <div class=\"space-y-1.5\">\n            <div class=\"flex items-center gap-2\">\n              <label class=\"text-xs font-medium text-fg-muted\"\n                >${mode.label}</label\n              >\n              ${hasMultipleModes && isActive\n                ? html`<${Badge} tone=\"cyan\">Primary</${Badge}>`\n                : null}\n              ${hasMultipleModes && !isActive && fieldValue\n                ? html`<button\n                    onclick=${() => handleSetActive(mode)}\n                    class=\"text-xs px-1.5 py-0.5 rounded-full text-fg-muted hover:text-body hover:bg-surface\"\n                  >\n                    Set primary\n                  </button>`\n                : null}\n              ${mode.url && !fieldValue\n                ? html`<a\n                    href=${mode.url}\n                    target=\"_blank\"\n                    class=\"text-xs hover:underline\"\n                    style=\"color: var(--accent-link)\"\n                    >Get</a\n                  >`\n                : null}\n            </div>\n            <${SecretInput}\n              value=${fieldValue}\n              onInput=${(e) => {\n                const newVal = e.target.value;\n                const cred = {\n                  type: mode.id,\n                  provider: profileProvider,\n                  [mode.field]: newVal,\n                };\n                if (currentValue?.expires) cred.expires = currentValue.expires;\n                onEditProfile(profileId, cred);\n                const savedProfile =\n                  authProfiles.find((p) => p.id === profileId) || null;\n                const isReverted =\n                  getCredentialValue(cred) ===\n                  getCredentialValue(savedProfile);\n                if (isReverted && hasMultipleModes) {\n                  onEditAuthOrder(provider, savedOrder);\n                } else if (hasMultipleModes && newVal && !isActive) {\n                  handleSetActive(mode);\n                }\n              }}\n              placeholder=${mode.placeholder || \"\"}\n              isSecret=${true}\n              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\"\n            />\n            ${mode.hint\n              ? html`<p class=\"text-xs text-fg-dim\">${mode.hint}</p>`\n              : null}\n          </div>\n        `;\n      })}\n      ${meta.modes.some((m) => m.isCodexOauth)\n        ? html`\n            <div class=\"border border-border rounded-lg p-3\">\n              <${CodexOAuthSection}\n                codexStatus=${codexStatus}\n                onRefreshCodex=${onRefreshCodex}\n              />\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/models-tab/use-models.js",
    "content": "import { useState, useEffect, useRef, useCallback } from \"preact/hooks\";\nimport {\n  fetchModels,\n  fetchModelsConfig,\n  saveModelsConfig,\n  fetchCodexStatus,\n  disconnectCodex,\n} from \"../../lib/api.js\";\nimport { showToast } from \"../toast.js\";\nimport { useCachedFetch } from \"../../hooks/use-cached-fetch.js\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport { invalidateCache } from \"../../lib/api-cache.js\";\nimport {\n  getModelCatalogModels,\n  isModelCatalogRefreshing,\n  kModelCatalogCacheKey,\n  kModelCatalogPollIntervalMs,\n} from \"../../lib/model-catalog.js\";\n\nlet kModelsTabCache = null;\nconst getCredentialValue = (value) =>\n  String(value?.key || value?.token || value?.access || \"\").trim();\nconst kNoModelsFoundError = \"No models found\";\nconst kModelSettingsLoadError = \"Failed to load model settings\";\n\nexport const useModels = (agentId) => {\n  const isScoped = !!agentId;\n  const normalizedAgentId = String(agentId || \"\").trim();\n  const useCache = !isScoped;\n  const [catalog, setCatalog] = useState(() => (useCache && kModelsTabCache?.catalog) || []);\n  const [catalogStatus, setCatalogStatus] = useState(\n    () =>\n      (useCache && kModelsTabCache?.catalogStatus) || {\n        source: \"\",\n        fetchedAt: null,\n        stale: false,\n        refreshing: false,\n      },\n  );\n  const [primary, setPrimary] = useState(() => (useCache && kModelsTabCache?.primary) || \"\");\n  const [configuredModels, setConfiguredModels] = useState(\n    () => (useCache && kModelsTabCache?.configuredModels) || {},\n  );\n  const [authProfiles, setAuthProfiles] = useState(\n    () => (useCache && kModelsTabCache?.authProfiles) || [],\n  );\n  const [authOrder, setAuthOrder] = useState(\n    () => (useCache && kModelsTabCache?.authOrder) || {},\n  );\n  const [codexStatus, setCodexStatus] = useState(\n    () => (useCache && kModelsTabCache?.codexStatus) || { connected: false },\n  );\n  const [loading, setLoading] = useState(() => !(useCache && kModelsTabCache));\n  const [saving, setSaving] = useState(false);\n  const [ready, setReady] = useState(() => !!(useCache && kModelsTabCache));\n  const [error, setError] = useState(\"\");\n\n  const [profileEdits, setProfileEdits] = useState({});\n  const [orderEdits, setOrderEdits] = useState({});\n\n  const savedPrimaryRef = useRef(kModelsTabCache?.primary || \"\");\n  const savedConfiguredRef = useRef(kModelsTabCache?.configuredModels || {});\n\n  const updateCache = useCallback((patch) => {\n    if (!isScoped) kModelsTabCache = { ...(kModelsTabCache || {}), ...patch };\n  }, [isScoped]);\n  const modelsConfigCacheKey = normalizedAgentId\n    ? `/api/models/config?agentId=${encodeURIComponent(normalizedAgentId)}`\n    : \"/api/models/config\";\n  const catalogFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, {\n    maxAgeMs: 30000,\n  });\n  const configFetchState = useCachedFetch(\n    modelsConfigCacheKey,\n    () => fetchModelsConfig(isScoped ? { agentId } : undefined),\n    { maxAgeMs: 30000 },\n  );\n  const codexFetchState = useCachedFetch(\"/api/codex/status\", fetchCodexStatus, {\n    maxAgeMs: 15000,\n  });\n  const catalogPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, {\n    enabled: ready && isModelCatalogRefreshing(catalogStatus),\n    pauseWhenHidden: true,\n    cacheKey: kModelCatalogCacheKey,\n  });\n\n  const syncCatalogError = useCallback((catalogModels) => {\n    setError((current) => {\n      if (catalogModels.length > 0) {\n        return current === kNoModelsFoundError ? \"\" : current;\n      }\n      return current || kNoModelsFoundError;\n    });\n  }, []);\n\n  const applyCatalogResult = useCallback(\n    (catalogResult) => {\n      const catalogModels = getModelCatalogModels(catalogResult);\n      const nextCatalogStatus = {\n        source: String(catalogResult?.source || \"\"),\n        fetchedAt: Number(catalogResult?.fetchedAt || 0) || null,\n        stale: Boolean(catalogResult?.stale),\n        refreshing: Boolean(catalogResult?.refreshing),\n      };\n      setCatalog(catalogModels);\n      setCatalogStatus(nextCatalogStatus);\n      updateCache({\n        catalog: catalogModels,\n        catalogStatus: nextCatalogStatus,\n      });\n      syncCatalogError(catalogModels);\n      return catalogModels;\n    },\n    [syncCatalogError, updateCache],\n  );\n\n  const refresh = useCallback(async () => {\n    if (!ready) setLoading(true);\n    setError(\"\");\n    try {\n      const [catalogResult, configResult, codex] = await Promise.all([\n        catalogFetchState.refresh({ force: true }),\n        configFetchState.refresh({ force: true }),\n        codexFetchState.refresh({ force: true }),\n      ]);\n      const catalogModels = applyCatalogResult(catalogResult);\n      const p = configResult.primary || \"\";\n      const cm = configResult.configuredModels || {};\n      const ap = configResult.authProfiles || [];\n      const ao = configResult.authOrder || {};\n      setPrimary(p);\n      setConfiguredModels(cm);\n      setAuthProfiles(ap);\n      setAuthOrder(ao);\n      setCodexStatus(codex || { connected: false });\n      setProfileEdits({});\n      setOrderEdits({});\n      savedPrimaryRef.current = p;\n      savedConfiguredRef.current = cm;\n      updateCache({\n        catalog: catalogModels,\n        primary: p,\n        configuredModels: cm,\n        authProfiles: ap,\n        authOrder: ao,\n        codexStatus: codex || { connected: false },\n      });\n    } catch (err) {\n      setError(kModelSettingsLoadError);\n      showToast(`${kModelSettingsLoadError}: ${err.message}`, \"error\");\n    } finally {\n      setReady(true);\n      setLoading(false);\n    }\n  }, [\n    applyCatalogResult,\n    catalogFetchState,\n    codexFetchState,\n    configFetchState,\n    ready,\n    updateCache,\n  ]);\n\n  useEffect(() => {\n    refresh();\n  }, [agentId]);\n\n  useEffect(() => {\n    if (!catalogPoll.data) return;\n    applyCatalogResult(catalogPoll.data);\n  }, [applyCatalogResult, catalogPoll.data]);\n\n  const stableStringify = (obj) =>\n    JSON.stringify(Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {}));\n\n  const modelConfigDirty =\n    primary !== savedPrimaryRef.current ||\n    stableStringify(configuredModels) !==\n      stableStringify(savedConfiguredRef.current);\n\n  const authDirty = (() => {\n    const hasProfileChanges = Object.entries(profileEdits).some(\n      ([id, cred]) => {\n        const existing = authProfiles.find((p) => p.id === id);\n        return getCredentialValue(cred) !== getCredentialValue(existing);\n      },\n    );\n    const hasOrderChanges = Object.entries(orderEdits).some(\n      ([provider, order]) => {\n        const existing = authOrder[provider];\n        return JSON.stringify(order) !== JSON.stringify(existing);\n      },\n    );\n    return hasProfileChanges || hasOrderChanges;\n  })();\n\n  const isDirty = modelConfigDirty || authDirty;\n\n  const addModel = useCallback(\n    (modelKey) => {\n      if (!modelKey) return;\n      setConfiguredModels((prev) => {\n        const next = { ...prev, [modelKey]: {} };\n        updateCache({ configuredModels: next });\n        return next;\n      });\n    },\n    [updateCache],\n  );\n\n  const removeModel = useCallback(\n    (modelKey) => {\n      setConfiguredModels((prev) => {\n        const next = { ...prev };\n        delete next[modelKey];\n        updateCache({ configuredModels: next });\n        return next;\n      });\n      if (primary === modelKey) {\n        const remaining = Object.keys(configuredModels).filter(\n          (k) => k !== modelKey,\n        );\n        const newPrimary = remaining[0] || \"\";\n        setPrimary(newPrimary);\n        updateCache({ primary: newPrimary });\n      }\n    },\n    [primary, configuredModels, updateCache],\n  );\n\n  const setPrimaryModel = useCallback(\n    (modelKey) => {\n      setPrimary(modelKey);\n      updateCache({ primary: modelKey });\n    },\n    [updateCache],\n  );\n\n  const editProfile = useCallback(\n    (profileId, credential) => {\n      const existing = authProfiles.find((p) => p.id === profileId);\n      if (getCredentialValue(credential) === getCredentialValue(existing)) {\n        setProfileEdits((prev) => {\n          const next = { ...prev };\n          delete next[profileId];\n          return next;\n        });\n        return;\n      }\n      setProfileEdits((prev) => ({ ...prev, [profileId]: credential }));\n    },\n    [authProfiles],\n  );\n\n  const editAuthOrder = useCallback(\n    (provider, orderedIds) => {\n      const existing = authOrder[provider] || null;\n      if (JSON.stringify(orderedIds) === JSON.stringify(existing)) {\n        setOrderEdits((prev) => {\n          const next = { ...prev };\n          delete next[provider];\n          return next;\n        });\n        return;\n      }\n      setOrderEdits((prev) => ({ ...prev, [provider]: orderedIds }));\n    },\n    [authOrder],\n  );\n\n  const getProfileValue = useCallback(\n    (profileId) => {\n      if (profileEdits[profileId] !== undefined) return profileEdits[profileId];\n      const existing = authProfiles.find((p) => p.id === profileId);\n      return existing || null;\n    },\n    [profileEdits, authProfiles],\n  );\n\n  const getEffectiveOrder = useCallback(\n    (provider) => {\n      if (orderEdits[provider] !== undefined) return orderEdits[provider];\n      return authOrder[provider] || null;\n    },\n    [orderEdits, authOrder],\n  );\n\n  const cancelChanges = useCallback(() => {\n    const savedPrimary = savedPrimaryRef.current || \"\";\n    const savedConfigured = savedConfiguredRef.current || {};\n    setPrimary(savedPrimary);\n    setConfiguredModels(savedConfigured);\n    setProfileEdits({});\n    setOrderEdits({});\n    updateCache({\n      primary: savedPrimary,\n      configuredModels: savedConfigured,\n    });\n  }, [updateCache]);\n\n  const saveAll = useCallback(async () => {\n    if (saving) return;\n    setSaving(true);\n    try {\n      const changedProfiles = Object.entries(profileEdits)\n        .filter(([id, cred]) => {\n          const existing = authProfiles.find((p) => p.id === id);\n          return getCredentialValue(cred) !== getCredentialValue(existing);\n        })\n        .map(([id, cred]) => ({ id, ...cred }));\n\n      const result = await saveModelsConfig({\n        primary,\n        configuredModels,\n        profiles: changedProfiles.length > 0 ? changedProfiles : undefined,\n        authOrder:\n          Object.keys(orderEdits).length > 0 ? orderEdits : undefined,\n        ...(isScoped ? { agentId } : {}),\n      });\n      if (!result.ok)\n        throw new Error(result.error || \"Failed to save config\");\n      showToast(\"Changes saved\", \"success\");\n      if (result.syncWarning) {\n        showToast(`Saved, but git-sync failed: ${result.syncWarning}`, \"warning\");\n      }\n      invalidateCache(kModelCatalogCacheKey);\n      await refresh();\n    } catch (err) {\n      showToast(err.message || \"Failed to save changes\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  }, [\n    saving,\n    primary,\n    configuredModels,\n    profileEdits,\n    orderEdits,\n    authProfiles,\n    isScoped,\n    agentId,\n    refresh,\n  ]);\n\n  const refreshCodexStatus = useCallback(async () => {\n    try {\n      const codex = await fetchCodexStatus();\n      setCodexStatus(codex || { connected: false });\n      updateCache({ codexStatus: codex || { connected: false } });\n    } catch {\n      setCodexStatus({ connected: false });\n      updateCache({ codexStatus: { connected: false } });\n    }\n  }, [updateCache]);\n\n  return {\n    catalog,\n    primary,\n    configuredModels,\n    authProfiles,\n    authOrder,\n    codexStatus,\n    loading,\n    saving,\n    ready,\n    error,\n    isDirty,\n    refresh,\n    addModel,\n    removeModel,\n    setPrimaryModel,\n    editProfile,\n    editAuthOrder,\n    getProfileValue,\n    getEffectiveOrder,\n    cancelChanges,\n    saveAll,\n    refreshCodexStatus,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/models.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  fetchEnvVars,\n  saveEnvVars,\n  fetchModels,\n  fetchModelStatus,\n  setPrimaryModel,\n  fetchCodexStatus,\n  disconnectCodex,\n  exchangeCodexOAuth,\n} from \"../lib/api.js\";\nimport { showToast } from \"./toast.js\";\nimport { Badge } from \"./badge.js\";\nimport { SecretInput } from \"./secret-input.js\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport {\n  getModelProvider,\n  getAuthProviderFromModelProvider,\n  getFeaturedModels,\n  kProviderAuthFields,\n  kProviderLabels,\n  kProviderOrder,\n} from \"../lib/model-config.js\";\nimport {\n  isCodexAuthCallbackMessage,\n  openCodexAuthWindow,\n} from \"../lib/codex-oauth-window.js\";\n\nconst html = htm.bind(h);\n\nconst getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || \"\";\nconst kAiCredentialKeys = Object.values(kProviderAuthFields)\n  .flat()\n  .map((field) => field.key)\n  .filter((key, idx, arr) => arr.indexOf(key) === idx);\nlet kModelsTabCache = null;\n\nexport const Models = () => {\n  const [envVars, setEnvVars] = useState(() => kModelsTabCache?.envVars || []);\n  const [models, setModels] = useState(() => kModelsTabCache?.models || []);\n  const [selectedModel, setSelectedModel] = useState(() => kModelsTabCache?.selectedModel || \"\");\n  const [showAllModels, setShowAllModels] = useState(() => kModelsTabCache?.showAllModels || false);\n  const [savingChanges, setSavingChanges] = useState(false);\n  const [codexStatus, setCodexStatus] = useState(() => kModelsTabCache?.codexStatus || { connected: false });\n  const [codexManualInput, setCodexManualInput] = useState(\"\");\n  const [codexExchanging, setCodexExchanging] = useState(false);\n  const [codexAuthStarted, setCodexAuthStarted] = useState(false);\n  const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);\n  const [modelsLoading, setModelsLoading] = useState(() => !kModelsTabCache);\n  const [modelsError, setModelsError] = useState(() => kModelsTabCache?.modelsError || \"\");\n  const [ready, setReady] = useState(() => !!kModelsTabCache);\n  const [savedModel, setSavedModel] = useState(() => kModelsTabCache?.savedModel || \"\");\n  const [modelDirty, setModelDirty] = useState(false);\n  const [savedAiValues, setSavedAiValues] = useState(() => kModelsTabCache?.savedAiValues || {});\n  const codexExchangeInFlightRef = useRef(false);\n  const codexPopupPollRef = useRef(null);\n\n  const refresh = async () => {\n    if (!ready) setModelsLoading(true);\n    setModelsError(\"\");\n    try {\n      const [env, modelCatalog, modelStatus, codex] = await Promise.all([\n        fetchEnvVars(),\n        fetchModels(),\n        fetchModelStatus(),\n        fetchCodexStatus(),\n      ]);\n      setEnvVars(env.vars || []);\n      const catalogModels = Array.isArray(modelCatalog.models) ? modelCatalog.models : [];\n      setModels(catalogModels);\n      const currentModel = modelStatus.modelKey || \"\";\n      setSelectedModel(currentModel);\n      setCodexStatus(codex || { connected: false });\n      setSavedModel(currentModel);\n      setModelDirty(false);\n      const nextSavedAiValues = Object.fromEntries(\n        kAiCredentialKeys.map((key) => [key, getKeyVal(env.vars || [], key)]),\n      );\n      setSavedAiValues(nextSavedAiValues);\n      const nextModelsError = catalogModels.length ? \"\" : \"No models found\";\n      setModelsError(nextModelsError);\n      kModelsTabCache = {\n        envVars: env.vars || [],\n        models: catalogModels,\n        selectedModel: currentModel,\n        savedModel: currentModel,\n        savedAiValues: nextSavedAiValues,\n        codexStatus: codex || { connected: false },\n        showAllModels,\n        modelsError: nextModelsError,\n      };\n    } catch (err) {\n      setModelsError(\"Failed to load model settings\");\n      showToast(`Failed to load model settings: ${err.message}`, \"error\");\n    } finally {\n      setReady(true);\n      setModelsLoading(false);\n    }\n  };\n\n  const refreshCodexConnection = async () => {\n    try {\n      const codex = await fetchCodexStatus();\n      setCodexStatus(codex || { connected: false });\n      if (codex?.connected) {\n        setCodexAuthStarted(false);\n        setCodexAuthWaiting(false);\n      }\n      kModelsTabCache = { ...(kModelsTabCache || {}), codexStatus: codex || { connected: false } };\n    } catch {\n      setCodexStatus({ connected: false });\n      kModelsTabCache = { ...(kModelsTabCache || {}), codexStatus: { connected: false } };\n    }\n  };\n\n  useEffect(() => {\n    refresh();\n  }, []);\n\n  useEffect(() => () => {\n    if (codexPopupPollRef.current) {\n      clearInterval(codexPopupPollRef.current);\n      codexPopupPollRef.current = null;\n    }\n  }, []);\n\n  const submitCodexAuthInput = async (input) => {\n    const normalizedInput = String(input || \"\").trim();\n    if (!normalizedInput || codexExchangeInFlightRef.current) return;\n    codexExchangeInFlightRef.current = true;\n    setCodexManualInput(normalizedInput);\n    setCodexExchanging(true);\n    try {\n      const result = await exchangeCodexOAuth(normalizedInput);\n      if (!result.ok) throw new Error(result.error || \"Codex OAuth exchange failed\");\n      setCodexManualInput(\"\");\n      showToast(\"Codex connected\", \"success\");\n      setCodexAuthStarted(false);\n      setCodexAuthWaiting(false);\n      await refreshCodexConnection();\n    } catch (err) {\n      setCodexAuthWaiting(false);\n      showToast(err.message || \"Codex OAuth exchange failed\", \"error\");\n    } finally {\n      codexExchangeInFlightRef.current = false;\n      setCodexExchanging(false);\n    }\n  };\n\n  useEffect(() => {\n    const onMessage = async (e) => {\n      if (e.data?.codex === \"success\") {\n        showToast(\"Codex connected\", \"success\");\n        await refreshCodexConnection();\n      } else if (isCodexAuthCallbackMessage(e.data)) {\n        await submitCodexAuthInput(e.data.input);\n      } else if (e.data?.codex === \"error\") {\n        showToast(`Codex auth failed: ${e.data.message || \"unknown error\"}`, \"error\");\n      }\n    };\n    window.addEventListener(\"message\", onMessage);\n    return () => window.removeEventListener(\"message\", onMessage);\n  }, [submitCodexAuthInput]);\n\n  const setEnvValue = (key, value) => {\n    setEnvVars((prev) => {\n      const existing = prev.some((entry) => entry.key === key);\n      const next = existing\n        ? prev.map((v) => (v.key === key ? { ...v, value } : v))\n        : [...prev, { key, value, editable: true }];\n      kModelsTabCache = { ...(kModelsTabCache || {}), envVars: next };\n      return next;\n    });\n  };\n\n  const saveChanges = async () => {\n    if (savingChanges) return;\n    if (!modelDirty && !aiCredentialsDirty) return;\n    if (modelDirty && !hasSelectedProviderAuth) {\n      showToast(\"Add credentials for the selected model provider before saving model changes\", \"error\");\n      return;\n    }\n    setSavingChanges(true);\n    try {\n      const targetModel = selectedModel;\n\n      if (aiCredentialsDirty) {\n        const payload = envVars\n          .filter((v) => v.editable)\n          .map((v) => ({ key: v.key, value: v.value }));\n        const envResult = await saveEnvVars(payload);\n        if (!envResult.ok) throw new Error(envResult.error || \"Failed to save env vars\");\n      }\n\n      if (modelDirty && targetModel) {\n        const modelResult = await setPrimaryModel(targetModel);\n        if (!modelResult.ok) throw new Error(modelResult.error || \"Failed to set primary model\");\n        const status = await fetchModelStatus();\n        if (status?.ok === false) {\n          throw new Error(status.error || \"Failed to verify primary model\");\n        }\n        const activeModel = status?.modelKey || \"\";\n        if (activeModel && activeModel !== targetModel) {\n          throw new Error(`Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`);\n        }\n        setSavedModel(targetModel);\n        setModelDirty(false);\n        kModelsTabCache = { ...(kModelsTabCache || {}), selectedModel: targetModel, savedModel: targetModel };\n      }\n\n      showToast(\"Changes saved\", \"success\");\n      await refresh();\n    } catch (err) {\n      showToast(err.message || \"Failed to save changes\", \"error\");\n    } finally {\n      setSavingChanges(false);\n    }\n  };\n\n  const startCodexAuth = () => {\n    if (codexStatus.connected) return;\n    setCodexAuthStarted(true);\n    setCodexAuthWaiting(true);\n    const popup = openCodexAuthWindow();\n    if (!popup || popup.closed) {\n      setCodexAuthWaiting(false);\n      return;\n    }\n    if (codexPopupPollRef.current) {\n      clearInterval(codexPopupPollRef.current);\n    }\n    codexPopupPollRef.current = setInterval(() => {\n      if (popup.closed) {\n        clearInterval(codexPopupPollRef.current);\n        codexPopupPollRef.current = null;\n        setCodexAuthWaiting(false);\n      }\n    }, 500);\n  };\n\n  const completeCodexAuth = async () => {\n    await submitCodexAuthInput(codexManualInput);\n  };\n\n  const handleCodexDisconnect = async () => {\n    const result = await disconnectCodex();\n    if (!result.ok) {\n      showToast(result.error || \"Failed to disconnect Codex\", \"error\");\n      return;\n    }\n    showToast(\"Codex disconnected\", \"success\");\n    setCodexAuthStarted(false);\n    setCodexAuthWaiting(false);\n    setCodexManualInput(\"\");\n    await refreshCodexConnection();\n  };\n\n  const selectedModelProvider = getModelProvider(selectedModel);\n  const selectedAuthProvider = getAuthProviderFromModelProvider(selectedModelProvider);\n  const featuredModels = getFeaturedModels(models);\n  const baseModelOptions = showAllModels\n    ? models\n    : featuredModels.length > 0\n    ? featuredModels\n    : models;\n  const selectedModelOption = models.find((model) => model.key === selectedModel);\n  const modelOptions =\n    selectedModelOption &&\n    !baseModelOptions.some((model) => model.key === selectedModelOption.key)\n      ? [...baseModelOptions, selectedModelOption]\n      : baseModelOptions;\n  const canToggleFullCatalog = featuredModels.length > 0 && models.length > featuredModels.length;\n  const primaryProvider = kProviderOrder.includes(selectedAuthProvider)\n    ? selectedAuthProvider\n    : kProviderOrder[0];\n  const otherProviders = kProviderOrder.filter((provider) => provider !== primaryProvider);\n  const aiCredentialsDirty = kAiCredentialKeys.some(\n    (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || \"\"),\n  );\n  const hasSelectedProviderAuth =\n    selectedModelProvider === \"openai-codex\"\n      ? !!codexStatus.connected\n      : (kProviderAuthFields[selectedAuthProvider] || []).some((field) =>\n          Boolean(getKeyVal(envVars, field.key)),\n        );\n  const canSaveChanges = !savingChanges && (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));\n\n  const renderCredentialField = (field) => html`\n    <div class=\"space-y-1\">\n      <label class=\"text-xs font-medium text-fg-muted\">${field.label}</label>\n      <${SecretInput}\n        value=${getKeyVal(envVars, field.key)}\n        onInput=${(e) => setEnvValue(field.key, e.target.value)}\n        placeholder=${field.placeholder || \"\"}\n        isSecret=${!field.isText}\n        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\"\n      />\n      <p class=\"text-xs text-fg-dim\">${field.hint}</p>\n    </div>\n  `;\n\n  const renderProviderContent = (provider) => {\n    const fields = kProviderAuthFields[provider] || [];\n    const hasCodex = provider === \"openai\";\n    return html`\n      ${fields.map((field) => renderCredentialField(field))}\n      ${hasCodex &&\n      html`\n        <div class=\"border border-border rounded-lg p-3 space-y-2\">\n          <div class=\"flex items-center justify-between\">\n            <span class=\"text-xs text-fg-muted\">Codex OAuth</span>\n            ${codexStatus.connected\n              ? html`<${Badge} tone=\"success\">Connected</${Badge}>`\n              : html`<${Badge} tone=\"warning\">Not connected</${Badge}>`}\n          </div>\n          ${codexAuthStarted\n            ? html`\n                <div class=\"flex items-center justify-between gap-2\">\n                  <p class=\"text-xs text-fg-muted\">\n                    ${codexAuthWaiting\n                      ? \"Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't.\"\n                      : \"Paste the redirect URL from your browser to finish connecting.\"}\n                  </p>\n                  <button\n                    onclick=${startCodexAuth}\n                    class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0\"\n                  >\n                    Restart\n                  </button>\n                </div>\n              `\n            : codexStatus.connected\n            ? html`\n                <div class=\"flex gap-2\">\n                  <button\n                    onclick=${startCodexAuth}\n                    class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary\"\n                  >\n                    Reconnect Codex\n                  </button>\n                  <button\n                    onclick=${handleCodexDisconnect}\n                    class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost\"\n                  >\n                    Disconnect\n                  </button>\n                </div>\n              `\n            : html`\n                <button\n                  onclick=${startCodexAuth}\n                  class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan\"\n                >\n                  Connect Codex OAuth\n                </button>\n              `}\n          ${codexAuthStarted\n            ? html`\n                <p class=\"text-xs text-fg-muted\">\n                  After login, copy the full redirect URL (starts with\n                  <code class=\"text-xs bg-field px-1 rounded\">http://localhost:1455/auth/callback</code>)\n                  and paste it here.\n                </p>\n                <input\n                  type=\"text\"\n                  value=${codexManualInput}\n                  onInput=${(e) => setCodexManualInput(e.target.value)}\n                  placeholder=\"http://localhost:1455/auth/callback?code=...&state=...\"\n                  class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted\"\n                />\n                <${ActionButton}\n                  onClick=${completeCodexAuth}\n                  disabled=${!codexManualInput.trim() || codexExchanging}\n                  loading=${codexExchanging}\n                  tone=\"primary\"\n                  size=\"sm\"\n                  idleLabel=\"Complete Codex OAuth\"\n                  loadingLabel=\"Completing...\"\n                  className=\"text-xs font-medium px-3 py-1.5\"\n                />\n              `\n            : null}\n        </div>\n      `}\n    `;\n  };\n\n  if (!ready) {\n    return html`\n      <div class=\"bg-surface border border-border rounded-xl p-4\">\n        <div class=\"flex items-center gap-2 text-sm text-fg-muted\">\n          <${LoadingSpinner} className=\"h-4 w-4\" />\n          Loading model settings...\n        </div>\n      </div>\n    `;\n  }\n\n  return html`\n    <div class=\"space-y-4\">\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n        <h2 class=\"font-semibold text-sm\">Primary Agent Model</h2>\n        <select\n          value=${selectedModel}\n          onInput=${(e) => {\n            const next = e.target.value;\n            setSelectedModel(next);\n            setModelDirty(next !== savedModel);\n            kModelsTabCache = { ...(kModelsTabCache || {}), selectedModel: next };\n          }}\n          class=\"w-full bg-field border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n        >\n          <option value=\"\">Select a model</option>\n          ${modelOptions.map(\n            (model) => html`<option value=${model.key}>${model.label || model.key}</option>`,\n          )}\n        </select>\n        <p class=\"text-xs text-fg-dim\">\n          ${modelsLoading ? \"Loading model catalog...\" : modelsError ? modelsError : \"\"}\n        </p>\n        ${canToggleFullCatalog\n          ? html`\n              <div>\n                <button\n                  type=\"button\"\n                  onclick=${() =>\n                    setShowAllModels((prev) => {\n                      const next = !prev;\n                      kModelsTabCache = { ...(kModelsTabCache || {}), showAllModels: next };\n                      return next;\n                    })}\n                  class=\"text-xs text-fg-muted hover:text-body\"\n                >\n                  ${showAllModels ? \"Show recommended models\" : \"Show full model catalog\"}\n                </button>\n              </div>\n            `\n          : null}\n        <div class=\"pt-2 border-t border-border space-y-3\">\n          ${renderProviderContent(primaryProvider)}\n        </div>\n      </div>\n\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n        <h2 class=\"font-semibold text-sm\">Other Providers</h2>\n        ${otherProviders.map(\n          (provider) => html`\n            <div class=\"bg-field border border-border rounded-lg p-3 space-y-3\">\n              <h3 class=\"text-xs font-semibold text-body\">${kProviderLabels[provider] || provider}</h3>\n              ${renderProviderContent(provider)}\n            </div>\n          `,\n        )}\n      </div>\n\n      <${ActionButton}\n        onClick=${saveChanges}\n        disabled=${!canSaveChanges}\n        loading=${savingChanges}\n        tone=\"primary\"\n        size=\"md\"\n        idleLabel=\"Save changes\"\n        loadingLabel=\"Saving...\"\n        className=\"w-full py-2.5 transition-all\"\n      />\n      ${modelDirty && !hasSelectedProviderAuth\n        ? html`\n            <p class=\"text-xs text-status-warning-muted\">\n              Set credentials for the selected provider before saving this model change.\n            </p>\n          `\n        : null}\n\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/browser-attach/index.js",
    "content": "import { h } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { marked } from \"marked\";\n\nconst html = htm.bind(h);\n\nconst kReleaseNotesUrl =\n  \"https://github.com/openclaw/openclaw/releases/tag/v2026.3.13\";\nconst kSetupInstructionsMarkdown = `Release reference: [OpenClaw 2026.3.13](${kReleaseNotesUrl})\n\n## Requirements\n\n- OpenClaw 2026.3.13+\n- Chrome 144+\n- Node.js installed on the Mac node so \\`npx\\` is available\n\n## Setup\n\n### 1) Enable remote debugging in Chrome\n\nOpen \\`chrome://inspect/#remote-debugging\\` and turn it on. Do **not** launch Chrome with \\`--remote-debugging-port\\`.\n\n### 2) Configure the node\n\nIn \\`~/.openclaw/openclaw.json\\` on the Mac node:\n\n\\`\\`\\`json\n{\n  \"browser\": {\n    \"defaultProfile\": \"user\"\n  }\n}\n\\`\\`\\`\n\nThe built-in \\`user\\` profile uses live Chrome attach. You do not need a custom \\`existing-session\\` profile.\n\n### 3) Approve Chrome consent\n\nOn first connect, Chrome prompts for DevTools MCP access. Click **Allow**.\n\n## Troubleshooting\n\n| Problem | Fix |\n| --- | --- |\n| Browser proxy times out (20s) | Restart Chrome cleanly and run the check again. |\n| Config validation error on existing-session | Do not define a custom existing-session profile. Use \\`defaultProfile: \"user\"\\`. |\n| EADDRINUSE on port 9222 | Quit Chrome launched with \\`--remote-debugging-port\\` and relaunch normally. |\n| Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. |\n| \\`npx chrome-devtools-mcp\\` not found | Install Node.js on the Mac node so \\`npx\\` exists in PATH. |`;\n\nexport const BrowserAttachCard = () => {\n  const setupInstructionsHtml = useMemo(\n    () =>\n      marked.parse(kSetupInstructionsMarkdown, {\n        gfm: true,\n        breaks: true,\n      }),\n    [],\n  );\n\n  return html`\n    <details\n      class=\"ac-surface-inset rounded-lg border border-border px-3 py-2.5\"\n    >\n      <summary class=\"cursor-pointer text-xs text-body hover:text-body\">\n        Chrome debugging setup / troubleshooting\n      </summary>\n      <div\n        class=\"pt-3 px-2 file-viewer-preview release-notes-preview text-xs leading-5\"\n        dangerouslySetInnerHTML=${{ __html: setupInstructionsHtml }}\n      ></div>\n    </details>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/connected-nodes/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { Badge } from \"../../badge.js\";\nimport { ConfirmDialog } from \"../../confirm-dialog.js\";\nimport { ComputerLineIcon, FileCopyLineIcon } from \"../../icons.js\";\nimport { LoadingSpinner } from \"../../loading-spinner.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"../../overflow-menu.js\";\nimport { useConnectedNodesCard } from \"./use-connected-nodes-card.js\";\n\nconst html = htm.bind(h);\n\nconst escapeDoubleQuotes = (value) => String(value || \"\").replace(/\"/g, '\\\\\"');\n\nconst buildReconnectCommand = ({ node, connectInfo, maskToken = false }) => {\n  const host = String(connectInfo?.gatewayHost || \"\").trim() || \"localhost\";\n  const port = Number(connectInfo?.gatewayPort) || 3000;\n  const token = String(connectInfo?.gatewayToken || \"\").trim();\n  const tlsFlag = connectInfo?.tls === true ? \"--tls\" : \"\";\n  const displayName = String(\n    node?.displayName || node?.nodeId || \"My Node\",\n  ).trim();\n  const tokenValue = maskToken ? \"****\" : token;\n\n  return [\n    tokenValue ? `OPENCLAW_GATEWAY_TOKEN=${tokenValue}` : \"\",\n    \"openclaw node run\",\n    `--host ${host}`,\n    `--port ${port}`,\n    tlsFlag,\n    `--display-name \"${escapeDoubleQuotes(displayName)}\"`,\n  ]\n    .filter(Boolean)\n    .join(\" \");\n};\n\nconst renderNodeStatusBadge = (node) => {\n  if (node?.connected) {\n    return html`<${Badge} tone=\"success\">Connected</${Badge}>`;\n  }\n  if (node?.paired) {\n    return html`<${Badge} tone=\"warning\">Disconnected</${Badge}>`;\n  }\n  return html`<${Badge} tone=\"danger\">Pending approval</${Badge}>`;\n};\n\nconst isBrowserCapableNode = (node) => {\n  const caps = Array.isArray(node?.caps) ? node.caps : [];\n  const commands = Array.isArray(node?.commands) ? node.commands : [];\n  return caps.includes(\"browser\") || commands.includes(\"browser.proxy\");\n};\n\nconst getBrowserStatusTone = (status) => {\n  if (status.running) return \"success\";\n  return \"warning\";\n};\n\nconst getBrowserStatusLabel = (status) => {\n  if (status.running) return \"Attached\";\n  return \"Not connected\";\n};\n\nexport const ConnectedNodesCard = ({\n  nodes = [],\n  pending = [],\n  loading = false,\n  error = \"\",\n  connectInfo = null,\n  onRefreshNodes = async () => {},\n}) => {\n  const state = useConnectedNodesCard({ nodes, onRefreshNodes });\n  const {\n    browserStatusByNodeId,\n    browserErrorByNodeId,\n    checkingBrowserNodeId,\n    browserAttachStateByNodeId,\n    menuOpenNodeId,\n    removeDialogNode,\n    removingNodeId,\n    handleCopyText,\n    handleCheckNodeBrowser,\n    handleAttachNodeBrowser,\n    handleDetachNodeBrowser,\n    handleOpenNodeMenu,\n    handleRemoveNode,\n    setMenuOpenNodeId,\n    setRemoveDialogNode,\n  } = state;\n\n  return html`\n    <div class=\"space-y-3\">\n      ${pending.length\n        ? html`\n            <div\n              class=\"bg-surface border border-yellow-500/40 rounded-xl px-4 py-3 text-xs text-status-warning\"\n            >\n              ${pending.length} pending node${pending.length === 1 ? \"\" : \"s\"}\n              waiting for approval.\n            </div>\n          `\n        : null}\n      ${loading\n        ? html`\n            <div class=\"bg-surface border border-border rounded-xl p-4\">\n              <div class=\"flex items-center gap-3 text-sm text-fg-muted\">\n                <${LoadingSpinner} className=\"h-4 w-4\" />\n                <span>Loading nodes...</span>\n              </div>\n            </div>\n          `\n        : error\n          ? html`\n              <div\n                class=\"bg-surface border border-border rounded-xl p-4 text-xs text-status-error-muted\"\n              >\n                ${error}\n              </div>\n            `\n          : !nodes.length\n            ? html`\n                <div\n                  class=\"bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center\"\n                >\n                  <div class=\"max-w-md w-full flex flex-col items-center gap-4\">\n                    <${ComputerLineIcon} className=\"h-12 w-12 text-cyan-400\" />\n                    <div class=\"space-y-2\">\n                      <h2 class=\"font-semibold text-lg text-bright\">\n                        No connected nodes yet\n                      </h2>\n                      <p class=\"text-xs text-fg-muted leading-5\">\n                        Connect a Mac, iOS, Android, or headless node to run\n                        system and browser commands through this gateway.\n                      </p>\n                    </div>\n                  </div>\n                </div>\n              `\n            : html`\n                <div class=\"space-y-2\">\n                  ${nodes.map((node) => {\n                    const nodeId = String(node?.nodeId || \"\").trim();\n                    const browserStatus = browserStatusByNodeId[nodeId] || null;\n                    const browserError = browserErrorByNodeId[nodeId] || \"\";\n                    const checkingBrowser = checkingBrowserNodeId === nodeId;\n                    const canCheckBrowser =\n                      node?.connected && isBrowserCapableNode(node) && nodeId;\n                    const browserAttachEnabled =\n                      browserAttachStateByNodeId?.[nodeId] === true;\n                    const hasBrowserCheckResult =\n                      !!browserStatus || !!browserError;\n                    const browserAttached = browserStatus?.running === true;\n                    const showResolvingSpinner =\n                      browserAttachEnabled &&\n                      !hasBrowserCheckResult &&\n                      !checkingBrowser;\n                    const showBrowserCheckButton =\n                      canCheckBrowser &&\n                      browserAttachEnabled &&\n                      !checkingBrowser &&\n                      hasBrowserCheckResult &&\n                      !browserAttached;\n                    return html`\n                      <div\n                        class=\"bg-surface border border-border rounded-xl p-4 space-y-2\"\n                      >\n                        <div class=\"flex items-center justify-between gap-2\">\n                          <div class=\"min-w-0 space-y-1\">\n                            <div class=\"flex items-center gap-2 min-w-0 mb-2\">\n                              <div class=\"text-sm font-medium truncate\">\n                                ${node?.displayName ||\n                                node?.nodeId ||\n                                \"Unnamed node\"}\n                              </div>\n                              ${nodeId\n                                ? html`\n                                    <button\n                                      type=\"button\"\n                                      class=\"shrink-0 inline-flex items-center gap-1 text-[11px] text-fg-muted hover:text-body\"\n                                      onclick=${() =>\n                                        handleCopyText(nodeId, {\n                                          successMessage: \"Device ID copied\",\n                                          errorMessage:\n                                            \"Could not copy device ID\",\n                                        })}\n                                    >\n                                      <${FileCopyLineIcon}\n                                        className=\"w-3.5 h-3.5\"\n                                      />\n                                      <span>Copy device id</span>\n                                    </button>\n                                  `\n                                : null}\n                            </div>\n                          </div>\n                          <div class=\"flex items-center gap-1.5\">\n                            ${renderNodeStatusBadge(node)}\n                            ${node?.paired\n                              ? html`\n                                <${OverflowMenu}\n                                  open=${menuOpenNodeId === nodeId}\n                                  ariaLabel=\"Open node actions\"\n                                  title=\"Open node actions\"\n                                  onClose=${() => setMenuOpenNodeId(\"\")}\n                                  onToggle=${() => handleOpenNodeMenu(nodeId)}\n                                >\n                                  <${OverflowMenuItem}\n                                    className=\"text-status-error hover:text-status-error\"\n                                    onClick=${() => {\n                                      setMenuOpenNodeId(\"\");\n                                      setRemoveDialogNode(node);\n                                    }}\n                                  >\n                                    Remove device\n                                  </${OverflowMenuItem}>\n                                </${OverflowMenu}>\n                              `\n                              : null}\n                          </div>\n                        </div>\n                        <div class=\"flex flex-wrap gap-2 text-[11px]\">\n                          <div class=\"ac-surface-inset rounded-lg px-2.5 py-1\">\n                            <span class=\"text-fg-muted\">platform: </span>\n                            <code>${node?.platform || \"unknown\"}</code>\n                          </div>\n                          <div class=\"ac-surface-inset rounded-lg px-2.5 py-1\">\n                            <span class=\"text-fg-muted\">version: </span>\n                            <code>${node?.version || \"unknown\"}</code>\n                          </div>\n                          <div class=\"ac-surface-inset rounded-lg px-2.5 py-1\">\n                            <span class=\"text-fg-muted\">capabilities: </span>\n                            <code\n                              >${Array.isArray(node?.caps)\n                                ? node.caps.join(\", \")\n                                : \"none\"}</code\n                            >\n                          </div>\n                        </div>\n                        ${canCheckBrowser\n                          ? html`\n                              <div class=\"space-y-2\">\n                                <div\n                                  class=\"ac-surface-inset rounded-lg px-3 py-2 space-y-2\"\n                                >\n                                  <div\n                                    class=\"flex items-start justify-between gap-2\"\n                                  >\n                                    <div class=\"space-y-0.5\">\n                                      <div class=\"text-sm font-medium\">\n                                        Browser\n                                      </div>\n                                      ${browserAttachEnabled\n                                        ? html`\n                                            <div\n                                              class=\"text-[11px] text-fg-muted\"\n                                            >\n                                              profile: <code>user</code>\n                                            </div>\n                                          `\n                                        : html`\n                                            <div\n                                              class=\"text-[11px] text-fg-muted\"\n                                            >\n                                              Attach is disabled until you click\n                                              ${\" \"}\n                                              <code>Attach</code>\n                                              ${\" \"} (prevents control prompts\n                                              when opening this tab).\n                                            </div>\n                                          `}\n                                    </div>\n                                    <div class=\"flex items-start gap-2\">\n                                      ${browserStatus\n                                        ? html`\n                                          <span class=\"inline-flex mt-0.5\">\n                                            <${Badge} tone=${getBrowserStatusTone(browserStatus)}\n                                              >${getBrowserStatusLabel(browserStatus)}</${Badge}\n                                            >\n                                          </span>\n                                        `\n                                        : null}\n                                      ${showResolvingSpinner\n                                        ? html`\n                                            <${LoadingSpinner}\n                                              className=\"h-3.5 w-3.5\"\n                                            />\n                                          `\n                                        : null}\n                                      ${checkingBrowser\n                                        ? html`\n                                            <${LoadingSpinner}\n                                              className=\"h-3.5 w-3.5\"\n                                            />\n                                          `\n                                        : null}\n                                      ${canCheckBrowser && !browserAttachEnabled\n                                        ? html`\n                                            <${ActionButton}\n                                              onClick=${() =>\n                                                handleAttachNodeBrowser(nodeId)}\n                                              idleLabel=\"Attach\"\n                                              tone=\"primary\"\n                                              size=\"sm\"\n                                            />\n                                          `\n                                        : null}\n                                      ${showBrowserCheckButton\n                                        ? html`\n                                            <${ActionButton}\n                                              onClick=${() =>\n                                                handleCheckNodeBrowser(nodeId)}\n                                              idleLabel=\"Check\"\n                                              tone=\"secondary\"\n                                              size=\"sm\"\n                                            />\n                                          `\n                                        : null}\n                                    </div>\n                                  </div>\n                                  ${browserStatus\n                                    ? html`\n                                        <div\n                                          class=\"flex items-center justify-between gap-2\"\n                                        >\n                                          <div\n                                            class=\"flex flex-wrap gap-2 text-[11px] text-fg-muted\"\n                                          >\n                                            <span\n                                              >driver:\n                                              <code\n                                                >${browserStatus?.driver ||\n                                                \"unknown\"}</code\n                                              ></span\n                                            >\n                                            <span\n                                              >transport:\n                                              <code\n                                                >${browserStatus?.transport ||\n                                                \"unknown\"}</code\n                                              ></span\n                                            >\n                                          </div>\n                                        </div>\n                                      `\n                                    : null}\n                                  ${browserError\n                                    ? html`<div\n                                        class=\"text-[11px] text-status-error-muted\"\n                                      >\n                                        ${browserError}\n                                      </div>`\n                                    : null}\n                                  ${canCheckBrowser &&\n                                  browserAttachEnabled &&\n                                  !checkingBrowser\n                                    ? html`\n                                        <div class=\"flex justify-end pt-1\">\n                                          <button\n                                            type=\"button\"\n                                            onclick=${() =>\n                                              handleDetachNodeBrowser(nodeId)}\n                                            class=\"shrink-0 text-[11px] text-fg-muted hover:text-body\"\n                                          >\n                                            Detach\n                                          </button>\n                                        </div>\n                                      `\n                                    : null}\n                                </div>\n                              </div>\n                            `\n                          : null}\n                        ${node?.paired && !node?.connected && connectInfo\n                          ? html`\n                              <div\n                                class=\"border-t border-border pt-2 space-y-2\"\n                              >\n                                <div class=\"text-[11px] text-fg-muted\">\n                                  Reconnect command\n                                </div>\n                                <div class=\"flex items-center gap-2\">\n                                  <input\n                                    type=\"text\"\n                                    readonly\n                                    value=${buildReconnectCommand({\n                                      node,\n                                      connectInfo,\n                                      maskToken: true,\n                                    })}\n                                    class=\"flex-1 min-w-0 bg-field border border-border rounded-lg px-2 py-1.5 text-[11px] font-mono text-body\"\n                                  />\n                                  <${ActionButton}\n                                    onClick=${() =>\n                                      handleCopyText(\n                                        buildReconnectCommand({\n                                          node,\n                                          connectInfo,\n                                          maskToken: false,\n                                        }),\n                                        {\n                                          successMessage:\n                                            \"Connection command copied\",\n                                          errorMessage:\n                                            \"Could not copy connection command\",\n                                        },\n                                      )}\n                                    tone=\"secondary\"\n                                    size=\"sm\"\n                                    iconOnly=${true}\n                                    idleIcon=${FileCopyLineIcon}\n                                    idleIconClassName=\"w-3.5 h-3.5\"\n                                    ariaLabel=\"Copy reconnect command\"\n                                    title=\"Copy reconnect command\"\n                                  />\n                                </div>\n                              </div>\n                            `\n                          : null}\n                      </div>\n                    `;\n                  })}\n                </div>\n              `}\n    </div>\n    <${ConfirmDialog}\n      visible=${!!removeDialogNode}\n      title=\"Remove device?\"\n      message=${removeDialogNode?.connected\n        ? \"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.\"\n        : \"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.\"}\n      confirmLabel=\"Remove device\"\n      confirmLoadingLabel=\"Removing...\"\n      confirmTone=\"warning\"\n      confirmLoading=${Boolean(removingNodeId)}\n      confirmDisabled=${Boolean(removingNodeId)}\n      onCancel=${() => {\n        if (removingNodeId) return;\n        setRemoveDialogNode(null);\n      }}\n      onConfirm=${handleRemoveNode}\n    />\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/connected-nodes/use-connected-nodes-card.js",
    "content": "import {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport { copyTextToClipboard } from \"../../../lib/clipboard.js\";\nimport { fetchNodeBrowserStatusForNode, removeNode } from \"../../../lib/api.js\";\nimport { readUiSettings, updateUiSettings } from \"../../../lib/ui-settings.js\";\nimport { showToast } from \"../../toast.js\";\n\nconst kBrowserCheckTimeoutMs = 35000;\nconst kBrowserPollIntervalMs = 10000;\nconst kBrowserAttachStateByNodeKey = \"nodesBrowserAttachStateByNode\";\nconst kBrowserClosedPageErrorPattern = /selected page has been closed/i;\n\nconst withTimeout = async (promise, timeoutMs = kBrowserCheckTimeoutMs) => {\n  let timeoutId = null;\n  try {\n    return await Promise.race([\n      promise,\n      new Promise((_, reject) => {\n        timeoutId = setTimeout(() => {\n          reject(new Error(\"Browser check timed out\"));\n        }, timeoutMs);\n      }),\n    ]);\n  } finally {\n    if (timeoutId) {\n      clearTimeout(timeoutId);\n    }\n  }\n};\n\nconst isBrowserCapableNode = (node) => {\n  const caps = Array.isArray(node?.caps) ? node.caps : [];\n  const commands = Array.isArray(node?.commands) ? node.commands : [];\n  return caps.includes(\"browser\") || commands.includes(\"browser.proxy\");\n};\n\nconst normalizeBrowserStatusError = (error) => {\n  const rawMessage = String(\n    error?.message || \"Could not check node browser status\",\n  ).trim();\n  if (kBrowserClosedPageErrorPattern.test(rawMessage)) {\n    return \"Selected Chrome page was closed. Click Attach to reconnect.\";\n  }\n  return rawMessage;\n};\n\nconst readBrowserAttachStateByNode = () => {\n  const uiSettings = readUiSettings();\n  const attachState = uiSettings?.[kBrowserAttachStateByNodeKey];\n  if (\n    !attachState ||\n    typeof attachState !== \"object\" ||\n    Array.isArray(attachState)\n  ) {\n    return {};\n  }\n  return attachState;\n};\n\nconst writeBrowserAttachStateByNode = (nextState = {}) => {\n  updateUiSettings((currentSettings) => {\n    const nextSettings =\n      currentSettings && typeof currentSettings === \"object\"\n        ? currentSettings\n        : {};\n    return {\n      ...nextSettings,\n      [kBrowserAttachStateByNodeKey]:\n        nextState && typeof nextState === \"object\" ? nextState : {},\n    };\n  });\n};\n\nexport const useConnectedNodesCard = ({\n  nodes = [],\n  onRefreshNodes = async () => {},\n} = {}) => {\n  const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});\n  const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});\n  const [checkingBrowserNodeId, setCheckingBrowserNodeId] = useState(\"\");\n  const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState(\n    () => readBrowserAttachStateByNode(),\n  );\n  const [menuOpenNodeId, setMenuOpenNodeId] = useState(\"\");\n  const [removeDialogNode, setRemoveDialogNode] = useState(null);\n  const [removingNodeId, setRemovingNodeId] = useState(\"\");\n  const browserPollCursorRef = useRef(0);\n  const browserCheckInFlightNodeIdRef = useRef(\"\");\n\n  const handleCopyText = async (\n    text,\n    {\n      successMessage = \"Connection command copied\",\n      errorMessage = \"Could not copy connection command\",\n    } = {},\n  ) => {\n    const copied = await copyTextToClipboard(text);\n    if (copied) {\n      showToast(successMessage, \"success\");\n      return;\n    }\n    showToast(errorMessage, \"error\");\n  };\n\n  const handleCheckNodeBrowser = useCallback(\n    async (nodeId, { silent = false } = {}) => {\n      const normalizedNodeId = String(nodeId || \"\").trim();\n      if (!normalizedNodeId || browserCheckInFlightNodeIdRef.current) return;\n      browserCheckInFlightNodeIdRef.current = normalizedNodeId;\n      if (!silent) {\n        setCheckingBrowserNodeId(normalizedNodeId);\n      }\n      setBrowserErrorByNodeId((prev) => ({\n        ...prev,\n        [normalizedNodeId]: \"\",\n      }));\n      try {\n        const result = await withTimeout(\n          fetchNodeBrowserStatusForNode(normalizedNodeId, \"user\"),\n        );\n        const status =\n          result?.status && typeof result.status === \"object\"\n            ? result.status\n            : null;\n        setBrowserStatusByNodeId((prev) => ({\n          ...prev,\n          [normalizedNodeId]: status,\n        }));\n      } catch (error) {\n        const message = normalizeBrowserStatusError(error);\n        // Stop poll loops after failures so we do not keep retrying a stale browser session.\n        setBrowserStatusByNodeId((prev) => ({\n          ...prev,\n          [normalizedNodeId]: null,\n        }));\n        setBrowserErrorByNodeId((prev) => ({\n          ...prev,\n          [normalizedNodeId]: message,\n        }));\n        if (!silent) {\n          showToast(message, \"error\");\n        }\n      } finally {\n        browserCheckInFlightNodeIdRef.current = \"\";\n        if (!silent) {\n          setCheckingBrowserNodeId(\"\");\n        }\n      }\n    },\n    [],\n  );\n\n  const setBrowserAttachStateForNode = useCallback((nodeId, enabled) => {\n    const normalizedNodeId = String(nodeId || \"\").trim();\n    if (!normalizedNodeId) return;\n    setBrowserAttachStateByNodeId((prevState) => {\n      const nextState = {\n        ...(prevState && typeof prevState === \"object\" ? prevState : {}),\n        [normalizedNodeId]: enabled === true,\n      };\n      writeBrowserAttachStateByNode(nextState);\n      return nextState;\n    });\n  }, []);\n\n  const handleAttachNodeBrowser = useCallback(\n    async (nodeId) => {\n      const normalizedNodeId = String(nodeId || \"\").trim();\n      if (!normalizedNodeId) return;\n      setBrowserAttachStateForNode(normalizedNodeId, true);\n      await handleCheckNodeBrowser(normalizedNodeId);\n    },\n    [handleCheckNodeBrowser, setBrowserAttachStateForNode],\n  );\n\n  const handleDetachNodeBrowser = useCallback(\n    (nodeId) => {\n      const normalizedNodeId = String(nodeId || \"\").trim();\n      if (!normalizedNodeId) return;\n      setBrowserAttachStateForNode(normalizedNodeId, false);\n      setBrowserStatusByNodeId((prevState) => {\n        const nextState = { ...(prevState || {}) };\n        delete nextState[normalizedNodeId];\n        return nextState;\n      });\n      setBrowserErrorByNodeId((prevState) => {\n        const nextState = { ...(prevState || {}) };\n        delete nextState[normalizedNodeId];\n        return nextState;\n      });\n    },\n    [setBrowserAttachStateForNode],\n  );\n\n  const handleOpenNodeMenu = useCallback((nodeId) => {\n    const normalizedNodeId = String(nodeId || \"\").trim();\n    if (!normalizedNodeId) return;\n    setMenuOpenNodeId((currentNodeId) =>\n      currentNodeId === normalizedNodeId ? \"\" : normalizedNodeId,\n    );\n  }, []);\n\n  const handleRemoveNode = useCallback(async () => {\n    const nodeId = String(removeDialogNode?.nodeId || \"\").trim();\n    if (!nodeId || removingNodeId) return;\n    setRemovingNodeId(nodeId);\n    try {\n      await removeNode(nodeId);\n      // Removing a device should also clear local browser-attach state for that node.\n      handleDetachNodeBrowser(nodeId);\n      showToast(\"Device removed\", \"success\");\n      setRemoveDialogNode(null);\n      setMenuOpenNodeId(\"\");\n      await onRefreshNodes();\n    } catch (removeError) {\n      showToast(removeError.message || \"Could not remove node\", \"error\");\n    } finally {\n      setRemovingNodeId(\"\");\n    }\n  }, [\n    handleDetachNodeBrowser,\n    onRefreshNodes,\n    removeDialogNode,\n    removingNodeId,\n  ]);\n\n  useEffect(() => {\n    if (checkingBrowserNodeId) return;\n    const pendingInitialNodeId = nodes\n      .map((node) => ({\n        nodeId: String(node?.nodeId || \"\").trim(),\n        connected: node?.connected === true,\n        browserCapable: isBrowserCapableNode(node),\n      }))\n      .find((entry) => {\n        if (!entry.nodeId || !entry.connected || !entry.browserCapable) return false;\n        if (browserAttachStateByNodeId?.[entry.nodeId] !== true) return false;\n        if (browserStatusByNodeId?.[entry.nodeId]) return false;\n        if (browserErrorByNodeId?.[entry.nodeId]) return false;\n        return true;\n      })?.nodeId;\n    if (!pendingInitialNodeId) return;\n    handleCheckNodeBrowser(pendingInitialNodeId, { silent: true });\n  }, [\n    browserAttachStateByNodeId,\n    browserErrorByNodeId,\n    browserStatusByNodeId,\n    checkingBrowserNodeId,\n    handleCheckNodeBrowser,\n    nodes,\n  ]);\n\n  useEffect(() => {\n    if (checkingBrowserNodeId) return;\n    const pollableNodeIds = nodes\n      .map((node) => ({\n        nodeId: String(node?.nodeId || \"\").trim(),\n        connected: node?.connected === true,\n        browserCapable: isBrowserCapableNode(node),\n        browserRunning:\n          browserStatusByNodeId?.[String(node?.nodeId || \"\").trim()]?.running ===\n          true,\n      }))\n      .filter(\n        (entry) =>\n          entry.nodeId &&\n          entry.connected &&\n          entry.browserCapable &&\n          browserAttachStateByNodeId?.[entry.nodeId] === true &&\n          entry.browserRunning,\n      )\n      .map((entry) => entry.nodeId);\n    if (!pollableNodeIds.length) return;\n\n    let active = true;\n    const poll = async () => {\n      if (!active || browserCheckInFlightNodeIdRef.current) return;\n      const pollIndex = browserPollCursorRef.current % pollableNodeIds.length;\n      browserPollCursorRef.current += 1;\n      const nextNodeId = pollableNodeIds[pollIndex];\n      await handleCheckNodeBrowser(nextNodeId, { silent: true });\n    };\n    const timer = setInterval(poll, kBrowserPollIntervalMs);\n    return () => {\n      active = false;\n      clearInterval(timer);\n    };\n  }, [\n    browserAttachStateByNodeId,\n    browserStatusByNodeId,\n    checkingBrowserNodeId,\n    handleCheckNodeBrowser,\n    nodes,\n  ]);\n\n  return {\n    browserStatusByNodeId,\n    browserErrorByNodeId,\n    checkingBrowserNodeId,\n    browserAttachStateByNodeId,\n    menuOpenNodeId,\n    removeDialogNode,\n    removingNodeId,\n    handleCopyText,\n    handleCheckNodeBrowser,\n    handleAttachNodeBrowser,\n    handleDetachNodeBrowser,\n    handleOpenNodeMenu,\n    handleRemoveNode,\n    setMenuOpenNodeId,\n    setRemoveDialogNode,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js",
    "content": "import { usePolling } from \"../../../hooks/usePolling.js\";\nimport { fetchNodesStatus } from \"../../../lib/api.js\";\n\nconst kNodesPollIntervalMs = 10000;\n\nexport const useConnectedNodes = ({ enabled = true } = {}) => {\n  const poll = usePolling(\n    async () => {\n      const result = await fetchNodesStatus();\n      const nodes = Array.isArray(result?.nodes) ? result.nodes : [];\n      const pending = Array.isArray(result?.pending) ? result.pending : [];\n      return { nodes, pending };\n    },\n    kNodesPollIntervalMs,\n    { enabled, cacheKey: \"/api/nodes\", dedupeInFlight: true },\n  );\n\n  return {\n    nodes: Array.isArray(poll.data?.nodes) ? poll.data.nodes : [],\n    pending: Array.isArray(poll.data?.pending) ? poll.data.pending : [],\n    loading: poll.data === null && !poll.error,\n    error: poll.error ? String(poll.error.message || \"Could not load nodes\") : \"\",\n    refresh: poll.refresh,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/exec-allowlist/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { useExecAllowlist } from \"./use-exec-allowlist.js\";\n\nconst html = htm.bind(h);\n\nexport const NodeExecAllowlistCard = () => {\n  const state = useExecAllowlist();\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <div class=\"space-y-1\">\n          <h3 class=\"font-semibold text-sm\">Gateway Exec Allowlist</h3>\n          <p class=\"text-xs text-fg-muted\">\n            Patterns here are used when <code>tools.exec.security</code> is set to\n            <code>allowlist</code>.\n          </p>\n        </div>\n        <${ActionButton}\n          onClick=${state.refresh}\n          idleLabel=\"Reload\"\n          tone=\"secondary\"\n          size=\"sm\"\n          disabled=${state.loading}\n        />\n      </div>\n\n      ${state.error ? html`<div class=\"text-xs text-status-error-muted\">${state.error}</div>` : null}\n\n      <div class=\"flex items-center gap-2\">\n        <input\n          type=\"text\"\n          value=${state.patternInput}\n          oninput=${(event) => state.setPatternInput(event.target.value)}\n          placeholder=\"/usr/bin/sw_vers\"\n          class=\"flex-1 min-w-0 bg-field border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-fg-muted focus:outline-none\"\n          disabled=${state.loading || state.saving}\n        />\n        <${ActionButton}\n          onClick=${state.addPattern}\n          loading=${state.saving}\n          idleLabel=\"Add Pattern\"\n          loadingLabel=\"Adding...\"\n          tone=\"primary\"\n          size=\"sm\"\n          disabled=${!String(state.patternInput || \"\").trim()}\n        />\n      </div>\n\n      <div class=\"text-[11px] text-fg-muted\">\n        Supports wildcard patterns like <code>*</code>, <code>**</code>, and\n        exact executable paths.\n      </div>\n\n      ${state.loading\n        ? html`<div class=\"text-xs text-fg-muted\">Loading allowlist...</div>`\n        : !state.allowlist.length\n          ? html`<div class=\"text-xs text-fg-muted\">No allowlist patterns configured.</div>`\n          : html`\n              <div class=\"space-y-2\">\n                ${state.allowlist.map(\n                  (entry) => html`\n                    <div class=\"ac-surface-inset rounded-lg px-3 py-2 flex items-center justify-between gap-2\">\n                      <div class=\"min-w-0\">\n                        <div class=\"text-xs font-mono text-body truncate\">\n                          ${entry?.pattern || \"\"}\n                        </div>\n                        <div class=\"text-[11px] text-fg-muted font-mono truncate\">\n                          ${entry?.id || \"\"}\n                        </div>\n                      </div>\n                      <${ActionButton}\n                        onClick=${() => state.removePattern(entry?.id)}\n                        loading=${state.removingId === String(entry?.id || \"\")}\n                        idleLabel=\"Remove\"\n                        loadingLabel=\"Removing...\"\n                        tone=\"danger\"\n                        size=\"sm\"\n                      />\n                    </div>\n                  `,\n                )}\n              </div>\n            `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js",
    "content": "import { useCallback, useEffect, useState } from \"preact/hooks\";\nimport {\n  addNodeExecAllowlistPattern,\n  fetchNodeExecApprovals,\n  removeNodeExecAllowlistPattern,\n} from \"../../../lib/api.js\";\nimport { showToast } from \"../../toast.js\";\n\nexport const useExecAllowlist = () => {\n  const [allowlist, setAllowlist] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(\"\");\n  const [patternInput, setPatternInput] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [removingId, setRemovingId] = useState(\"\");\n\n  const refresh = useCallback(async () => {\n    setLoading(true);\n    setError(\"\");\n    try {\n      const result = await fetchNodeExecApprovals();\n      const nextAllowlist = Array.isArray(result?.allowlist) ? result.allowlist : [];\n      setAllowlist(nextAllowlist);\n    } catch (err) {\n      setError(err.message || \"Could not load allowlist\");\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    refresh();\n  }, [refresh]);\n\n  const addPattern = useCallback(async () => {\n    const nextPattern = String(patternInput || \"\").trim();\n    if (!nextPattern || saving) return;\n    setSaving(true);\n    try {\n      await addNodeExecAllowlistPattern(nextPattern);\n      setPatternInput(\"\");\n      showToast(\"Allowlist pattern added\", \"success\");\n      await refresh();\n    } catch (err) {\n      showToast(err.message || \"Could not add allowlist pattern\", \"error\");\n    } finally {\n      setSaving(false);\n    }\n  }, [patternInput, refresh, saving]);\n\n  const removePattern = useCallback(async (entryId) => {\n    const id = String(entryId || \"\").trim();\n    if (!id || removingId) return;\n    setRemovingId(id);\n    try {\n      await removeNodeExecAllowlistPattern(id);\n      showToast(\"Allowlist pattern removed\", \"success\");\n      await refresh();\n    } catch (err) {\n      showToast(err.message || \"Could not remove allowlist pattern\", \"error\");\n    } finally {\n      setRemovingId(\"\");\n    }\n  }, [refresh, removingId]);\n\n  return {\n    allowlist,\n    loading,\n    error,\n    patternInput,\n    saving,\n    removingId,\n    setPatternInput,\n    refresh,\n    addPattern,\n    removePattern,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/exec-config/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { useExecConfig } from \"./use-exec-config.js\";\n\nconst html = htm.bind(h);\n\nexport const NodeExecConfigCard = ({\n  nodes = [],\n  onRestartRequired = () => {},\n}) => {\n  const state = useExecConfig({ onRestartRequired });\n\n  const availableNodeOptions = nodes\n    .filter((node) => String(node?.nodeId || \"\").trim())\n    .map((node) => ({\n      value: String(node.nodeId).trim(),\n      label: String(node?.displayName || node.nodeId).trim(),\n    }));\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-2\">\n        <div class=\"space-y-1\">\n          <h3 class=\"font-semibold text-sm\">Exec Routing</h3>\n          <p class=\"text-xs text-fg-muted\">\n            Set where command execution runs and how strict approval policy should be.\n          </p>\n        </div>\n        <${ActionButton}\n          onClick=${state.refresh}\n          idleLabel=\"Reload\"\n          tone=\"secondary\"\n          size=\"sm\"\n          disabled=${state.loading}\n        />\n      </div>\n\n      ${state.error ? html`<div class=\"text-xs text-status-error-muted\">${state.error}</div>` : null}\n\n      <div class=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n        <label class=\"space-y-1\">\n          <div class=\"text-xs text-fg-muted\">Host</div>\n          <select\n            value=${state.config.host}\n            disabled=${state.loading || state.saving}\n            oninput=${(event) => state.updateField(\"host\", event.target.value)}\n            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 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            <option value=\"gateway\">gateway</option>\n            <option value=\"node\">node</option>\n          </select>\n        </label>\n\n        <label class=\"space-y-1\">\n          <div class=\"text-xs text-fg-muted\">Security</div>\n          <select\n            value=${state.config.security}\n            disabled=${state.loading || state.saving}\n            oninput=${(event) => state.updateField(\"security\", event.target.value)}\n            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 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            <option value=\"deny\">deny</option>\n            <option value=\"allowlist\">allowlist</option>\n            <option value=\"full\">full</option>\n          </select>\n        </label>\n\n        <label class=\"space-y-1\">\n          <div class=\"text-xs text-fg-muted\">Ask</div>\n          <select\n            value=${state.config.ask}\n            disabled=${state.loading || state.saving}\n            oninput=${(event) => state.updateField(\"ask\", event.target.value)}\n            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 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            <option value=\"off\">off</option>\n            <option value=\"on-miss\">on-miss</option>\n            <option value=\"always\">always</option>\n          </select>\n        </label>\n\n        <label class=\"space-y-1\">\n          <div class=\"text-xs text-fg-muted\">Node target</div>\n          <select\n            value=${state.config.node}\n            disabled=${state.loading || state.saving || state.config.host !== \"node\"}\n            oninput=${(event) => state.updateField(\"node\", event.target.value)}\n            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 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            <option value=\"\">${availableNodeOptions.length ? \"Select node...\" : \"No nodes available\"}</option>\n            ${availableNodeOptions.map(\n              (option) => html`\n                <option value=${option.value}>${option.label}</option>\n              `,\n            )}\n          </select>\n        </label>\n      </div>\n\n      <div class=\"rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-status-warning\">\n        Save applies config immediately, but gateway restart may still be required by OpenClaw.\n      </div>\n\n      <div class=\"flex justify-end\">\n        <${ActionButton}\n          onClick=${state.save}\n          loading=${state.saving}\n          idleLabel=\"Save Exec Config\"\n          loadingLabel=\"Saving...\"\n          tone=\"primary\"\n          size=\"sm\"\n          disabled=${state.loading || (state.config.host === \"node\" && !state.config.node)}\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/exec-config/use-exec-config.js",
    "content": "import { useCallback, useEffect, useState } from \"preact/hooks\";\nimport { fetchNodeExecConfig, saveNodeExecConfig } from \"../../../lib/api.js\";\nimport { showToast } from \"../../toast.js\";\n\nconst kDefaultExecConfig = {\n  host: \"gateway\",\n  security: \"allowlist\",\n  ask: \"on-miss\",\n  node: \"\",\n};\n\nexport const useExecConfig = ({ onRestartRequired = () => {} } = {}) => {\n  const [config, setConfig] = useState(kDefaultExecConfig);\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [error, setError] = useState(\"\");\n\n  const refresh = useCallback(async () => {\n    setLoading(true);\n    setError(\"\");\n    try {\n      const result = await fetchNodeExecConfig();\n      const nextConfig = {\n        ...kDefaultExecConfig,\n        ...(result?.config || {}),\n      };\n      setConfig(nextConfig);\n    } catch (err) {\n      setError(err.message || \"Could not load exec settings\");\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    refresh();\n  }, [refresh]);\n\n  const updateField = useCallback((field, value) => {\n    setConfig((prev) => {\n      const next = { ...prev, [field]: value };\n      if (field === \"host\" && value !== \"node\") {\n        next.node = \"\";\n      }\n      return next;\n    });\n  }, []);\n\n  const save = useCallback(async () => {\n    if (saving) return false;\n    setSaving(true);\n    setError(\"\");\n    try {\n      const result = await saveNodeExecConfig(config);\n      if (result?.restartRequired) {\n        onRestartRequired(true);\n      }\n      showToast(\"Node exec config saved\", \"success\");\n      return true;\n    } catch (err) {\n      const message = err.message || \"Could not save exec settings\";\n      setError(message);\n      showToast(message, \"error\");\n      return false;\n    } finally {\n      setSaving(false);\n    }\n  }, [config, onRestartRequired, saving]);\n\n  return {\n    config,\n    loading,\n    saving,\n    error,\n    refresh,\n    updateField,\n    save,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { PageHeader } from \"../page-header.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { useNodesTab } from \"./use-nodes-tab.js\";\nimport { ConnectedNodesCard } from \"./connected-nodes/index.js\";\nimport { BrowserAttachCard } from \"./browser-attach/index.js\";\nimport { NodesSetupWizard } from \"./setup-wizard/index.js\";\n\nconst html = htm.bind(h);\n\nexport const NodesTab = ({ onRestartRequired = () => {} }) => {\n  const { state, actions } = useNodesTab();\n\n  return html`\n    <div class=\"space-y-4\">\n      <${PageHeader}\n        title=\"Nodes\"\n        actions=${html`\n          <${ActionButton}\n            onClick=${actions.refreshNodes}\n            loading=${state.refreshingNodes}\n            loadingMode=\"inline\"\n            idleLabel=\"Refresh\"\n            tone=\"secondary\"\n            size=\"sm\"\n          />\n          <${ActionButton}\n            onClick=${actions.openWizard}\n            idleLabel=\"Connect Node\"\n            tone=\"primary\"\n            size=\"sm\"\n          />\n        `}\n      />\n\n      <${ConnectedNodesCard}\n        nodes=${state.nodes}\n        pending=${state.pending}\n        loading=${state.loadingNodes}\n        error=${state.nodesError}\n        connectInfo=${state.connectInfo}\n        onRefreshNodes=${actions.refreshNodes}\n      />\n\n      <${BrowserAttachCard} />\n\n      <${NodesSetupWizard}\n        visible=${state.wizardVisible}\n        nodes=${state.nodes}\n        refreshNodes=${actions.refreshNodes}\n        onRestartRequired=${onRestartRequired}\n        onClose=${actions.closeWizard}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/setup-wizard/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ModalShell } from \"../../modal-shell.js\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { CloseIcon, FileCopyLineIcon } from \"../../icons.js\";\nimport { DevicePairings } from \"../../device-pairings.js\";\nimport { copyTextToClipboard } from \"../../../lib/clipboard.js\";\nimport { showToast } from \"../../toast.js\";\nimport { useSetupWizard } from \"./use-setup-wizard.js\";\n\nconst html = htm.bind(h);\n\nconst kWizardSteps = [\"Install OpenClaw CLI\", \"Connect Node\"];\n\nconst renderCommandBlock = ({ command = \"\", onCopy = () => {} }) => html`\n  <div class=\"rounded-lg border border-border bg-field p-3\">\n    <pre\n      class=\"pt-1 pl-2 text-[11px] leading-5 whitespace-pre-wrap break-all font-mono text-body\"\n    >\n${command}</pre\n    >\n    <div class=\"pt-3\">\n      <button\n        type=\"button\"\n        onclick=${onCopy}\n        class=\"text-xs px-2 py-1 rounded-lg ac-btn-ghost inline-flex items-center gap-1.5\"\n      >\n        <${FileCopyLineIcon} className=\"w-3.5 h-3.5\" />\n        Copy\n      </button>\n    </div>\n  </div>\n`;\n\nconst copyAndToast = async (value, label = \"text\") => {\n  const copied = await copyTextToClipboard(value);\n  if (copied) {\n    showToast(\"Copied to clipboard\", \"success\");\n    return;\n  }\n  showToast(`Could not copy ${label}`, \"error\");\n};\n\nexport const NodesSetupWizard = ({\n  visible = false,\n  nodes = [],\n  refreshNodes = async () => {},\n  onRestartRequired = () => {},\n  onClose = () => {},\n}) => {\n  const state = useSetupWizard({\n    visible,\n    nodes,\n    refreshNodes,\n    onRestartRequired,\n    onClose,\n  });\n  const isFinalStep = state.step === kWizardSteps.length - 1;\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      closeOnOverlayClick=${false}\n      closeOnEscape=${false}\n      panelClassName=\"relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4\"\n    >\n      <button\n        type=\"button\"\n        onclick=${onClose}\n        class=\"absolute top-6 right-6 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n        aria-label=\"Close modal\"\n      >\n        <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n      </button>\n\n      <div class=\"text-xs text-fg-muted\">Node Setup Wizard</div>\n      <div class=\"flex items-center gap-1\">\n        ${kWizardSteps.map(\n          (_label, idx) => html`\n            <div\n              class=${`h-1 flex-1 rounded-full transition-colors ${idx <= state.step ? \"bg-accent\" : \"bg-border\"}`}\n              style=${idx <= state.step ? \"background: var(--accent)\" : \"\"}\n            ></div>\n          `,\n        )}\n      </div>\n      <h3 class=\"font-semibold text-base\">\n        Step ${state.step + 1} of ${kWizardSteps.length}: ${kWizardSteps[state.step]}\n      </h3>\n\n      ${\n        state.step === 0\n          ? html`\n              <div class=\"text-xs text-fg-muted\">\n                Install OpenClaw on the machine you want to connect as a node.\n              </div>\n              ${renderCommandBlock({\n                command: \"npm install -g openclaw\",\n                onCopy: () =>\n                  copyAndToast(\"npm install -g openclaw\", \"command\"),\n              })}\n              <div class=\"text-xs text-fg-muted\">Requires Node.js 22+.</div>\n            `\n          : null\n      }\n\n      ${\n        state.step === 1\n          ? html`\n              <div class=\"space-y-2\">\n                <label class=\"space-y-1 block\">\n                  <div class=\"text-xs text-fg-muted\">Display name</div>\n                  <input\n                    type=\"text\"\n                    value=${state.displayName}\n                    oninput=${(event) =>\n                      state.setDisplayName(event.target.value)}\n                    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\"\n                  />\n                </label>\n\n                <div>\n                  <div class=\"text-xs text-fg-muted mb-1\">\n                    Run this on the device you want to connect:\n                  </div>\n                  ${state.loadingConnectInfo\n                    ? html`<div class=\"text-xs text-fg-muted\">\n                        Loading command...\n                      </div>`\n                    : renderCommandBlock({\n                        command:\n                          state.connectCommand ||\n                          \"Could not build connect command.\",\n                        onCopy: () =>\n                          copyAndToast(state.connectCommand || \"\", \"command\"),\n                      })}\n                </div>\n                ${state.devicePending.length\n                  ? html`\n                      <${DevicePairings}\n                        pending=${state.devicePending}\n                        onApprove=${state.handleDeviceApprove}\n                        onReject=${state.handleDeviceReject}\n                      />\n                    `\n                  : state.selectedPairedNode &&\n                      !state.selectedPairedNode.connected\n                    ? html`\n                        <div\n                          class=\"rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-status-warning\"\n                        >\n                          Node is paired but currently disconnected. Run the\n                          node command again on your device, then Finish will\n                          enable.\n                        </div>\n                      `\n                    : html`\n                        <div\n                          class=\"rounded-lg border border-border bg-field px-3 py-2 text-xs text-fg-muted\"\n                        >\n                          Pairing request will show up here. Checks every 3s.\n                        </div>\n                      `}\n              </div>\n            `\n          : null\n      }\n\n      <div class=\"grid grid-cols-2 gap-2 pt-2\">\n        ${\n          state.step === 0\n            ? html`<div></div>`\n            : html`\n                <${ActionButton}\n                  onClick=${() => state.setStep(Math.max(0, state.step - 1))}\n                  idleLabel=\"Back\"\n                  tone=\"secondary\"\n                  size=\"md\"\n                  className=\"w-full justify-center\"\n                />\n              `\n        }\n        ${\n          isFinalStep\n            ? html`\n                <${ActionButton}\n                  onClick=${async () => {\n                    const ok = await state.applyGatewayNodeRouting();\n                    if (!ok) return;\n                    state.completeWizard();\n                  Promise.resolve(refreshNodes()).catch(() => {});\n                  }}\n                  loading=${state.configuring}\n                  idleLabel=${state.canFinish ? \"Finish\" : \"Awaiting pairing\"}\n                  loadingLabel=\"Finishing...\"\n                  tone=\"primary\"\n                  size=\"md\"\n                  className=\"w-full justify-center\"\n                  disabled=${!state.canFinish}\n                />\n              `\n            : html`\n                <${ActionButton}\n                  onClick=${() =>\n                    state.setStep(\n                      Math.min(kWizardSteps.length - 1, state.step + 1),\n                    )}\n                  idleLabel=\"Next\"\n                  tone=\"primary\"\n                  size=\"md\"\n                  className=\"w-full justify-center\"\n                />\n              `\n        }\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport {\n  approveDevice,\n  fetchDevicePairings,\n  fetchNodeConnectInfo,\n  rejectDevice,\n  routeExecToNode,\n} from \"../../../lib/api.js\";\nimport { showToast } from \"../../toast.js\";\n\nconst kNodeDiscoveryPollIntervalMs = 3000;\n\nexport const useSetupWizard = ({\n  visible = false,\n  nodes = [],\n  refreshNodes = async () => {},\n  onRestartRequired = () => {},\n  onClose = () => {},\n} = {}) => {\n  const [step, setStep] = useState(0);\n  const [connectInfo, setConnectInfo] = useState(null);\n  const [loadingConnectInfo, setLoadingConnectInfo] = useState(false);\n  const [displayName, setDisplayName] = useState(\"My Mac Node\");\n  const [selectedNodeId, setSelectedNodeId] = useState(\"\");\n  const [configuring, setConfiguring] = useState(false);\n  const [devicePending, setDevicePending] = useState([]);\n  const [approvedInSession, setApprovedInSession] = useState(false);\n  const refreshInFlightRef = useRef(false);\n\n  useEffect(() => {\n    if (!visible) return;\n    setStep(0);\n    setSelectedNodeId(\"\");\n    setConfiguring(false);\n    setApprovedInSession(false);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible) return;\n    setLoadingConnectInfo(true);\n    fetchNodeConnectInfo()\n      .then((result) => {\n        setConnectInfo(result || null);\n      })\n      .catch((err) => {\n        showToast(err.message || \"Could not load node connect command\", \"error\");\n      })\n      .finally(() => {\n        setLoadingConnectInfo(false);\n      });\n  }, [visible]);\n\n  const pairedNodes = useMemo(() => {\n    const seen = new Set();\n    const unique = [];\n    for (const entry of nodes) {\n      const nodeId = String(entry?.nodeId || \"\").trim();\n      if (!nodeId || seen.has(nodeId)) continue;\n      if (entry?.paired === false) continue;\n      seen.add(nodeId);\n      unique.push({\n        nodeId,\n        displayName: String(entry?.displayName || entry?.name || nodeId),\n        connected: entry?.connected === true,\n      });\n    }\n    return unique;\n  }, [nodes]);\n\n  const selectedPairedNode = useMemo(\n    () =>\n      pairedNodes.find(\n        (entry) => entry.nodeId === String(selectedNodeId || \"\").trim(),\n      ) || null,\n    [pairedNodes, selectedNodeId],\n  );\n\n  const connectCommand = useMemo(() => {\n    if (!connectInfo) return \"\";\n    const host = String(connectInfo.gatewayHost || \"\").trim() || \"localhost\";\n    const port = Number(connectInfo.gatewayPort) || 3000;\n    const token = String(connectInfo.gatewayToken || \"\").trim();\n    const tls = connectInfo.tls === true ? \" --tls\" : \"\";\n    const escapedDisplayName = String(displayName || \"\")\n      .trim()\n      .replace(/\"/g, '\\\\\"');\n    return [\n      token ? `OPENCLAW_GATEWAY_TOKEN=${token}` : \"\",\n      \"openclaw node run\",\n      `--host ${host}`,\n      `--port ${port}`,\n      tls.trim(),\n      escapedDisplayName ? `--display-name \"${escapedDisplayName}\"` : \"\",\n    ]\n      .filter(Boolean)\n      .join(\" \");\n  }, [connectInfo, displayName]);\n\n  const refreshNodeList = useCallback(async () => {\n    if (refreshInFlightRef.current) return;\n    refreshInFlightRef.current = true;\n    try {\n      await refreshNodes();\n      const deviceData = await fetchDevicePairings();\n      const pendingList = Array.isArray(deviceData?.pending)\n        ? deviceData.pending\n        : [];\n      setDevicePending(pendingList);\n    } finally {\n      refreshInFlightRef.current = false;\n    }\n  }, [refreshNodes]);\n\n  useEffect(() => {\n    if (!visible || step !== 1) return;\n    let active = true;\n    const poll = async () => {\n      if (!active) return;\n      try {\n        await refreshNodeList();\n      } catch {}\n    };\n    poll();\n    const timer = setInterval(poll, kNodeDiscoveryPollIntervalMs);\n    return () => {\n      active = false;\n      clearInterval(timer);\n    };\n  }, [refreshNodeList, step, visible]);\n\n  useEffect(() => {\n    if (!visible || step !== 1) return;\n    const hasSelected = pairedNodes.some(\n      (entry) => entry.nodeId === String(selectedNodeId || \"\").trim(),\n    );\n    const normalizedDisplayName = String(displayName || \"\").trim().toLowerCase();\n    const preferredNode =\n      pairedNodes.find(\n        (entry) =>\n          String(entry?.displayName || \"\")\n            .trim()\n            .toLowerCase() === normalizedDisplayName,\n      ) || pairedNodes[0];\n    if (!preferredNode) return;\n    if (hasSelected && String(selectedNodeId || \"\").trim() === preferredNode.nodeId) return;\n    setSelectedNodeId(preferredNode.nodeId);\n  }, [displayName, pairedNodes, selectedNodeId, step, visible]);\n\n  const handleDeviceApprove = useCallback(async (requestId) => {\n    try {\n      await approveDevice(requestId);\n      showToast(\"Pairing approved\", \"success\");\n      setApprovedInSession(true);\n      await refreshNodeList();\n    } catch (err) {\n      showToast(err.message || \"Could not approve pairing\", \"error\");\n    }\n  }, [refreshNodeList]);\n\n  const handleDeviceReject = useCallback(async (requestId) => {\n    try {\n      await rejectDevice(requestId);\n      showToast(\"Pairing rejected\", \"info\");\n      await refreshNodeList();\n    } catch (err) {\n      showToast(err.message || \"Could not reject pairing\", \"error\");\n    }\n  }, [refreshNodeList]);\n\n  const applyGatewayNodeRouting = useCallback(async () => {\n    const nodeId = String(selectedNodeId || \"\").trim();\n    if (!nodeId || configuring) return false;\n    setConfiguring(true);\n    try {\n      await routeExecToNode(nodeId);\n      onRestartRequired(true);\n      showToast(\"Gateway routing now points to the selected node\", \"success\");\n      return true;\n    } catch (err) {\n      showToast(err.message || \"Could not configure gateway node routing\", \"error\");\n      return false;\n    } finally {\n      setConfiguring(false);\n    }\n  }, [configuring, onRestartRequired, selectedNodeId]);\n\n  const completeWizard = useCallback(() => {\n    onClose();\n  }, [onClose]);\n\n  return {\n    step,\n    setStep,\n    connectInfo,\n    loadingConnectInfo,\n    displayName,\n    setDisplayName,\n    selectedNodeId,\n    setSelectedNodeId,\n    pairedNodes,\n    selectedPairedNode,\n    devicePending,\n    approvedInSession,\n    configuring,\n    canFinish: approvedInSession && Boolean(selectedPairedNode?.connected),\n    connectCommand,\n    refreshNodeList,\n    nodeDiscoveryPollIntervalMs: kNodeDiscoveryPollIntervalMs,\n    handleDeviceApprove,\n    handleDeviceReject,\n    applyGatewayNodeRouting,\n    completeWizard,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/nodes-tab/use-nodes-tab.js",
    "content": "import { useCallback, useEffect, useState } from \"preact/hooks\";\nimport { fetchNodeConnectInfo } from \"../../lib/api.js\";\nimport { useCachedFetch } from \"../../hooks/use-cached-fetch.js\";\nimport { showToast } from \"../toast.js\";\nimport { useConnectedNodes } from \"./connected-nodes/user-connected-nodes.js\";\n\nexport const useNodesTab = () => {\n  const connectedNodesState = useConnectedNodes({ enabled: true });\n  const [wizardVisible, setWizardVisible] = useState(false);\n  const [refreshingNodes, setRefreshingNodes] = useState(false);\n  const {\n    data: connectInfo,\n    error: connectInfoError,\n  } = useCachedFetch(\"/api/nodes/connect-info\", fetchNodeConnectInfo, {\n    maxAgeMs: 60000,\n  });\n  const pairedNodes = Array.isArray(connectedNodesState.nodes)\n    ? connectedNodesState.nodes.filter((entry) => entry?.paired !== false)\n    : [];\n\n  useEffect(() => {\n    if (!connectInfoError) return;\n    showToast(\n      connectInfoError.message || \"Could not load node connect command\",\n      \"error\",\n    );\n  }, [connectInfoError]);\n\n  const refreshNodes = useCallback(async () => {\n    if (refreshingNodes) return;\n    setRefreshingNodes(true);\n    try {\n      await connectedNodesState.refresh();\n    } finally {\n      setRefreshingNodes(false);\n    }\n  }, [connectedNodesState.refresh, refreshingNodes]);\n\n  return {\n    state: {\n      wizardVisible,\n      nodes: pairedNodes,\n      pending: connectedNodesState.pending,\n      loadingNodes: connectedNodesState.loading,\n      refreshingNodes,\n      nodesError: connectedNodesState.error,\n      connectInfo,\n    },\n    actions: {\n      openWizard: () => setWizardVisible(true),\n      closeWizard: () => setWizardVisible(false),\n      refreshNodes,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/pairing-utils.js",
    "content": "export const getPreferredPairingChannel = (vals = {}) => {\n  if (vals.TELEGRAM_BOT_TOKEN) return \"telegram\";\n  if (vals.DISCORD_BOT_TOKEN) return \"discord\";\n  if (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN) return \"slack\";\n  return \"\";\n};\n\nexport const isChannelPaired = (channels = {}, channel = \"\") => {\n  if (!channel) return false;\n  const info = channels?.[channel];\n  if (!info) return false;\n  return info.status === \"paired\" && Number(info.paired || 0) > 0;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/use-welcome-codex.js",
    "content": "import { useEffect, useRef, useState } from \"preact/hooks\";\nimport {\n  disconnectCodex,\n  exchangeCodexOAuth,\n  fetchCodexStatus,\n} from \"../../lib/api.js\";\nimport {\n  isCodexAuthCallbackMessage,\n  openCodexAuthWindow,\n} from \"../../lib/codex-oauth-window.js\";\n\nexport const useWelcomeCodex = ({ setFormError } = {}) => {\n  const [codexStatus, setCodexStatus] = useState({ connected: false });\n  const [codexLoading, setCodexLoading] = useState(true);\n  const [codexManualInput, setCodexManualInput] = useState(\"\");\n  const [codexExchanging, setCodexExchanging] = useState(false);\n  const [codexAuthStarted, setCodexAuthStarted] = useState(false);\n  const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);\n  const codexExchangeInFlightRef = useRef(false);\n  const codexPopupPollRef = useRef(null);\n\n  const refreshCodexStatus = async () => {\n    try {\n      const status = await fetchCodexStatus();\n      setCodexStatus(status);\n      if (status?.connected) {\n        setCodexAuthStarted(false);\n        setCodexAuthWaiting(false);\n      }\n    } catch {\n      setCodexStatus({ connected: false });\n    } finally {\n      setCodexLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    refreshCodexStatus();\n  }, []);\n\n  const submitCodexAuthInput = async (input) => {\n    const normalizedInput = String(input || \"\").trim();\n    if (!normalizedInput || codexExchangeInFlightRef.current) return;\n    codexExchangeInFlightRef.current = true;\n    setCodexManualInput(normalizedInput);\n    setCodexExchanging(true);\n    setFormError(null);\n    try {\n      const result = await exchangeCodexOAuth(normalizedInput);\n      if (!result.ok)\n        throw new Error(result.error || \"Codex OAuth exchange failed\");\n      setCodexManualInput(\"\");\n      setCodexAuthStarted(false);\n      setCodexAuthWaiting(false);\n      await refreshCodexStatus();\n    } catch (err) {\n      setCodexAuthWaiting(false);\n      setFormError(err.message || \"Codex OAuth exchange failed\");\n    } finally {\n      codexExchangeInFlightRef.current = false;\n      setCodexExchanging(false);\n    }\n  };\n\n  useEffect(() => {\n    const onMessage = async (e) => {\n      if (e.data?.codex === \"success\") {\n        await refreshCodexStatus();\n      } else if (isCodexAuthCallbackMessage(e.data)) {\n        await submitCodexAuthInput(e.data.input);\n      }\n      if (e.data?.codex === \"error\") {\n        setFormError(`Codex auth failed: ${e.data.message || \"unknown error\"}`);\n      }\n    };\n    window.addEventListener(\"message\", onMessage);\n    return () => window.removeEventListener(\"message\", onMessage);\n  }, [setFormError, submitCodexAuthInput]);\n\n  useEffect(\n    () => () => {\n      if (codexPopupPollRef.current) {\n        clearInterval(codexPopupPollRef.current);\n        codexPopupPollRef.current = null;\n      }\n    },\n    [],\n  );\n\n  const startCodexAuth = () => {\n    if (codexStatus.connected) return;\n    setCodexAuthStarted(true);\n    setCodexAuthWaiting(true);\n    const popup = openCodexAuthWindow();\n    if (!popup || popup.closed) {\n      setCodexAuthWaiting(false);\n      return;\n    }\n    if (codexPopupPollRef.current) {\n      clearInterval(codexPopupPollRef.current);\n    }\n    codexPopupPollRef.current = setInterval(() => {\n      if (popup.closed) {\n        clearInterval(codexPopupPollRef.current);\n        codexPopupPollRef.current = null;\n        setCodexAuthWaiting(false);\n      }\n    }, 500);\n  };\n\n  const completeCodexAuth = async () => {\n    await submitCodexAuthInput(codexManualInput);\n  };\n\n  const handleCodexDisconnect = async () => {\n    const result = await disconnectCodex();\n    if (!result.ok) {\n      setFormError(result.error || \"Failed to disconnect Codex\");\n      return;\n    }\n    setCodexAuthStarted(false);\n    setCodexAuthWaiting(false);\n    setCodexManualInput(\"\");\n    await refreshCodexStatus();\n  };\n\n  return {\n    codexStatus,\n    codexLoading,\n    codexManualInput,\n    setCodexManualInput,\n    codexExchanging,\n    codexAuthStarted,\n    codexAuthWaiting,\n    startCodexAuth,\n    completeCodexAuth,\n    handleCodexDisconnect,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/use-welcome-pairing.js",
    "content": "import { useEffect, useState } from \"preact/hooks\";\nimport { approvePairing, fetchPairings, fetchStatus, rejectPairing } from \"../../lib/api.js\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport { isChannelPaired } from \"./pairing-utils.js\";\n\nexport const useWelcomePairing = ({\n  isPairingStep = false,\n  selectedPairingChannel = \"\",\n} = {}) => {\n  const [pairingError, setPairingError] = useState(null);\n  const [pairingComplete, setPairingComplete] = useState(false);\n\n  const pairingStatusPoll = usePolling(fetchStatus, 3000, {\n    enabled: isPairingStep,\n  });\n  const pairingRequestsPoll = usePolling(\n    async () => {\n      const payload = await fetchPairings();\n      const allPending = payload.pending || [];\n      return allPending.filter((p) => p.channel === selectedPairingChannel);\n    },\n    1000,\n    {\n      enabled: isPairingStep && !!selectedPairingChannel,\n      dedupeInFlight: true,\n    },\n  );\n  const pairingChannels = pairingStatusPoll.data?.channels || {};\n  const canFinishPairing = isChannelPaired(pairingChannels, selectedPairingChannel);\n\n  useEffect(() => {\n    if (isPairingStep && canFinishPairing) {\n      setPairingComplete(true);\n    }\n  }, [isPairingStep, canFinishPairing]);\n\n  const handlePairingApprove = async (id, channel, accountId = \"\") => {\n    try {\n      setPairingError(null);\n      const result = await approvePairing(id, channel, accountId);\n      if (!result.ok) throw new Error(result.error || \"Could not approve pairing\");\n      setPairingComplete(true);\n      pairingRequestsPoll.refresh();\n      pairingStatusPoll.refresh();\n    } catch (err) {\n      setPairingError(err.message || \"Could not approve pairing\");\n    }\n  };\n\n  const handlePairingReject = async (id, channel, accountId = \"\") => {\n    try {\n      setPairingError(null);\n      const result = await rejectPairing(id, channel, accountId);\n      if (!result.ok) throw new Error(result.error || \"Could not reject pairing\");\n      pairingRequestsPoll.refresh();\n    } catch (err) {\n      setPairingError(err.message || \"Could not reject pairing\");\n    }\n  };\n\n  const resetPairingState = () => {\n    setPairingError(null);\n    setPairingComplete(false);\n  };\n\n  return {\n    pairingStatusPoll,\n    pairingRequestsPoll,\n    pairingChannels,\n    canFinishPairing,\n    pairingError,\n    pairingComplete,\n    handlePairingApprove,\n    handlePairingReject,\n    resetPairingState,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/use-welcome-storage.js",
    "content": "import { useEffect, useState } from \"preact/hooks\";\n\nimport { kOnboardingStorageKey } from \"../../lib/storage-keys.js\";\nexport { kOnboardingStorageKey };\nexport const kOnboardingStepKey = \"_step\";\nexport const kPairingChannelKey = \"_pairingChannel\";\nexport const kOnboardingSetupErrorKey = \"_lastSetupError\";\n\nconst loadInitialSetupState = () => {\n  try {\n    return JSON.parse(localStorage.getItem(kOnboardingStorageKey) || \"{}\");\n  } catch {\n    return {};\n  }\n};\n\nexport const useWelcomeStorage = ({\n  kSetupStepIndex,\n  kPairingStepIndex,\n} = {}) => {\n  const [initialSetupState] = useState(loadInitialSetupState);\n  const [vals, setVals] = useState(() => ({ ...initialSetupState }));\n  const [setupError, setSetupError] = useState(null);\n  const initialSetupError = String(\n    initialSetupState?.[kOnboardingSetupErrorKey] || \"\",\n  ).trim();\n  const shouldRecoverFromSetupState = !!initialSetupError;\n  const [step, setStep] = useState(() => {\n    const parsedStep = Number.parseInt(\n      String(initialSetupState?.[kOnboardingStepKey] || \"\"),\n      10,\n    );\n    if (!Number.isFinite(parsedStep)) return -1;\n    const clampedStep = Math.max(-1, Math.min(kPairingStepIndex, parsedStep));\n    if (clampedStep === kSetupStepIndex && shouldRecoverFromSetupState) return 0;\n    return clampedStep;\n  });\n\n  useEffect(() => {\n    localStorage.setItem(\n      kOnboardingStorageKey,\n      JSON.stringify({\n        ...vals,\n        [kOnboardingStepKey]: step,\n        ...(setupError ? { [kOnboardingSetupErrorKey]: setupError } : {}),\n      }),\n    );\n  }, [vals, step, setupError]);\n\n  const setValue = (key, value) => setVals((prev) => ({ ...prev, [key]: value }));\n\n  return {\n    vals,\n    setVals,\n    setValue,\n    step,\n    setStep,\n    setupError,\n    setSetupError,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-config.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { kAllAiAuthFields } from \"../../lib/model-config.js\";\n\nconst html = htm.bind(h);\n\nexport const kRepoModeNew = \"new\";\nexport const kRepoModeExisting = \"existing\";\nexport const kGithubFlowFresh = \"fresh\";\nexport const kGithubFlowImport = \"import\";\nexport const kGithubTargetRepoModeCreate = \"create\";\nexport const kGithubTargetRepoModeExistingEmpty = \"existing-empty\";\n\nconst hasValue = (value) => !!String(value || \"\").trim();\n\nexport const normalizeGithubRepoInput = (repoInput) =>\n  String(repoInput || \"\")\n    .trim()\n    .replace(/^git@github\\.com:/, \"\")\n    .replace(/^https:\\/\\/github\\.com\\//, \"\")\n    .replace(/\\.git$/, \"\");\n\nexport const isValidGithubRepoInput = (repoInput) => {\n  const cleaned = normalizeGithubRepoInput(repoInput);\n  if (!cleaned) return false;\n  const parts = cleaned.split(\"/\").filter(Boolean);\n  return parts.length === 2 && !parts.some((part) => /\\s/.test(part));\n};\n\nconst getGithubGroupError = (vals) => {\n  const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh;\n  if (!hasValue(vals.GITHUB_TOKEN)) {\n    return \"Enter a GitHub personal access token to continue.\";\n  }\n  if (!hasValue(vals.GITHUB_WORKSPACE_REPO)) {\n    return 'Enter the target repo as \"owner/repo\".';\n  }\n  if (!isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) {\n    return 'Target repo must be in \"owner/repo\" format.';\n  }\n  if (githubFlow === kGithubFlowImport) {\n    if (!hasValue(vals._GITHUB_SOURCE_REPO)) {\n      return 'Enter the source repo as \"owner/repo\".';\n    }\n    if (!isValidGithubRepoInput(vals._GITHUB_SOURCE_REPO)) {\n      return 'Source repo must be in \"owner/repo\" format.';\n    }\n  }\n  return \"\";\n};\n\nconst getAiGroupError = (vals, ctx = {}) => {\n  if (!hasValue(vals.MODEL_KEY) || !String(vals.MODEL_KEY).includes(\"/\")) {\n    return \"Choose a model to continue.\";\n  }\n  if (ctx.selectedProvider === \"openai-codex\" && ctx.codexLoading) {\n    return \"Checking Codex OAuth status. Try Next again in a moment.\";\n  }\n  if (!ctx.hasAi) {\n    return ctx.selectedProvider === \"openai-codex\"\n      ? \"Connect Codex OAuth to continue.\"\n      : \"Add credentials for the selected model provider to continue.\";\n  }\n  return \"\";\n};\n\nconst getChannelsGroupError = (vals) => {\n  const hasTelegram = hasValue(vals.TELEGRAM_BOT_TOKEN);\n  const hasDiscord = hasValue(vals.DISCORD_BOT_TOKEN);\n  const hasSlackBot = hasValue(vals.SLACK_BOT_TOKEN);\n  const hasSlackApp = hasValue(vals.SLACK_APP_TOKEN);\n\n  if (hasSlackBot && !hasSlackApp) {\n    return \"Add the Slack app token to continue with Slack.\";\n  }\n  if (!hasSlackBot && hasSlackApp) {\n    return \"Add the Slack bot token to continue with Slack.\";\n  }\n  if (!hasTelegram && !hasDiscord && !(hasSlackBot && hasSlackApp)) {\n    return \"Add at least one channel to continue.\";\n  }\n  return \"\";\n};\n\nexport const getWelcomeGroupError = (groupId, vals, ctx = {}) => {\n  switch (groupId) {\n    case \"github\":\n      return getGithubGroupError(vals);\n    case \"ai\":\n      return getAiGroupError(vals, ctx);\n    case \"channels\":\n      return getChannelsGroupError(vals);\n    default:\n      return \"\";\n  }\n};\n\nexport const kWelcomeGroups = [\n  {\n    id: \"github\",\n    title: \"GitHub\",\n    description: \"Auto-backup your config and workspace\",\n    fields: [\n      {\n        key: \"_GITHUB_SOURCE_REPO\",\n        label: \"Source Repo\",\n        placeholder: \"username/existing-openclaw\",\n        isText: true,\n      },\n      {\n        key: \"GITHUB_WORKSPACE_REPO\",\n        label: \"New Workspace Repo\",\n        placeholder: \"username/my-agent\",\n        isText: true,\n      },\n      {\n        key: \"GITHUB_TOKEN\",\n        label: \"Personal Access Token\",\n        hint: html`Create a${\" \"}<a\n            href=\"https://github.com/settings/tokens\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >classic PAT</a\n          >${\" \"}with${\" \"}<code class=\"text-xs bg-field px-1 rounded\"\n            >repo</code\n          >${\" \"}scope, or a${\" \"}<a\n            href=\"https://github.com/settings/personal-access-tokens/new\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >fine-grained token</a\n          >${\" \"}with Contents + Metadata access`,\n        placeholder: \"ghp_... or github_pat_...\",\n      },\n    ],\n    validate: (vals, ctx = {}) => !getWelcomeGroupError(\"github\", vals, ctx),\n  },\n  {\n    id: \"ai\",\n    title: \"Primary Agent Model\",\n    description: \"Choose your main model and authenticate its provider\",\n    fields: kAllAiAuthFields,\n    validate: (vals, ctx = {}) => !getWelcomeGroupError(\"ai\", vals, ctx),\n  },\n  {\n    id: \"channels\",\n    title: \"Channels\",\n    description: \"At least one is required to talk to your agent\",\n    fields: [\n      {\n        key: \"TELEGRAM_BOT_TOKEN\",\n        label: \"Telegram Bot Token\",\n        hint: html`From${\" \"}<a\n            href=\"https://t.me/BotFather\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >@BotFather</a\n          >${\" \"}·${\" \"}<a\n            href=\"https://docs.openclaw.ai/channels/telegram\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >full guide</a\n          >`,\n        placeholder: \"123456789:AAH...\",\n      },\n      {\n        key: \"DISCORD_BOT_TOKEN\",\n        label: \"Discord Bot Token\",\n        hint: html`From${\" \"}<a\n            href=\"https://discord.com/developers/applications\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >Developer Portal</a\n          >${\" \"}·${\" \"}<a\n            href=\"https://docs.openclaw.ai/channels/discord\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >full guide</a\n          >`,\n        placeholder: \"MTQ3...\",\n      },\n      {\n        key: \"SLACK_BOT_TOKEN\",\n        label: \"Slack Bot Token\",\n        hint: html`From your Slack app's${\" \"}<a\n            href=\"https://api.slack.com/apps\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >OAuth & Permissions</a\n          >${\" \"}page${\" \"}·${\" \"}<a\n            href=\"https://docs.openclaw.ai/channels/slack\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >full guide</a\n          >`,\n        placeholder: \"xoxb-...\",\n      },\n      {\n        key: \"SLACK_APP_TOKEN\",\n        label: \"Slack App Token (Socket Mode)\",\n        hint: html`From${\" \"}<a\n            href=\"https://api.slack.com/apps\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >Basic Information</a\n          >${\" \"}→ App-Level Tokens (needs${\" \"}<code>connections:write</code>${\" \"}scope)`,\n        placeholder: \"xapp-...\",\n      },\n    ],\n    validate: (vals, ctx = {}) => !getWelcomeGroupError(\"channels\", vals, ctx),\n  },\n  {\n    id: \"tools\",\n    title: \"Tools (optional)\",\n    description: \"Enable extra capabilities for your agent\",\n    fields: [\n      {\n        key: \"BRAVE_API_KEY\",\n        label: \"Brave Search API Key\",\n        hint: html`From${\" \"}<a\n            href=\"https://brave.com/search/api/\"\n            target=\"_blank\"\n            class=\"hover:underline\"\n            style=\"color: var(--accent-link)\"\n            >brave.com/search/api</a\n          >${\" \"}-${\" \"}free tier available`,\n        placeholder: \"BSA...\",\n      },\n    ],\n    validate: () => true,\n  },\n];\n\nexport const findFirstInvalidWelcomeGroup = (vals, ctx = {}) =>\n  kWelcomeGroups.find((group) => getWelcomeGroupError(group.id, vals, ctx)) || null;\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-form-step.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { SecretInput } from \"../secret-input.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { Badge } from \"../badge.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport { getChannelMeta } from \"../channels.js\";\nimport {\n  kGithubFlowFresh,\n  kGithubFlowImport,\n  kGithubTargetRepoModeCreate,\n  kGithubTargetRepoModeExistingEmpty,\n} from \"./welcome-config.js\";\n\nconst html = htm.bind(h);\nconst kChannelAccordionDefs = [\n  { id: \"telegram\", title: \"Telegram\", fieldKeys: [\"TELEGRAM_BOT_TOKEN\"] },\n  { id: \"discord\", title: \"Discord\", fieldKeys: [\"DISCORD_BOT_TOKEN\"] },\n  {\n    id: \"slack\",\n    title: \"Slack\",\n    fieldKeys: [\"SLACK_BOT_TOKEN\", \"SLACK_APP_TOKEN\"],\n  },\n];\n\nexport const WelcomeFormStep = ({\n  activeGroup,\n  vals,\n  hasAi,\n  setValue,\n  modelOptions,\n  modelsLoading,\n  modelsError,\n  canToggleFullCatalog,\n  showAllModels,\n  setShowAllModels,\n  selectedProvider,\n  codexLoading,\n  codexStatus,\n  startCodexAuth,\n  handleCodexDisconnect,\n  codexAuthStarted,\n  codexAuthWaiting,\n  codexManualInput,\n  setCodexManualInput,\n  completeCodexAuth,\n  codexExchanging,\n  visibleAiFieldKeys,\n  error,\n  step,\n  totalGroups,\n  goBack,\n  goNext,\n  loading,\n  githubStepLoading,\n  handleSubmit,\n}) => {\n  const [showOptionalOpenai, setShowOptionalOpenai] = useState(false);\n  const [showOptionalGemini, setShowOptionalGemini] = useState(false);\n  const [expandedChannels, setExpandedChannels] = useState(() => new Set([\"telegram\"]));\n  const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh;\n  const freshRepoMode =\n    githubFlow === kGithubFlowImport\n      ? kGithubTargetRepoModeCreate\n      : vals._GITHUB_TARGET_REPO_MODE || kGithubTargetRepoModeCreate;\n  const githubTokenPlaceholder =\n    githubFlow === kGithubFlowImport ||\n    freshRepoMode === kGithubTargetRepoModeExistingEmpty\n      ? \"ghp_... or github_pat_...\"\n      : \"ghp_...\";\n\n  useEffect(() => {\n    if (activeGroup.id !== \"github\") return;\n  }, [activeGroup.id]);\n\n  useEffect(() => {\n    if (step === totalGroups - 1) {\n      setShowOptionalOpenai(!vals.OPENAI_API_KEY);\n      setShowOptionalGemini(!vals.GEMINI_API_KEY);\n    }\n  }, [step === totalGroups - 1]);\n  useEffect(() => {\n    if (activeGroup.id !== \"channels\") return;\n    setExpandedChannels((current) => {\n      if (current.size > 0) return current;\n      return new Set([\"telegram\"]);\n    });\n  }, [activeGroup.id]);\n\n  const renderStandardField = (field) => html`\n    <div class=\"space-y-1\" key=${field.key}>\n      <label class=\"text-xs font-medium text-fg-muted\">${field.label}</label>\n      <${SecretInput}\n        key=${field.key}\n        value=${vals[field.key] || \"\"}\n        onInput=${(e) => setValue(field.key, e.target.value)}\n        placeholder=${activeGroup.id === \"github\" && field.key === \"GITHUB_TOKEN\"\n          ? githubTokenPlaceholder\n          : field.placeholder || \"\"}\n        isSecret=${!field.isText}\n        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\"\n      />\n      <p class=\"text-xs text-fg-dim\">\n        ${activeGroup.id === \"github\" &&\n        field.key === \"GITHUB_WORKSPACE_REPO\"\n          ? githubFlow === kGithubFlowImport\n            ? \"Your new project will live here\"\n            : freshRepoMode === kGithubTargetRepoModeExistingEmpty\n              ? \"Enter the owner/repo of an existing empty repository\"\n              : \"A new private repo will be created for you\"\n          : activeGroup.id === \"github\" && field.key === \"_GITHUB_SOURCE_REPO\"\n            ? \"The repo to import from\"\n            : activeGroup.id === \"github\" && field.key === \"GITHUB_TOKEN\"\n              ? githubFlow === kGithubFlowImport\n                ? freshRepoMode === kGithubTargetRepoModeCreate\n                  ? html`Use a classic PAT with${\" \"}<code\n                        class=\"text-xs bg-field px-1 rounded\"\n                        >repo</code\n                      >${\" \"}scope to create the target repo. Fine-grained\n                      works if the target already exists and can access both\n                      repos.`\n                  : html`Use a classic PAT with${\" \"}<code\n                        class=\"text-xs bg-field px-1 rounded\"\n                        >repo</code\n                      >${\" \"}scope, or a fine-grained token with Contents +\n                      Metadata access to both the source repo and target\n                      repo`\n                : freshRepoMode === kGithubTargetRepoModeExistingEmpty\n                  ? html`Use a classic PAT with${\" \"}<code\n                        class=\"text-xs bg-field px-1 rounded\"\n                        >repo</code\n                      >${\" \"}scope, or a fine-grained token with Contents +\n                      Metadata access to this repo`\n                  : html`Use a classic PAT with${\" \"}<code\n                        class=\"text-xs bg-field px-1 rounded\"\n                        >repo</code\n                      >${\" \"}scope to create a new private repository`\n              : field.hint}\n      </p>\n    </div>\n  `;\n  const fieldLookup = new Map((activeGroup.fields || []).map((field) => [field.key, field]));\n  const toggleChannelSection = (channelId) =>\n    setExpandedChannels((current) => {\n      const next = new Set(current);\n      if (next.has(channelId)) {\n        next.delete(channelId);\n      } else {\n        next.add(channelId);\n      }\n      return next;\n    });\n  const renderChannelAccordion = () =>\n    html`<div class=\"space-y-2\">\n      ${kChannelAccordionDefs.map((section) => {\n        const isExpanded = expandedChannels.has(section.id);\n        const sectionFields = section.fieldKeys\n          .map((fieldKey) => fieldLookup.get(fieldKey))\n          .filter(Boolean);\n        const channelMeta = getChannelMeta(section.id);\n        const hasValue = section.fieldKeys.some((fieldKey) =>\n          String(vals[fieldKey] || \"\").trim(),\n        );\n        return html`\n          <div class=\"bg-field border border-border rounded-lg overflow-hidden\">\n            <button\n              type=\"button\"\n              onclick=${() => toggleChannelSection(section.id)}\n              class=\"w-full flex items-center justify-between gap-2 px-3 py-2 text-left hover:bg-surface\"\n            >\n              <span class=\"inline-flex items-center gap-2 min-w-0\">\n                ${channelMeta.iconSrc\n                  ? html`<img\n                      src=${channelMeta.iconSrc}\n                      alt=\"\"\n                      class=\"w-4 h-4 rounded-sm\"\n                      aria-hidden=\"true\"\n                    />`\n                  : null}\n                <span class=\"text-sm text-body\">${section.title}</span>\n                ${hasValue\n                  ? html`<${Badge} tone=\"success\">Configured</${Badge}>`\n                  : null}\n              </span>\n              <span\n                class=${`ac-history-toggle shrink-0 transition-transform ${isExpanded ? \"rotate-90\" : \"\"}`}\n                aria-hidden=\"true\"\n                >▸</span\n              >\n            </button>\n            ${isExpanded\n              ? html`\n                  <div class=\"px-3 pb-3 pt-2 space-y-2 border-t border-border\">\n                    ${sectionFields.map((field) => renderStandardField(field))}\n                  </div>\n                `\n              : null}\n          </div>\n        `;\n      })}\n    </div>`;\n\n  return html`\n    <div class=\"flex items-center justify-between\">\n      <div>\n        <h2 class=\"text-sm font-medium text-body\">${activeGroup.title}</h2>\n        <p class=\"text-xs text-fg-muted\">${activeGroup.description}</p>\n      </div>\n      ${activeGroup.validate(vals, { hasAi })\n        ? html`<span\n            class=\"text-xs font-medium px-2 py-0.5 rounded-full bg-status-success-bg text-status-success\"\n            >✓</span\n          >`\n        : activeGroup.id !== \"tools\"\n          ? html`<span\n              class=\"text-xs font-medium px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-muted\"\n              >Required</span\n            >`\n          : null}\n    </div>\n\n    ${activeGroup.id === \"ai\" &&\n    html`\n      <div class=\"space-y-1\">\n        <label class=\"text-xs font-medium text-fg-muted\">Model</label>\n        <select\n          value=${vals.MODEL_KEY || \"\"}\n          onInput=${(e) => setValue(\"MODEL_KEY\", e.target.value)}\n          class=\"w-full bg-field border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n        >\n          <option value=\"\">Select a model</option>\n          ${modelOptions.map(\n            (model) => html`\n              <option value=${model.key}>${model.label || model.key}</option>\n            `,\n          )}\n        </select>\n        <p class=\"text-xs text-fg-dim\">\n          ${modelsLoading\n            ? \"Loading model catalog...\"\n            : modelsError\n              ? modelsError\n              : \"\"}\n        </p>\n        ${canToggleFullCatalog &&\n        html`\n          <button\n            type=\"button\"\n            onclick=${() => setShowAllModels((prev) => !prev)}\n            class=\"text-xs text-fg-muted hover:text-body\"\n          >\n            ${showAllModels\n              ? \"Show recommended models\"\n              : \"Show full model catalog\"}\n          </button>\n        `}\n      </div>\n    `}\n    ${activeGroup.id === \"ai\" &&\n    selectedProvider === \"openai-codex\" &&\n    html`\n      <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n        <div class=\"flex items-center justify-between\">\n          <span class=\"text-xs text-fg-muted\">Codex OAuth</span>\n          ${codexLoading\n            ? html`<span class=\"text-xs text-fg-muted\">Checking...</span>`\n            : codexStatus.connected\n              ? html`<${Badge} tone=\"success\">Connected</${Badge}>`\n              : html`<${Badge} tone=\"warning\">Not connected</${Badge}>`}\n        </div>\n        <div class=\"flex gap-2\">\n          <${ActionButton}\n            onClick=${startCodexAuth}\n            tone=${codexStatus.connected || codexAuthStarted\n              ? \"neutral\"\n              : \"primary\"}\n            size=\"sm\"\n            idleLabel=${codexStatus.connected\n              ? \"Reconnect Codex\"\n              : \"Connect Codex OAuth\"}\n            className=\"font-medium\"\n          />\n          ${codexStatus.connected &&\n          html`\n            <${ActionButton}\n              onClick=${handleCodexDisconnect}\n              tone=\"ghost\"\n              size=\"sm\"\n              idleLabel=\"Disconnect\"\n              className=\"font-medium\"\n            />\n          `}\n        </div>\n        ${codexAuthStarted &&\n        html`\n          <div class=\"space-y-1 pt-1\">\n            <p class=\"text-xs text-fg-muted\">\n              ${codexAuthWaiting\n                ? \"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 \"\n                : \"Paste the full redirect URL from the address bar (starts with \"}\n              <code class=\"text-xs bg-field px-1 rounded\"\n                >http://localhost:1455/auth/callback</code\n              >) ${codexAuthWaiting ? \" to finish setup.\" : \" to finish setup.\"}\n            </p>\n            <input\n              type=\"text\"\n              value=${codexManualInput}\n              onInput=${(e) => setCodexManualInput(e.target.value)}\n              placeholder=\"http://localhost:1455/auth/callback?code=...&state=...\"\n              class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted\"\n            />\n            <${ActionButton}\n              onClick=${completeCodexAuth}\n              disabled=${!codexManualInput.trim() || codexExchanging}\n              loading=${codexExchanging}\n              tone=\"primary\"\n              size=\"sm\"\n              idleLabel=\"Complete Codex OAuth\"\n              loadingLabel=\"Completing...\"\n              className=\"font-medium\"\n            />\n          </div>\n        `}\n      </div>\n    `}\n    ${activeGroup.id === \"github\" &&\n    html`\n      <div class=\"space-y-3\">\n        ${githubFlow === kGithubFlowFresh\n          ? html`\n              <div class=\"space-y-1\">\n                <${SegmentedControl}\n                  options=${[\n                    {\n                      label: \"Create new repo\",\n                      value: kGithubTargetRepoModeCreate,\n                    },\n                    {\n                      label: \"Use existing empty repo\",\n                      value: kGithubTargetRepoModeExistingEmpty,\n                    },\n                  ]}\n                  value=${freshRepoMode}\n                  onChange=${(value) =>\n                    setValue(\"_GITHUB_TARGET_REPO_MODE\", value)}\n                  fullWidth=${true}\n                />\n              </div>\n            `\n          : null}\n      </div>\n    `}\n    ${activeGroup.id === \"channels\"\n      ? renderChannelAccordion()\n      : (activeGroup.id === \"ai\"\n          ? activeGroup.fields.filter((field) =>\n              visibleAiFieldKeys.has(field.key),\n            )\n          : activeGroup.id === \"github\"\n            ? activeGroup.fields.filter((field) =>\n                githubFlow === kGithubFlowImport\n                  ? true\n                  : field.key !== \"_GITHUB_SOURCE_REPO\",\n              )\n            : activeGroup.fields\n        ).map((field) => renderStandardField(field))}\n    ${error\n      ? html`<div\n          class=\"bg-status-error-bg border border-status-error-border rounded-xl p-3 text-status-error text-sm\"\n        >\n          ${error}\n        </div>`\n      : null}\n    ${step === totalGroups - 1 && (showOptionalOpenai || showOptionalGemini)\n      ? html`\n          ${showOptionalOpenai\n            ? html`<div class=\"space-y-1\">\n                <label class=\"text-xs font-medium text-fg-muted\"\n                  >OpenAI API Key</label\n                >\n                <${SecretInput}\n                  value=${vals.OPENAI_API_KEY || \"\"}\n                  onInput=${(e) => setValue(\"OPENAI_API_KEY\", e.target.value)}\n                  placeholder=\"sk-...\"\n                  isSecret=${true}\n                  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\"\n                />\n                <p class=\"text-xs text-fg-dim\">\n                  Used for memory embeddings -${\" \"}\n                  <a\n                    href=\"https://platform.openai.com\"\n                    target=\"_blank\"\n                    class=\"hover:underline\"\n                    style=\"color: var(--accent-link)\"\n                    >get key</a\n                  >\n                </p>\n              </div>`\n            : null}\n          ${showOptionalGemini\n            ? html`<div class=\"space-y-1\">\n                <label class=\"text-xs font-medium text-fg-muted\"\n                  >Gemini API Key</label\n                >\n                <${SecretInput}\n                  value=${vals.GEMINI_API_KEY || \"\"}\n                  onInput=${(e) => setValue(\"GEMINI_API_KEY\", e.target.value)}\n                  placeholder=\"AI...\"\n                  isSecret=${true}\n                  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\"\n                />\n                <p class=\"text-xs text-fg-dim\">\n                  Used for memory embeddings and Nano Banana -${\" \"}\n                  <a\n                    href=\"https://aistudio.google.com\"\n                    target=\"_blank\"\n                    class=\"hover:underline\"\n                    style=\"color: var(--accent-link)\"\n                    >get key</a\n                  >\n                </p>\n              </div>`\n            : null}\n        `\n      : null}\n\n    <div class=\"grid grid-cols-2 gap-2 pt-3\">\n      ${step < totalGroups - 1\n        ? html`\n            ${step >= 0\n              ? html`<${ActionButton}\n                  onClick=${goBack}\n                  tone=\"secondary\"\n                  size=\"md\"\n                  idleLabel=\"Back\"\n                  className=\"w-full\"\n                />`\n              : html`<div class=\"w-full\"></div>`}\n            <${ActionButton}\n              onClick=${goNext}\n              loading=${activeGroup.id === \"github\" && githubStepLoading}\n              tone=\"primary\"\n              size=\"md\"\n              idleLabel=${activeGroup.id === \"github\" &&\n              githubFlow === kGithubFlowImport\n                ? \"Check compatibility\"\n                : \"Next\"}\n              loadingLabel=\"Checking...\"\n              className=\"w-full\"\n            />\n          `\n        : html`\n            ${step >= 0\n              ? html`<${ActionButton}\n                  onClick=${goBack}\n                  tone=\"secondary\"\n                  size=\"md\"\n                  idleLabel=\"Back\"\n                  className=\"w-full\"\n                />`\n              : html`<div class=\"w-full\"></div>`}\n            <${ActionButton}\n              onClick=${handleSubmit}\n              loading=${loading}\n              tone=\"primary\"\n              size=\"md\"\n              idleLabel=\"Next\"\n              loadingLabel=\"Starting...\"\n              className=\"w-full\"\n            />\n          `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-header.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const WelcomeHeader = ({\n  groups,\n  step,\n  isPreStep,\n  isSetupStep,\n  isPairingStep,\n  stepNumber,\n  activeStepLabel,\n}) => {\n  const progressSteps = [\n    ...groups,\n    { id: \"setup\", title: \"Initializing\" },\n    { id: \"pairing\", title: \"Pairing\" },\n  ];\n\n  return html`\n    <div class=\"text-center mb-1\">\n      <span\n        class=\"ac-logo-mark block mx-auto mb-3\"\n        style=\"--ac-logo-width: 32px; --ac-logo-height: 33px;\"\n        aria-hidden=\"true\"\n      ></span>\n      <h1 class=\"text-2xl font-semibold mb-2\">Setup</h1>\n      <p style=\"color: var(--text-muted)\" class=\"text-sm\">\n        Let's get your agent running\n      </p>\n      <div class=\"mt-4 mb-2 flex items-center justify-center\">\n        <span\n          class=\"text-[11px] px-2.5 py-1 rounded-full border border-border font-medium\"\n          style=\"background: var(--field-bg-contrast); color: var(--text-muted)\"\n        >\n          ${isPreStep\n            ? \"Choose your destiny\"\n            : `Step ${stepNumber} of ${progressSteps.length} - ${activeStepLabel}`}\n        </span>\n      </div>\n    </div>\n\n    <div class=\"flex items-center gap-2\">\n      ${progressSteps.map((group, idx) => {\n        const isActive = idx === step;\n        const isComplete =\n          idx < step || (isSetupStep && group.id === \"setup\");\n        const isPairingComplete =\n          idx < step || (isPairingStep && group.id === \"pairing\");\n        const bg = isPreStep\n          ? \"var(--border-strong)\"\n          : isActive\n            ? \"var(--accent)\"\n            : group.id === \"pairing\"\n              ? isPairingComplete\n                ? \"var(--accent-dim)\"\n                : \"var(--border-strong)\"\n              : isComplete\n                ? \"var(--accent-dim)\"\n                : \"var(--border-strong)\";\n        return html`\n          <div\n            class=\"h-1 flex-1 rounded-full transition-colors ${isActive ? \"ac-step-pill-pulse\" : \"\"}\"\n            style=${{ background: bg }}\n            title=${group.title}\n          ></div>\n        `;\n      })}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-import-step.js",
    "content": "import { h } from \"preact\";\nimport { useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\nimport { buildApprovedImportSecrets } from \"./welcome-secret-review-utils.js\";\n\nconst html = htm.bind(h);\n\nconst kCategories = [\n  {\n    key: \"gatewayConfig\",\n    label: \"Gateway Config\",\n    icon: \"⚙️\",\n    description: \"openclaw.json configuration\",\n    showFiles: true,\n  },\n  {\n    key: \"envFiles\",\n    label: \"Environment Files\",\n    icon: \"🔐\",\n    description: \".env files with variables\",\n    showFiles: true,\n  },\n  {\n    key: \"workspaceFiles\",\n    label: \"Workspace Files\",\n    icon: \"📄\",\n    description: \"Prompt files (AGENTS.md, SOUL.md, etc.)\",\n    showFiles: true,\n  },\n  {\n    key: \"skills\",\n    label: \"Skills\",\n    icon: \"🛠\",\n    description: \"Custom skill definitions\",\n    showFiles: true,\n  },\n  {\n    key: \"cronJobs\",\n    label: \"Cron Jobs\",\n    icon: \"⏰\",\n    description: \"Scheduled tasks\",\n    showFiles: true,\n  },\n  {\n    key: \"webhooks\",\n    label: \"Hooks\",\n    icon: \"🔗\",\n    description: \"Webhook mappings and internal hooks\",\n    showDirs: true,\n  },\n  {\n    key: \"memory\",\n    label: \"Memory\",\n    icon: \"🧠\",\n    description: \"Agent memory and embeddings\",\n    showDirs: true,\n  },\n];\n\nconst CategoryCard = ({ category, data }) => {\n  const [expanded, setExpanded] = useState(false);\n  if (!data?.found) return null;\n  const isHooksCategory = category.key === \"webhooks\";\n  const warningItems = Array.isArray(data.transformWarnings)\n    ? data.transformWarnings\n    : [];\n  const warningPathPrefixes = new Set(\n    warningItems\n      .map((warning) => String(warning.actualPath || \"\").trim())\n      .filter(Boolean)\n      .map((pathValue) => pathValue.split(\"/\").slice(0, -2).join(\"/\")),\n  );\n\n  const items = [\n    ...(data.jobNames || []),\n    ...(data.hookNames || []),\n    ...(data.files || []),\n    ...(data.dirs || []).filter((dir) => !warningPathPrefixes.has(dir)),\n    ...(data.extraMarkdown || []),\n  ];\n  const count =\n    typeof data.jobCount === \"number\" && data.jobCount > 0\n      ? data.jobCount\n      : typeof data.hookCount === \"number\" && data.hookCount > 0\n        ? data.hookCount\n        : items.length;\n  const warningCount =\n    typeof data.warningCount === \"number\"\n      ? data.warningCount\n      : warningItems.length;\n\n  return html`\n    <div class=\"border border-border rounded-lg p-3\">\n      <button\n        type=\"button\"\n        onclick=${() => setExpanded((p) => !p)}\n        class=\"w-full flex items-center justify-between text-left\"\n      >\n        <div class=\"flex items-center gap-2\">\n          <span class=\"text-sm\">${category.icon}</span>\n          <span class=\"text-xs font-medium text-body\"\n            >${category.label}</span\n          >\n          <span\n            class=\"text-xs px-1.5 py-0.5 rounded-full bg-status-info-bg text-status-info\"\n            >${count}</span\n          >\n        </div>\n        <div class=\"flex items-center gap-2\">\n          ${warningCount > 0\n            ? html`\n                <span\n                  class=\"text-xs px-1.5 py-0.5 rounded-full bg-status-warning-bg text-status-warning\"\n                >\n                  ⚠ ${warningCount}\n                </span>\n              `\n            : null}\n          <span class=\"text-xs text-fg-muted\">${expanded ? \"▲\" : \"▼\"}</span>\n        </div>\n      </button>\n      ${expanded &&\n      items.length > 0 &&\n      html`\n        <div class=\"mt-2 space-y-1\">\n          ${items.map(\n            (item) => html`\n              <div\n                class=\"text-xs font-mono bg-field rounded px-2 py-1 text-fg-muted\"\n              >\n                ${item}\n              </div>\n            `,\n          )}\n          ${isHooksCategory\n            ? warningItems.map(\n                (warning) => html`\n                  <div\n                    class=\"text-xs font-mono bg-field rounded px-2 py-1 text-status-warning\"\n                  >\n                    ${warning.actualPath}\n                  </div>\n                `,\n              )\n            : null}\n        </div>\n      `}\n    </div>\n  `;\n};\n\nexport const WelcomeImportStep = ({\n  scanResult,\n  scanning,\n  error,\n  onApprove,\n  onShowSecretReview,\n  onBack,\n}) => {\n  if (scanning) {\n    return html`\n      <div class=\"flex flex-col items-center justify-center py-8 gap-3\">\n        <${LoadingSpinner} />\n        <p class=\"text-sm text-fg-muted\">Scanning repository...</p>\n      </div>\n    `;\n  }\n\n  if (error) {\n    return html`\n      <div class=\"space-y-3\">\n        <div\n          class=\"bg-status-error-bg border border-status-error-border rounded-xl p-3 text-status-error text-sm\"\n        >\n          ${error}\n        </div>\n        <button\n          onclick=${onBack}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-secondary\"\n        >\n          Back\n        </button>\n      </div>\n    `;\n  }\n\n  if (!scanResult) return null;\n\n  const secretCount = (scanResult.secrets || []).length;\n  const hasConflicts = scanResult.managedConflicts?.found;\n\n  return html`\n    <div class=\"space-y-3\">\n      <div>\n        <h2 class=\"text-sm font-medium text-body\">Import Summary</h2>\n        <p class=\"text-xs text-fg-muted\">\n          ${scanResult.hasOpenclawSetup\n            ? \"Found an existing OpenClaw setup\"\n            : \"No OpenClaw config detected — we'll set up fresh after import\"}\n        </p>\n      </div>\n\n      <div class=\"space-y-2\">\n        ${kCategories.map(\n          (cat) => html`\n            <${CategoryCard}\n              key=${cat.key}\n              category=${cat}\n              data=${scanResult[cat.key]}\n            />\n          `,\n        )}\n      </div>\n\n      ${scanResult.credentials?.found &&\n      html`\n        <div\n          class=\"bg-status-warning-bg border border-status-warning-border rounded-lg p-3 text-xs text-status-warning\"\n        >\n          Deployment-specific files found (credentials, device identity) — these\n          will not be imported.\n        </div>\n      `}\n      ${hasConflicts &&\n      html`\n        <div\n          class=\"bg-status-warning-bg border border-status-warning-border rounded-lg p-3 text-xs text-status-warning\"\n        >\n          AlphaClaw-managed files detected\n          (${(scanResult.managedConflicts.files || []).join(\", \")}). These will\n          be overwritten with AlphaClaw defaults.\n        </div>\n      `}\n      ${scanResult.managedEnvConflicts?.found\n        ? html`\n            <div\n              class=\"bg-status-warning-bg border border-status-warning-border rounded-lg p-3 text-xs text-status-warning\"\n            >\n              AlphaClaw controls deployment tokens and env vars\n              (${(scanResult.managedEnvConflicts.vars || []).join(\", \")}).\n              Imported values for these will be overwritten with\n              AlphaClaw-managed env var references during import.\n            </div>\n          `\n        : null}\n      ${scanResult.webhooks?.warningCount > 0\n        ? html`\n            <div\n              class=\"bg-status-warning-bg border border-status-warning-border rounded-lg p-3 text-xs text-status-warning\"\n            >\n              AlphaClaw expects hook transforms at\n              <code class=\"text-xs bg-field px-1 rounded\"\n                >hooks/transforms/name/name-transform.mjs</code\n              >. We found some that do not match and will try to patch them\n              during import. The originals will be backed up under\n              <code class=\"text-xs bg-field px-1 rounded\"\n                >hooks/transforms/_backup</code\n              >.\n            </div>\n          `\n        : null}\n      ${secretCount > 0 &&\n      html`\n        <div\n          class=\"bg-status-info-bg border border-status-info-border rounded-lg p-3 flex items-center justify-between\"\n        >\n          <div>\n            <span class=\"text-xs text-status-info font-medium\">\n              ${`${secretCount} possible secret${secretCount === 1 ? \"\" : \"s\"} detected`}\n            </span>\n            <p class=\"text-xs text-fg-muted mt-0.5\">\n              Review and extract to environment variables\n            </p>\n          </div>\n          <${ActionButton}\n            onClick=${onShowSecretReview}\n            tone=\"primary\"\n            size=\"sm\"\n            idleLabel=\"Review\"\n            className=\"font-medium\"\n          />\n        </div>\n      `}\n\n      <div class=\"grid grid-cols-2 gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onBack}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=\"Back\"\n          className=\"w-full\"\n        />\n        <${ActionButton}\n          onClick=${() =>\n            onApprove(buildApprovedImportSecrets(scanResult.secrets))}\n          loading=${scanning}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=\"Import\"\n          loadingLabel=\"Importing...\"\n          className=\"w-full\"\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-pairing-step.js",
    "content": "import { h } from \"preact\";\nimport { useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { Badge } from \"../badge.js\";\n\nconst html = htm.bind(h);\n\nconst kChannelMeta = {\n  telegram: {\n    label: \"Telegram\",\n    iconSrc: \"/assets/icons/telegram.svg\",\n  },\n  discord: {\n    label: \"Discord\",\n    iconSrc: \"/assets/icons/discord.svg\",\n  },\n  slack: {\n    label: \"Slack\",\n    iconSrc: \"/assets/icons/slack.svg\",\n  },\n  whatsapp: {\n    label: \"WhatsApp\",\n    iconSrc: \"/assets/icons/whatsapp.svg\",\n  },\n};\n\nconst PairingRow = ({ pairing, onApprove, onReject }) => {\n  const [busyAction, setBusyAction] = useState(\"\");\n\n  const handleApprove = async () => {\n    setBusyAction(\"approve\");\n    try {\n      await onApprove(pairing.id, pairing.channel, pairing.accountId || \"\");\n    } finally {\n      setBusyAction(\"\");\n    }\n  };\n\n  const handleReject = async () => {\n    setBusyAction(\"reject\");\n    try {\n      await onReject(pairing.id, pairing.channel, pairing.accountId || \"\");\n    } finally {\n      setBusyAction(\"\");\n    }\n  };\n\n  return html`\n    <div class=\"bg-field rounded-lg p-3 mb-2\">\n      <div class=\"flex items-center justify-between gap-2 mb-2\">\n        <div class=\"font-medium text-sm\">\n          ${pairing.code || pairing.id || \"Pending request\"}\n        </div>\n        <span\n          class=\"text-[11px] px-2 py-0.5 rounded-full border border-border text-fg-muted\"\n        >\n          Request\n        </span>\n      </div>\n      <p class=\"text-xs text-fg-muted mb-3\">\n        Approve to connect this account and finish setup.\n      </p>\n      <div class=\"flex gap-2\">\n        <button\n          onclick=${handleApprove}\n          disabled=${!!busyAction}\n          class=\"ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"\"}\"\n        >\n          ${busyAction === \"approve\" ? \"Approving...\" : \"Approve\"}\n        </button>\n        <button\n          onclick=${handleReject}\n          disabled=${!!busyAction}\n          class=\"ac-btn-secondary text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"\"}\"\n        >\n          ${busyAction === \"reject\" ? \"Rejecting...\" : \"Reject\"}\n        </button>\n      </div>\n    </div>\n  `;\n};\n\nexport const WelcomePairingStep = ({\n  channel,\n  pairings,\n  loading,\n  error,\n  onApprove,\n  onReject,\n  canFinish,\n  onContinue,\n  onSkip,\n}) => {\n  const channelMeta = kChannelMeta[channel] || {\n    label: channel\n      ? channel.charAt(0).toUpperCase() + channel.slice(1)\n      : \"Channel\",\n    iconSrc: \"\",\n  };\n\n  if (!channel) {\n    return html`\n      <div\n        class=\"bg-status-error-bg border border-status-error-border rounded-xl p-3 text-status-error text-sm\"\n      >\n        Missing channel configuration. Go back and add a channel credential.\n      </div>\n    `;\n  }\n\n  if (canFinish) {\n    return html`\n      <div class=\"min-h-[300px] pb-6 px-6 flex flex-col\">\n        <div class=\"flex-1 flex items-center justify-center text-center\">\n          <div class=\"space-y-3 max-w-xl mx-auto\">\n            <p class=\"text-sm font-medium text-status-success mb-12\">\n              🎉 Setup complete\n            </p>\n            <p class=\"text-xs text-body\">\n              Your ${channelMeta.label} channel is connected. You can switch to${\" \"}\n              ${channelMeta.label} and start using your agent now.\n            </p>\n            <p class=\"text-xs text-fg-muted font-normal opacity-85\">\n              Continue to the dashboard to explore extras like Google Workspace\n              and additional integrations.\n            </p>\n          </div>\n        </div>\n        <button\n          onclick=${onContinue}\n          class=\"w-full max-w-xl mx-auto text-sm font-medium px-4 py-2.5 rounded-xl transition-all ac-btn-cyan mt-3\"\n        >\n          Continue to dashboard\n        </button>\n      </div>\n    `;\n  }\n\n  return html`\n    <div class=\"min-h-[300px] pb-6 flex flex-col gap-3\">\n      <div class=\"flex items-center justify-end gap-2\">\n        <${Badge} tone=\"warning\"\n          >${\n            loading\n              ? \"Checking...\"\n              : pairings.length > 0\n                ? \"Pairing request detected\"\n                : \"Awaiting pairing\"\n          }</${Badge}\n        >\n      </div>\n\n      ${\n        pairings.length > 0\n          ? html`<div class=\"flex-1 flex items-center\">\n              <div class=\"w-full\">\n                ${pairings.map(\n                  (pairing) =>\n                    html`<${PairingRow}\n                      key=${pairing.id}\n                      pairing=${pairing}\n                      onApprove=${onApprove}\n                      onReject=${onReject}\n                    />`,\n                )}\n              </div>\n            </div>`\n          : html`<div\n              class=\"flex-1 flex items-center justify-center text-center py-4\"\n            >\n              <div class=\"space-y-4\">\n                ${channelMeta.iconSrc\n                  ? html`<img\n                      src=${channelMeta.iconSrc}\n                      alt=${channelMeta.label}\n                      class=\"w-8 h-8 mx-auto rounded-md\"\n                    />`\n                  : null}\n                <p class=\"text-body text-sm\">\n                  Send a message to your ${channelMeta.label} bot\n                </p>\n                <p class=\"text-fg-dim text-xs\">\n                  The pairing request will appear here in 5-10 seconds\n                </p>\n              </div>\n            </div>`\n      }\n\n      ${\n        error\n          ? html`<div\n              class=\"bg-status-error-bg border border-status-error-border rounded-xl p-3 text-status-error text-sm\"\n            >\n              ${error}\n            </div>`\n          : null\n      }\n      ${\n        pairings.length === 0\n          ? html`<div class=\"pt-3 text-center\">\n              <button\n                type=\"button\"\n                onclick=${onSkip}\n                class=\"ac-tip-link text-xs font-medium\"\n              >\n                Skip pairing for now\n              </button>\n            </div>`\n          : null\n      }\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-placeholder-review-step.js",
    "content": "import { h } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { SecretInput } from \"../secret-input.js\";\n\nconst html = htm.bind(h);\n\nconst isResolvedValue = (value) => {\n  const normalized = String(value || \"\").trim();\n  return !!normalized && normalized !== \"placeholder\";\n};\n\nconst PlaceholderRow = ({ item, value, onInput }) => {\n  return html`\n    <div class=\"border border-border rounded-lg p-3 space-y-2\">\n      <div class=\"flex items-start justify-between gap-3\">\n        <div class=\"min-w-0\">\n          <div class=\"flex items-center gap-2 flex-wrap\">\n            <code\n              class=\"text-xs text-body bg-field px-1.5 py-0.5 rounded\"\n              >${item.key}</code\n            >\n          </div>\n        </div>\n      </div>\n      <${SecretInput}\n        value=${value}\n        onInput=${(event) => onInput(event.target.value)}\n        placeholder=\"Enter value\"\n        inputClass=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted font-mono\"\n      />\n    </div>\n  `;\n};\n\nexport const WelcomePlaceholderReviewStep = ({\n  placeholderReview,\n  vals,\n  setValue,\n  onContinue,\n}) => {\n  const items = Array.isArray(placeholderReview?.vars)\n    ? placeholderReview.vars\n    : [];\n  const unresolvedItems = useMemo(\n    () =>\n      items\n        .filter((item) => !isResolvedValue(vals[item.key]))\n        .map((item) => item.key),\n    [items, vals],\n  );\n  const unresolvedCount = unresolvedItems.length;\n\n  if (items.length === 0) return null;\n\n  return html`\n    <div class=\"space-y-3\">\n      <div>\n        <h2 class=\"text-sm font-medium text-body\">Add Missing Env Vars</h2>\n      </div>\n\n      <div class=\"space-y-2 max-h-80 overflow-y-auto\">\n        ${items.map(\n          (item) => html`\n            <${PlaceholderRow}\n              key=${item.key}\n              item=${item}\n              value=${String(vals[item.key] || \"\") === \"placeholder\"\n                ? \"\"\n                : vals[item.key] || \"\"}\n              onInput=${(nextValue) => setValue(item.key, nextValue)}\n            />\n          `,\n        )}\n      </div>\n\n      <div\n        class=\"bg-status-warning-bg border border-status-warning-border rounded-lg p-3 text-xs text-status-warning\"\n      >\n        ${unresolvedCount > 0\n          ? `${unresolvedCount} detected env var${unresolvedCount === 1 ? \"\" : \"s\"} need values. You can continue without them, but the gateway might fail to start.`\n          : \"All imported placeholder env vars have values now.\"}\n      </div>\n\n      <div class=\"pt-1\">\n        <${ActionButton}\n          onClick=${onContinue}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=${unresolvedCount > 0\n            ? `Continue with ${unresolvedCount} Unresolved`\n            : \"Continue\"}\n          className=\"w-full\"\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-pre-step.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { kGithubFlowFresh, kGithubFlowImport } from \"./welcome-config.js\";\n\nconst html = htm.bind(h);\n\nexport const WelcomePreStep = ({ onSelectFlow }) => {\n  return html`\n    <div class=\"space-y-3\">\n      <button\n        type=\"button\"\n        onclick=${() => onSelectFlow(kGithubFlowFresh)}\n        class=\"w-full flex items-center gap-4 text-left p-4 rounded-xl ac-path-card\"\n      >\n        <div\n          class=\"ac-path-icon flex-shrink-0 w-10 h-10 flex items-center justify-center bg-field rounded-lg border border-border text-body\"\n        >\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"currentColor\"\n            class=\"w-5 h-5\"\n          >\n            <path\n              d=\"M14 4.4375C15.3462 4.4375 16.4375 3.34619 16.4375 2H17.5625C17.5625 3.34619 18.6538 4.4375 20 4.4375V5.5625C18.6538 5.5625 17.5625 6.65381 17.5625 8H16.4375C16.4375 6.65381 15.3462 5.5625 14 5.5625V4.4375ZM1 11C4.31371 11 7 8.31371 7 5H9C9 8.31371 11.6863 11 15 11V13C11.6863 13 9 15.6863 9 19H7C7 15.6863 4.31371 13 1 13V11ZM4.87601 12C6.18717 12.7276 7.27243 13.8128 8 15.124 8.72757 13.8128 9.81283 12.7276 11.124 12 9.81283 11.2724 8.72757 10.1872 8 8.87601 7.27243 10.1872 6.18717 11.2724 4.87601 12ZM17.25 14C17.25 15.7949 15.7949 17.25 14 17.25V18.75C15.7949 18.75 17.25 20.2051 17.25 22H18.75C18.75 20.2051 20.2051 18.75 22 18.75V17.25C20.2051 17.25 18.75 15.7949 18.75 14H17.25Z\"\n            ></path>\n          </svg>\n        </div>\n        <div>\n          <div\n            class=\"ac-path-title text-sm font-medium text-body mb-0.5 transition-colors duration-150\"\n          >\n            Start fresh\n          </div>\n          <div\n            class=\"ac-path-desc text-xs text-fg-muted transition-colors duration-150\"\n          >\n            Create a new repository and set up your agent from scratch.\n          </div>\n        </div>\n      </button>\n\n      <button\n        type=\"button\"\n        onclick=${() => onSelectFlow(kGithubFlowImport)}\n        class=\"w-full flex items-center gap-4 text-left p-4 rounded-xl ac-path-card\"\n      >\n        <div\n          class=\"ac-path-icon flex-shrink-0 w-10 h-10 flex items-center justify-center bg-field rounded-lg border border-border text-body\"\n        >\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"w-5 h-5\"\n          >\n            <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path>\n            <polyline points=\"7 10 12 15 17 10\"></polyline>\n            <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>\n          </svg>\n        </div>\n        <div class=\"flex-1 min-w-0\">\n          <div class=\"flex items-center gap-2 mb-0.5\">\n            <div\n              class=\"ac-path-title text-sm font-medium text-body transition-colors duration-150\"\n            >\n              Import existing setup\n            </div>\n            <span class=\"shrink-0 ml-1 text-[11px] text-status-warning\">\n              Experimental\n            </span>\n          </div>\n          <div\n            class=\"ac-path-desc text-xs text-fg-muted transition-colors duration-150\"\n          >\n            Connect an existing repository that already has an OpenClaw setup.\n          </div>\n        </div>\n      </button>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-secret-review-step.js",
    "content": "import { h } from \"preact\";\nimport { useState, useCallback } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\nimport { buildApprovedImportSecrets } from \"./welcome-secret-review-utils.js\";\n\nconst html = htm.bind(h);\n\nconst SecretRow = ({ secret, selected, onToggle, envVarName, onEnvVarChange }) =>\n  html`\n    <div\n      class=\"border border-border rounded-lg p-3 space-y-2 ${selected\n        ? \"bg-status-info-bg border-status-info-border\"\n        : \"\"}\"\n    >\n      <div class=\"flex items-start gap-2\">\n        <input\n          type=\"checkbox\"\n          checked=${selected}\n          onChange=${onToggle}\n          class=\"mt-0.5 rounded\"\n        />\n        <div class=\"flex-1 min-w-0\">\n          <div class=\"flex items-center gap-2 flex-wrap\">\n            <span class=\"text-xs font-mono text-body truncate\"\n              >${secret.maskedValue}</span\n            >\n            ${secret.confidence === \"high\"\n              ? html`<span\n                  class=\"text-xs px-1.5 py-0.5 rounded-full bg-status-error-bg text-status-error\"\n                  >high confidence</span\n                >`\n              : html`<span\n                  class=\"text-xs px-1.5 py-0.5 rounded-full bg-status-warning-bg text-status-warning\"\n                  >possible</span\n                >`}\n          </div>\n          <div class=\"text-xs text-fg-muted mt-1\">\n            Found in${\" \"}\n            <span class=\"font-mono\">${secret.file || \"config\"}</span>\n            ${secret.configPath\n              ? html` at <span class=\"font-mono\">${secret.configPath}</span>`\n              : null}\n          </div>\n          ${secret.duplicateIn &&\n          html`\n            <div class=\"text-xs text-status-warning-muted mt-1\">\n              Also found in${\" \"}<span class=\"font-mono\"\n                >${secret.duplicateIn}</span\n              >\n            </div>\n          `}\n        </div>\n      </div>\n      ${selected &&\n      html`\n        <div class=\"pl-6\">\n          <label class=\"text-xs text-fg-muted\">Extract as env var:</label>\n          <input\n            type=\"text\"\n            value=${envVarName}\n            onInput=${(e) => onEnvVarChange(e.target.value)}\n            class=\"w-full mt-1 bg-field border border-border rounded-lg px-3 py-1.5 text-xs text-body outline-none focus:border-fg-muted font-mono\"\n          />\n        </div>\n      `}\n    </div>\n  `;\n\nexport const WelcomeSecretReviewStep = ({\n  secrets = [],\n  onApprove,\n  onBack,\n  loading,\n  error,\n}) => {\n  const [selections, setSelections] = useState(() => {\n    const initial = {};\n    for (const secret of secrets) {\n      initial[secret.configPath] = {\n        selected: secret.confidence === \"high\",\n        envVarName: secret.suggestedEnvVar || \"\",\n      };\n    }\n    return initial;\n  });\n\n  const toggleSecret = useCallback(\n    (configPath) => {\n      setSelections((prev) => ({\n        ...prev,\n        [configPath]: {\n          ...prev[configPath],\n          selected: !prev[configPath]?.selected,\n        },\n      }));\n    },\n    [],\n  );\n\n  const updateEnvVarName = useCallback(\n    (configPath, name) => {\n      setSelections((prev) => ({\n        ...prev,\n        [configPath]: {\n          ...prev[configPath],\n          envVarName: name,\n        },\n      }));\n    },\n    [],\n  );\n\n  const selectedCount = Object.values(selections).filter(\n    (s) => s.selected,\n  ).length;\n\n  const handleExtract = () => {\n    const approved = buildApprovedImportSecrets(\n      secrets.map((secret) => ({\n        ...secret,\n        confidence: selections[secret.configPath]?.selected\n          ? \"high\"\n          : \"medium\",\n        suggestedEnvVar:\n          selections[secret.configPath]?.envVarName || secret.suggestedEnvVar,\n      })),\n    );\n    onApprove(approved);\n  };\n\n  if (loading) {\n    return html`\n      <div class=\"flex flex-col items-center justify-center py-8 gap-3\">\n        <${LoadingSpinner} />\n        <p class=\"text-sm text-fg-muted\">Applying import...</p>\n      </div>\n    `;\n  }\n\n  return html`\n    <div class=\"space-y-3\">\n      <div>\n        <h2 class=\"text-sm font-medium text-body\">Review Secrets</h2>\n        <p class=\"text-xs text-fg-muted\">\n          Select secrets to extract into environment variables. Inline values in\n          config will be replaced with ${\"`\"}${\"${\"}ENV_VAR_NAME${\"}\"}${\"`\"} references.\n        </p>\n      </div>\n\n      ${error &&\n      html`\n        <div\n          class=\"bg-status-error-bg border border-status-error-border rounded-xl p-3 text-status-error text-sm\"\n        >\n          ${error}\n        </div>\n      `}\n\n      <div class=\"space-y-2 max-h-80 overflow-y-auto\">\n        ${secrets.map(\n          (secret) => html`\n            <${SecretRow}\n              key=${secret.configPath}\n              secret=${secret}\n              selected=${selections[secret.configPath]?.selected || false}\n              envVarName=${selections[secret.configPath]?.envVarName || \"\"}\n              onToggle=${() => toggleSecret(secret.configPath)}\n              onEnvVarChange=${(name) =>\n                updateEnvVarName(secret.configPath, name)}\n            />\n          `,\n        )}\n      </div>\n\n      <div class=\"grid grid-cols-2 gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onBack}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=\"Back\"\n          className=\"w-full\"\n        />\n        <${ActionButton}\n          onClick=${handleExtract}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=${selectedCount > 0\n            ? `Extract ${selectedCount} Secret${selectedCount === 1 ? \"\" : \"s\"}`\n            : \"Skip All\"}\n          className=\"w-full\"\n        />\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-secret-review-utils.js",
    "content": "export const buildApprovedImportSecrets = (secrets = []) =>\n  (Array.isArray(secrets) ? secrets : [])\n    .filter((secret) => secret?.confidence === \"high\")\n    .map((secret) => ({\n      ...secret,\n      suggestedEnvVar: secret?.suggestedEnvVar || \"\",\n    }));\n\nexport const buildApprovedImportVals = (approvedSecrets = []) =>\n  (Array.isArray(approvedSecrets) ? approvedSecrets : []).reduce(\n    (nextVals, secret) => {\n      const envVar = String(secret?.suggestedEnvVar || \"\").trim();\n      const value = String(secret?.value || \"\");\n      if (!envVar || !value) return nextVals;\n      nextVals[envVar] = value;\n      return nextVals;\n    },\n    {},\n  );\n"
  },
  {
    "path": "lib/public/js/components/onboarding/welcome-setup-step.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"../loading-spinner.js\";\n\nconst html = htm.bind(h);\nconst kSetupTips = [\n  {\n    label: \"🛡️ Safety tip\",\n    text: \"Be careful what you give access to. Read access is always safer than write access.\",\n  },\n  {\n    label: \"🧠 Best practice\",\n    text: \"Trust but verify. Your agent may not always know what it's doing, so check the results.\",\n  },\n  {\n    label: \"💡 Idea\",\n    text: \"Ask your agent to create a morning briefing for you.\",\n  },\n  {\n    label: \"🧠 Best practice\",\n    text: \"Ask your agent to review its own code and make sure it's doing what you want it to do.\",\n  },\n  {\n    label: \"💡 Idea\",\n    text: \"Tell your agent to review the latest news and provide a summary.\",\n  },\n  {\n    label: \"🛡️ Safety tip\",\n    text: \"Be incredibly careful installing skills from the internet - they may contain malicious code.\",\n  },\n];\n\nexport const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {\n  const [tipIndex, setTipIndex] = useState(0);\n\n  useEffect(() => {\n    if (error || !loading) return;\n    const timer = setInterval(() => {\n      setTipIndex((idx) => (idx + 1) % kSetupTips.length);\n    }, 5200);\n    return () => clearInterval(timer);\n  }, [error, loading]);\n\n  if (error) {\n    return html`\n      <div class=\"py-4 flex flex-col items-center text-center gap-3\">\n        <h3 class=\"text-lg font-semibold text-body\">Setup failed</h3>\n        <p class=\"text-sm text-fg-muted\">Fix the values and try again.</p>\n      </div>\n      <div\n        class=\"bg-status-error-bg border border-status-error-border rounded-xl p-3 text-status-error text-sm\"\n      >\n        ${error}\n      </div>\n      <div class=\"grid grid-cols-2 gap-2\">\n        <button\n          onclick=${onBack}\n          disabled=${loading}\n          class=\"w-full text-sm font-medium px-4 py-3 rounded-xl transition-all ac-btn-secondary ${loading\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"\"}\"\n        >\n          Back\n        </button>\n        <button\n          onclick=${onRetry}\n          disabled=${loading}\n          class=\"w-full text-sm font-medium px-4 py-3 rounded-xl transition-all ac-btn-cyan ${loading\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"\"}\"\n        >\n          ${loading ? \"Retrying...\" : \"Retry\"}\n        </button>\n      </div>\n    `;\n  }\n\n  const currentTip = kSetupTips[tipIndex];\n\n  return html`\n    <div class=\"relative min-h-[320px] pt-4 pb-20 flex\">\n      <div\n        class=\"flex-1 flex flex-col items-center justify-center text-center gap-4\"\n      >\n        <${LoadingSpinner} className=\"h-8 w-8 text-body\" />\n        <h3 class=\"text-lg font-semibold text-body\">\n          Initializing OpenClaw...\n        </h3>\n        <p class=\"text-sm text-fg-muted\">This could take 10-15 seconds</p>\n      </div>\n      <div\n        class=\"absolute bottom-3 left-3 right-3 bg-field border border-border rounded-lg px-3 py-2 text-xs text-fg-muted\"\n      >\n        <span class=\"text-fg-muted\">${currentTip.label}: </span>\n        ${currentTip.text}\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/overflow-menu.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef } from \"preact/hooks\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst VerticalDotsIcon = ({ className = \"\" }) => html`\n  <svg class=${className} width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden=\"true\">\n    <circle cx=\"8\" cy=\"3\" r=\"1.5\" />\n    <circle cx=\"8\" cy=\"8\" r=\"1.5\" />\n    <circle cx=\"8\" cy=\"13\" r=\"1.5\" />\n  </svg>\n`;\n\nexport const OverflowMenu = ({\n  open = false,\n  onToggle = () => {},\n  onClose = () => {},\n  ariaLabel = \"Open menu\",\n  title = \"\",\n  menuRef = null,\n  renderTrigger = null,\n  triggerDisabled = false,\n  children = null,\n}) => {\n  const internalMenuRef = useRef(null);\n  const setMenuNodeRef = (node) => {\n    internalMenuRef.current = node;\n    if (typeof menuRef === \"function\") {\n      menuRef(node);\n      return;\n    }\n    if (menuRef && typeof menuRef === \"object\") {\n      menuRef.current = node;\n    }\n  };\n\n  useEffect(() => {\n    if (!open) return undefined;\n    const handleWindowClick = (event) => {\n      const root = internalMenuRef.current;\n      if (!root) return;\n      if (root.contains(event.target)) return;\n      onClose(event);\n    };\n    window.addEventListener(\"click\", handleWindowClick);\n    return () => window.removeEventListener(\"click\", handleWindowClick);\n  }, [open, onClose]);\n\n  return html`\n  <div class=\"brand-menu\" ref=${setMenuNodeRef}>\n    ${typeof renderTrigger === \"function\"\n      ? renderTrigger({\n          open,\n          onToggle: (event) => {\n            event.stopPropagation();\n            onToggle(event);\n          },\n          ariaLabel,\n          title: title || ariaLabel,\n        })\n      : html`\n          <button\n            type=\"button\"\n            class=\"brand-menu-trigger\"\n            aria-label=${ariaLabel}\n            aria-expanded=${open ? \"true\" : \"false\"}\n            title=${title || ariaLabel}\n            disabled=${triggerDisabled}\n            onclick=${(event) => {\n              event.stopPropagation();\n              onToggle(event);\n            }}\n          >\n            <${VerticalDotsIcon} />\n          </button>\n        `}\n    ${open\n      ? html`\n          <div class=\"brand-dropdown\" onclick=${(event) => event.stopPropagation()}>\n            ${children}\n          </div>\n        `\n      : null}\n  </div>\n`;\n};\n\nexport const OverflowMenuItem = ({\n  children = null,\n  onClick = () => {},\n  className = \"\",\n  iconSrc = \"\",\n  disabled = false,\n}) => html`\n  <button\n    type=\"button\"\n    class=${`brand-dropdown-item ${className} ${disabled\n      ? \"opacity-50 cursor-not-allowed\"\n      : \"\"}`.trim()}\n    disabled=${disabled}\n    onclick=${(event) => {\n      event.stopPropagation();\n      if (disabled) return;\n      onClick(event);\n    }}\n  >\n    ${iconSrc\n      ? html`\n          <span class=\"flex w-full items-center gap-2 leading-none\">\n            <img\n              src=${iconSrc}\n              alt=\"\"\n              class=\"block w-4 h-4 rounded-sm\"\n              aria-hidden=\"true\"\n            />\n            <span>${children}</span>\n          </span>\n        `\n      : children}\n  </button>\n`;\n"
  },
  {
    "path": "lib/public/js/components/page-header.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const PageHeader = ({ title = \"\", actions = null, leading = null }) => html`\n  <div class=\"flex items-center justify-between gap-3\">\n    <div>\n      ${leading || html`<h2 class=\"font-semibold text-base\">${title}</h2>`}\n    </div>\n    <div class=\"flex items-center gap-2\">${actions}</div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/pairings.js",
    "content": "import { h } from 'preact';\nimport { useEffect, useState } from 'preact/hooks';\nimport htm from 'htm';\nimport { ActionButton } from './action-button.js';\nimport { LoadingSpinner } from './loading-spinner.js';\nconst html = htm.bind(h);\n\nexport const PairingRow = ({ p, onApprove, onReject }) => {\n  const [busy, setBusy] = useState(null);\n\n  const handle = async (action) => {\n    setBusy(action);\n    try {\n      if (action === \"approve\") await onApprove(p.id, p.channel, p.accountId);\n      else await onReject(p.id, p.channel, p.accountId);\n    } catch {\n      setBusy(null);\n    }\n  };\n\n  const label = (p.channel || 'unknown').charAt(0).toUpperCase() + (p.channel || '').slice(1);\n  const accountId = String(p.accountId || \"\").trim();\n  const accountName = String(p.accountName || \"\").trim();\n  const accountSuffix =\n    accountId && accountId !== \"default\"\n      ? ` · ${accountName || accountId}`\n      : \"\";\n\n  if (busy === \"approve\") {\n    return html`\n      <div class=\"bg-field rounded-lg p-3 mb-2 flex items-center gap-2\">\n        <span class=\"text-status-success text-sm\">Approved</span>\n        <span class=\"text-fg-muted text-xs\">${label}${accountSuffix} · ${p.code || p.id || '?'}</span>\n      </div>`;\n  }\n  if (busy === \"reject\") {\n    return html`\n      <div class=\"bg-field rounded-lg p-3 mb-2 flex items-center gap-2\">\n        <span class=\"text-fg-muted text-sm\">Rejected</span>\n        <span class=\"text-fg-muted text-xs\">${label}${accountSuffix} · ${p.code || p.id || '?'}</span>\n      </div>`;\n  }\n\n  return html`\n    <div class=\"bg-field rounded-lg p-3 mb-2\">\n      <div class=\"font-medium text-sm mb-2\">${label}${accountSuffix} · <code class=\"text-fg-muted\">${p.code || p.id || '?'}</code></div>\n      <div class=\"flex gap-2\">\n        <${ActionButton}\n          onClick=${() => handle(\"approve\")}\n          tone=\"success\"\n          size=\"sm\"\n          idleLabel=\"Approve\"\n          className=\"font-medium px-3 py-1.5\"\n        />\n        <${ActionButton}\n          onClick=${() => handle(\"reject\")}\n          tone=\"secondary\"\n          size=\"sm\"\n          idleLabel=\"Reject\"\n          className=\"font-medium px-3 py-1.5\"\n        />\n      </div>\n    </div>`;\n};\n\nconst ALL_CHANNELS = ['telegram', 'discord', 'slack', 'whatsapp'];\n\nconst capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);\n\nconst getPairingKey = (p) => {\n  const channel = String(p?.channel || \"\").trim().toLowerCase();\n  const accountId = String(p?.accountId || \"\").trim() || \"default\";\n  const id = String(p?.id || p?.code || \"\").trim();\n  return channel && id ? `${channel}\\u0000${accountId}\\u0000${id}` : \"\";\n};\n\nexport function Pairings({\n  pending,\n  channels,\n  visible,\n  onApprove,\n  onReject,\n  statusRefreshing = false,\n  pollingInFlight = false,\n}) {\n  const [hiddenPairingKeys, setHiddenPairingKeys] = useState(() => new Set());\n  const pendingList = Array.isArray(pending) ? pending : [];\n\n  useEffect(() => {\n    setHiddenPairingKeys((current) => {\n      if (current.size === 0) return current;\n      const pendingKeys = new Set(\n        pendingList.map(getPairingKey).filter(Boolean),\n      );\n      const next = new Set();\n      for (const key of current) {\n        if (pendingKeys.has(key)) {\n          next.add(key);\n        }\n      }\n      return next.size === current.size ? current : next;\n    });\n  }, [pending]);\n\n  const hidePairing = (p) => {\n    const key = getPairingKey(p);\n    if (!key) return;\n    setHiddenPairingKeys((current) => {\n      if (current.has(key)) return current;\n      const next = new Set(current);\n      next.add(key);\n      return next;\n    });\n  };\n\n  const handleApprove = async (p) => {\n    await onApprove(p.id, p.channel, p.accountId);\n    hidePairing(p);\n  };\n\n  const handleReject = async (p) => {\n    await onReject(p.id, p.channel, p.accountId);\n    hidePairing(p);\n  };\n\n  const visiblePending = pendingList.filter(\n    (p) => !hiddenPairingKeys.has(getPairingKey(p)),\n  );\n\n  if (!visible) return null;\n\n  const unpaired = ALL_CHANNELS\n    .filter((ch) => {\n      const info = channels?.[ch];\n      if (!info) return false;\n      const accounts =\n        info.accounts && typeof info.accounts === \"object\" ? info.accounts : {};\n      if (Object.keys(accounts).length > 0) {\n        return Object.values(accounts).some(\n          (acc) => acc && acc.status !== \"paired\",\n        );\n      }\n      return info.status !== \"paired\";\n    })\n    .map(capitalize);\n\n  const channelList = unpaired.length <= 2\n    ? unpaired.join(' or ')\n    : unpaired.slice(0, -1).join(', ') + ', or ' + unpaired[unpaired.length - 1];\n\n  if (unpaired.length === 0 && visiblePending.length === 0) return null;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <div class=\"flex items-center justify-between gap-3 mb-3\">\n        <h2 class=\"card-label\">Pending Pairings</h2>\n        ${pollingInFlight\n          ? html`\n              <div class=\"inline-flex items-center text-fg-muted\" aria-label=\"Pairings refresh in progress\">\n                <${LoadingSpinner} className=\"h-3.5 w-3.5 text-fg-muted\" />\n              </div>\n            `\n          : null}\n      </div>\n      ${visiblePending.length > 0\n        ? html`<div>\n            ${visiblePending.map((p) => html`\n              <${PairingRow}\n                key=${getPairingKey(p) || p.id}\n                p=${p}\n                onApprove=${() => handleApprove(p)}\n                onReject=${() => handleReject(p)}\n              />\n            `)}\n          </div>`\n        : statusRefreshing\n        ? html`<div class=\"text-center py-4 space-y-2\">\n            <p class=\"text-body text-sm\">Updating pairing status...</p>\n          </div>`\n        : html`<div class=\"text-center py-4 space-y-2\">\n            <div class=\"text-3xl\">💬</div>\n            <p class=\"text-body text-sm\">Send a message to your bot on ${channelList}</p>\n            <p class=\"text-fg-dim text-xs\">The pairing request will appear here — it may take a few moments</p>\n          </div>`}\n    </div>`;\n}\n"
  },
  {
    "path": "lib/public/js/components/pane-shell.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\n/**\n * Shared layout shell for pages that need a fixed header with a\n * separately scrollable body. The header stays pinned at the top\n * while body content scrolls underneath.\n *\n * @param {preact.ComponentChildren} props.header  Content rendered in the fixed header area.\n * @param {preact.ComponentChildren} props.children  Content rendered in the scrollable body.\n */\nexport const PaneShell = ({ header, children }) => html`\n  <div class=\"ac-pane-shell\">\n    <div class=\"ac-pane-header\">\n      <div class=\"ac-pane-header-content\">\n        ${header}\n      </div>\n    </div>\n    <div class=\"ac-pane-body\">\n      <div class=\"ac-pane-body-content\">\n        ${children}\n      </div>\n    </div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/pill-tabs.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst kPillBaseClassName =\n  \"inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-medium transition-colors\";\nconst kPillActiveClassName =\n  \"border-cyan-500/40 bg-cyan-500/10 text-status-info shadow-[0_0_0_1px_rgba(34,211,238,0.08)]\";\nconst kPillInactiveClassName =\n  \"border-border bg-field text-fg-muted hover:border-fg-muted hover:text-body\";\n\nexport const PillTabs = ({\n  tabs = [],\n  activeTab = \"\",\n  onSelectTab = () => {},\n  className = \"flex items-center gap-2\",\n} = {}) => html`\n  <div class=${className}>\n    ${tabs.map(\n      (tab) => html`\n        <button\n          key=${String(tab?.value || \"\")}\n          type=\"button\"\n          class=${`${kPillBaseClassName} ${activeTab === tab?.value ? kPillActiveClassName : kPillInactiveClassName}`}\n          onclick=${() => onSelectTab(tab?.value)}\n        >\n          ${String(tab?.label || tab?.value || \"\")}\n        </button>\n      `,\n    )}\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/pop-actions.js",
    "content": "import { h } from \"preact\";\nimport { useState, useEffect, useRef } from \"preact/hooks\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst kEnterDurationMs = 260;\nconst kExitDurationMs = 200;\n\n/**\n * Wrapper that pop-animates children in/out based on `visible`.\n * Use for header save/cancel actions or any contextual action group.\n *\n * @param {boolean}  props.visible   Whether the actions should be shown.\n * @param {string}   [props.className] Extra classes on the container.\n * @param {preact.ComponentChildren} props.children\n */\nexport const PopActions = ({ visible = false, className = \"\", children }) => {\n  const [phase, setPhase] = useState(visible ? \"visible\" : \"hidden\");\n  const enterTimerRef = useRef(null);\n  const exitTimerRef = useRef(null);\n\n  useEffect(() => {\n    clearTimeout(enterTimerRef.current);\n    clearTimeout(exitTimerRef.current);\n    if (visible) {\n      if (phase !== \"visible\") {\n        setPhase(\"entering\");\n        enterTimerRef.current = setTimeout(\n          () => setPhase(\"visible\"),\n          kEnterDurationMs,\n        );\n      }\n    } else if (phase !== \"hidden\") {\n      setPhase(\"exiting\");\n      exitTimerRef.current = setTimeout(() => setPhase(\"hidden\"), kExitDurationMs);\n    }\n    return () => {\n      clearTimeout(enterTimerRef.current);\n      clearTimeout(exitTimerRef.current);\n    };\n  }, [visible, phase]);\n\n  const phaseClass =\n    phase === \"entering\"\n      ? \"ac-pop-actions-in\"\n      : phase === \"exiting\"\n        ? \"ac-pop-actions-out\"\n        : phase === \"visible\"\n          ? \"ac-pop-actions-visible\"\n        : \"ac-pop-actions-hidden\";\n\n  return html`\n    <div class=${`ac-pop-actions ${phaseClass} ${className}`.trim()}>\n      ${children}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/providers.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  fetchEnvVars,\n  saveEnvVars,\n  fetchModels,\n  fetchModelStatus,\n  setPrimaryModel,\n  fetchCodexStatus,\n  disconnectCodex,\n  exchangeCodexOAuth,\n} from \"../lib/api.js\";\nimport { showToast } from \"./toast.js\";\nimport { Badge } from \"./badge.js\";\nimport { SecretInput } from \"./secret-input.js\";\nimport { PageHeader } from \"./page-header.js\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport {\n  getModelProvider,\n  getAuthProviderFromModelProvider,\n  getFeaturedModels,\n  kProviderAuthFields,\n  kProviderLabels,\n  kProviderOrder,\n  kProviderFeatures,\n  kCoreProviders,\n} from \"../lib/model-config.js\";\nimport {\n  isCodexAuthCallbackMessage,\n  openCodexAuthWindow,\n} from \"../lib/codex-oauth-window.js\";\n\nconst html = htm.bind(h);\n\nconst getKeyVal = (vars, key) => vars.find((v) => v.key === key)?.value || \"\";\nconst kAiCredentialKeys = Object.values(kProviderAuthFields)\n  .flat()\n  .map((field) => field.key)\n  .filter((key, idx, arr) => arr.indexOf(key) === idx);\nlet kProvidersTabCache = null;\n\nconst FeatureTags = ({ provider, features = null }) => {\n  const resolvedFeatures = Array.isArray(features)\n    ? features\n    : kProviderFeatures[provider] || [];\n  const uniqueFeatures = Array.from(new Set(resolvedFeatures));\n  if (!uniqueFeatures.length) return null;\n  return html`\n    <div class=\"flex flex-wrap gap-1.5\">\n      ${uniqueFeatures.map(\n        (f) => html`\n          <span\n            class=\"text-xs px-1.5 py-0.5 rounded-md bg-white/5 text-fg-muted\"\n            >${f}</span\n          >\n        `,\n      )}\n    </div>\n  `;\n};\n\nexport const Providers = ({ onRestartRequired = () => {} }) => {\n  const [envVars, setEnvVars] = useState(\n    () => kProvidersTabCache?.envVars || [],\n  );\n  const [models, setModels] = useState(() => kProvidersTabCache?.models || []);\n  const [selectedModel, setSelectedModel] = useState(\n    () => kProvidersTabCache?.selectedModel || \"\",\n  );\n  const [showAllModels, setShowAllModels] = useState(\n    () => kProvidersTabCache?.showAllModels || false,\n  );\n  const [savingChanges, setSavingChanges] = useState(false);\n  const [codexStatus, setCodexStatus] = useState(\n    () => kProvidersTabCache?.codexStatus || { connected: false },\n  );\n  const [codexManualInput, setCodexManualInput] = useState(\"\");\n  const [codexExchanging, setCodexExchanging] = useState(false);\n  const [codexAuthStarted, setCodexAuthStarted] = useState(false);\n  const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);\n  const [modelsLoading, setModelsLoading] = useState(() => !kProvidersTabCache);\n  const [modelsError, setModelsError] = useState(\n    () => kProvidersTabCache?.modelsError || \"\",\n  );\n  const [ready, setReady] = useState(() => !!kProvidersTabCache);\n  const [savedModel, setSavedModel] = useState(\n    () => kProvidersTabCache?.savedModel || \"\",\n  );\n  const [modelDirty, setModelDirty] = useState(false);\n  const [savedAiValues, setSavedAiValues] = useState(\n    () => kProvidersTabCache?.savedAiValues || {},\n  );\n  const [showMoreProviders, setShowMoreProviders] = useState(false);\n  const codexExchangeInFlightRef = useRef(false);\n  const codexPopupPollRef = useRef(null);\n\n  const refresh = async () => {\n    if (!ready) setModelsLoading(true);\n    setModelsError(\"\");\n    try {\n      const [env, modelCatalog, modelStatus, codex] = await Promise.all([\n        fetchEnvVars(),\n        fetchModels(),\n        fetchModelStatus(),\n        fetchCodexStatus(),\n      ]);\n      setEnvVars(env.vars || []);\n      const catalogModels = Array.isArray(modelCatalog.models)\n        ? modelCatalog.models\n        : [];\n      setModels(catalogModels);\n      const currentModel = modelStatus.modelKey || \"\";\n      setSelectedModel(currentModel);\n      setCodexStatus(codex || { connected: false });\n      setSavedModel(currentModel);\n      setModelDirty(false);\n      const nextSavedAiValues = Object.fromEntries(\n        kAiCredentialKeys.map((key) => [key, getKeyVal(env.vars || [], key)]),\n      );\n      setSavedAiValues(nextSavedAiValues);\n      const nextModelsError = catalogModels.length ? \"\" : \"No models found\";\n      setModelsError(nextModelsError);\n      kProvidersTabCache = {\n        envVars: env.vars || [],\n        models: catalogModels,\n        selectedModel: currentModel,\n        savedModel: currentModel,\n        savedAiValues: nextSavedAiValues,\n        codexStatus: codex || { connected: false },\n        showAllModels,\n        modelsError: nextModelsError,\n      };\n    } catch (err) {\n      setModelsError(\"Failed to load provider settings\");\n      showToast(`Failed to load provider settings: ${err.message}`, \"error\");\n    } finally {\n      setReady(true);\n      setModelsLoading(false);\n    }\n  };\n\n  const refreshCodexConnection = async () => {\n    try {\n      const codex = await fetchCodexStatus();\n      setCodexStatus(codex || { connected: false });\n      if (codex?.connected) {\n        setCodexAuthStarted(false);\n        setCodexAuthWaiting(false);\n      }\n      kProvidersTabCache = {\n        ...(kProvidersTabCache || {}),\n        codexStatus: codex || { connected: false },\n      };\n    } catch {\n      setCodexStatus({ connected: false });\n      kProvidersTabCache = {\n        ...(kProvidersTabCache || {}),\n        codexStatus: { connected: false },\n      };\n    }\n  };\n\n  useEffect(() => {\n    refresh();\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (codexPopupPollRef.current) {\n        clearInterval(codexPopupPollRef.current);\n        codexPopupPollRef.current = null;\n      }\n    },\n    [],\n  );\n\n  const submitCodexAuthInput = async (input) => {\n    const normalizedInput = String(input || \"\").trim();\n    if (!normalizedInput || codexExchangeInFlightRef.current) return;\n    codexExchangeInFlightRef.current = true;\n    setCodexManualInput(normalizedInput);\n    setCodexExchanging(true);\n    try {\n      const result = await exchangeCodexOAuth(normalizedInput);\n      if (!result.ok)\n        throw new Error(result.error || \"Codex OAuth exchange failed\");\n      setCodexManualInput(\"\");\n      showToast(\"Codex connected\", \"success\");\n      setCodexAuthStarted(false);\n      setCodexAuthWaiting(false);\n      await refreshCodexConnection();\n    } catch (err) {\n      setCodexAuthWaiting(false);\n      showToast(err.message || \"Codex OAuth exchange failed\", \"error\");\n    } finally {\n      codexExchangeInFlightRef.current = false;\n      setCodexExchanging(false);\n    }\n  };\n\n  useEffect(() => {\n    const onMessage = async (e) => {\n      if (e.data?.codex === \"success\") {\n        showToast(\"Codex connected\", \"success\");\n        await refreshCodexConnection();\n      } else if (isCodexAuthCallbackMessage(e.data)) {\n        await submitCodexAuthInput(e.data.input);\n      } else if (e.data?.codex === \"error\") {\n        showToast(\n          `Codex auth failed: ${e.data.message || \"unknown error\"}`,\n          \"error\",\n        );\n      }\n    };\n    window.addEventListener(\"message\", onMessage);\n    return () => window.removeEventListener(\"message\", onMessage);\n  }, [submitCodexAuthInput]);\n\n  const setEnvValue = (key, value) => {\n    setEnvVars((prev) => {\n      const existing = prev.some((entry) => entry.key === key);\n      const next = existing\n        ? prev.map((v) => (v.key === key ? { ...v, value } : v))\n        : [...prev, { key, value, editable: true }];\n      kProvidersTabCache = { ...(kProvidersTabCache || {}), envVars: next };\n      return next;\n    });\n  };\n\n  const selectedModelProvider = getModelProvider(selectedModel);\n  const selectedAuthProvider = getAuthProviderFromModelProvider(\n    selectedModelProvider,\n  );\n  const primaryProvider = kProviderOrder.includes(selectedAuthProvider)\n    ? selectedAuthProvider\n    : kProviderOrder[0];\n  const otherProviders = kProviderOrder.filter((p) => p !== primaryProvider);\n  const featuredModels = getFeaturedModels(models);\n  const baseModelOptions = showAllModels\n    ? models\n    : featuredModels.length > 0\n      ? featuredModels\n      : models;\n  const selectedModelOption = models.find(\n    (model) => model.key === selectedModel,\n  );\n  const modelOptions =\n    selectedModelOption &&\n    !baseModelOptions.some((model) => model.key === selectedModelOption.key)\n      ? [...baseModelOptions, selectedModelOption]\n      : baseModelOptions;\n  const canToggleFullCatalog =\n    featuredModels.length > 0 && models.length > featuredModels.length;\n\n  const aiCredentialsDirty = kAiCredentialKeys.some(\n    (key) => getKeyVal(envVars, key) !== (savedAiValues[key] || \"\"),\n  );\n  const hasSelectedProviderAuth =\n    selectedModelProvider === \"openai-codex\"\n      ? !!codexStatus.connected\n      : (kProviderAuthFields[selectedAuthProvider] || []).some((field) =>\n          Boolean(getKeyVal(envVars, field.key)),\n        );\n  const canSaveChanges =\n    !savingChanges &&\n    (aiCredentialsDirty || (modelDirty && hasSelectedProviderAuth));\n\n  const saveChanges = async () => {\n    if (savingChanges) return;\n    if (!modelDirty && !aiCredentialsDirty) return;\n    if (modelDirty && !hasSelectedProviderAuth) {\n      showToast(\n        \"Add credentials for the selected model provider before saving model changes\",\n        \"error\",\n      );\n      return;\n    }\n    setSavingChanges(true);\n    try {\n      const targetModel = selectedModel;\n\n      if (aiCredentialsDirty) {\n        const payload = envVars\n          .filter((v) => v.editable)\n          .map((v) => ({ key: v.key, value: v.value }));\n        const envResult = await saveEnvVars(payload);\n        if (!envResult.ok)\n          throw new Error(envResult.error || \"Failed to save env vars\");\n        if (envResult.restartRequired) onRestartRequired(true);\n      }\n\n      if (modelDirty && targetModel) {\n        const modelResult = await setPrimaryModel(targetModel);\n        if (!modelResult.ok)\n          throw new Error(modelResult.error || \"Failed to set primary model\");\n        const status = await fetchModelStatus();\n        if (status?.ok === false) {\n          throw new Error(status.error || \"Failed to verify primary model\");\n        }\n        const activeModel = status?.modelKey || \"\";\n        if (activeModel && activeModel !== targetModel) {\n          throw new Error(\n            `Primary model did not apply. Expected ${targetModel} but active is ${activeModel}`,\n          );\n        }\n        setSavedModel(targetModel);\n        setModelDirty(false);\n        kProvidersTabCache = {\n          ...(kProvidersTabCache || {}),\n          selectedModel: targetModel,\n          savedModel: targetModel,\n        };\n      }\n\n      await refresh();\n      showToast(\"Changes saved\", \"success\");\n    } catch (err) {\n      showToast(err.message || \"Failed to save changes\", \"error\");\n    } finally {\n      setSavingChanges(false);\n    }\n  };\n\n  const startCodexAuth = () => {\n    if (codexStatus.connected) return;\n    setCodexAuthStarted(true);\n    setCodexAuthWaiting(true);\n    const popup = openCodexAuthWindow();\n    if (!popup || popup.closed) {\n      setCodexAuthWaiting(false);\n      return;\n    }\n    if (codexPopupPollRef.current) {\n      clearInterval(codexPopupPollRef.current);\n    }\n    codexPopupPollRef.current = setInterval(() => {\n      if (popup.closed) {\n        clearInterval(codexPopupPollRef.current);\n        codexPopupPollRef.current = null;\n        setCodexAuthWaiting(false);\n      }\n    }, 500);\n  };\n\n  const completeCodexAuth = async () => {\n    await submitCodexAuthInput(codexManualInput);\n  };\n\n  const handleCodexDisconnect = async () => {\n    const result = await disconnectCodex();\n    if (!result.ok) {\n      showToast(result.error || \"Failed to disconnect Codex\", \"error\");\n      return;\n    }\n    showToast(\"Codex disconnected\", \"success\");\n    setCodexAuthStarted(false);\n    setCodexAuthWaiting(false);\n    setCodexManualInput(\"\");\n    await refreshCodexConnection();\n  };\n\n  const renderCredentialField = (field) => html`\n    <div class=\"space-y-1\">\n      <div class=\"flex items-center gap-3\">\n        <label class=\"text-xs font-medium text-fg-muted\">${field.label}</label>\n        ${field.url && !getKeyVal(envVars, field.key)\n          ? html`<a\n              href=${field.url}\n              target=\"_blank\"\n              class=\"text-xs hover:underline\"\n              style=\"color: var(--accent-link)\"\n              >Get</a\n            >`\n          : null}\n      </div>\n      <${SecretInput}\n        value=${getKeyVal(envVars, field.key)}\n        onInput=${(e) => setEnvValue(field.key, e.target.value)}\n        placeholder=${field.placeholder || \"\"}\n        isSecret=${!field.isText}\n        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\"\n      />\n      ${field.hint\n        ? html`<p class=\"text-xs text-fg-dim\">${field.hint}</p>`\n        : null}\n    </div>\n  `;\n\n  const renderCodexOAuth = () => html`\n    <div class=\"border border-border rounded-lg p-3 space-y-2\">\n      <div class=\"flex items-center justify-between\">\n        <span class=\"text-xs text-fg-muted\">Codex OAuth</span>\n        ${codexStatus.connected\n          ? html`<${Badge} tone=\"success\">Connected</${Badge}>`\n          : html`<${Badge} tone=\"warning\">Not connected</${Badge}>`}\n      </div>\n      ${codexAuthStarted\n        ? html`\n            <div class=\"flex items-center justify-between gap-2\">\n              <p class=\"text-xs text-fg-muted\">\n                ${codexAuthWaiting\n                  ? \"Complete login in the popup. AlphaClaw should finish automatically, but you can paste the redirect URL below if it doesn't.\"\n                  : \"Paste the redirect URL from your browser to finish connecting.\"}\n              </p>\n              <button\n                onclick=${startCodexAuth}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary shrink-0\"\n              >\n                Restart\n              </button>\n            </div>\n          `\n        : codexStatus.connected\n        ? html`\n            <div class=\"flex gap-2\">\n              <button\n                onclick=${startCodexAuth}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary\"\n              >\n                Reconnect Codex\n              </button>\n              <button\n                onclick=${handleCodexDisconnect}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-ghost\"\n              >\n                Disconnect\n              </button>\n            </div>\n          `\n        : html`\n              <button\n                onclick=${startCodexAuth}\n                class=\"text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan\"\n              >\n                Connect Codex OAuth\n              </button>\n            `}\n      ${codexAuthStarted\n        ? html`\n            <p class=\"text-xs text-fg-muted\">\n              After login, copy the full redirect URL (starts with\n              <code class=\"text-xs bg-field px-1 rounded\"\n                >http://localhost:1455/auth/callback</code\n              >) and paste it here.\n            </p>\n            <input\n              type=\"text\"\n              value=${codexManualInput}\n              onInput=${(e) => setCodexManualInput(e.target.value)}\n              placeholder=\"http://localhost:1455/auth/callback?code=...&state=...\"\n              class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body outline-none focus:border-fg-muted\"\n            />\n            <${ActionButton}\n              onClick=${completeCodexAuth}\n              disabled=${!codexManualInput.trim() || codexExchanging}\n              loading=${codexExchanging}\n              tone=\"primary\"\n              size=\"sm\"\n              idleLabel=\"Complete Codex OAuth\"\n              loadingLabel=\"Completing...\"\n              className=\"text-xs font-medium px-3 py-1.5\"\n            />\n          `\n        : null}\n    </div>\n  `;\n\n  const providerHasKey = (provider) => {\n    const fields = kProviderAuthFields[provider] || [];\n    return fields.some((f) => !!getKeyVal(envVars, f.key));\n  };\n\n  const renderProviderCard = (provider) => {\n    const fields = kProviderAuthFields[provider] || [];\n    const hasCodex = provider === \"openai\";\n    const hasKey = providerHasKey(provider);\n    const openAiFeatures = kProviderFeatures.openai || [];\n    return html`\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n        <div class=\"flex items-center gap-2\">\n          <h3 class=\"font-semibold text-sm\">\n            ${kProviderLabels[provider] || provider}\n          </h3>\n          ${hasKey\n            ? html`<span\n                class=\"inline-block w-1.5 h-1.5 rounded-full bg-green-500\"\n              />`\n            : null}\n        </div>\n        ${fields.map((field) => renderCredentialField(field))}\n        ${provider === \"openai\"\n          ? html`<${FeatureTags} features=${openAiFeatures} />`\n          : null}\n        ${hasCodex ? renderCodexOAuth() : null}\n        ${provider !== \"openai\"\n          ? html`<${FeatureTags} provider=${provider} />`\n          : null}\n      </div>\n    `;\n  };\n\n  if (!ready) {\n    return html`\n      <div class=\"space-y-4\">\n        <${PageHeader}\n          title=\"Providers\"\n          actions=${html`\n            <${ActionButton}\n              disabled=${true}\n              tone=\"primary\"\n              size=\"sm\"\n              idleLabel=\"Save changes\"\n              className=\"transition-all\"\n            />\n          `}\n        />\n        <div class=\"bg-surface border border-border rounded-xl p-4\">\n          <div class=\"flex items-center gap-2 text-sm text-fg-muted\">\n            <${LoadingSpinner} className=\"h-4 w-4\" />\n            Loading provider settings...\n          </div>\n        </div>\n      </div>\n    `;\n  }\n\n  const renderPrimaryProviderContent = () => {\n    const fields = kProviderAuthFields[primaryProvider] || [];\n    const hasCodex = primaryProvider === \"openai\";\n    return html`\n      ${fields.map((field) => renderCredentialField(field))}\n      ${hasCodex ? renderCodexOAuth() : null}\n    `;\n  };\n\n  return html`\n    <div class=\"space-y-4\">\n      <${PageHeader}\n        title=\"Providers\"\n        actions=${html`\n          <${ActionButton}\n            onClick=${saveChanges}\n            disabled=${!canSaveChanges}\n            loading=${savingChanges}\n            tone=\"primary\"\n            size=\"sm\"\n            idleLabel=\"Save changes\"\n            loadingLabel=\"Saving...\"\n            className=\"transition-all\"\n          />\n        `}\n      />\n\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n        <h2 class=\"font-semibold text-sm\">Primary Agent Model</h2>\n        <select\n          value=${selectedModel}\n          onInput=${(e) => {\n            const next = e.target.value;\n            setSelectedModel(next);\n            setModelDirty(next !== savedModel);\n            kProvidersTabCache = {\n              ...(kProvidersTabCache || {}),\n              selectedModel: next,\n            };\n          }}\n          class=\"w-full bg-field border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-body outline-none focus:border-fg-muted\"\n        >\n          <option value=\"\">Select a model</option>\n          ${modelOptions.map(\n            (model) =>\n              html`<option value=${model.key}>\n                ${model.label || model.key}\n              </option>`,\n          )}\n        </select>\n        <p class=\"text-xs text-fg-dim\">\n          ${modelsLoading\n            ? \"Loading model catalog...\"\n            : modelsError\n              ? modelsError\n              : \"\"}\n        </p>\n        ${canToggleFullCatalog\n          ? html`\n              <div>\n                <button\n                  type=\"button\"\n                  onclick=${() =>\n                    setShowAllModels((prev) => {\n                      const next = !prev;\n                      kProvidersTabCache = {\n                        ...(kProvidersTabCache || {}),\n                        showAllModels: next,\n                      };\n                      return next;\n                    })}\n                  class=\"text-xs text-fg-muted hover:text-body\"\n                >\n                  ${showAllModels\n                    ? \"Show recommended models\"\n                    : \"Show full model catalog\"}\n                </button>\n              </div>\n            `\n          : null}\n        <div class=\"pt-2 border-t border-border space-y-3\">\n          ${renderPrimaryProviderContent()}\n        </div>\n      </div>\n\n      ${otherProviders\n        .filter((p) => kCoreProviders.has(p))\n        .map((provider) => renderProviderCard(provider))}\n      ${showMoreProviders\n        ? otherProviders\n            .filter((p) => !kCoreProviders.has(p))\n            .map((provider) => renderProviderCard(provider))\n        : null}\n      ${otherProviders.some((p) => !kCoreProviders.has(p))\n        ? html`\n            <button\n              type=\"button\"\n              onclick=${() => setShowMoreProviders((prev) => !prev)}\n              class=\"w-full text-xs px-3 py-1.5 rounded-lg ac-btn-ghost\"\n            >\n              ${showMoreProviders\n                ? \"Hide additional providers\"\n                : \"More providers\"}\n            </button>\n          `\n        : null}\n      ${modelDirty && !hasSelectedProviderAuth\n        ? html`\n            <p class=\"text-xs text-status-warning-muted\">\n              Set credentials for the selected provider before saving this model\n              change.\n            </p>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/routes/agents-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { AgentsTab } from \"../agents-tab/index.js\";\n\nconst html = htm.bind(h);\n\nexport const AgentsRoute = ({\n  agents = [],\n  loading = false,\n  saving = false,\n  agentsActions = {},\n  selectedAgentId = \"\",\n  activeTab = \"overview\",\n  onSelectAgent = () => {},\n  onSelectTab = () => {},\n  onNavigateToBrowseFile = () => {},\n  onSetLocation = () => {},\n}) => html`\n  <${AgentsTab}\n    agents=${agents}\n    loading=${loading}\n    saving=${saving}\n    agentsActions=${agentsActions}\n    selectedAgentId=${selectedAgentId}\n    activeTab=${activeTab}\n    onSelectAgent=${onSelectAgent}\n    onSelectTab=${onSelectTab}\n    onNavigateToBrowseFile=${onNavigateToBrowseFile}\n    onSetLocation=${onSetLocation}\n  />\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/browse-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { FileViewer } from \"../file-viewer/index.js\";\n\nconst html = htm.bind(h);\n\nexport const BrowseRoute = ({\n  activeBrowsePath = \"\",\n  browseView = \"edit\",\n  lineTarget = 0,\n  lineEndTarget = 0,\n  selectedBrowsePath = \"\",\n  onNavigateToBrowseFile = () => {},\n  onEditSelectedBrowseFile = () => {},\n  onClearSelection = () => {},\n}) => html`\n  <div class=\"w-full\">\n    <${FileViewer}\n      filePath=${activeBrowsePath}\n      isPreviewOnly=${false}\n      browseView=${browseView}\n      lineTarget=${lineTarget}\n      lineEndTarget=${lineEndTarget}\n      onRequestEdit=${(targetPath) => {\n        const normalizedTargetPath = String(targetPath || \"\");\n        if (normalizedTargetPath && normalizedTargetPath !== selectedBrowsePath) {\n          onNavigateToBrowseFile(normalizedTargetPath, { view: \"edit\" });\n          return;\n        }\n        onEditSelectedBrowseFile();\n      }}\n      onRequestClearSelection=${onClearSelection}\n    />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/chat-route.js",
    "content": "import { h } from \"preact\";\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport htm from \"htm\";\nimport { marked } from \"marked\";\nimport { authFetch } from \"../../lib/api.js\";\nimport { kChatSessionDraftsStorageKey } from \"../../lib/storage-keys.js\";\nimport { getSessionDisplayLabel } from \"../../lib/session-keys.js\";\nimport { showToast } from \"../toast.js\";\n\nconst html = htm.bind(h);\nconst kWsReconnectMaxAttempts = 8;\nconst kAutoscrollBottomThresholdPx = 40;\nconst kChatDebugQueryFlag = \"chatDebug\";\nconst kComposerMaxLines = 5;\nconst kComposerFontSizePx = 12;\nconst kComposerLineHeight = 1.4;\nconst kComposerPaddingYPx = 20;\n\nconst resizeComposerTextarea = (element) => {\n  if (!element) return;\n  const linePx = kComposerFontSizePx * kComposerLineHeight;\n  const minH = linePx + kComposerPaddingYPx;\n  const maxH = linePx * kComposerMaxLines + kComposerPaddingYPx;\n  element.style.height = \"auto\";\n  const next = Math.min(Math.max(element.scrollHeight, minH), maxH);\n  element.style.height = `${next}px`;\n};\n\nconst buildMessage = ({\n  role = \"assistant\",\n  content = \"\",\n  createdAt = Date.now(),\n  debugPayload = null,\n} = {}) => ({\n  id: crypto.randomUUID(),\n  role,\n  content: String(content || \"\"),\n  createdAt: Number(createdAt) || Date.now(),\n  debugPayload,\n});\n\nconst formatChatTime = (createdAt) => {\n  const value = Number(createdAt || 0);\n  if (!value) return \"\";\n  try {\n    return new Date(value).toLocaleTimeString([], {\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n    });\n  } catch {\n    return \"\";\n  }\n};\n\nconst escapeHtmlForMarkdown = (value = \"\") =>\n  String(value || \"\")\n    .replaceAll(\"&\", \"&amp;\")\n    .replaceAll(\"<\", \"&lt;\")\n    .replaceAll(\">\", \"&gt;\");\n\nconst normalizeMarkdownInput = (value = \"\") => {\n  const source = String(value || \"\").replace(/\\r\\n/g, \"\\n\");\n  if (source.includes(\"\\n\")) return source;\n  // Some runtimes persist escaped sequences in history payloads.\n  return source.includes(\"\\\\n\") ? source.replace(/\\\\n/g, \"\\n\") : source;\n};\n\nconst normalizeListMarkers = (value = \"\") =>\n  String(value || \"\").replace(/^(\\s*)\\d+\\.\\s+/gm, \"$1- \");\n\nconst parseJsonMessage = (value = \"\") => {\n  const source = String(value || \"\").trim();\n  if (!source) return null;\n  if (!(source.startsWith(\"{\") || source.startsWith(\"[\"))) return null;\n  try {\n    return JSON.parse(source);\n  } catch {\n    return null;\n  }\n};\n\nconst extractToolCallsFromPayload = (payload = null) => {\n  const normalizedPayload =\n    payload && typeof payload === \"object\" ? payload : {};\n  if (\n    Array.isArray(normalizedPayload?.toolCalls) &&\n    normalizedPayload.toolCalls.length > 0\n  ) {\n    return normalizedPayload.toolCalls;\n  }\n  const rawParts = Array.isArray(normalizedPayload?.rawMessage?.content)\n    ? normalizedPayload.rawMessage.content\n    : [];\n  return rawParts\n    .filter((part) => String(part?.type || \"\").toLowerCase() === \"toolcall\")\n    .map((part) => ({\n      id: String(part?.id || \"\"),\n      name: String(part?.name || \"\"),\n      arguments: part?.arguments || null,\n      partialJson: String(part?.partialJson || \"\"),\n    }))\n    .filter((toolCall) => toolCall.name || toolCall.id);\n};\n\nconst normalizeToolResult = (toolResult = null) => {\n  if (!toolResult || typeof toolResult !== \"object\") return null;\n  const rawMessage = toolResult?.rawMessage || toolResult;\n  if (!rawMessage || typeof rawMessage !== \"object\") return null;\n  const contentParts = Array.isArray(rawMessage?.content) ? rawMessage.content : [];\n  const text = contentParts\n    .map((part) => String(part?.text || \"\"))\n    .filter((value) => value.length > 0)\n    .join(\"\\n\")\n    .trim();\n  return {\n    toolCallId: String(rawMessage?.toolCallId || toolResult?.toolCallId || \"\"),\n    toolName: String(rawMessage?.toolName || toolResult?.toolName || \"\"),\n    text,\n    isError: Boolean(\n      rawMessage?.isError === true ||\n        toolResult?.isError === true ||\n        String(rawMessage?.status || \"\").toLowerCase() === \"error\",\n    ),\n    rawMessage,\n  };\n};\n\nconst buildToolMessage = ({\n  toolCall = null,\n  toolResult = null,\n  createdAt = Date.now(),\n  debugPayload = null,\n} = {}) => {\n  const normalizedToolCall =\n    toolCall && typeof toolCall === \"object\" ? toolCall : {};\n  const name = String(\n    normalizedToolCall?.name || toolResult?.toolName || \"unknown\",\n  );\n  return buildMessage({\n    role: \"tool\",\n    content: `Tool call: ${name}`,\n    createdAt,\n    debugPayload:\n      debugPayload ||\n      ({\n        timestamp: createdAt,\n        metadata: null,\n        rawMessage: null,\n        toolCalls: normalizedToolCall?.name || normalizedToolCall?.id ? [normalizedToolCall] : [],\n        toolResult: toolResult || null,\n      }),\n  });\n};\n\nconst renderMarkdownHtml = (value = \"\") =>\n  marked.parse(\n    escapeHtmlForMarkdown(normalizeListMarkers(normalizeMarkdownInput(value))),\n    {\n      gfm: true,\n      breaks: true,\n    },\n  );\n\nexport const ChatRoute = ({ sessions = [], selectedSessionKey = \"\" }) => {\n  const [messagesBySession, setMessagesBySession] = useState({});\n  const [draft, setDraft] = useState(\"\");\n  const [draftBySession, setDraftBySession] = useState(() => {\n    try {\n      const rawValue = localStorage.getItem(kChatSessionDraftsStorageKey);\n      if (!rawValue) return {};\n      const parsed = JSON.parse(rawValue);\n      return parsed && typeof parsed === \"object\" ? parsed : {};\n    } catch {\n      return {};\n    }\n  });\n  const [sending, setSending] = useState(false);\n  const [streaming, setStreaming] = useState(false);\n  const [isConnected, setIsConnected] = useState(false);\n  const [rawHistoryBySession, setRawHistoryBySession] = useState({});\n  const [debugEventsBySession, setDebugEventsBySession] = useState({});\n  const [activeRunBySession, setActiveRunBySession] = useState({});\n  const [connectionError, setConnectionError] = useState(\"\");\n  const [historyLoading, setHistoryLoading] = useState(false);\n  const [assistantStreamStarted, setAssistantStreamStarted] = useState(false);\n  const wsRef = useRef(null);\n  const threadRef = useRef(null);\n  const composerRef = useRef(null);\n  const reconnectTimerRef = useRef(null);\n  const reconnectAttemptsRef = useRef(0);\n  const selectedSessionKeyRef = useRef(selectedSessionKey);\n  const realtimeDisabledRef = useRef(false);\n  const shouldAutoScrollRef = useRef(true);\n  const appendDebugEvent = useCallback((sessionKey, label, payload) => {\n    const normalizedSessionKey = String(\n      sessionKey || selectedSessionKeyRef.current || \"\",\n    );\n    if (!normalizedSessionKey) return;\n    const nextEvent = {\n      id: crypto.randomUUID(),\n      at: Date.now(),\n      label: String(label || \"\"),\n      payload: payload ?? null,\n    };\n    setDebugEventsBySession((currentMap) => {\n      const existing = currentMap[normalizedSessionKey] || [];\n      const nextList = [...existing, nextEvent].slice(-30);\n      return {\n        ...currentMap,\n        [normalizedSessionKey]: nextList,\n      };\n    });\n  }, []);\n\n  useEffect(() => {\n    selectedSessionKeyRef.current = selectedSessionKey;\n  }, [selectedSessionKey]);\n\n  useEffect(() => {\n    setAssistantStreamStarted(false);\n  }, [selectedSessionKey]);\n\n  useLayoutEffect(() => {\n    resizeComposerTextarea(composerRef.current);\n  }, [draft, selectedSessionKey]);\n\n  useEffect(() => {\n    if (!selectedSessionKey) return;\n    setDraft(String(draftBySession[selectedSessionKey] || \"\"));\n  }, [draftBySession, selectedSessionKey]);\n\n  useEffect(() => {\n    try {\n      localStorage.setItem(\n        kChatSessionDraftsStorageKey,\n        JSON.stringify(draftBySession),\n      );\n    } catch {}\n  }, [draftBySession]);\n\n  const selectedSession = useMemo(\n    () =>\n      sessions.find(\n        (sessionRow) =>\n          String(sessionRow?.key || \"\") === String(selectedSessionKey || \"\"),\n      ) || null,\n    [selectedSessionKey, sessions],\n  );\n  const chatDebugEnabled = useMemo(() => {\n    try {\n      const params = new URLSearchParams(window.location.search || \"\");\n      return params.get(kChatDebugQueryFlag) === \"1\";\n    } catch {\n      return false;\n    }\n  }, []);\n\n  const messages = useMemo(\n    () => messagesBySession[selectedSessionKey] || [],\n    [messagesBySession, selectedSessionKey],\n  );\n\n  useEffect(() => {\n    let mounted = true;\n\n    const connect = () => {\n      if (realtimeDisabledRef.current) return;\n      const protocol = window.location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n      const ws = new WebSocket(\n        `${protocol}//${window.location.host}/api/ws/chat`,\n      );\n      wsRef.current = ws;\n\n      ws.onopen = () => {\n        if (!mounted) return;\n        setIsConnected(true);\n        setConnectionError(\"\");\n        reconnectAttemptsRef.current = 0;\n        const currentSessionKey = String(selectedSessionKeyRef.current || \"\");\n        if (currentSessionKey) {\n          setHistoryLoading(true);\n          ws.send(\n            JSON.stringify({\n              type: \"history\",\n              sessionKey: currentSessionKey,\n            }),\n          );\n        }\n      };\n\n      ws.onclose = () => {\n        if (!mounted) return;\n        setIsConnected(false);\n        setStreaming(false);\n        setSending(false);\n        setAssistantStreamStarted(false);\n        setHistoryLoading(false);\n        if (realtimeDisabledRef.current) return;\n        if (reconnectAttemptsRef.current >= kWsReconnectMaxAttempts) return;\n        const delayMs = Math.min(\n          1000 * 2 ** reconnectAttemptsRef.current,\n          5000,\n        );\n        reconnectAttemptsRef.current += 1;\n        setConnectionError(\"Realtime chat socket disconnected.\");\n        reconnectTimerRef.current = setTimeout(connect, delayMs);\n      };\n\n      ws.onerror = () => {\n        if (!mounted) return;\n        setIsConnected(false);\n        setHistoryLoading(false);\n        setConnectionError(\"Realtime chat socket failed to connect.\");\n      };\n\n      ws.onmessage = (event) => {\n        let payload = null;\n        try {\n          payload = JSON.parse(String(event?.data || \"\"));\n        } catch {\n          return;\n        }\n        if (!payload || typeof payload !== \"object\") return;\n        appendDebugEvent(\n          String(payload.sessionKey || selectedSessionKeyRef.current || \"\"),\n          `ws:${String(payload.type || \"unknown\")}`,\n          payload,\n        );\n\n        if (payload.type === \"history\") {\n          const historySessionKey = String(payload.sessionKey || \"\");\n          if (!historySessionKey) return;\n          const historyMessages = (\n            Array.isArray(payload.messages) ? payload.messages : []\n          )\n            .map((messageRow) =>\n              buildMessage({\n                role: String(messageRow?.role || \"assistant\"),\n                content: String(messageRow?.content || \"\"),\n                createdAt: Number(messageRow?.timestamp) || Date.now(),\n                debugPayload: messageRow || null,\n              }),\n            )\n            .filter(\n              (messageRow) =>\n                String(messageRow.content || \"\").trim() ||\n                extractToolCallsFromPayload(messageRow?.debugPayload).length >\n                  0,\n            );\n          setMessagesBySession((currentMap) => ({\n            ...currentMap,\n            [historySessionKey]: historyMessages,\n          }));\n          setRawHistoryBySession((currentMap) => ({\n            ...currentMap,\n            [historySessionKey]: payload.rawHistory || null,\n          }));\n          setHistoryLoading(false);\n          return;\n        }\n\n        if (payload.type === \"chunk\") {\n          const chunkSessionKey = String(\n            payload.sessionKey || selectedSessionKeyRef.current || \"\",\n          );\n          const messageId = String(payload.messageId || \"\");\n          const chunkText = String(payload.content || \"\");\n          if (!chunkSessionKey || !messageId) return;\n          setSending(false);\n          setStreaming(true);\n          setAssistantStreamStarted(true);\n          setMessagesBySession((currentMap) => {\n            const currentMessages = currentMap[chunkSessionKey] || [];\n            const lastMessage = currentMessages[currentMessages.length - 1];\n            if (\n              lastMessage &&\n              lastMessage.role === \"assistant\" &&\n              String(lastMessage.id || \"\") === messageId\n            ) {\n              return {\n                ...currentMap,\n                [chunkSessionKey]: [\n                  ...currentMessages.slice(0, -1),\n                  {\n                    ...lastMessage,\n                    content: `${String(lastMessage.content || \"\")}${chunkText}`,\n                    debugPayload: {\n                      ...(lastMessage?.debugPayload || {}),\n                      source: \"stream\",\n                      messageId,\n                      sessionKey: chunkSessionKey,\n                      chunkCount:\n                        Number(lastMessage?.debugPayload?.chunkCount || 1) + 1,\n                      lastChunk: chunkText,\n                    },\n                  },\n                ],\n              };\n            }\n            return {\n              ...currentMap,\n              [chunkSessionKey]: [\n                ...currentMessages,\n                {\n                  id: messageId,\n                  role: \"assistant\",\n                  content: chunkText,\n                  createdAt: Date.now(),\n                  debugPayload: {\n                    source: \"stream\",\n                    messageId,\n                    sessionKey: chunkSessionKey,\n                    chunkCount: 1,\n                    lastChunk: chunkText,\n                  },\n                },\n              ],\n            };\n          });\n          return;\n        }\n\n        if (payload.type === \"tool\") {\n          const toolSessionKey = String(\n            payload.sessionKey || selectedSessionKeyRef.current || \"\",\n          );\n          if (!toolSessionKey) return;\n          setSending(false);\n          setAssistantStreamStarted(true);\n          const toolPhase = String(payload.phase || \"\").toLowerCase();\n          const toolCall =\n            payload?.toolCall && typeof payload.toolCall === \"object\"\n              ? payload.toolCall\n              : null;\n          const toolResult =\n            payload?.toolResult && typeof payload.toolResult === \"object\"\n              ? payload.toolResult\n              : null;\n          const toolCallId = String(\n            toolCall?.id || toolResult?.toolCallId || payload?.toolCallId || \"\",\n          );\n          const toolTimestamp = Number(payload.timestamp) || Date.now();\n          setMessagesBySession((currentMap) => {\n            const currentMessages = currentMap[toolSessionKey] || [];\n            if (toolPhase === \"result\") {\n              let matched = false;\n              const nextMessages = currentMessages.map((messageRow) => {\n                if (matched || messageRow.role !== \"tool\") return messageRow;\n                const messageToolCalls = extractToolCallsFromPayload(\n                  messageRow.debugPayload,\n                );\n                const messageToolCallId = String(messageToolCalls?.[0]?.id || \"\");\n                const messageToolName = String(messageToolCalls?.[0]?.name || \"\");\n                const resultToolName = String(toolResult?.toolName || \"\");\n                const hasResultAlready = Boolean(\n                  normalizeToolResult(messageRow?.debugPayload?.toolResult),\n                );\n                const shouldMatchById =\n                  toolCallId && messageToolCallId && messageToolCallId === toolCallId;\n                const shouldMatchByNameFallback =\n                  !toolCallId &&\n                  !messageToolCallId &&\n                  resultToolName &&\n                  messageToolName === resultToolName &&\n                  !hasResultAlready;\n                if (!shouldMatchById && !shouldMatchByNameFallback) {\n                  return messageRow;\n                }\n                matched = true;\n                return {\n                  ...messageRow,\n                  debugPayload: {\n                    ...(messageRow.debugPayload || {}),\n                    toolResult: toolResult || null,\n                    rawEvent: payload?.rawEvent || null,\n                  },\n                };\n              });\n              if (matched) {\n                return {\n                  ...currentMap,\n                  [toolSessionKey]: nextMessages,\n                };\n              }\n            }\n            if (toolPhase === \"call\" && toolCall) {\n              const duplicateCall = currentMessages.some((messageRow) => {\n                if (messageRow.role !== \"tool\") return false;\n                const existingCall = extractToolCallsFromPayload(\n                  messageRow.debugPayload,\n                )[0];\n                if (!existingCall) return false;\n                const existingId = String(existingCall?.id || \"\");\n                if (toolCallId && existingId && existingId === toolCallId) {\n                  return true;\n                }\n                const existingName = String(existingCall?.name || \"\");\n                const incomingName = String(toolCall?.name || \"\");\n                return !toolCallId && existingName && incomingName && existingName === incomingName;\n              });\n              if (duplicateCall) return currentMap;\n              return {\n                ...currentMap,\n                [toolSessionKey]: [\n                  ...currentMessages,\n                  buildToolMessage({\n                    toolCall,\n                    createdAt: toolTimestamp,\n                    debugPayload: {\n                      timestamp: toolTimestamp,\n                      metadata: null,\n                      rawMessage: null,\n                      toolCalls: [toolCall],\n                      toolResult: null,\n                      rawEvent: payload?.rawEvent || null,\n                    },\n                  }),\n                ],\n              };\n            }\n            if (toolPhase === \"result\" && toolResult) {\n              return {\n                ...currentMap,\n                [toolSessionKey]: [\n                  ...currentMessages,\n                  buildToolMessage({\n                    toolCall: toolCall || {\n                      id: String(toolResult?.toolCallId || \"\"),\n                      name: String(toolResult?.toolName || \"unknown\"),\n                      arguments: null,\n                      partialJson: \"\",\n                    },\n                    toolResult,\n                    createdAt: toolTimestamp,\n                    debugPayload: {\n                      timestamp: toolTimestamp,\n                      metadata: null,\n                      rawMessage: null,\n                      toolCalls: toolCall ? [toolCall] : [],\n                      toolResult,\n                      rawEvent: payload?.rawEvent || null,\n                    },\n                  }),\n                ],\n              };\n            }\n            return currentMap;\n          });\n          return;\n        }\n\n        if (payload.type === \"started\") {\n          const nextSessionKey = String(\n            payload.sessionKey || selectedSessionKeyRef.current || \"\",\n          );\n          const runId = String(payload.runId || \"\");\n          if (!nextSessionKey || !runId) return;\n          setSending(false);\n          setActiveRunBySession((currentMap) => ({\n            ...currentMap,\n            [nextSessionKey]: runId,\n          }));\n          return;\n        }\n\n        if (payload.type === \"done\") {\n          const doneSessionKey = String(\n            payload.sessionKey || selectedSessionKeyRef.current || \"\",\n          );\n          if (doneSessionKey) {\n            setActiveRunBySession((currentMap) => {\n              const nextMap = { ...currentMap };\n              delete nextMap[doneSessionKey];\n              return nextMap;\n            });\n          }\n          setSending(false);\n          setStreaming(false);\n          setAssistantStreamStarted(false);\n          setHistoryLoading(false);\n          if (doneSessionKey && ws && ws.readyState === 1) {\n            setHistoryLoading(true);\n            appendDebugEvent(doneSessionKey, \"ws:history-request-after-done\", {\n              type: \"history\",\n              sessionKey: doneSessionKey,\n            });\n            ws.send(\n              JSON.stringify({\n                type: \"history\",\n                sessionKey: doneSessionKey,\n              }),\n            );\n          }\n          return;\n        }\n\n        if (payload.type === \"error\") {\n          setSending(false);\n          setStreaming(false);\n          setAssistantStreamStarted(false);\n          setHistoryLoading(false);\n          const errorSessionKey = String(\n            payload.sessionKey || selectedSessionKeyRef.current || \"\",\n          );\n          if (errorSessionKey) {\n            setActiveRunBySession((currentMap) => {\n              const nextMap = { ...currentMap };\n              delete nextMap[errorSessionKey];\n              return nextMap;\n            });\n            setMessagesBySession((currentMap) => ({\n              ...currentMap,\n              [errorSessionKey]: [\n                ...(currentMap[errorSessionKey] || []),\n                buildMessage({\n                  role: \"assistant\",\n                  content:\n                    String(payload.message || \"\").trim() ||\n                    \"Something went wrong.\",\n                }),\n              ],\n            }));\n          }\n          if (payload.message) showToast(String(payload.message), \"error\");\n        }\n      };\n    };\n\n    connect();\n\n    return () => {\n      mounted = false;\n      if (reconnectTimerRef.current) {\n        clearTimeout(reconnectTimerRef.current);\n      }\n      const ws = wsRef.current;\n      wsRef.current = null;\n      if (ws) ws.close();\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!selectedSessionKey) return;\n    const ws = wsRef.current;\n    if (ws && ws.readyState === 1) {\n      setHistoryLoading(true);\n      appendDebugEvent(selectedSessionKey, \"ws:history-request\", {\n        type: \"history\",\n        sessionKey: selectedSessionKey,\n      });\n      ws.send(\n        JSON.stringify({\n          type: \"history\",\n          sessionKey: selectedSessionKey,\n        }),\n      );\n      return;\n    }\n    // Fallback for environments where websocket upgrade is unavailable:\n    // load history over HTTP so the UI can still show prior messages.\n    let cancelled = false;\n    const loadHistory = async () => {\n      try {\n        setHistoryLoading(true);\n        const response = await authFetch(\n          `/api/chat/history?sessionKey=${encodeURIComponent(selectedSessionKey)}`,\n        );\n        const payload = await response.json();\n        if (cancelled) return;\n        if (!response.ok || payload?.ok === false) {\n          throw new Error(payload?.error || \"Could not load chat history\");\n        }\n        appendDebugEvent(selectedSessionKey, \"http:history-response\", payload);\n        const historyMessages = (\n          Array.isArray(payload.messages) ? payload.messages : []\n        )\n          .map((messageRow) =>\n            buildMessage({\n              role: String(messageRow?.role || \"assistant\"),\n              content: String(messageRow?.content || \"\"),\n              createdAt: Number(messageRow?.timestamp) || Date.now(),\n              debugPayload: messageRow || null,\n            }),\n          )\n          .filter(\n            (messageRow) =>\n              String(messageRow.content || \"\").trim() ||\n              extractToolCallsFromPayload(messageRow?.debugPayload).length > 0,\n          );\n        setMessagesBySession((currentMap) => ({\n          ...currentMap,\n          [selectedSessionKey]: historyMessages,\n        }));\n        setRawHistoryBySession((currentMap) => ({\n          ...currentMap,\n          [selectedSessionKey]: payload.rawHistory || null,\n        }));\n        if (!isConnected) {\n          // If HTTP history works while WS is down, stop noisy reconnect loops.\n          realtimeDisabledRef.current = true;\n          if (reconnectTimerRef.current) {\n            clearTimeout(reconnectTimerRef.current);\n            reconnectTimerRef.current = null;\n          }\n          const ws = wsRef.current;\n          if (ws) ws.close();\n          setConnectionError(\"Realtime unavailable; using HTTP fallback.\");\n        }\n      } catch (err) {\n        if (cancelled) return;\n        const errorMessage = err.message || \"Could not load chat history.\";\n        appendDebugEvent(selectedSessionKey, \"http:history-error\", {\n          error: errorMessage,\n        });\n        if (\n          errorMessage.toLowerCase().includes(\"runtime unavailable\") ||\n          errorMessage.toLowerCase().includes(\"websocket unavailable\")\n        ) {\n          realtimeDisabledRef.current = true;\n          if (reconnectTimerRef.current) {\n            clearTimeout(reconnectTimerRef.current);\n            reconnectTimerRef.current = null;\n          }\n          const ws = wsRef.current;\n          if (ws) ws.close();\n          setConnectionError(\n            \"Chat runtime unavailable (missing server dependency).\",\n          );\n        } else {\n          setConnectionError(errorMessage);\n        }\n      } finally {\n        if (!cancelled) setHistoryLoading(false);\n      }\n    };\n    loadHistory();\n    return () => {\n      cancelled = true;\n    };\n  }, [isConnected, selectedSessionKey]);\n\n  const handleThreadScroll = useCallback(() => {\n    const threadElement = threadRef.current;\n    if (!threadElement) return;\n    const distanceFromBottom =\n      threadElement.scrollHeight -\n      threadElement.scrollTop -\n      threadElement.clientHeight;\n    shouldAutoScrollRef.current =\n      distanceFromBottom <= kAutoscrollBottomThresholdPx;\n  }, []);\n\n  useEffect(() => {\n    const threadElement = threadRef.current;\n    if (!threadElement) return;\n    if (!shouldAutoScrollRef.current) return;\n    threadElement.scrollTop = threadElement.scrollHeight;\n  }, [messages, historyLoading, streaming]);\n\n  const handleDraftInput = useCallback(\n    (event) => {\n      const nextValue = String(event?.target?.value || \"\");\n      setDraft(nextValue);\n      if (!selectedSessionKey) return;\n      setDraftBySession((currentMap) => ({\n        ...currentMap,\n        [selectedSessionKey]: nextValue,\n      }));\n    },\n    [selectedSessionKey],\n  );\n\n  const handleSend = useCallback(() => {\n    const messageText = String(draft || \"\").trim();\n    const ws = wsRef.current;\n    if (!messageText || !selectedSessionKey || sending || streaming) return;\n    if (!ws || ws.readyState !== 1) {\n      showToast(\n        \"Chat websocket is unavailable in this environment.\",\n        \"warning\",\n      );\n      return;\n    }\n\n    const userMessage = buildMessage({\n      role: \"user\",\n      content: messageText,\n      debugPayload: {\n        source: \"composer\",\n        type: \"message\",\n        content: messageText,\n        sessionKey: selectedSessionKey,\n      },\n    });\n    setDraft(\"\");\n    setDraftBySession((currentMap) => ({\n      ...currentMap,\n      [selectedSessionKey]: \"\",\n    }));\n    setAssistantStreamStarted(false);\n    setSending(true);\n    setMessagesBySession((currentMap) => ({\n      ...currentMap,\n      [selectedSessionKey]: [\n        ...(currentMap[selectedSessionKey] || []),\n        userMessage,\n      ],\n    }));\n    setStreaming(true);\n    ws.send(\n      JSON.stringify({\n        type: \"message\",\n        content: messageText,\n        sessionKey: selectedSessionKey,\n      }),\n    );\n    appendDebugEvent(selectedSessionKey, \"ws:message-request\", {\n      type: \"message\",\n      content: messageText,\n      sessionKey: selectedSessionKey,\n    });\n  }, [appendDebugEvent, draft, selectedSessionKey, sending, streaming]);\n\n  const handleStop = useCallback(() => {\n    const ws = wsRef.current;\n    if (!ws || ws.readyState !== 1 || !selectedSessionKey) return;\n    ws.send(\n      JSON.stringify({\n        type: \"stop\",\n        sessionKey: selectedSessionKey,\n      }),\n    );\n    appendDebugEvent(selectedSessionKey, \"ws:stop-request\", {\n      type: \"stop\",\n      sessionKey: selectedSessionKey,\n    });\n    setStreaming(false);\n    setSending(false);\n    setAssistantStreamStarted(false);\n  }, [appendDebugEvent, selectedSessionKey]);\n\n  const handleComposerKeyDown = useCallback(\n    (event) => {\n      if (event.key !== \"Enter\") return;\n      if (event.shiftKey) return;\n      if (event.isComposing) return;\n      event.preventDefault();\n      handleSend();\n    },\n    [handleSend],\n  );\n\n  const rawHistory = selectedSessionKey\n    ? rawHistoryBySession[selectedSessionKey]\n    : null;\n  const debugEvents = selectedSessionKey\n    ? debugEventsBySession[selectedSessionKey] || []\n    : [];\n\n  return html`\n    <div class=\"chat-route-shell\">\n      <div class=\"chat-route-header\">\n        <div>\n          <div class=\"chat-route-title\">Chat</div>\n          <div class=\"chat-route-subtitle\">\n            ${getSessionDisplayLabel(selectedSession) ||\n            \"Pick a session in the sidebar\"}\n          </div>\n          ${connectionError\n            ? html`<div class=\"chat-route-warning\">${connectionError}</div>`\n            : null}\n        </div>\n      </div>\n      <div class=\"chat-thread\" ref=${threadRef} onscroll=${handleThreadScroll}>\n        ${!selectedSessionKey\n          ? html`<div class=\"chat-empty-state\">\n              Select a session to begin chatting.\n            </div>`\n          : historyLoading\n            ? html`<div class=\"chat-empty-state\">Loading history...</div>`\n            : messages.length === 0\n              ? html`<div class=\"chat-empty-state\">\n                  Start a message in this session.\n                </div>`\n              : messages.map(\n                  (message) => html`\n                    ${(() => {\n                      const toolCalls = extractToolCallsFromPayload(\n                        message.debugPayload,\n                      );\n                      const hasVisibleContent =\n                        String(message.content || \"\").trim().length > 0;\n                      const isToolMessage = message.role === \"tool\";\n                      const shouldRenderContent =\n                        hasVisibleContent &&\n                        !isToolMessage &&\n                        !(\n                          toolCalls.length > 0 &&\n                          String(message.content || \"\").startsWith(\n                            \"Tool calls:\",\n                          )\n                        );\n                      const primaryToolCall = toolCalls[0] || null;\n                      const matchedResult = normalizeToolResult(\n                        message?.debugPayload?.toolResult || null,\n                      );\n                      return html`\n                        <div\n                          key=${message.id}\n                          class=${`chat-bubble ${message.role === \"user\" ? \"is-user\" : \"is-assistant\"}`}\n                        >\n                          ${!isToolMessage\n                            ? html`\n                                <div class=\"chat-bubble-meta\">\n                                  <span\n                                    >${message.role === \"user\"\n                                      ? \"You\"\n                                      : \"Agent\"}</span\n                                  >\n                                  <span\n                                    >${formatChatTime(message.createdAt)}</span\n                                  >\n                                </div>\n                              `\n                            : null}\n                          ${isToolMessage && primaryToolCall\n                            ? html`\n                                <details class=\"chat-tool-inline-message\">\n                                  <summary>\n                                    <span class=\"chat-tool-inline-icon\"\n                                      >🛠️</span\n                                    >\n                                    <span class=\"chat-tool-inline-title\"\n                                      >${String(\n                                        primaryToolCall?.name || \"unknown\",\n                                      )}</span\n                                    >\n                                    <span class=\"chat-tool-inline-time\"\n                                      >${formatChatTime(\n                                        message.createdAt,\n                                      )}</span\n                                    >\n                                  </summary>\n                                  <div class=\"chat-tool-inline-body\">\n                                    <div class=\"chat-tool-inline-label\">\n                                      Payload\n                                    </div>\n                                    <pre>\n${JSON.stringify(\n                                        {\n                                          id:\n                                            String(primaryToolCall?.id || \"\") ||\n                                            null,\n                                          name:\n                                            String(\n                                              primaryToolCall?.name || \"\",\n                                            ) || null,\n                                          arguments:\n                                            primaryToolCall?.arguments || null,\n                                          partialJson:\n                                            String(\n                                              primaryToolCall?.partialJson ||\n                                                \"\",\n                                            ) || null,\n                                        },\n                                        null,\n                                        2,\n                                      )}</pre\n                                    >\n                                    ${matchedResult\n                                      ? html`\n                                          <div class=\"chat-tool-inline-label\">\n                                            Result${matchedResult.isError\n                                              ? \" (error)\"\n                                              : \"\"}\n                                          </div>\n                                          <pre>\n${JSON.stringify(\n                                              {\n                                                toolCallId:\n                                                  matchedResult.toolCallId,\n                                                toolName:\n                                                  matchedResult.toolName,\n                                                text: matchedResult.text || \"\",\n                                                isError: matchedResult.isError,\n                                                rawMessage:\n                                                  matchedResult.rawMessage ||\n                                                  null,\n                                              },\n                                              null,\n                                              2,\n                                            )}</pre\n                                          >\n                                        `\n                                      : null}\n                                  </div>\n                                </details>\n                              `\n                            : null}\n                          ${shouldRenderContent\n                            ? (() => {\n                                const parsedJson = parseJsonMessage(\n                                  message.content,\n                                );\n                                if (parsedJson) {\n                                  return html`<pre\n                                    class=\"chat-bubble-content chat-bubble-json\"\n                                  >\n${JSON.stringify(parsedJson, null, 2)}</pre\n                                  >`;\n                                }\n                                return html`\n                                  <div\n                                    class=\"chat-bubble-content chat-bubble-markdown\"\n                                    dangerouslySetInnerHTML=${{\n                                      __html: renderMarkdownHtml(\n                                        message.content,\n                                      ),\n                                    }}\n                                  ></div>\n                                `;\n                              })()\n                            : null}\n                          ${!isToolMessage\n                            ? html`\n                                <details class=\"chat-message-json\">\n                                  <summary>JSON</summary>\n                                  <pre>\n${JSON.stringify(\n                                      message.debugPayload || {\n                                        role: message.role,\n                                        content: message.content,\n                                        createdAt: message.createdAt,\n                                      },\n                                      null,\n                                      2,\n                                    )}</pre\n                                  >\n                                </details>\n                              `\n                            : null}\n                        </div>\n                      `;\n                    })()}\n                  `,\n                )}\n        ${selectedSessionKey &&\n        (sending || streaming) &&\n        !assistantStreamStarted\n          ? html`\n              <div class=\"chat-bubble is-assistant chat-typing-indicator\">\n                <div class=\"chat-typing-dots\" aria-hidden=\"true\">\n                  <span></span><span></span><span></span>\n                </div>\n              </div>\n            `\n          : null}\n        ${selectedSessionKey\n          ? chatDebugEnabled\n            ? html`\n                <details class=\"chat-raw-debug\">\n                  <summary>Raw history JSON</summary>\n                  <pre>${JSON.stringify(rawHistory || null, null, 2)}</pre>\n                </details>\n                <details class=\"chat-raw-debug\">\n                  <summary>Inbound event log</summary>\n                  <pre>${JSON.stringify(debugEvents, null, 2)}</pre>\n                </details>\n              `\n            : null\n          : null}\n      </div>\n      <div class=\"chat-composer\">\n        <textarea\n          class=\"chat-composer-input\"\n          ref=${composerRef}\n          rows=${1}\n          placeholder=${selectedSessionKey\n            ? \"Message… (Enter to send, Shift+Enter for newline)\"\n            : \"Select a session to start\"}\n          value=${draft}\n          disabled=${!selectedSessionKey || sending || !isConnected}\n          oninput=${handleDraftInput}\n          onkeydown=${handleComposerKeyDown}\n        ></textarea>\n        <div class=\"chat-composer-actions\">\n          ${streaming\n            ? html`\n                <button\n                  type=\"button\"\n                  class=\"ac-btn-secondary chat-composer-stop\"\n                  disabled=${!isConnected}\n                  onclick=${handleStop}\n                >\n                  Stop\n                </button>\n              `\n            : null}\n          <button\n            type=\"button\"\n            class=\"ac-btn-cyan chat-composer-send\"\n            disabled=${!selectedSessionKey ||\n            sending ||\n            streaming ||\n            !isConnected ||\n            !String(draft || \"\").trim()}\n            onclick=${handleSend}\n          >\n            ${sending ? \"Sending...\" : \"Send\"}\n          </button>\n        </div>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/routes/cron-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { CronTab } from \"../cron-tab/index.js\";\n\nconst html = htm.bind(h);\n\nexport const CronRoute = ({ jobId = \"\", onSetLocation = () => {} }) => html`\n  <${CronTab} jobId=${jobId} onSetLocation=${onSetLocation} />\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/doctor-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { DoctorTab } from \"../doctor/index.js\";\n\nconst html = htm.bind(h);\n\nexport const DoctorRoute = ({ onNavigateToBrowseFile = () => {} }) => html`\n  <div class=\"pt-4\">\n    <${DoctorTab}\n      isActive=${true}\n      onOpenFile=${(relativePath, options = {}) => {\n        const browsePath = `workspace/${String(relativePath || \"\").trim().replace(/^workspace\\//, \"\")}`;\n        onNavigateToBrowseFile(browsePath, {\n          view: \"edit\",\n          ...(options.line ? { line: options.line } : {}),\n          ...(options.lineEnd ? { lineEnd: options.lineEnd } : {}),\n        });\n      }}\n    />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/envars-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Envars } from \"../envars.js\";\n\nconst html = htm.bind(h);\n\nexport const EnvarsRoute = ({ onRestartRequired = () => {} }) => html`\n  <${Envars} onRestartRequired=${onRestartRequired} />\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/general-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { GeneralTab } from \"../general/index.js\";\n\nconst html = htm.bind(h);\n\nexport const GeneralRoute = ({\n  statusData = null,\n  watchdogData = null,\n  doctorStatusData = null,\n  agents = [],\n  doctorWarningDismissedUntilMs = 0,\n  onRefreshStatuses = () => {},\n  onSetLocation = () => {},\n  onNavigate = () => {},\n  restartingGateway = false,\n  onRestartGateway = () => {},\n  restartSignal = 0,\n  onRestartRequired = () => {},\n  onDismissDoctorWarning = () => {},\n}) => html`\n  <div class=\"pt-4\">\n    <${GeneralTab}\n      statusData=${statusData}\n      watchdogData=${watchdogData}\n      doctorStatusData=${doctorStatusData}\n      agents=${agents}\n      doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs}\n      onRefreshStatuses=${onRefreshStatuses}\n      onSwitchTab=${(nextTab) => onSetLocation(`/${nextTab}`)}\n      onNavigate=${onNavigate}\n      onOpenGmailWebhook=${() => onSetLocation(\"/webhooks/gmail\")}\n      isActive=${true}\n      restartingGateway=${restartingGateway}\n      onRestartGateway=${onRestartGateway}\n      restartSignal=${restartSignal}\n      onRestartRequired=${onRestartRequired}\n      onDismissDoctorWarning=${onDismissDoctorWarning}\n    />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/index.js",
    "content": "export { AgentsRoute } from \"./agents-route.js\";\nexport { BrowseRoute } from \"./browse-route.js\";\nexport { ChatRoute } from \"./chat-route.js\";\nexport { CronRoute } from \"./cron-route.js\";\nexport { DoctorRoute } from \"./doctor-route.js\";\nexport { EnvarsRoute } from \"./envars-route.js\";\nexport { GeneralRoute } from \"./general-route.js\";\nexport { ModelsRoute } from \"./models-route.js\";\nexport { NodesRoute } from \"./nodes-route.js\";\nexport { ProvidersRoute } from \"./providers-route.js\";\nexport { RouteRedirect } from \"./route-redirect.js\";\nexport { TelegramRoute } from \"./telegram-route.js\";\nexport { UsageRoute } from \"./usage-route.js\";\nexport { WatchdogRoute } from \"./watchdog-route.js\";\nexport { WebhooksRoute } from \"./webhooks-route.js\";\n"
  },
  {
    "path": "lib/public/js/components/routes/models-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Models } from \"../models-tab/index.js\";\n\nconst html = htm.bind(h);\n\nexport const ModelsRoute = ({ onRestartRequired = () => {} }) => html`\n  <${Models} onRestartRequired=${onRestartRequired} />\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/nodes-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { NodesTab } from \"../nodes-tab/index.js\";\n\nconst html = htm.bind(h);\n\nexport const NodesRoute = ({ onRestartRequired = () => {} }) => html`\n  <div class=\"pt-4 max-w-2xl w-full mx-auto\">\n    <${NodesTab} onRestartRequired=${onRestartRequired} />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/providers-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Providers } from \"../providers.js\";\n\nconst html = htm.bind(h);\n\nexport const ProvidersRoute = ({ onRestartRequired = () => {} }) => html`\n  <div class=\"pt-4\">\n    <${Providers} onRestartRequired=${onRestartRequired} />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/route-redirect.js",
    "content": "import { useEffect } from \"preact/hooks\";\nimport { useLocation } from \"wouter-preact\";\n\nexport const RouteRedirect = ({ to }) => {\n  const [, setLocation] = useLocation();\n  useEffect(() => {\n    setLocation(to);\n  }, [to, setLocation]);\n  return null;\n};\n"
  },
  {
    "path": "lib/public/js/components/routes/telegram-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { TelegramWorkspace } from \"../telegram-workspace/index.js\";\n\nconst html = htm.bind(h);\n\nexport const TelegramRoute = ({ accountId = \"default\", onBack = () => {} }) => html`\n  <div class=\"pt-4\">\n    <${TelegramWorkspace} key=${accountId} accountId=${accountId} onBack=${onBack} />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/usage-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { UsageTab } from \"../usage-tab/index.js\";\n\nconst html = htm.bind(h);\n\nexport const UsageRoute = ({ sessionId = \"\", onSetLocation = () => {} }) => html`\n  <div class=\"pt-4\">\n    <${UsageTab}\n      sessionId=${sessionId}\n      onSelectSession=${(id) => onSetLocation(`/usage/${encodeURIComponent(String(id || \"\"))}`)}\n      onBackToSessions=${() => onSetLocation(\"/usage\")}\n    />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/watchdog-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { WatchdogTab } from \"../watchdog-tab/index.js\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogRoute = ({\n  statusData = null,\n  watchdogStatus = null,\n  onRefreshStatuses = () => {},\n  restartingGateway = false,\n  onRestartGateway = () => {},\n  restartSignal = 0,\n}) => html`\n  <div class=\"pt-4\">\n    <${WatchdogTab}\n      gatewayStatus=${statusData?.gateway || null}\n      openclawVersion=${statusData?.openclawVersion || null}\n      watchdogStatus=${watchdogStatus}\n      onRefreshStatuses=${onRefreshStatuses}\n      restartingGateway=${restartingGateway}\n      onRestartGateway=${onRestartGateway}\n      restartSignal=${restartSignal}\n    />\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/routes/webhooks-route.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Webhooks } from \"../webhooks/index.js\";\n\nconst html = htm.bind(h);\n\nexport const WebhooksRoute = ({\n  hookName = \"\",\n  routeHistoryRef = null,\n  getCurrentPath = () => \"\",\n  onSetLocation = () => {},\n  onRestartRequired = () => {},\n  onNavigateToBrowseFile = () => {},\n}) => {\n  const handleBackToList = () => {\n    const historyStack = routeHistoryRef?.current || [];\n    const hasPreviousRoute = historyStack.length > 1;\n    if (!hasPreviousRoute) {\n      onSetLocation(\"/webhooks\");\n      return;\n    }\n    const currentPath = getCurrentPath();\n    window.history.back();\n    window.setTimeout(() => {\n      if (getCurrentPath() === currentPath) {\n        onSetLocation(\"/webhooks\");\n      }\n    }, 180);\n  };\n\n  return html`\n    <div class=\"pt-4\">\n      <${Webhooks}\n        selectedHookName=${hookName}\n        onSelectHook=${(name) => onSetLocation(`/webhooks/${encodeURIComponent(name)}`)}\n        onBackToList=${handleBackToList}\n        onRestartRequired=${onRestartRequired}\n        onOpenFile=${(relativePath) =>\n          onNavigateToBrowseFile(String(relativePath || \"\").trim(), { view: \"edit\" })}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/scope-picker.js",
    "content": "import { h } from 'preact';\nimport { useState } from 'preact/hooks';\nimport htm from 'htm';\nconst html = htm.bind(h);\n\nexport const SERVICES = [\n  { key: 'gmail', icon: '📧', label: 'Gmail', defaultRead: true, defaultWrite: false },\n  { key: 'calendar', icon: '📅', label: 'Calendar', defaultRead: true, defaultWrite: true },\n  { key: 'drive', icon: '📁', label: 'Drive', defaultRead: true, defaultWrite: false },\n  { key: 'sheets', icon: '📊', label: 'Sheets', defaultRead: true, defaultWrite: false },\n  { key: 'docs', icon: '📝', label: 'Docs', defaultRead: true, defaultWrite: false },\n  { key: 'tasks', icon: '✅', label: 'Tasks', defaultRead: false, defaultWrite: false },\n  { key: 'contacts', icon: '👤', label: 'Contacts', defaultRead: false, defaultWrite: false },\n  { key: 'meet', icon: '🎥', label: 'Meet', defaultRead: false, defaultWrite: false },\n];\n\nconst API_ENABLE_URLS = {\n  gmail: 'gmail.googleapis.com',\n  calendar: 'calendar-json.googleapis.com',\n  tasks: 'tasks.googleapis.com',\n  drive: 'drive.googleapis.com',\n  contacts: 'people.googleapis.com',\n  sheets: 'sheets.googleapis.com',\n  docs: 'docs.googleapis.com',\n  meet: 'meet.googleapis.com',\n};\n\nfunction getApiEnableUrl(svc) {\n  return `https://console.developers.google.com/apis/api/${API_ENABLE_URLS[svc] || ''}/overview`;\n}\n\nexport function ScopePicker({ scopes, onToggle, apiStatus, loading }) {\n  const [showAll, setShowAll] = useState(false);\n  const status = apiStatus || {};\n  const kVisibleCount = 5;\n  const hasMore = SERVICES.length > kVisibleCount;\n  const visibleServices = showAll ? SERVICES : SERVICES.slice(0, kVisibleCount);\n\n  return html`<div class=\"space-y-2\">\n    ${visibleServices.map(s => {\n      const readOn = scopes.includes(`${s.key}:read`);\n      const writeOn = scopes.includes(`${s.key}:write`);\n      const api = status[s.key];\n      let apiIndicator = null;\n      if (loading && !api && (readOn || writeOn)) {\n        apiIndicator = html`<span class=\"text-fg-muted text-xs flex items-center gap-1\"><span class=\"inline-block w-3 h-3 border-2 border-fg-muted border-t-transparent rounded-full ac-spinner\"></span></span>`;\n      } else if (api) {\n        if (api.status === 'ok') {\n          apiIndicator = html`<a href=${api.enableUrl || getApiEnableUrl(s.key)} target=\"_blank\" class=\"text-status-success-muted hover:text-status-success text-xs px-1.5 py-0.5 rounded bg-green-500/10\">API ✓</a>`;\n        } else if (api.status === 'not_enabled') {\n          apiIndicator = html`<a href=${api.enableUrl} target=\"_blank\" class=\"text-status-error-muted hover:text-status-error text-xs underline\">Enable API</a>`;\n        } else if (api.status === 'error') {\n          apiIndicator = html`<a href=${api.enableUrl || getApiEnableUrl(s.key)} target=\"_blank\" class=\"text-status-warning-muted hover:text-status-warning text-xs underline\">Enable API</a>`;\n        }\n      }\n\n      return html`\n        <div class=\"flex items-center justify-between bg-field rounded-lg px-3 py-2\">\n          <span class=\"text-sm\">${s.icon} ${s.label}</span>\n          <div class=\"flex items-center gap-2\">\n            ${apiIndicator}\n            <button onclick=${() => onToggle(`${s.key}:read`)} class=\"scope-btn scope-btn-read ${readOn ? 'active' : ''} text-xs px-2 py-0.5 rounded\">Read</button>\n            <button onclick=${() => onToggle(`${s.key}:write`)} class=\"scope-btn scope-btn-write ${writeOn ? 'active' : ''} text-xs px-2 py-0.5 rounded\">Write</button>\n          </div>\n        </div>`;\n    })}\n    ${hasMore ? html`\n      <button\n        type=\"button\"\n        onclick=${() => setShowAll((prev) => !prev)}\n        class=\"ml-3 text-xs text-fg-muted hover:text-body\"\n      >\n        ${showAll ? 'Show fewer services' : `Show more services (${SERVICES.length - kVisibleCount})`}\n      </button>\n    ` : null}\n  </div>`;\n}\n\n// Returns new scopes array after toggling, with read/write dependency logic\nexport function toggleScopeLogic(scopes, scope) {\n  const isActive = scopes.includes(scope);\n  let next = isActive ? scopes.filter(s => s !== scope) : [...scopes, scope];\n\n  if (scope.endsWith(':write') && !isActive) {\n    // enabling write → also enable read\n    const readScope = scope.replace(':write', ':read');\n    if (!next.includes(readScope)) next.push(readScope);\n  }\n  if (scope.endsWith(':read') && isActive) {\n    // disabling read → also disable write\n    const writeScope = scope.replace(':read', ':write');\n    next = next.filter(s => s !== writeScope);\n  }\n\n  return next;\n}\n\n// Get default scopes from SERVICES\nexport function getDefaultScopes() {\n  const scopes = [];\n  for (const s of SERVICES) {\n    if (s.defaultRead) scopes.push(`${s.key}:read`);\n    if (s.defaultWrite) scopes.push(`${s.key}:write`);\n  }\n  return scopes;\n}\n"
  },
  {
    "path": "lib/public/js/components/secret-input.js",
    "content": "import { h } from \"preact\";\nimport { useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\nconst html = htm.bind(h);\n\n/**\n * Reusable input with show/hide toggle for secret values.\n *\n * Props:\n *   value, onInput, placeholder, inputClass, disabled\n *   isSecret  – treat as password field (default true)\n */\nexport const SecretInput = ({\n  value = \"\",\n  onInput,\n  onBlur,\n  placeholder = \"\",\n  inputClass = \"\",\n  disabled = false,\n  loading = false,\n  isSecret = true,\n}) => {\n  const [visible, setVisible] = useState(false);\n  const showToggle = isSecret;\n  const isDisabled = disabled || loading;\n\n  return html`\n    <div class=\"flex-1 min-w-0 flex items-center gap-1\">\n      <input\n        type=${isSecret && !visible ? \"password\" : \"text\"}\n        value=${value}\n        placeholder=${placeholder}\n        onInput=${onInput}\n        onBlur=${onBlur}\n        disabled=${isDisabled}\n        class=${inputClass}\n        autocomplete=\"off\"\n      />\n      ${loading\n        ? html`<${LoadingSpinner} className=\"h-3 w-3 text-fg-muted shrink-0\" />`\n        : null}\n      ${showToggle\n        ? html`<button\n            type=\"button\"\n            onclick=${() => setVisible((v) => !v)}\n            disabled=${isDisabled}\n            class=\"text-fg-muted hover:text-body px-1 text-xs shrink-0\"\n          >\n            ${visible ? \"Hide\" : \"Show\"}\n          </button>`\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/segmented-control.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Tooltip } from \"./tooltip.js\";\n\nconst html = htm.bind(h);\n\n/**\n * Reusable segmented control (pill toggle).\n *\n * @param {Object}   props\n * @param {Array<{label:string, value:*, title?:string}>} props.options\n * @param {*}        props.value        Currently selected value.\n * @param {Function} props.onChange      Called with the new value on click.\n * @param {string}   [props.className]  Extra classes on the wrapper.\n * @param {\"sm\"|\"lg\"} [props.size]      Visual size variant.\n * @param {boolean}  [props.fullWidth]  Stretch wrapper and options to 100%.\n */\nexport const SegmentedControl = ({\n  options = [],\n  value,\n  onChange = () => {},\n  className = \"\",\n  size = \"sm\",\n  fullWidth = false,\n}) => html`\n  <div\n    class=${`ac-segmented-control ${size === \"lg\" ? \"ac-segmented-control-lg\" : \"\"} ${fullWidth ? \"ac-segmented-control-full\" : \"\"} ${className}`.trim()}\n  >\n    ${options.map(\n      (option) => {\n        const btn = html`\n          <button\n            class=${`ac-segmented-control-button ${option.value === value ? \"active\" : \"\"}`}\n            onclick=${() => onChange(option.value)}\n          >\n            ${option.label}\n          </button>\n        `;\n        return option.title\n          ? html`<${Tooltip} text=${option.title} delay=${1000} widthClass=\"w-auto max-w-64 whitespace-normal\">${btn}</${Tooltip}>`\n          : btn;\n      },\n    )}\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/session-select-field.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport {\n  getSessionDisplayLabel,\n  getSessionRowKey,\n} from \"../lib/session-keys.js\";\n\nconst html = htm.bind(h);\n\nexport const SessionSelectField = ({\n  label = \"Send to session\",\n  sessions = [],\n  selectedSessionKey = \"\",\n  onChangeSessionKey = () => {},\n  disabled = false,\n  loading = false,\n  error = \"\",\n  allowNone = false,\n  noneValue = \"__none__\",\n  noneLabel = \"None\",\n  emptyOptionLabel = \"No sessions available\",\n  helperText = \"\",\n  emptyStateText = \"\",\n  loadingLabel = \"Loading sessions...\",\n  containerClassName = \"space-y-2\",\n  labelClassName = \"text-xs text-fg-muted\",\n  selectClassName = \"w-full bg-field border border-border rounded-lg px-3 py-2 text-xs text-body focus:border-fg-muted\",\n  helperClassName = \"text-xs text-fg-muted\",\n  statusClassName = \"text-xs text-fg-muted\",\n  errorClassName = \"text-xs text-status-error-muted\",\n}) => {\n  const resolvedValue = selectedSessionKey || (allowNone ? noneValue : \"\");\n  const isDisabled = disabled || loading;\n  return html`\n    <div class=${containerClassName}>\n      ${label\n        ? html`<label class=${labelClassName}>${label}</label>`\n        : null}\n      <select\n        value=${resolvedValue}\n        onInput=${(event) => {\n          const nextValue = String(event.currentTarget?.value || \"\");\n          onChangeSessionKey(allowNone && nextValue === noneValue ? \"\" : nextValue);\n        }}\n        disabled=${isDisabled}\n        class=${selectClassName}\n      >\n        ${loading\n          ? html`<option value=${resolvedValue || \"\"}>${loadingLabel}</option>`\n          : html`\n              ${allowNone\n                ? html`<option value=${noneValue}>${noneLabel}</option>`\n                : null}\n              ${!allowNone && sessions.length === 0\n                ? html`<option value=\"\">${emptyOptionLabel}</option>`\n                : null}\n              ${sessions.map(\n                (sessionRow) => html`\n                  <option value=${getSessionRowKey(sessionRow)}>\n                    ${String(\n                      getSessionDisplayLabel(sessionRow) ||\n                        getSessionRowKey(sessionRow) ||\n                        \"Session\",\n                    )}\n                  </option>\n                `,\n              )}\n            `}\n      </select>\n      ${helperText\n        ? html`<div class=${helperClassName}>${helperText}</div>`\n        : null}\n      ${error\n        ? html`<div class=${errorClassName}>${error}</div>`\n        : null}\n      ${\n        !loading && !error && emptyStateText && sessions.length === 0\n          ? html`<div class=${statusClassName}>${emptyStateText}</div>`\n          : null\n      }\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/sidebar-git-panel.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { fetchBrowseGitSummary, syncBrowseChanges } from \"../lib/api.js\";\nimport { formatLocaleDateTime } from \"../lib/format.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport { GitBranchLineIcon, GithubFillIcon } from \"./icons.js\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\nimport { showToast } from \"./toast.js\";\n\nconst html = htm.bind(h);\nconst kRefreshMs = 10000;\nconst kSyncCommitFileNameLimit = 4;\nconst kCommitHistoryLimit = 12;\n\nconst formatCommitTime = (unixSeconds) => {\n  return formatLocaleDateTime(unixSeconds, {\n    fallback: \"\",\n    valueIsUnixSeconds: true,\n  });\n};\n\nconst getRepoName = (summary) => {\n  const slug = String(summary?.repoSlug || \"\").trim();\n  if (slug) return slug;\n  const pathValue = String(summary?.repoPath || \"\");\n  const segment = pathValue.split(\"/\").filter(Boolean).pop();\n  return segment || \"repo\";\n};\n\nconst getChangedFilePresentation = (changedFile) => {\n  const statusKind = String(changedFile?.statusKind || \"M\").toUpperCase();\n  if (statusKind === \"U\") {\n    return {\n      statusLabel: \"U\",\n      statusClass: \"is-untracked\",\n      rowClass: \"is-clickable\",\n      canOpen: true,\n    };\n  }\n  if (statusKind === \"D\") {\n    return {\n      statusLabel: \"D\",\n      statusClass: \"is-deleted\",\n      rowClass: \"is-clickable\",\n      canOpen: true,\n    };\n  }\n  return {\n    statusLabel: \"M\",\n    statusClass: \"is-modified\",\n    rowClass: \"is-clickable\",\n    canOpen: true,\n  };\n};\n\nconst formatDelta = (value, prefix) => {\n  if (value === null || value === undefined || value === \"\") return \"\";\n  const numericValue = Number(value);\n  if (!Number.isFinite(numericValue) || numericValue <= 0) return \"\";\n  return `${prefix}${numericValue}`;\n};\n\nconst isDirectoryChangePath = (changedPath, statusKind) => {\n  const safePath = String(changedPath || \"\").trim();\n  const safeStatusKind = String(statusKind || \"\").toUpperCase();\n  if (!safePath) return false;\n  if (safePath.endsWith(\"/\")) return true;\n  return safeStatusKind === \"U\" && safePath.endsWith(\"\\\\\");\n};\n\nconst getRemoteSyncPresentation = (summary) => {\n  const safeState = String(summary?.syncState || \"\").trim();\n  const aheadCount = Number(summary?.aheadCount) || 0;\n  const behindCount = Number(summary?.behindCount) || 0;\n  if (safeState === \"ahead\") {\n    return {\n      label: \"↑\",\n      title: `Ahead by ${aheadCount}`,\n      className: \"is-ahead\",\n    };\n  }\n  if (safeState === \"behind\") {\n    return {\n      label: \"↓\",\n      title: `Behind by ${behindCount}`,\n      className: \"is-behind\",\n    };\n  }\n  if (safeState === \"diverged\") {\n    return {\n      label: \"↕\",\n      title: `Diverged (${aheadCount} ahead, ${behindCount} behind)`,\n      className: \"is-diverged\",\n    };\n  }\n  if (safeState === \"upstream-gone\") {\n    return {\n      label: \"!\",\n      title: \"Upstream missing\",\n      className: \"is-upstream-gone\",\n    };\n  }\n  if (safeState === \"no-upstream\" || !summary?.hasUpstream) {\n    return {\n      label: \"!\",\n      title: \"Not linked\",\n      className: \"is-no-upstream\",\n    };\n  }\n  return {\n    label: \"\",\n    title: \"Up to date\",\n    className: \"is-up-to-date\",\n  };\n};\n\nconst buildSyncCommitMessage = (summary) => {\n  const changedFiles = Array.isArray(summary?.changedFiles) ? summary.changedFiles : [];\n  const changedFilesCount = Number(summary?.changedFilesCount) || 0;\n  const filePaths = changedFiles\n    .map((file) => String(file?.path || \"\").trim())\n    .filter(Boolean);\n  const totalCount = changedFilesCount || filePaths.length;\n  if (totalCount <= 0) return \"sync changes\";\n\n  const fileNames = filePaths\n    .map((filePath) => filePath.split(\"/\").filter(Boolean).pop() || filePath);\n  const uniqueFileNames = Array.from(new Set(fileNames));\n  if (uniqueFileNames.length <= 0) {\n    const noun = totalCount === 1 ? \"file\" : \"files\";\n    return `Edited ${totalCount} ${noun}`;\n  }\n\n  const shownFileNames = uniqueFileNames.slice(0, kSyncCommitFileNameLimit);\n  const remainingCount = Math.max(0, totalCount - shownFileNames.length);\n  const noun = totalCount === 1 ? \"file\" : \"files\";\n  const suffix = remainingCount > 0 ? ` +${remainingCount} more` : \"\";\n  return `Edited ${totalCount} ${noun} - ${shownFileNames.join(\", \")}${suffix}`;\n};\n\nexport const SidebarGitPanel = ({\n  onSelectFile = () => {},\n  isActive = true,\n}) => {\n  const [loading, setLoading] = useState(true);\n  const [syncing, setSyncing] = useState(false);\n  const [error, setError] = useState(\"\");\n  const [summary, setSummary] = useState(null);\n\n  useEffect(() => {\n    if (!isActive) return () => {};\n    let active = true;\n    let intervalId = null;\n\n    const loadSummary = async () => {\n      if (!active) return;\n      try {\n        const data = await fetchBrowseGitSummary();\n        if (!active) return;\n        setSummary(data);\n        setError(\"\");\n      } catch (nextError) {\n        if (!active) return;\n        setError(nextError.message || \"Could not load git summary\");\n      } finally {\n        if (active) setLoading(false);\n      }\n    };\n\n    const handleFileSaved = () => {\n      loadSummary();\n    };\n\n    loadSummary();\n    intervalId = window.setInterval(loadSummary, kRefreshMs);\n    window.addEventListener(\"alphaclaw:browse-file-saved\", handleFileSaved);\n\n    return () => {\n      active = false;\n      if (intervalId) window.clearInterval(intervalId);\n      window.removeEventListener(\"alphaclaw:browse-file-saved\", handleFileSaved);\n    };\n  }, [isActive]);\n\n  if (loading) {\n    return html`\n      <div class=\"sidebar-git-panel sidebar-git-loading\" aria-label=\"Loading git summary\">\n        <${LoadingSpinner} className=\"h-4 w-4\" />\n      </div>\n    `;\n  }\n\n  if (error) {\n    return html`<div class=\"sidebar-git-panel sidebar-git-panel-error\">${error}</div>`;\n  }\n\n  if (!summary?.isRepo) {\n    return html`\n      <div class=\"sidebar-git-panel\">\n        <div class=\"sidebar-git-meta\">No git repo at this root</div>\n      </div>\n    `;\n  }\n\n  const hasUncommittedChanges = (summary.changedFiles || []).length > 0;\n  const aheadCount = Number(summary?.aheadCount) || 0;\n  const canSyncChanges = hasUncommittedChanges || aheadCount > 0;\n  const remoteSync = getRemoteSyncPresentation(summary);\n  const handleSyncChanges = async () => {\n    if (!canSyncChanges || syncing) return;\n    try {\n      setSyncing(true);\n      const commitMessage = buildSyncCommitMessage(summary);\n      const syncResult = await syncBrowseChanges(commitMessage);\n      if (syncResult?.committed || syncResult?.pushed) {\n        window.dispatchEvent(new CustomEvent(\"alphaclaw:browse-git-synced\"));\n        showToast(syncResult.message || \"Changes synced\", \"success\");\n      } else {\n        showToast(syncResult?.message || \"No changes to sync\", \"info\");\n      }\n      const nextSummary = await fetchBrowseGitSummary();\n      setSummary(nextSummary);\n      setError(\"\");\n    } catch (syncError) {\n      showToast(syncError.message || \"Could not sync changes\", \"error\");\n    } finally {\n      setSyncing(false);\n    }\n  };\n\n  return html`\n    <div class=\"sidebar-git-panel\">\n      <div class=\"sidebar-git-bar\">\n        ${summary.repoUrl\n          ? html`\n              <a\n                class=\"sidebar-git-bar-main sidebar-git-link\"\n                href=${summary.repoUrl}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                title=${summary.repoUrl}\n              >\n                <${GithubFillIcon} className=\"sidebar-git-bar-icon\" />\n                <span class=\"sidebar-git-repo-name\">${getRepoName(summary)}</span>\n              </a>\n            `\n          : html`\n              <span class=\"sidebar-git-bar-main\">\n                <${GithubFillIcon} className=\"sidebar-git-bar-icon\" />\n                <span class=\"sidebar-git-repo-name\">${getRepoName(summary)}</span>\n              </span>\n            `}\n      </div>\n      <div class=\"sidebar-git-bar sidebar-git-bar-secondary\">\n        <span class=\"sidebar-git-bar-main\">\n          <${GitBranchLineIcon} className=\"sidebar-git-bar-icon\" />\n          <span class=\"sidebar-git-branch\">${summary.branch || \"unknown\"}</span>\n        </span>\n        ${remoteSync.label\n          ? html`\n              <span\n                class=${`sidebar-git-sync-status ${remoteSync.className}`.trim()}\n                title=${remoteSync.title || \"\"}\n                aria-label=${remoteSync.title || \"\"}\n              >\n                ${remoteSync.label}\n              </span>\n            `\n          : null}\n      </div>\n      <div class=\"sidebar-git-scroll\">\n        ${(summary.changedFiles || []).length > 0\n          ? html`\n              <div class=\"sidebar-git-changes-label\">\n                ${`Unsynced Changes (${summary.changedFilesCount || (summary.changedFiles || []).length})`}\n              </div>\n              <ul class=\"sidebar-git-changes-list\">\n                ${(summary.changedFiles || []).map((changedFile) => {\n                  const presentation = getChangedFilePresentation(changedFile);\n                  const changedPath = String(changedFile?.path || \"\");\n                  const plusDelta = formatDelta(changedFile?.addedLines, \"+\");\n                  const minusDelta = formatDelta(changedFile?.deletedLines, \"-\");\n                  return html`\n                    <li\n                      class=${`sidebar-git-change-row ${presentation.statusClass} ${presentation.rowClass}`.trim()}\n                      title=${changedPath}\n                      onclick=${() => {\n                        if (!presentation.canOpen || !changedPath) return;\n                        const directorySelection = isDirectoryChangePath(\n                          changedPath,\n                          changedFile?.statusKind,\n                        );\n                        if (directorySelection) {\n                          onSelectFile(changedPath, {\n                            directory: true,\n                            preservePreview: true,\n                          });\n                          return;\n                        }\n                        onSelectFile(changedPath, { view: \"diff\" });\n                      }}\n                    >\n                      <span class=\"sidebar-git-change-path\">${changedPath}</span>\n                      <span class=\"sidebar-git-change-meta\">\n                        ${plusDelta\n                          ? html`<span class=\"sidebar-git-change-plus\">${plusDelta}</span>`\n                          : null}\n                        ${minusDelta\n                          ? html`<span class=\"sidebar-git-change-minus\">${minusDelta}</span>`\n                          : null}\n                        <span class=\"sidebar-git-change-status\">${presentation.statusLabel}</span>\n                      </span>\n                    </li>\n                  `;\n                })}\n              </ul>\n              <div class=\"sidebar-git-actions\">\n                <${ActionButton}\n                  onClick=${handleSyncChanges}\n                  disabled=${!canSyncChanges}\n                  loading=${syncing}\n                  loadingMode=\"inline\"\n                  idleLabel=\"Sync Changes\"\n                  loadingLabel=\"Syncing...\"\n                  tone=\"primary\"\n                  size=\"sm\"\n                  className=\"sidebar-git-sync-button\"\n                />\n              </div>\n            `\n          : null}\n        ${(summary.commits || []).length > 0\n          ? html`\n              <div class=\"sidebar-git-changes-label\">commit history</div>\n              <ul class=\"sidebar-git-list\">\n                ${(summary.commits || []).slice(0, kCommitHistoryLimit).map(\n                  (commit) => html`\n                    <li title=${formatCommitTime(commit.timestamp)}>\n                      ${commit.url\n                        ? html`\n                            <a\n                              class=\"sidebar-git-commit-link\"\n                              href=${commit.url}\n                              target=\"_blank\"\n                              rel=\"noopener noreferrer\"\n                            >\n                              <span class=\"sidebar-git-hash\">${commit.shortHash}</span>\n                              <span>${commit.message}</span>\n                            </a>\n                          `\n                        : html`\n                            <span class=\"sidebar-git-hash\">${commit.shortHash}</span>\n                            <span>${commit.message}</span>\n                          `}\n                    </li>\n                  `,\n                )}\n              </ul>\n            `\n          : null}\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/sidebar.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  AddLineIcon,\n  AlarmLineIcon,\n  BarChartLineIcon,\n  Brain2LineIcon,\n  BracesLineIcon,\n  Chat4LineIcon,\n  ChevronDownIcon,\n  ComputerLineIcon,\n  EyeLineIcon,\n  FolderLineIcon,\n  HomeLineIcon,\n  PulseLineIcon,\n  RobotLineIcon,\n  SignalTowerLineIcon,\n} from \"./icons.js\";\nimport { FileTree } from \"./file-tree.js\";\nimport { OverflowMenu, OverflowMenuItem } from \"./overflow-menu.js\";\nimport { UpdateActionButton } from \"./update-action-button.js\";\nimport { SidebarGitPanel } from \"./sidebar-git-panel.js\";\nimport { UpdateModal } from \"./update-modal.js\";\nimport {\n  readUiSettings,\n  updateUiSettings,\n  writeUiSettings,\n} from \"../lib/ui-settings.js\";\nimport {\n  getAgentIdFromSessionKey,\n  getSessionChannelForIcon,\n  getSessionDisplayLabel,\n  getSessionRowKey,\n} from \"../lib/session-keys.js\";\nimport { sanitizeAgentEmoji } from \"../lib/agent-identity.js\";\nimport { ThemeToggle } from \"./theme-toggle.js\";\n\nconst html = htm.bind(h);\nconst kBrowseBottomPanelUiSettingKey = \"browseBottomPanelHeightPx\";\nconst kBrowsePanelMinHeightPx = 120;\nconst kBrowseBottomMinHeightPx = 120;\nconst kBrowseResizerHeightPx = 6;\nconst kDefaultBrowseBottomPanelHeightPx = 260;\nconst kChatSidebarCollapsedAgentIdsKey = \"chatSidebarCollapsedAgentIds\";\nconst kChatChannelIconSrc = {\n  telegram: \"/assets/icons/telegram.svg\",\n  discord: \"/assets/icons/discord.svg\",\n  slack: \"/assets/icons/slack.svg\",\n};\nconst readChatSidebarCollapsedAgentIds = () => {\n  const raw = readUiSettings()[kChatSidebarCollapsedAgentIdsKey];\n  return Array.isArray(raw) ? raw : [];\n};\nconst kSidebarNavIconsById = {\n  cron: AlarmLineIcon,\n  usage: BarChartLineIcon,\n  doctor: PulseLineIcon,\n  watchdog: EyeLineIcon,\n  models: Brain2LineIcon,\n  envars: BracesLineIcon,\n  webhooks: SignalTowerLineIcon,\n  nodes: ComputerLineIcon,\n};\n\nconst readStoredBrowseBottomPanelHeight = () => {\n  try {\n    const settings = readUiSettings();\n    const fromSharedSettings = Number.parseInt(\n      String(settings?.[kBrowseBottomPanelUiSettingKey] || \"\"),\n      10,\n    );\n    if (Number.isFinite(fromSharedSettings) && fromSharedSettings > 0) {\n      return fromSharedSettings;\n    }\n    return kDefaultBrowseBottomPanelHeightPx;\n  } catch {\n    return kDefaultBrowseBottomPanelHeightPx;\n  }\n};\n\nconst renderNavItem = ({ item, selectedNavId, onSelectNavItem }) => {\n  const NavIcon = kSidebarNavIconsById[item.id] || null;\n  return html`\n    <a\n      class=${selectedNavId === item.id ? \"active\" : \"\"}\n      onclick=${() => onSelectNavItem(item.id)}\n    >\n      ${NavIcon ? html`<${NavIcon} className=\"sidebar-nav-icon\" />` : null}\n      <span>${item.label}</span>\n    </a>\n  `;\n};\n\nconst getAgentIdentityEmoji = (agent) => sanitizeAgentEmoji(agent?.identity?.emoji);\n\nexport const AppSidebar = ({\n  mobileSidebarOpen = false,\n  authEnabled = false,\n  menuRef = null,\n  menuOpen = false,\n  onToggleMenu = () => {},\n  onLogout = () => {},\n  sidebarTab = \"menu\",\n  onSelectSidebarTab = () => {},\n  navSections = [],\n  selectedNavId = \"\",\n  onSelectNavItem = () => {},\n  selectedBrowsePath = \"\",\n  onSelectBrowseFile = () => {},\n  onPreviewBrowseFile = () => {},\n  acHasUpdate = false,\n  acVersion = \"\",\n  acCurrentOpenclawVersion = \"\",\n  acLatest = \"\",\n  acLatestOpenclawVersion = \"\",\n  acUpdateStrategy = null,\n  acUpdating = false,\n  onAcUpdate = () => {},\n  agents = [],\n  selectedAgentId = \"\",\n  onSelectAgent = () => {},\n  onAddAgent = () => {},\n  chatSessions = [],\n  selectedChatSessionKey = \"\",\n  onSelectChatSession = () => {},\n}) => {\n  const browseLayoutRef = useRef(null);\n  const browseBottomPanelRef = useRef(null);\n  const browseResizeStartRef = useRef({ startY: 0, startHeight: 0 });\n  const [browseBottomPanelHeightPx, setBrowseBottomPanelHeightPx] = useState(\n    readStoredBrowseBottomPanelHeight,\n  );\n  const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false);\n  const [updateModalOpen, setUpdateModalOpen] = useState(false);\n  const [collapsedChatAgentIds, setCollapsedChatAgentIds] = useState(() =>\n    readChatSidebarCollapsedAgentIds(),\n  );\n\n  const chatSessionGroups = useMemo(() => {\n    const rows = Array.isArray(chatSessions) ? chatSessions : [];\n    const order = [];\n    const byAgent = new Map();\n    for (const row of rows) {\n      const aid = String(\n        row.agentId ||\n          getAgentIdFromSessionKey(getSessionRowKey(row)) ||\n          \"unknown\",\n      );\n      if (!byAgent.has(aid)) {\n        byAgent.set(aid, {\n          agentId: aid,\n          agentLabel: String(row.agentLabel || \"\").trim() || aid,\n          sessions: [],\n        });\n        order.push(aid);\n      }\n      byAgent.get(aid).sessions.push(row);\n    }\n    const groups = order.map((aid) => byAgent.get(aid));\n    groups.sort((a, b) => {\n      if (a.agentId === \"main\" && b.agentId !== \"main\") return -1;\n      if (b.agentId === \"main\" && a.agentId !== \"main\") return 1;\n      return a.agentLabel.localeCompare(b.agentLabel);\n    });\n    return groups;\n  }, [chatSessions]);\n\n  const toggleChatAgentCollapsed = (agentId) => {\n    const id = String(agentId || \"\");\n    setCollapsedChatAgentIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      const arr = Array.from(next);\n      updateUiSettings((s) => ({\n        ...s,\n        [kChatSidebarCollapsedAgentIdsKey]: arr,\n      }));\n      return arr;\n    });\n  };\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings[kBrowseBottomPanelUiSettingKey] = browseBottomPanelHeightPx;\n    writeUiSettings(settings);\n  }, [browseBottomPanelHeightPx]);\n\n  const getClampedBrowseBottomPanelHeight = (value) => {\n    const layoutElement = browseLayoutRef.current;\n    if (!layoutElement) return value;\n    const layoutRect = layoutElement.getBoundingClientRect();\n    const maxHeight = Math.max(\n      kBrowseBottomMinHeightPx,\n      layoutRect.height - kBrowsePanelMinHeightPx - kBrowseResizerHeightPx,\n    );\n    return Math.max(\n      kBrowseBottomMinHeightPx,\n      Math.min(maxHeight, value),\n    );\n  };\n\n  const resizeBrowsePanelWithClientY = (clientY) => {\n    const { startY, startHeight } = browseResizeStartRef.current;\n    const proposedHeight = startHeight + (startY - clientY);\n    setBrowseBottomPanelHeightPx(getClampedBrowseBottomPanelHeight(proposedHeight));\n  };\n\n  useEffect(() => {\n    const layoutElement = browseLayoutRef.current;\n    if (!layoutElement || typeof ResizeObserver === \"undefined\") return () => {};\n    const observer = new ResizeObserver(() => {\n      const layoutRect = layoutElement.getBoundingClientRect();\n      if (layoutRect.height <= 0) return;\n      setBrowseBottomPanelHeightPx((currentHeight) =>\n        getClampedBrowseBottomPanelHeight(currentHeight),\n      );\n    });\n    observer.observe(layoutElement);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (!isResizingBrowsePanels) return () => {};\n    const handlePointerMove = (event) => resizeBrowsePanelWithClientY(event.clientY);\n    const handlePointerUp = () => setIsResizingBrowsePanels(false);\n    window.addEventListener(\"pointermove\", handlePointerMove);\n    window.addEventListener(\"pointerup\", handlePointerUp);\n    return () => {\n      window.removeEventListener(\"pointermove\", handlePointerMove);\n      window.removeEventListener(\"pointerup\", handlePointerUp);\n    };\n  }, [isResizingBrowsePanels]);\n\n  const onBrowsePanelResizerPointerDown = (event) => {\n    event.preventDefault();\n    const measuredHeight =\n      browseBottomPanelRef.current?.getBoundingClientRect().height ||\n      browseBottomPanelHeightPx;\n    browseResizeStartRef.current = {\n      startY: event.clientY,\n      startHeight: measuredHeight,\n    };\n    setBrowseBottomPanelHeightPx(getClampedBrowseBottomPanelHeight(measuredHeight));\n    setIsResizingBrowsePanels(true);\n  };\n\n  const setupSection = navSections.find((section) => section.label === \"Setup\") || null;\n  const remainingSections = navSections.filter((section) => section.label !== \"Setup\");\n\n  return html`\n    <div class=${`app-sidebar ${mobileSidebarOpen ? \"mobile-open\" : \"\"}`}>\n    <div class=\"sidebar-brand\">\n      <span\n        class=\"ac-logo-mark\"\n        style=\"--ac-logo-width: 20px; --ac-logo-height: 20px;\"\n        aria-hidden=\"true\"\n      ></span>\n      <span><span style=\"color: var(--accent)\">alpha</span>claw</span>\n      <span style=\"margin-left: auto; display: inline-flex; align-items: center; gap: 4px;\">\n        <${ThemeToggle} />\n      ${authEnabled && html`\n        <${OverflowMenu}\n          open=${menuOpen}\n          onToggle=${onToggleMenu}\n          onClose=${onToggleMenu}\n          ariaLabel=\"Menu\"\n          title=\"Menu\"\n          menuRef=${menuRef}\n        >\n          <${OverflowMenuItem} onClick=${() => onLogout()}>\n            Log out\n          </${OverflowMenuItem}>\n        </${OverflowMenu}>\n      `}\n      </span>\n    </div>\n    <div class=\"sidebar-tabs\">\n      <button\n        class=${`sidebar-tab ${sidebarTab === \"menu\" ? \"active\" : \"\"}`}\n        aria-label=\"Menu tab\"\n        title=\"Menu\"\n        onclick=${() => onSelectSidebarTab(\"menu\")}\n      >\n        <${HomeLineIcon} className=\"sidebar-tab-icon\" />\n      </button>\n      <button\n        class=${`sidebar-tab ${sidebarTab === \"browse\" ? \"active\" : \"\"}`}\n        aria-label=\"Browse tab\"\n        title=\"Browse\"\n        onclick=${() => onSelectSidebarTab(\"browse\")}\n      >\n        <${FolderLineIcon} className=\"sidebar-tab-icon\" />\n      </button>\n      <button\n        class=${`sidebar-tab ${sidebarTab === \"chat\" ? \"active\" : \"\"}`}\n        aria-label=\"Chat tab\"\n        title=\"Chat\"\n        onclick=${() => onSelectSidebarTab(\"chat\")}\n      >\n        <${Chat4LineIcon} className=\"sidebar-tab-icon\" />\n      </button>\n    </div>\n    <div\n      style=${{\n        display: sidebarTab === \"menu\" ? \"flex\" : \"none\",\n        flexDirection: \"column\",\n        flex: \"1 1 auto\",\n        minHeight: 0,\n      }}\n    >\n      ${setupSection\n        ? html`\n            <div class=\"sidebar-label\">Menu</div>\n            <nav class=\"sidebar-nav\">\n              ${setupSection.items.map((item) =>\n                renderNavItem({ item, selectedNavId, onSelectNavItem }),\n              )}\n            </nav>\n          `\n        : null}\n      <div class=\"sidebar-agents-header\">\n        <div class=\"sidebar-label sidebar-agents-label\">Agents</div>\n        <button\n          type=\"button\"\n          class=\"sidebar-agents-add-button\"\n          onclick=${onAddAgent}\n          title=\"Add agent\"\n          aria-label=\"Add agent\"\n        >\n          <${AddLineIcon} className=\"sidebar-agents-add-icon\" />\n        </button>\n      </div>\n      <div class=\"sidebar-agents-list\">\n        ${agents.map(\n          (agent) => {\n            const identityEmoji = getAgentIdentityEmoji(agent);\n            return html`\n              <button\n                key=${agent.id}\n                class=${`sidebar-agent-item ${selectedAgentId === agent.id ? \"active\" : \"\"}`}\n                onclick=${() => onSelectAgent(agent.id)}\n              >\n                ${identityEmoji\n                  ? html`<span class=\"sidebar-agent-emoji\" aria-hidden=\"true\">${identityEmoji}</span>`\n                  : html`<${RobotLineIcon} className=\"sidebar-agent-icon\" />`}\n                <span class=\"sidebar-agent-name\">${agent.name || agent.id}</span>\n              </button>\n            `;\n          },\n        )}\n      </div>\n      ${remainingSections.map(\n        (section) => html`\n          <div class=\"sidebar-label\">${section.label}</div>\n          <nav class=\"sidebar-nav\">\n            ${section.items.map((item) =>\n              renderNavItem({ item, selectedNavId, onSelectNavItem }),\n            )}\n          </nav>\n        `,\n      )}\n      <div class=\"sidebar-footer\">\n        ${acHasUpdate\n          ? html`\n              <${UpdateActionButton}\n                onClick=${() => setUpdateModalOpen(true)}\n                loading=${acUpdating}\n                warning=${true}\n                idleLabel=\"Update available\"\n                loadingLabel=\"Updating...\"\n                className=\"w-full justify-center\"\n              />\n            `\n          : null}\n      </div>\n    </div>\n    <div\n      style=${{\n        display: sidebarTab === \"chat\" ? \"flex\" : \"none\",\n        flexDirection: \"column\",\n        flex: \"1 1 auto\",\n        minHeight: 0,\n      }}\n    >\n      <div class=\"sidebar-chat-header\">\n        <div class=\"sidebar-label sidebar-chat-label\">Sessions</div>\n      </div>\n      <div class=\"sidebar-chat-sessions-list\">\n        ${chatSessions.length === 0\n          ? html`<div class=\"sidebar-chat-empty\">No sessions found</div>`\n          : chatSessionGroups.map(\n              (group) => html`\n                <div key=${group.agentId} class=\"sidebar-chat-agent-group\">\n                  <button\n                    type=\"button\"\n                    class=\"sidebar-chat-agent-toggle\"\n                    onclick=${() => toggleChatAgentCollapsed(group.agentId)}\n                    aria-expanded=${!collapsedChatAgentIds.includes(\n                      group.agentId,\n                    )}\n                  >\n                    <span\n                      class=${`sidebar-chat-agent-chevron ${collapsedChatAgentIds.includes(group.agentId) ? \"is-collapsed\" : \"\"}`}\n                      aria-hidden=\"true\"\n                    >\n                      <${ChevronDownIcon} className=\"sidebar-chat-agent-chevron-icon\" />\n                    </span>\n                    <span class=\"sidebar-chat-agent-label\">${group.agentLabel}</span>\n                  </button>\n                  ${collapsedChatAgentIds.includes(group.agentId)\n                    ? null\n                    : html`\n                        <div class=\"sidebar-chat-agent-sessions\">\n                          ${group.sessions.map((sessionRow) => {\n                            const displayLabel = getSessionDisplayLabel(sessionRow);\n                            const channelIconSrc =\n                              kChatChannelIconSrc[\n                                String(\n                                  getSessionChannelForIcon(sessionRow) || \"\",\n                                ).toLowerCase()\n                              ] || \"\";\n                            return html`\n                              <button\n                                key=${sessionRow.key}\n                                class=${`sidebar-chat-session-item ${selectedChatSessionKey === sessionRow.key ? \"active\" : \"\"}`}\n                                onclick=${() =>\n                                  onSelectChatSession(sessionRow.key)}\n                                title=${displayLabel}\n                              >\n                                ${channelIconSrc\n                                  ? html`<img\n                                      src=${channelIconSrc}\n                                      alt=\"\"\n                                      width=\"12\"\n                                      height=\"12\"\n                                      class=\"sidebar-chat-session-channel-icon\"\n                                    />`\n                                  : null}\n                                <span class=\"sidebar-chat-session-name\"\n                                  >${displayLabel}</span\n                                >\n                              </button>\n                            `;\n                          })}\n                        </div>\n                      `}\n                </div>\n              `,\n            )}\n      </div>\n    </div>\n    <div\n      style=${{\n        display: sidebarTab === \"browse\" ? \"flex\" : \"none\",\n        flexDirection: \"column\",\n        flex: \"1 1 auto\",\n        minHeight: 0,\n        overflow: \"hidden\",\n      }}\n    >\n      <div class=\"sidebar-browse-layout\" ref=${browseLayoutRef}>\n        <div\n          class=\"sidebar-browse-panel\"\n        >\n          <${FileTree}\n            onSelectFile=${onSelectBrowseFile}\n            selectedPath=${selectedBrowsePath}\n            onPreviewFile=${onPreviewBrowseFile}\n            isActive=${sidebarTab === \"browse\"}\n          />\n        </div>\n        <div\n          class=${`sidebar-browse-resizer ${isResizingBrowsePanels ? \"is-resizing\" : \"\"}`}\n          onpointerdown=${onBrowsePanelResizerPointerDown}\n          role=\"separator\"\n          aria-orientation=\"horizontal\"\n          aria-label=\"Resize browse and git panels\"\n        ></div>\n        <div class=\"sidebar-browse-bottom\">\n          <div\n            class=\"sidebar-browse-bottom-inner\"\n            ref=${browseBottomPanelRef}\n            style=${{ height: `${browseBottomPanelHeightPx}px` }}\n          >\n          <${SidebarGitPanel}\n            onSelectFile=${onSelectBrowseFile}\n            isActive=${sidebarTab === \"browse\"}\n          />\n          </div>\n        </div>\n      </div>\n    </div>\n    <${UpdateModal}\n      visible=${updateModalOpen}\n      onClose=${() => {\n        if (acUpdating) return;\n        setUpdateModalOpen(false);\n      }}\n      currentVersion=${acVersion}\n      currentOpenclawVersion=${acCurrentOpenclawVersion}\n      version=${acLatest}\n      latestOpenclawVersion=${acLatestOpenclawVersion}\n      updateStrategy=${acUpdateStrategy}\n      onUpdate=${onAcUpdate}\n      updating=${acUpdating}\n    />\n  </div>\n`;\n};\n"
  },
  {
    "path": "lib/public/js/components/summary-stat-card.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const SummaryStatCard = ({\n  title = \"\",\n  value = \"—\",\n  toneClassName = \"\",\n  valueClassName = \"text-lg font-semibold text-body\",\n  monospace = false,\n} = {}) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <h3 class=\"card-label text-xs\">${title}</h3>\n    <div class=${`${valueClassName} mt-2 ${monospace ? \"font-mono\" : \"\"} ${toneClassName}`}>${value}</div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/telegram-workspace/index.js",
    "content": "import { h } from \"preact\";\nimport { useState, useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { showToast } from \"../toast.js\";\nimport * as api from \"../../lib/telegram-api.js\";\nimport {\n  StepIndicator,\n  VerifyBotStep,\n  CreateGroupStep,\n  AddBotStep,\n  TopicsStep,\n  SummaryStep,\n} from \"./onboarding.js\";\nimport { ManageTelegramWorkspace } from \"./manage.js\";\n\nconst html = htm.bind(h);\n\nconst kSteps = [\n  { id: \"verify-bot\", label: \"Verify Bot\" },\n  { id: \"create-group\", label: \"Create Group\" },\n  { id: \"add-bot\", label: \"Add Bot\" },\n  { id: \"topics\", label: \"Topics\" },\n  { id: \"summary\", label: \"Summary\" },\n];\n\nimport {\n  kTelegramWorkspaceStorageKey,\n  kTelegramWorkspaceCacheKey,\n} from \"../../lib/storage-keys.js\";\n\nconst resolveStorageKey = (baseKey, accountId) => {\n  const suffix = String(accountId || \"\").trim();\n  if (!suffix || suffix === \"default\") return baseKey;\n  return `${baseKey}.${suffix}`;\n};\n\nconst loadTelegramWorkspaceState = (accountId) => {\n  try {\n    const raw = window.localStorage.getItem(resolveStorageKey(kTelegramWorkspaceStorageKey, accountId));\n    if (!raw) return {};\n    const parsed = JSON.parse(raw);\n    return parsed && typeof parsed === \"object\" ? parsed : {};\n  } catch {\n    return {};\n  }\n};\nconst saveTelegramWorkspaceState = (accountId, state) => {\n  try {\n    window.localStorage.setItem(\n      resolveStorageKey(kTelegramWorkspaceStorageKey, accountId),\n      JSON.stringify(state),\n    );\n  } catch {}\n};\nconst removeTelegramWorkspaceState = (accountId) => {\n  try {\n    window.localStorage.removeItem(resolveStorageKey(kTelegramWorkspaceStorageKey, accountId));\n  } catch {}\n};\nconst loadTelegramWorkspaceCache = (accountId) => {\n  try {\n    const raw = window.localStorage.getItem(resolveStorageKey(kTelegramWorkspaceCacheKey, accountId));\n    if (!raw) return null;\n    const parsed = JSON.parse(raw);\n    const data = parsed?.data;\n    if (!data || typeof data !== \"object\") return null;\n    return data;\n  } catch {\n    return null;\n  }\n};\nconst saveTelegramWorkspaceCache = (accountId, data) => {\n  try {\n    window.localStorage.setItem(\n      resolveStorageKey(kTelegramWorkspaceCacheKey, accountId),\n      JSON.stringify({ cachedAt: Date.now(), data }),\n    );\n  } catch {}\n};\nconst removeTelegramWorkspaceCache = (accountId) => {\n  try {\n    window.localStorage.removeItem(resolveStorageKey(kTelegramWorkspaceCacheKey, accountId));\n  } catch {}\n};\n\nconst BackButton = ({ onBack }) => html`\n  <button\n    onclick=${onBack}\n    class=\"flex items-center gap-1.5 text-sm text-fg-muted hover:text-body transition-colors mb-4\"\n  >\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"currentColor\">\n      <path\n        d=\"M10.354 3.354a.5.5 0 00-.708-.708l-5 5a.5.5 0 000 .708l5 5a.5.5 0 00.708-.708L5.707 8l4.647-4.646z\"\n      />\n    </svg>\n    Back\n  </button>\n`;\n\nconst MultiGroupView = ({\n  accountId,\n  groups,\n  concurrency,\n  debugEnabled,\n  onResetOnboarding,\n}) => {\n  const [expandedGroupId, setExpandedGroupId] = useState(\n    () => groups[0]?.groupId || \"\",\n  );\n\n  const toggle = (gId) =>\n    setExpandedGroupId((current) => (current === gId ? \"\" : gId));\n\n  return html`\n    <div class=\"space-y-3\">\n      ${groups.map(\n        (g) => html`\n          <div\n            key=${g.groupId}\n            class=\"border border-border rounded-lg overflow-hidden\"\n          >\n            <button\n              onclick=${() => toggle(g.groupId)}\n              class=\"w-full flex items-center justify-between px-3 py-2.5 bg-field hover:bg-field transition-colors text-left\"\n            >\n              <div>\n                <p class=\"text-sm text-body font-medium\">\n                  ${g.groupName || g.groupId}\n                </p>\n                <p class=\"text-[11px] text-fg-muted font-mono\">${g.groupId}</p>\n              </div>\n              <svg\n                width=\"16\"\n                height=\"16\"\n                viewBox=\"0 0 16 16\"\n                fill=\"currentColor\"\n                class=\"text-fg-muted transition-transform ${expandedGroupId ===\n                g.groupId\n                  ? \"rotate-180\"\n                  : \"\"}\"\n              >\n                <path\n                  d=\"M4.354 5.646a.5.5 0 00-.708.708l4 4a.5.5 0 00.708 0l4-4a.5.5 0 00-.708-.708L8 9.293 4.354 5.646z\"\n                />\n              </svg>\n            </button>\n            ${expandedGroupId === g.groupId &&\n            html`\n              <div class=\"p-3 border-t border-border\">\n                <${ManageTelegramWorkspace}\n                  accountId=${accountId}\n                  groupId=${g.groupId}\n                  groupName=${g.groupName}\n                  initialTopics=${g.topics}\n                  configAgentMaxConcurrent=${concurrency?.agentMaxConcurrent}\n                  configSubagentMaxConcurrent=${concurrency?.subagentMaxConcurrent}\n                  debugEnabled=${debugEnabled}\n                  onResetOnboarding=${onResetOnboarding}\n                />\n              </div>\n            `}\n          </div>\n        `,\n      )}\n    </div>\n  `;\n};\n\nexport const TelegramWorkspace = ({ accountId = \"default\", onBack }) => {\n  const initialState = loadTelegramWorkspaceState(accountId);\n  const cachedWorkspace = loadTelegramWorkspaceCache(accountId);\n  const [step, setStep] = useState(() => {\n    const value = Number.parseInt(String(initialState.step ?? 0), 10);\n    if (!Number.isFinite(value)) return 0;\n    return Math.min(Math.max(value, 0), kSteps.length - 1);\n  });\n  const [botInfo, setBotInfo] = useState(null);\n  const [groupId, setGroupId] = useState(initialState.groupId || \"\");\n  const [groupInfo, setGroupInfo] = useState(initialState.groupInfo || null);\n  const [verifyGroupError, setVerifyGroupError] = useState(\n    initialState.verifyGroupError || null,\n  );\n  const [allowUserId, setAllowUserId] = useState(\n    initialState.allowUserId || \"\",\n  );\n  const [topics, setTopics] = useState(initialState.topics || {});\n  const [workspaceConfig, setWorkspaceConfig] = useState(() => ({\n    ready: !!cachedWorkspace,\n    configured: !!cachedWorkspace?.configured,\n    groups: cachedWorkspace?.groups || [],\n    groupId: cachedWorkspace?.groupId || \"\",\n    groupName: cachedWorkspace?.groupName || \"\",\n    topics: cachedWorkspace?.topics || {},\n    debugEnabled: !!cachedWorkspace?.debugEnabled,\n    concurrency: cachedWorkspace?.concurrency || {\n      agentMaxConcurrent: null,\n      subagentMaxConcurrent: null,\n    },\n  }));\n\n  const goNext = () => setStep((s) => Math.min(kSteps.length - 1, s + 1));\n  const goBack = () => setStep((s) => Math.max(0, s - 1));\n  const resetOnboarding = async () => {\n    try {\n      const data = await api.resetWorkspace({ accountId });\n      if (!data.ok) throw new Error(data.error || \"Failed to reset onboarding\");\n      removeTelegramWorkspaceState(accountId);\n      removeTelegramWorkspaceCache(accountId);\n      setStep(0);\n      setBotInfo(null);\n      setGroupId(\"\");\n      setGroupInfo(null);\n      setVerifyGroupError(null);\n      setAllowUserId(\"\");\n      setTopics({});\n      setWorkspaceConfig({\n        ready: true,\n        configured: false,\n        groups: [],\n        groupId: \"\",\n        groupName: \"\",\n        topics: {},\n        debugEnabled: !!workspaceConfig?.debugEnabled,\n        concurrency: { agentMaxConcurrent: null, subagentMaxConcurrent: null },\n      });\n      showToast(\"Telegram onboarding reset\", \"success\");\n    } catch (e) {\n      showToast(e.message || \"Failed to reset onboarding\", \"error\");\n    }\n  };\n  const handleDone = () => {\n    removeTelegramWorkspaceState(accountId);\n    const doneGroupName = groupInfo?.chat?.title || groupId;\n    saveTelegramWorkspaceCache(accountId, {\n      ready: true,\n      configured: true,\n      groups: [{ groupId, groupName: doneGroupName, topics: topics || {} }],\n      groupId,\n      groupName: doneGroupName,\n      topics: topics || {},\n      debugEnabled: !!workspaceConfig?.debugEnabled,\n      concurrency: workspaceConfig?.concurrency || {\n        agentMaxConcurrent: null,\n        subagentMaxConcurrent: null,\n      },\n    });\n    window.location.reload();\n  };\n\n  useEffect(() => {\n    saveTelegramWorkspaceState(accountId, {\n      step,\n      groupId,\n      groupInfo,\n      verifyGroupError,\n      allowUserId,\n      topics,\n    });\n  }, [\n    accountId,\n    step,\n    groupId,\n    groupInfo,\n    verifyGroupError,\n    allowUserId,\n    topics,\n  ]);\n\n  useEffect(() => {\n    let active = true;\n    const bootstrapWorkspace = async () => {\n      try {\n        const data = await api.workspace({ accountId });\n        if (!active || !data?.ok) return;\n        const groups = Array.isArray(data.groups) ? data.groups : [];\n        if (!data.configured || groups.length === 0) {\n          const nextConfig = {\n            ready: true,\n            configured: false,\n            groups: [],\n            groupId: \"\",\n            groupName: \"\",\n            topics: {},\n            debugEnabled: !!data?.debugEnabled,\n            concurrency: {\n              agentMaxConcurrent: null,\n              subagentMaxConcurrent: null,\n            },\n          };\n          setWorkspaceConfig(nextConfig);\n          saveTelegramWorkspaceCache(accountId, nextConfig);\n          return;\n        }\n        const first = groups[0];\n        const nextConfig = {\n          ready: true,\n          configured: true,\n          groups,\n          groupId: first.groupId,\n          groupName: first.groupName || first.groupId,\n          topics: first.topics || {},\n          debugEnabled: !!data.debugEnabled,\n          concurrency: data.concurrency || {\n            agentMaxConcurrent: null,\n            subagentMaxConcurrent: null,\n          },\n        };\n        setWorkspaceConfig(nextConfig);\n        saveTelegramWorkspaceCache(accountId, nextConfig);\n        setGroupId(first.groupId);\n        setTopics(first.topics || {});\n        setGroupInfo({\n          chat: {\n            id: first.groupId,\n            title: first.groupName || first.groupId,\n            isForum: true,\n          },\n          bot: {\n            status: \"administrator\",\n            isAdmin: true,\n            canManageTopics: true,\n          },\n        });\n        setVerifyGroupError(null);\n        setAllowUserId(\"\");\n        setStep((currentStep) => (currentStep < 3 ? 3 : currentStep));\n      } catch {}\n    };\n    bootstrapWorkspace();\n    return () => {\n      active = false;\n    };\n  }, [accountId]);\n\n  return html`\n    <div class=\"space-y-4\">\n      <${BackButton} onBack=${onBack} />\n      <div class=\"bg-surface border border-border rounded-xl p-4\">\n        ${!workspaceConfig.ready\n          ? html`\n              <div class=\"min-h-[220px] flex items-center justify-center\">\n                <p class=\"text-sm text-fg-muted\">Loading workspace...</p>\n              </div>\n            `\n          : workspaceConfig.configured\n            ? html`\n                <div class=\"flex items-center justify-between mb-4\">\n                  <div class=\"flex items-center gap-2\">\n                    <img\n                      src=\"/assets/icons/telegram.svg\"\n                      alt=\"\"\n                      class=\"w-5 h-5\"\n                    />\n                    <h2 class=\"font-semibold text-sm\">\n                      Manage Telegram Workspace\n                    </h2>\n                  </div>\n                </div>\n                ${(workspaceConfig.groups || []).length <= 1\n                  ? html`\n                      <${ManageTelegramWorkspace}\n                        accountId=${accountId}\n                        groupId=${workspaceConfig.groupId}\n                        groupName=${workspaceConfig.groupName}\n                        initialTopics=${workspaceConfig.topics}\n                        configAgentMaxConcurrent=${workspaceConfig.concurrency\n                          ?.agentMaxConcurrent}\n                        configSubagentMaxConcurrent=${workspaceConfig.concurrency\n                          ?.subagentMaxConcurrent}\n                        debugEnabled=${workspaceConfig.debugEnabled}\n                        onResetOnboarding=${resetOnboarding}\n                      />\n                    `\n                  : html`\n                      <${MultiGroupView}\n                        accountId=${accountId}\n                        groups=${workspaceConfig.groups}\n                        concurrency=${workspaceConfig.concurrency}\n                        debugEnabled=${workspaceConfig.debugEnabled}\n                        onResetOnboarding=${resetOnboarding}\n                      />\n                    `}\n              `\n            : html`\n                <div class=\"flex items-center justify-between mb-4\">\n                  <div class=\"flex items-center gap-2\">\n                    <img\n                      src=\"/assets/icons/telegram.svg\"\n                      alt=\"\"\n                      class=\"w-5 h-5\"\n                    />\n                    <h2 class=\"font-semibold text-sm\">\n                      Set Up Telegram Workspace\n                    </h2>\n                  </div>\n                  <span class=\"text-xs text-fg-muted\"\n                    >Step ${step + 1} of ${kSteps.length}</span\n                  >\n                </div>\n\n                <${StepIndicator} currentStep=${step} steps=${kSteps} />\n\n                ${step === 0 &&\n                html`\n                  <${VerifyBotStep}\n                    accountId=${accountId}\n                    botInfo=${botInfo}\n                    setBotInfo=${setBotInfo}\n                    onNext=${goNext}\n                  />\n                `}\n                ${step === 1 &&\n                html`\n                  <${CreateGroupStep} onNext=${goNext} onBack=${goBack} />\n                `}\n                ${step === 2 &&\n                html`\n                  <${AddBotStep}\n                    accountId=${accountId}\n                    groupId=${groupId}\n                    setGroupId=${setGroupId}\n                    groupInfo=${groupInfo}\n                    setGroupInfo=${setGroupInfo}\n                    userId=${allowUserId}\n                    setUserId=${setAllowUserId}\n                    verifyGroupError=${verifyGroupError}\n                    setVerifyGroupError=${setVerifyGroupError}\n                    onNext=${goNext}\n                    onBack=${goBack}\n                  />\n                `}\n                ${step === 3 &&\n                html`\n                  <${TopicsStep}\n                    accountId=${accountId}\n                    groupId=${groupId}\n                    topics=${topics}\n                    setTopics=${setTopics}\n                    onNext=${goNext}\n                    onBack=${goBack}\n                  />\n                `}\n                ${step === 4 &&\n                html`\n                  <${SummaryStep}\n                    groupId=${groupId}\n                    groupInfo=${groupInfo}\n                    topics=${topics}\n                    onBack=${goBack}\n                    onDone=${handleDone}\n                  />\n                `}\n              `}\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/telegram-workspace/manage.js",
    "content": "import { h } from \"preact\";\nimport { useState, useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { showToast } from \"../toast.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { ConfirmDialog } from \"../confirm-dialog.js\";\nimport * as api from \"../../lib/telegram-api.js\";\nimport { fetchAgents } from \"../../lib/api.js\";\n\nconst html = htm.bind(h);\n\nconst AgentSelect = ({ value, agents, onChange, className = \"\" }) => html`\n  <select\n    value=${value}\n    onChange=${(e) => onChange(e.target.value)}\n    class=\"bg-field border border-border rounded-lg px-2 py-1.5 text-xs text-body focus:outline-none focus:border-fg-muted ${className}\"\n  >\n    <option value=\"\">Default</option>\n    ${agents.map(\n      (a) => html`<option value=${a.id}>${a.name || a.id}</option>`,\n    )}\n  </select>\n`;\n\nexport const ManageTelegramWorkspace = ({\n  accountId,\n  groupId,\n  groupName,\n  initialTopics,\n  configAgentMaxConcurrent,\n  configSubagentMaxConcurrent,\n  debugEnabled,\n  onResetOnboarding,\n}) => {\n  const [topics, setTopics] = useState(initialTopics || {});\n  const [newTopicName, setNewTopicName] = useState(\"\");\n  const [newTopicInstructions, setNewTopicInstructions] = useState(\"\");\n  const [newTopicAgentId, setNewTopicAgentId] = useState(\"\");\n  const [showCreateTopic, setShowCreateTopic] = useState(false);\n  const [creating, setCreating] = useState(false);\n  const [deleting, setDeleting] = useState(null);\n  const [editingTopicId, setEditingTopicId] = useState(\"\");\n  const [editingTopicName, setEditingTopicName] = useState(\"\");\n  const [editingTopicInstructions, setEditingTopicInstructions] = useState(\"\");\n  const [editingTopicAgentId, setEditingTopicAgentId] = useState(\"\");\n  const [renamingTopicId, setRenamingTopicId] = useState(\"\");\n  const [error, setError] = useState(null);\n  const [deleteTopicConfirm, setDeleteTopicConfirm] = useState(null);\n  const [agents, setAgents] = useState([]);\n\n  const loadTopics = async () => {\n    const data = await api.listTopics(groupId, { accountId });\n    if (data.ok) setTopics(data.topics || {});\n  };\n\n  useEffect(() => {\n    loadTopics();\n  }, [groupId]);\n  useEffect(() => {\n    if (initialTopics && Object.keys(initialTopics).length > 0) {\n      setTopics(initialTopics);\n    }\n  }, [initialTopics]);\n\n  useEffect(() => {\n    fetchAgents()\n      .then((data) => setAgents(Array.isArray(data?.agents) ? data.agents : []))\n      .catch(() => {});\n  }, []);\n\n  const createSingle = async () => {\n    const name = newTopicName.trim();\n    const systemInstructions = newTopicInstructions.trim();\n    const agentId = newTopicAgentId.trim();\n    if (!name) return;\n    setCreating(true);\n    setError(null);\n    try {\n      const data = await api.createTopicsBulk(groupId, [\n        {\n          name,\n          ...(systemInstructions ? { systemInstructions } : {}),\n          ...(agentId ? { agentId } : {}),\n        },\n      ], { accountId });\n      if (!data.ok)\n        throw new Error(data.results?.[0]?.error || \"Failed to create topic\");\n      const failed = data.results.filter((r) => !r.ok);\n      if (failed.length > 0) throw new Error(failed[0].error);\n      setNewTopicName(\"\");\n      setNewTopicInstructions(\"\");\n      setNewTopicAgentId(\"\");\n      setShowCreateTopic(false);\n      await loadTopics();\n      showToast(`Created topic: ${name}`, \"success\");\n    } catch (e) {\n      setError(e.message);\n    }\n    setCreating(false);\n  };\n\n  const handleDelete = async (topicId, topicName) => {\n    setDeleting(topicId);\n    try {\n      const data = await api.deleteTopic(groupId, topicId, { accountId });\n      if (!data.ok) throw new Error(data.error);\n      await loadTopics();\n      if (data.removedFromRegistryOnly) {\n        showToast(`Removed stale topic from registry: ${topicName}`, \"success\");\n      } else {\n        showToast(`Deleted topic: ${topicName}`, \"success\");\n      }\n    } catch (e) {\n      showToast(`Failed to delete: ${e.message}`, \"error\");\n    }\n    setDeleting(null);\n  };\n\n  const startRename = (topicId, topicName, topicInstructions = \"\", topicAgentId = \"\") => {\n    setEditingTopicId(String(topicId));\n    setEditingTopicName(String(topicName || \"\"));\n    setEditingTopicInstructions(String(topicInstructions || \"\"));\n    setEditingTopicAgentId(String(topicAgentId || \"\"));\n  };\n\n  const cancelRename = () => {\n    setEditingTopicId(\"\");\n    setEditingTopicName(\"\");\n    setEditingTopicInstructions(\"\");\n    setEditingTopicAgentId(\"\");\n  };\n\n  const saveRename = async (topicId) => {\n    const nextName = editingTopicName.trim();\n    const nextSystemInstructions = editingTopicInstructions.trim();\n    const nextAgentId = editingTopicAgentId.trim();\n    if (!nextName) {\n      setError(\"Topic name is required\");\n      return;\n    }\n    setRenamingTopicId(String(topicId));\n    setError(null);\n    try {\n      const data = await api.updateTopic(groupId, topicId, {\n        name: nextName,\n        systemInstructions: nextSystemInstructions,\n        agentId: nextAgentId,\n      }, { accountId });\n      if (!data.ok) throw new Error(data.error || \"Failed to update topic\");\n      await loadTopics();\n      showToast(`Updated topic: ${nextName}`, \"success\");\n      cancelRename();\n    } catch (e) {\n      setError(e.message);\n    }\n    setRenamingTopicId(\"\");\n  };\n\n  const topicEntries = Object.entries(topics || {});\n  const topicCount = topicEntries.length;\n  const computedMaxConcurrent = Math.max(topicCount * 3, 8);\n  const computedSubagentMaxConcurrent = Math.max(computedMaxConcurrent - 2, 4);\n  const maxConcurrent = Number.isFinite(configAgentMaxConcurrent)\n    ? configAgentMaxConcurrent\n    : computedMaxConcurrent;\n  const subagentMaxConcurrent = Number.isFinite(configSubagentMaxConcurrent)\n    ? configSubagentMaxConcurrent\n    : computedSubagentMaxConcurrent;\n\n  return html`\n    <div class=\"space-y-4\">\n      ${debugEnabled &&\n      html`\n        <div class=\"flex justify-end\">\n          <button\n            onclick=${onResetOnboarding}\n            class=\"text-xs px-3 py-1.5 rounded-lg border border-border text-fg-muted hover:text-body hover:border-fg-muted transition-colors\"\n          >\n            Reset onboarding\n          </button>\n        </div>\n      `}\n      <div class=\"bg-field border border-border rounded-lg p-3 space-y-1\">\n        <p class=\"text-sm text-body font-medium\">${groupName || groupId}</p>\n        <p class=\"text-xs text-fg-muted font-mono\">${groupId}</p>\n      </div>\n\n      <div class=\"space-y-2\">\n        <h2 class=\"card-label mb-3\">Existing Topics</h2>\n        ${topicEntries.length > 0\n          ? html`\n              <div\n                class=\"bg-field border border-border rounded-lg overflow-hidden\"\n              >\n                <table class=\"w-full text-xs table-fixed\">\n                  <thead>\n                    <tr class=\"border-b border-border\">\n                      <th class=\"text-left px-3 py-2 text-fg-muted font-medium\">\n                        Topic\n                      </th>\n                      <th\n                        class=\"text-left px-3 py-2 text-fg-muted font-medium w-36\"\n                      >\n                        Thread ID\n                      </th>\n                      ${agents.length > 0 &&\n                      html`\n                        <th\n                          class=\"text-left px-3 py-2 text-fg-muted font-medium w-32\"\n                        >\n                          Agent\n                        </th>\n                      `}\n                      <th class=\"px-3 py-2 w-28\" />\n                    </tr>\n                  </thead>\n                  <tbody>\n                    ${topicEntries.map(\n                      ([id, topic]) => html`\n                        ${editingTopicId === String(id)\n                          ? html`\n                              <tr\n                                class=\"border-b border-border last:border-0 align-top\"\n                              >\n                                <td class=\"px-3 py-2\" colspan=${agents.length > 0 ? 4 : 3}>\n                                  <div class=\"space-y-2\">\n                                    <input\n                                      type=\"text\"\n                                      value=${editingTopicName}\n                                      onInput=${(e) =>\n                                        setEditingTopicName(e.target.value)}\n                                      onKeyDown=${(e) => {\n                                        if (e.key === \"Enter\") saveRename(id);\n                                        if (e.key === \"Escape\") cancelRename();\n                                      }}\n                                      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\"\n                                    />\n                                    <textarea\n                                      value=${editingTopicInstructions}\n                                      onInput=${(e) =>\n                                        setEditingTopicInstructions(\n                                          e.target.value,\n                                        )}\n                                      placeholder=\"System instructions (optional)\"\n                                      rows=\"6\"\n                                      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 resize-y\"\n                                    />\n                                    ${agents.length > 0 &&\n                                    html`\n                                      <div class=\"flex items-center gap-2\">\n                                        <label class=\"text-xs text-fg-muted\">Agent:</label>\n                                        <${AgentSelect}\n                                          value=${editingTopicAgentId}\n                                          agents=${agents}\n                                          onChange=${setEditingTopicAgentId}\n                                        />\n                                      </div>\n                                    `}\n                                    <div class=\"flex items-center gap-2\">\n                                      <button\n                                        onclick=${() => saveRename(id)}\n                                        disabled=${renamingTopicId ===\n                                        String(id)}\n                                        class=\"text-xs px-2 py-1 rounded transition-all ac-btn-cyan ${renamingTopicId ===\n                                        String(id)\n                                          ? \"opacity-50 cursor-not-allowed\"\n                                          : \"\"}\"\n                                      >\n                                        Save\n                                      </button>\n                                      <button\n                                        onclick=${cancelRename}\n                                        class=\"text-xs px-2 py-1 rounded border border-border text-fg-muted hover:text-body hover:border-fg-muted\"\n                                      >\n                                        Cancel\n                                      </button>\n                                    </div>\n                                  </div>\n                                </td>\n                              </tr>\n                            `\n                          : html`\n                              <tr\n                                class=\"border-b border-border last:border-0 align-middle\"\n                              >\n                                <td class=\"px-3 py-2 text-body\">\n                                  <div class=\"flex items-center gap-2\">\n                                    <span>${topic.name}</span>\n                                    <button\n                                      onclick=${() =>\n                                        startRename(\n                                          id,\n                                          topic.name,\n                                          topic.systemInstructions,\n                                          topic.agentId,\n                                        )}\n                                      class=\"inline-flex items-center justify-center text-white/80 hover:text-white transition-colors\"\n                                      title=\"Edit topic\"\n                                      aria-label=\"Edit topic\"\n                                    >\n                                      <svg\n                                        width=\"14\"\n                                        height=\"14\"\n                                        viewBox=\"0 0 16 16\"\n                                        fill=\"currentColor\"\n                                        aria-hidden=\"true\"\n                                      >\n                                        <path\n                                          d=\"M11.854 1.146a.5.5 0 00-.708 0L3 9.293V13h3.707l8.146-8.146a.5.5 0 000-.708l-3-3zM3.5 12.5v-2.793l7-7L13.793 6l-7 7H3.5z\"\n                                        />\n                                      </svg>\n                                    </button>\n                                  </div>\n                                  ${topic.systemInstructions &&\n                                  html`\n                                    <p\n                                      class=\"text-[11px] text-fg-muted mt-1 line-clamp-1\"\n                                    >\n                                      ${topic.systemInstructions}\n                                    </p>\n                                  `}\n                                </td>\n                                <td\n                                  class=\"px-3 py-2 text-fg-muted font-mono w-36\"\n                                >\n                                  ${id}\n                                </td>\n                                ${agents.length > 0 &&\n                                html`\n                                  <td class=\"px-3 py-2 text-fg-muted w-32\">\n                                    ${topic.agentId\n                                      ? html`<span class=\"text-body\">${agents.find((a) => a.id === topic.agentId)?.name || topic.agentId}</span>`\n                                      : html`<span class=\"text-fg-dim\">default</span>`}\n                                  </td>\n                                `}\n                                <td class=\"px-3 py-2\">\n                                  <div\n                                    class=\"flex items-center gap-2 justify-end\"\n                                  >\n                                    <button\n                                      onclick=${() =>\n                                        setDeleteTopicConfirm({\n                                          id: String(id),\n                                          name: String(topic.name || \"\"),\n                                        })}\n                                      disabled=${deleting === id}\n                                      class=\"text-xs px-2 py-1 rounded border border-border text-fg-muted hover:text-status-error hover:border-red-500 ${deleting ===\n                                      id\n                                        ? \"opacity-50 cursor-not-allowed\"\n                                        : \"\"}\"\n                                      title=\"Delete topic\"\n                                    >\n                                      Delete\n                                    </button>\n                                  </div>\n                                </td>\n                              </tr>\n                            `}\n                      `,\n                    )}\n                  </tbody>\n                </table>\n              </div>\n            `\n          : html`<p class=\"text-xs text-fg-muted\">No topics yet.</p>`}\n      </div>\n\n      ${showCreateTopic &&\n      html`\n        <div class=\"space-y-2 bg-field border border-border rounded-lg p-3\">\n          <label class=\"text-xs text-fg-muted\">Create new topic</label>\n          <div class=\"space-y-2\">\n            <input\n              type=\"text\"\n              value=${newTopicName}\n              onInput=${(e) => setNewTopicName(e.target.value)}\n              onKeyDown=${(e) => {\n                if (e.key === \"Enter\") createSingle();\n              }}\n              placeholder=\"Topic name\"\n              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\"\n            />\n            <textarea\n              value=${newTopicInstructions}\n              onInput=${(e) => setNewTopicInstructions(e.target.value)}\n              placeholder=\"System instructions (optional)\"\n              rows=\"5\"\n              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 resize-y\"\n            />\n            ${agents.length > 0 &&\n            html`\n              <div class=\"flex items-center gap-2\">\n                <label class=\"text-xs text-fg-muted\">Agent:</label>\n                <${AgentSelect}\n                  value=${newTopicAgentId}\n                  agents=${agents}\n                  onChange=${setNewTopicAgentId}\n                />\n              </div>\n            `}\n            <div class=\"flex justify-end\">\n              <${ActionButton}\n                onClick=${createSingle}\n                disabled=${creating || !newTopicName.trim()}\n                loading=${creating}\n                tone=\"secondary\"\n                size=\"lg\"\n                idleLabel=\"Add topic\"\n                loadingLabel=\"Creating...\"\n              />\n            </div>\n          </div>\n        </div>\n      `}\n      ${error &&\n      html`\n        <div class=\"bg-red-500/10 border border-red-500/20 rounded-lg p-3\">\n          <p class=\"text-sm text-status-error-muted\">${error}</p>\n        </div>\n      `}\n\n      <div class=\"flex items-center justify-start\">\n        <button\n          onclick=${() => setShowCreateTopic((v) => !v)}\n          class=\"${showCreateTopic\n            ? \"w-auto text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-body hover:border-fg-muted\"\n            : \"w-auto text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan\"}\"\n        >\n          ${showCreateTopic ? \"Close create topic\" : \"Create topic\"}\n        </button>\n      </div>\n\n      <div class=\"border-t border-white/10\" />\n\n      <p class=\"text-xs text-fg-muted\">\n        Concurrency is auto-scaled to support your group:\n        <span class=\"text-body\"> agent ${maxConcurrent}</span>,\n        <span class=\"text-body\"> subagent ${subagentMaxConcurrent}</span>\n        <span class=\"text-fg-dim\"> (${topicCount} topics)</span>.\n      </p>\n      <p class=\"text-[11px] text-fg-muted\">\n        This registry can drift if topics are created, renamed, or removed\n        outside this page. Your agent will update the registry if it notices a\n        discrepancy.\n      </p>\n      <${ConfirmDialog}\n        visible=${!!deleteTopicConfirm}\n        title=\"Delete topic?\"\n        message=${deleteTopicConfirm\n          ? `This will delete \"${deleteTopicConfirm.name}\" (thread ${deleteTopicConfirm.id}) from your Telegram workspace.`\n          : \"This will delete this topic from your Telegram workspace.\"}\n        confirmLabel=\"Delete topic\"\n        confirmLoadingLabel=\"Deleting...\"\n        confirmTone=\"warning\"\n        confirmLoading=${!!deleting}\n        cancelLabel=\"Cancel\"\n        onCancel=${() => {\n          if (deleting) return;\n          setDeleteTopicConfirm(null);\n        }}\n        onConfirm=${async () => {\n          if (!deleteTopicConfirm) return;\n          const pendingDelete = deleteTopicConfirm;\n          setDeleteTopicConfirm(null);\n          await handleDelete(pendingDelete.id, pendingDelete.name);\n        }}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/telegram-workspace/onboarding.js",
    "content": "import { h } from \"preact\";\nimport { useState, useEffect } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { Badge } from \"../badge.js\";\nimport { showToast } from \"../toast.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { ConfirmDialog } from \"../confirm-dialog.js\";\nimport * as api from \"../../lib/telegram-api.js\";\n\nconst html = htm.bind(h);\n\nexport const StepIndicator = ({ currentStep, steps }) => html`\n  <div class=\"flex items-center gap-1 mb-6\">\n    ${steps.map(\n      (s, i) => html`\n        <div\n          class=\"h-1 flex-1 rounded-full transition-colors ${i <= currentStep\n            ? \"bg-accent\"\n            : \"bg-border\"}\"\n          style=${i <= currentStep ? \"background: var(--accent)\" : \"\"}\n        />\n      `,\n    )}\n  </div>\n`;\n\n// Step 1: Verify Bot\nexport const VerifyBotStep = ({ accountId, botInfo, setBotInfo, onNext }) => {\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState(null);\n\n  const verify = async () => {\n    setLoading(true);\n    setError(null);\n    try {\n      const data = await api.verifyBot({ accountId });\n      if (!data.ok) throw new Error(data.error);\n      setBotInfo(data.bot);\n    } catch (e) {\n      setError(e.message);\n    }\n    setLoading(false);\n  };\n\n  useEffect(() => {\n    if (!botInfo) verify();\n  }, []);\n\n  return html`\n    <div class=\"space-y-4\">\n      <h3 class=\"text-sm font-semibold\">Verify Bot Setup</h3>\n\n      ${botInfo &&\n      html`\n        <div class=\"bg-field border border-border rounded-lg p-3\">\n          <div class=\"flex items-center gap-2\">\n            <span class=\"text-sm text-body font-medium\">@${botInfo.username}</span>\n            <${Badge} tone=\"success\">Connected</${Badge}>\n          </div>\n          <p class=\"text-xs text-fg-muted mt-1\">${botInfo.first_name}</p>\n        </div>\n      `}\n      ${error &&\n      html`\n        <div class=\"bg-red-500/10 border border-red-500/20 rounded-lg p-3\">\n          <p class=\"text-sm text-status-error-muted\">${error}</p>\n        </div>\n      `}\n      ${!botInfo &&\n      !loading &&\n      !error &&\n      html` <p class=\"text-sm text-fg-muted\">Checking bot token...</p> `}\n\n      <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n        <p class=\"text-xs font-medium text-body\">\n          Before continuing, configure BotFather:\n        </p>\n        <ol class=\"text-xs text-fg-muted space-y-1.5 list-decimal list-inside\">\n          <li>\n            Open <span class=\"text-body\">@BotFather</span> in Telegram\n          </li>\n          <li>\n            Send <code class=\"bg-field px-1 rounded\">/mybots</code> and\n            select your bot\n          </li>\n          <li>\n            Go to <span class=\"text-body\">Bot Settings</span> >\n            <span class=\"text-body\">Group Privacy</span>\n          </li>\n          <li>Turn it <span class=\"text-status-warning-muted font-medium\">OFF</span></li>\n        </ol>\n      </div>\n\n      <div class=\"grid grid-cols-2 gap-2\">\n        <div />\n        <button\n          onclick=${onNext}\n          disabled=${!botInfo}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan ${!botInfo\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"\"}\"\n        >\n          Next\n        </button>\n      </div>\n    </div>\n  `;\n};\n\n// Step 2: Create Group\nexport const CreateGroupStep = ({ onNext, onBack }) => html`\n  <div class=\"space-y-4\">\n    <h3 class=\"text-sm font-semibold\">Create a Telegram Group</h3>\n\n    <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n      <p class=\"text-xs font-medium text-body\">Create the group</p>\n      <ol class=\"text-xs text-fg-muted space-y-2 list-decimal list-inside\">\n        <li>\n          Open Telegram and create a${\" \"}\n          <span class=\"text-body\">new group</span>\n        </li>\n        <li>\n          Search for and add <span class=\"text-body\">your bot</span> as a\n          member\n        </li>\n        <li>\n          Hit <span class=\"text-body\">Next</span>, give the group a name\n          (e.g. \"My Workspace\"), and create it\n        </li>\n      </ol>\n    </div>\n\n    <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n      <p class=\"text-xs font-medium text-body\">Enable topics</p>\n      <ol class=\"text-xs text-fg-muted space-y-2 list-decimal list-inside\">\n        <li>Tap the group name at the top to open settings</li>\n        <li>\n          Tap <span class=\"text-body\">Edit</span> (pencil icon), scroll to\n          <span class=\"text-body\"> Topics</span>, toggle it\n          <span class=\"text-status-warning-muted font-medium\"> ON</span>\n        </li>\n      </ol>\n    </div>\n\n    <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n      <p class=\"text-xs font-medium text-body\">Make the bot an admin</p>\n      <ol class=\"text-xs text-fg-muted space-y-2 list-decimal list-inside\">\n        <li>Go to <span class=\"text-body\">Members</span>, tap your bot</li>\n        <li>\n          Promote it to <span class=\"text-status-warning-muted font-medium\">Admin</span>\n        </li>\n        <li>\n          Make sure\n          <span class=\"text-status-warning-muted font-medium\"> Manage Topics </span>\n          permission is enabled\n        </li>\n      </ol>\n    </div>\n\n    <p class=\"text-xs text-fg-muted\">\n      Once all three steps are done, continue to verify the setup.\n    </p>\n\n    <div class=\"grid grid-cols-2 gap-2\">\n      <button\n        onclick=${onBack}\n        class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-body hover:border-fg-muted\"\n      >\n        Back\n      </button>\n      <button\n        onclick=${onNext}\n        class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan\"\n      >\n        Next\n      </button>\n    </div>\n  </div>\n`;\n\n// Step 3: Add Bot to Group / Verify Group\nexport const AddBotStep = ({\n  accountId,\n  groupId,\n  setGroupId,\n  groupInfo,\n  setGroupInfo,\n  userId,\n  setUserId,\n  verifyGroupError,\n  setVerifyGroupError,\n  onNext,\n  onBack,\n}) => {\n  const [input, setInput] = useState(groupId || \"\");\n  const [loading, setLoading] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const verifyWarnings = groupInfo\n    ? [\n        ...(!groupInfo.chat?.isForum\n          ? [\"Topics are OFF. Enable Topics in Telegram group settings.\"]\n          : []),\n        ...(!groupInfo.bot?.isAdmin\n          ? [\"Bot is not an admin. Promote it to admin in group members.\"]\n          : []),\n        ...(!groupInfo.bot?.canManageTopics\n          ? [\n              \"Bot is missing Manage Topics permission. Enable it in admin permissions.\",\n            ]\n          : []),\n      ]\n    : [];\n\n  const verify = async () => {\n    const id = input.trim();\n    if (!id) return;\n    setLoading(true);\n    setVerifyGroupError(null);\n    try {\n      const data = await api.verifyGroup(id, { accountId });\n      if (!data.ok) throw new Error(data.error);\n      setGroupId(id);\n      setGroupInfo(data);\n      if (!String(userId || \"\").trim() && data.suggestedUserId) {\n        setUserId(String(data.suggestedUserId));\n      }\n    } catch (e) {\n      setVerifyGroupError(e.message);\n      setGroupInfo(null);\n    }\n    setLoading(false);\n  };\n  const canContinue = !!(\n    groupInfo &&\n    groupInfo.chat?.isForum &&\n    groupInfo.bot?.isAdmin &&\n    groupInfo.bot?.canManageTopics\n  );\n  const continueWithConfig = async () => {\n    if (!canContinue || saving) return;\n    setVerifyGroupError(null);\n    setSaving(true);\n    try {\n      const userIdValue = String(userId || \"\").trim();\n      const data = await api.configureGroup(groupId, {\n        ...(userIdValue ? { userId: userIdValue } : {}),\n        groupName: groupInfo?.chat?.title || groupId,\n        requireMention: false,\n      }, { accountId });\n      if (!data?.ok)\n        throw new Error(data?.error || \"Failed to configure Telegram group\");\n      if (data.userId) setUserId(String(data.userId));\n      onNext();\n    } catch (e) {\n      setVerifyGroupError(e.message);\n    }\n    setSaving(false);\n  };\n\n  return html`\n    <div class=\"space-y-4\">\n      <h3 class=\"text-sm font-semibold\">Verify Group</h3>\n\n      <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n        <p class=\"text-xs text-fg-muted\">To get your group chat ID:</p>\n        <ol class=\"text-xs text-fg-muted space-y-1 list-decimal list-inside\">\n          <li>\n            Invite <span class=\"text-body\">@myidbot</span> to your group\n          </li>\n          <li>\n            Send <code class=\"bg-field px-1 rounded\">/getgroupid</code>\n          </li>\n          <li>\n            Copy the ID (starts with\n            <code class=\"bg-field px-1 rounded\">-100</code>)\n          </li>\n        </ol>\n      </div>\n\n      <div class=\"flex gap-2\">\n        <input\n          type=\"text\"\n          value=${input}\n          onInput=${(e) => setInput(e.target.value)}\n          placeholder=\"-100XXXXXXXXXX\"\n          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\"\n        />\n        <${ActionButton}\n          onClick=${verify}\n          disabled=${!input.trim() || loading}\n          loading=${loading}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=\"Verify\"\n          loadingMode=\"inline\"\n          className=\"rounded-lg\"\n        />\n      </div>\n\n      ${verifyGroupError &&\n      html`\n        <div class=\"bg-red-500/10 border border-red-500/20 rounded-lg p-3\">\n          <p class=\"text-sm text-status-error-muted\">${verifyGroupError}</p>\n        </div>\n      `}\n      ${groupInfo &&\n      html`\n        <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n          <div class=\"flex items-center gap-2\">\n            <span class=\"text-sm text-body font-medium\">${groupInfo.chat.title}</span>\n            <${Badge} tone=\"success\">Verified</${Badge}>\n          </div>\n          <div class=\"flex gap-3 text-xs text-fg-muted\">\n            <span>Topics: ${groupInfo.chat.isForum ? \"ON\" : \"OFF\"}</span>\n            <span>Bot: ${groupInfo.bot.status}</span>\n          </div>\n        </div>\n      `}\n      ${groupInfo &&\n      verifyWarnings.length === 0 &&\n      html`\n        <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n          <p class=\"text-xs text-fg-muted\">Your Telegram User ID</p>\n          <input\n            type=\"text\"\n            value=${userId}\n            onInput=${(e) => setUserId(e.target.value)}\n            placeholder=\"e.g. 123456789\"\n            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\"\n          />\n          <p class=\"text-xs text-fg-muted\">\n            Auto-filled from Telegram admins. Edit if needed.\n          </p>\n        </div>\n      `}\n      ${verifyWarnings.length > 0 &&\n      html`\n        <div\n          class=\"bg-red-500/10 border border-red-500/20 rounded-lg p-3 space-y-3\"\n        >\n          <p class=\"text-xs font-medium text-status-error\">\n            Fix these before continuing:\n          </p>\n          <ul class=\"text-xs text-status-error space-y-1 list-disc list-inside\">\n            ${verifyWarnings.map((message) => html`<li>${message}</li>`)}\n          </ul>\n          <p class=\"text-xs text-status-error \">Once fixed, hit Verify again.</p>\n        </div>\n      `}\n\n      <div class=\"grid grid-cols-2 gap-2\">\n        <button\n          onclick=${onBack}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-body hover:border-fg-muted\"\n        >\n          Back\n        </button>\n        <button\n          onclick=${continueWithConfig}\n          disabled=${!canContinue || saving}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan ${!canContinue ||\n          saving\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"\"}\"\n        >\n          ${saving ? \"Saving...\" : \"Next\"}\n        </button>\n      </div>\n    </div>\n  `;\n};\n\n// Step 4: Create Topics\nexport const TopicsStep = ({ accountId, groupId, topics, setTopics, onNext, onBack }) => {\n  const [newTopicName, setNewTopicName] = useState(\"\");\n  const [newTopicInstructions, setNewTopicInstructions] = useState(\"\");\n  const [creating, setCreating] = useState(false);\n  const [error, setError] = useState(null);\n  const [deleting, setDeleting] = useState(null);\n  const [deleteTopicConfirm, setDeleteTopicConfirm] = useState(null);\n\n  const loadTopics = async () => {\n    const data = await api.listTopics(groupId, { accountId });\n    if (data.ok) setTopics(data.topics);\n  };\n\n  useEffect(() => {\n    loadTopics();\n  }, [groupId]);\n\n  const createSingle = async () => {\n    const name = newTopicName.trim();\n    const systemInstructions = newTopicInstructions.trim();\n    if (!name) return;\n    setCreating(true);\n    setError(null);\n    try {\n      const data = await api.createTopicsBulk(groupId, [\n        { name, ...(systemInstructions ? { systemInstructions } : {}) },\n      ], { accountId });\n      if (!data.ok)\n        throw new Error(data.results?.[0]?.error || \"Failed to create topic\");\n      const failed = data.results.filter((r) => !r.ok);\n      if (failed.length > 0) throw new Error(failed[0].error);\n      setNewTopicName(\"\");\n      setNewTopicInstructions(\"\");\n      await loadTopics();\n      showToast(`Created topic: ${name}`, \"success\");\n    } catch (e) {\n      setError(e.message);\n    }\n    setCreating(false);\n  };\n\n  const handleDelete = async (topicId, topicName) => {\n    setDeleting(topicId);\n    try {\n      const data = await api.deleteTopic(groupId, topicId, { accountId });\n      if (!data.ok) throw new Error(data.error);\n      await loadTopics();\n      if (data.removedFromRegistryOnly) {\n        showToast(`Removed stale topic from registry: ${topicName}`, \"success\");\n      } else {\n        showToast(`Deleted topic: ${topicName}`, \"success\");\n      }\n    } catch (e) {\n      showToast(`Failed to delete: ${e.message}`, \"error\");\n    }\n    setDeleting(null);\n  };\n\n  const topicEntries = Object.entries(topics || {});\n\n  return html`\n    <div class=\"space-y-4\">\n      <h3 class=\"text-sm font-semibold\">Create Topics</h3>\n\n      ${topicEntries.length > 0 &&\n      html`\n        <div\n          class=\"bg-field border border-border rounded-lg overflow-hidden\"\n        >\n          <table class=\"w-full text-xs\">\n            <thead>\n              <tr class=\"border-b border-border\">\n                <th class=\"text-left px-3 py-2 text-fg-muted font-medium\">\n                  Topic\n                </th>\n                <th class=\"text-left px-3 py-2 text-fg-muted font-medium\">\n                  Thread ID\n                </th>\n                <th class=\"px-3 py-2 w-8\" />\n              </tr>\n            </thead>\n            <tbody>\n              ${topicEntries.map(\n                ([id, t]) => html`\n                  <tr class=\"border-b border-border last:border-0\">\n                    <td class=\"px-3 py-2 text-body\">${t.name}</td>\n                    <td class=\"px-3 py-2 text-fg-muted font-mono\">${id}</td>\n                    <td class=\"px-3 py-2\">\n                      <button\n                        onclick=${() =>\n                          setDeleteTopicConfirm({\n                            id: String(id),\n                            name: String(t.name || \"\"),\n                          })}\n                        disabled=${deleting === id}\n                        class=\"text-fg-dim hover:text-status-error-muted transition-colors ${deleting ===\n                        id\n                          ? \"opacity-50\"\n                          : \"\"}\"\n                        title=\"Delete topic\"\n                      >\n                        <svg\n                          width=\"14\"\n                          height=\"14\"\n                          viewBox=\"0 0 16 16\"\n                          fill=\"currentColor\"\n                        >\n                          <path\n                            d=\"M4.646 4.646a.5.5 0 01.708 0L8 7.293l2.646-2.647a.5.5 0 01.708.708L8.707 8l2.647 2.646a.5.5 0 01-.708.708L8 8.707l-2.646 2.647a.5.5 0 01-.708-.708L7.293 8 4.646 5.354a.5.5 0 010-.708z\"\n                          />\n                        </svg>\n                      </button>\n                    </td>\n                  </tr>\n                `,\n              )}\n            </tbody>\n          </table>\n        </div>\n      `}\n\n      <div class=\"space-y-2\">\n        <label class=\"text-xs text-fg-muted\">Add a topic</label>\n        <div class=\"space-y-2\">\n          <div class=\"flex gap-2\">\n            <input\n              type=\"text\"\n              value=${newTopicName}\n              onInput=${(e) => setNewTopicName(e.target.value)}\n              onKeyDown=${(e) => {\n                if (e.key === \"Enter\") createSingle();\n              }}\n              placeholder=\"Topic name\"\n              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\"\n            />\n          </div>\n          <textarea\n            value=${newTopicInstructions}\n            onInput=${(e) => setNewTopicInstructions(e.target.value)}\n            placeholder=\"System instructions (optional)\"\n            rows=\"4\"\n            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 resize-y\"\n          />\n          <div class=\"flex justify-end\">\n            <${ActionButton}\n              onClick=${createSingle}\n              disabled=${creating || !newTopicName.trim()}\n              loading=${creating}\n              tone=\"secondary\"\n              size=\"lg\"\n              idleLabel=\"Add\"\n              loadingMode=\"inline\"\n              className=\"min-w-[88px]\"\n            />\n          </div>\n        </div>\n      </div>\n      <div class=\"border-t border-white/10 pt-2\" />\n\n      ${error &&\n      html`\n        <div class=\"bg-red-500/10 border border-red-500/20 rounded-lg p-3\">\n          <p class=\"text-sm text-status-error-muted\">${error}</p>\n        </div>\n      `}\n\n      <div class=\"grid grid-cols-2 gap-2\">\n        <button\n          onclick=${onBack}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-body hover:border-fg-muted\"\n        >\n          Back\n        </button>\n        <button\n          onclick=${onNext}\n          disabled=${topicEntries.length === 0}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan\"\n        >\n          Next\n        </button>\n      </div>\n      <${ConfirmDialog}\n        visible=${!!deleteTopicConfirm}\n        title=\"Delete topic?\"\n        message=${deleteTopicConfirm\n          ? `This will delete \"${deleteTopicConfirm.name}\" (thread ${deleteTopicConfirm.id}) from your Telegram workspace.`\n          : \"This will delete this topic from your Telegram workspace.\"}\n        confirmLabel=\"Delete topic\"\n        confirmLoadingLabel=\"Deleting...\"\n        confirmTone=\"warning\"\n        confirmLoading=${!!deleting}\n        cancelLabel=\"Cancel\"\n        onCancel=${() => {\n          if (deleting) return;\n          setDeleteTopicConfirm(null);\n        }}\n        onConfirm=${async () => {\n          if (!deleteTopicConfirm) return;\n          const pendingDelete = deleteTopicConfirm;\n          setDeleteTopicConfirm(null);\n          await handleDelete(pendingDelete.id, pendingDelete.name);\n        }}\n      />\n    </div>\n  `;\n};\n\n// Step 5: Summary\nexport const SummaryStep = ({ groupId, groupInfo, topics, onBack, onDone }) => {\n  return html`\n    <div class=\"space-y-4\">\n      <div class=\"max-w-xl mx-auto text-center space-y-10 mt-10\">\n        <p class=\"text-sm font-medium text-status-success\">🎉 Setup complete</p>\n        <p class=\"text-xs text-fg-muted\">\n          The topic registry has been injected into\n          <code class=\"bg-field px-1 rounded\">TOOLS.md</code> so your agent\n          knows which thread ID maps to which topic name.\n        </p>\n\n        <div class=\"bg-field border border-border rounded-lg p-3\">\n          <p class=\"text-xs text-fg-muted\">\n            If you used <span class=\"text-body\">@myidbot</span> to find IDs,\n            you can remove it from the group now.\n          </p>\n        </div>\n      </div>\n\n      <div class=\"grid grid-cols-2 gap-2\">\n        <button\n          onclick=${onBack}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-body hover:border-fg-muted\"\n        >\n          Back\n        </button>\n        <button\n          onclick=${onDone}\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan\"\n        >\n          Done\n        </button>\n      </div>\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/theme-toggle.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ComputerLineIcon, MoonIcon, SunIcon } from \"./icons.js\";\nimport { kThemeStorageKey } from \"../lib/storage-keys.js\";\n\nconst html = htm.bind(h);\n\nconst kOptions = [\n  { id: \"dark\", label: \"Dark\", Icon: MoonIcon },\n  { id: \"light\", label: \"Light\", Icon: SunIcon },\n  { id: \"system\", label: \"System\", Icon: ComputerLineIcon },\n];\n\n/** Map a preference to the icon component shown on the trigger button. */\nconst kPrefIcon = { dark: MoonIcon, light: SunIcon, system: ComputerLineIcon };\n\n/** Resolve a preference string to an effective \"dark\" | \"light\" value. */\nconst resolveEffective = (pref) => {\n  if (pref === \"dark\" || pref === \"light\") return pref;\n  try {\n    return window.matchMedia(\"(prefers-color-scheme: light)\").matches ? \"light\" : \"dark\";\n  } catch {\n    return \"dark\";\n  }\n};\n\n/** Read the stored preference. Falls back to \"dark\" (not OS). */\nconst readPreference = () => {\n  try {\n    const saved = localStorage.getItem(kThemeStorageKey);\n    if (saved === \"dark\" || saved === \"light\" || saved === \"system\") return saved;\n  } catch {}\n  return \"dark\";\n};\n\nconst applyEffective = (effective) => {\n  document.documentElement.dataset.theme = effective;\n};\n\nconst savePreference = (pref) => {\n  try { localStorage.setItem(kThemeStorageKey, pref); } catch {}\n};\n\nexport const ThemeToggle = () => {\n  const [pref, setPref] = useState(readPreference);\n  const [open, setOpen] = useState(false);\n  const menuRef = useRef(null);\n\n  // Apply effective theme whenever preference changes (and listen for OS changes when \"system\").\n  useEffect(() => {\n    applyEffective(resolveEffective(pref));\n\n    if (pref !== \"system\") return;\n\n    const mql = window.matchMedia(\"(prefers-color-scheme: light)\");\n    const onChange = () => applyEffective(resolveEffective(\"system\"));\n    mql.addEventListener(\"change\", onChange);\n    return () => mql.removeEventListener(\"change\", onChange);\n  }, [pref]);\n\n  // Close dropdown on outside click.\n  useEffect(() => {\n    if (!open) return;\n    const handler = (e) => {\n      if (menuRef.current && !menuRef.current.contains(e.target)) setOpen(false);\n    };\n    window.addEventListener(\"click\", handler, true);\n    return () => window.removeEventListener(\"click\", handler, true);\n  }, [open]);\n\n  const select = (id) => {\n    setPref(id);\n    savePreference(id);\n    applyEffective(resolveEffective(id));\n    setOpen(false);\n  };\n\n  const TriggerIcon = kPrefIcon[pref] || MoonIcon;\n\n  return html`\n    <div\n      ref=${menuRef}\n      class=\"theme-toggle-menu\"\n    >\n      <button\n        type=\"button\"\n        onclick=${() => setOpen((o) => !o)}\n        title=\"Theme\"\n        aria-label=\"Toggle theme\"\n        aria-expanded=${open}\n        class=\"theme-toggle-trigger\"\n      >\n        <${TriggerIcon} className=\"w-3.5 h-3.5\" />\n      </button>\n      ${open && html`\n        <div class=\"theme-toggle-dropdown\">\n          ${kOptions.map(({ id, label, Icon }) => html`\n            <button\n              key=${id}\n              type=\"button\"\n              class=\"theme-toggle-option ${pref === id ? \"active\" : \"\"}\"\n              onclick=${() => select(id)}\n            >\n              <${Icon} className=\"w-3.5 h-3.5\" />\n              <span>${label}</span>\n            </button>\n          `)}\n        </div>\n      `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/toast.js",
    "content": "import { h } from 'preact';\nimport { useState, useEffect } from 'preact/hooks';\nimport { createPortal } from 'preact/compat';\nimport htm from 'htm';\nconst html = htm.bind(h);\n\nlet toastId = 0;\nlet addToastFn = null;\n\nconst kToastTypeByAlias = {\n  success: \"success\",\n  error: \"error\",\n  warning: \"warning\",\n  info: \"info\",\n  green: \"success\",\n  red: \"error\",\n  yellow: \"warning\",\n  blue: \"info\",\n};\n\nconst kToastClassByType = {\n  success: \"bg-status-success-bg border border-status-success-border text-status-success\",\n  error: \"bg-status-error-bg border border-status-error-border text-status-error\",\n  warning: \"bg-status-warning-bg border border-status-warning-border text-status-warning\",\n  info: \"bg-status-info-bg border border-status-info-border text-status-info\",\n};\n\nconst normalizeToastType = (type) => {\n  const normalized = String(type || \"\")\n    .trim()\n    .toLowerCase();\n  return kToastTypeByAlias[normalized] || \"info\";\n};\n\nexport function showToast(text, type = \"info\") {\n  if (addToastFn) addToastFn({ id: ++toastId, text, type: normalizeToastType(type) });\n}\n\nexport function ToastContainer({\n  className = \"fixed bottom-4 right-4 z-50 space-y-2\",\n}) {\n  const [toasts, setToasts] = useState([]);\n\n  useEffect(() => {\n    addToastFn = (t) => {\n      setToasts(prev => [...prev, t]);\n      setTimeout(() => setToasts(prev => prev.filter(x => x.id !== t.id)), 4000);\n    };\n    return () => { addToastFn = null; };\n  }, []);\n\n  if (toasts.length === 0) return null;\n\n  return createPortal(\n    html`<div class=${className} style=${{ zIndex: 70 }}>\n      ${toasts.map(t => html`\n        <div key=${t.id} class=\"${kToastClassByType[normalizeToastType(t.type)]} px-4 py-2 rounded-lg text-sm\">\n          ${t.text}\n        </div>\n      `)}\n    </div>`,\n    document.body,\n  );\n}\n"
  },
  {
    "path": "lib/public/js/components/toggle-switch.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const ToggleSwitch = ({\n  checked = false,\n  disabled = false,\n  onChange = () => {},\n  label = \"Enabled\",\n}) => html`\n  <label class=\"ac-toggle\">\n    <input\n      class=\"ac-toggle-input\"\n      type=\"checkbox\"\n      checked=${!!checked}\n      disabled=${!!disabled}\n      onchange=${(e) => onChange(!!e.target.checked)}\n    />\n    <span class=\"ac-toggle-track\" aria-hidden=\"true\">\n      <span class=\"ac-toggle-thumb\"></span>\n    </span>\n    ${label ? html`<span class=\"ac-toggle-label\">${label}</span>` : null}\n  </label>\n`;\n"
  },
  {
    "path": "lib/public/js/components/tooltip.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useRef, useState } from \"preact/hooks\";\nimport { createPortal } from \"preact/compat\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst kViewportPadding = 8;\nconst kTooltipOffset = 8;\nconst kWarmWindowMs = 400;\n\nlet lastTooltipClosedAt = 0;\n\nconst isFocusVisibleWithin = (element) => {\n  if (!element || typeof document === \"undefined\") return false;\n  const active = document.activeElement;\n  if (!active || !element.contains(active)) return false;\n  return typeof active.matches === \"function\" && active.matches(\":focus-visible\");\n};\n\nconst getTooltipPosition = (triggerEl, tooltipEl) => {\n  if (!triggerEl) return null;\n  const triggerRect = triggerEl.getBoundingClientRect();\n  const tooltipRect = tooltipEl?.getBoundingClientRect?.() || {\n    width: 0,\n    height: 0,\n  };\n  const minLeft = kViewportPadding + tooltipRect.width / 2;\n  const maxLeft = window.innerWidth - kViewportPadding - tooltipRect.width / 2;\n  const centeredLeft = triggerRect.left + triggerRect.width / 2;\n  const left = tooltipRect.width\n    ? Math.min(Math.max(centeredLeft, minLeft), maxLeft)\n    : centeredLeft;\n\n  let top = triggerRect.bottom + kTooltipOffset;\n  const canRenderAbove =\n    triggerRect.top - kTooltipOffset - tooltipRect.height >= kViewportPadding;\n  const wouldOverflowBelow =\n    top + tooltipRect.height + kViewportPadding > window.innerHeight;\n  if (wouldOverflowBelow && canRenderAbove) {\n    top = triggerRect.top - kTooltipOffset - tooltipRect.height;\n  }\n\n  return {\n    left: `${left}px`,\n    top: `${Math.max(kViewportPadding, top)}px`,\n  };\n};\n\nexport const Tooltip = ({\n  text = \"\",\n  widthClass = \"w-64\",\n  tooltipClassName = \"\",\n  triggerClassName = \"\",\n  children = null,\n  disabled = false,\n  delay = 0,\n}) => {\n  const triggerRef = useRef(null);\n  const tooltipRef = useRef(null);\n  const delayTimerRef = useRef(null);\n  const suppressFocusOpenRef = useRef(false);\n  const [open, setOpen] = useState(false);\n  const [positionStyle, setPositionStyle] = useState(null);\n\n  useEffect(() => {\n    if (!open || disabled || !text) return undefined;\n\n    const updatePosition = () => {\n      const nextStyle = getTooltipPosition(triggerRef.current, tooltipRef.current);\n      if (nextStyle) setPositionStyle(nextStyle);\n    };\n\n    updatePosition();\n    window.addEventListener(\"resize\", updatePosition);\n    window.addEventListener(\"scroll\", updatePosition, true);\n    return () => {\n      window.removeEventListener(\"resize\", updatePosition);\n      window.removeEventListener(\"scroll\", updatePosition, true);\n    };\n  }, [open, disabled, text]);\n\n  const handleOpen = () => {\n    if (disabled || !text) return;\n    const warm = Date.now() - lastTooltipClosedAt < kWarmWindowMs;\n    const shouldOpenNow = () =>\n      triggerRef.current?.matches?.(\":hover\") ||\n      isFocusVisibleWithin(triggerRef.current);\n    if (delay > 0 && !warm) {\n      clearTimeout(delayTimerRef.current);\n      delayTimerRef.current = setTimeout(() => {\n        if (!shouldOpenNow()) return;\n        setOpen(true);\n      }, delay);\n    } else {\n      if (!shouldOpenNow()) return;\n      setOpen(true);\n    }\n  };\n\n  const handleClose = () => {\n    clearTimeout(delayTimerRef.current);\n    if (open) lastTooltipClosedAt = Date.now();\n    setOpen(false);\n  };\n\n  return html`\n    <span\n      ref=${triggerRef}\n      class=${triggerClassName || \"inline-flex\"}\n      onPointerDown=${() => {\n        suppressFocusOpenRef.current = true;\n        clearTimeout(delayTimerRef.current);\n      }}\n      onPointerUp=${() => {\n        suppressFocusOpenRef.current = false;\n      }}\n      onPointerCancel=${() => {\n        suppressFocusOpenRef.current = false;\n      }}\n      onMouseEnter=${handleOpen}\n      onMouseLeave=${handleClose}\n      onFocusIn=${() => {\n        if (suppressFocusOpenRef.current) return;\n        if (!isFocusVisibleWithin(triggerRef.current)) return;\n        handleOpen();\n      }}\n      onFocusOut=${(event) => {\n        suppressFocusOpenRef.current = false;\n        if (event.currentTarget.contains(event.relatedTarget)) return;\n        handleClose();\n      }}\n    >\n      ${children}\n      ${open && !disabled && text && typeof document !== \"undefined\"\n        ? createPortal(\n            html`\n              <span\n                ref=${tooltipRef}\n                role=\"tooltip\"\n                class=${`pointer-events-none fixed left-0 top-0 z-[80] -translate-x-1/2 rounded-md border border-border bg-modal px-2 py-1 text-[11px] text-body shadow-lg ${widthClass} ${tooltipClassName}`.trim()}\n                style=${positionStyle || { visibility: \"hidden\" }}\n              >\n                ${text}\n              </span>\n            `,\n            document.body,\n          )\n        : null}\n    </span>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/update-action-button.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"./action-button.js\";\n\nconst html = htm.bind(h);\n\nexport const UpdateActionButton = ({\n  onClick,\n  disabled = false,\n  loading = false,\n  warning = false,\n  idleLabel = \"Check updates\",\n  loadingLabel = \"Checking...\",\n  className = \"\",\n}) => html`\n  <${ActionButton}\n    onClick=${onClick}\n    disabled=${disabled}\n    loading=${loading}\n    tone=${warning ? \"warning\" : \"neutral\"}\n    size=\"sm\"\n    idleLabel=${idleLabel}\n    loadingLabel=${loadingLabel}\n    className=${className}\n  />\n`;\n"
  },
  {
    "path": "lib/public/js/components/update-modal.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { marked } from \"marked\";\nimport { fetchAlphaclawReleaseNotes } from \"../lib/api.js\";\nimport { ModalShell } from \"./modal-shell.js\";\nimport { ActionButton } from \"./action-button.js\";\nimport { LoadingSpinner } from \"./loading-spinner.js\";\nimport { CloseIcon } from \"./icons.js\";\n\nconst html = htm.bind(h);\n\nconst getReleaseTagFromVersion = (version) => {\n  const rawVersion = String(version || \"\").trim();\n  if (!rawVersion) return \"\";\n  return rawVersion.startsWith(\"v\") ? rawVersion : `v${rawVersion}`;\n};\n\nconst formatPublishedAt = (value) => {\n  const dateMs = Date.parse(String(value || \"\"));\n  if (!Number.isFinite(dateMs)) return \"\";\n  try {\n    return new Intl.DateTimeFormat(undefined, {\n      dateStyle: \"medium\",\n      timeStyle: \"short\",\n    }).format(new Date(dateMs));\n  } catch {\n    return \"\";\n  }\n};\n\nconst getReleaseUrl = (tag) =>\n  tag\n    ? `https://github.com/chrysb/alphaclaw/releases/tag/${encodeURIComponent(tag)}`\n    : \"https://github.com/chrysb/alphaclaw/releases\";\n\nconst VersionSummaryRow = ({\n  label = \"\",\n  currentVersion = \"\",\n  latestVersion = \"\",\n}) => {\n  const currentLabel = String(currentVersion || \"\").trim() || \"Unknown\";\n  const latestLabel = String(latestVersion || \"\").trim() || \"Unknown\";\n  const changed = currentLabel !== latestLabel;\n  return html`\n    <div class=\"ac-surface-inset border border-border rounded-lg px-3 py-2\">\n      <p class=\"text-[11px] uppercase tracking-[0.18em] text-fg-muted\">${label}</p>\n      <p class=\"mt-1 text-sm text-body\">\n        ${changed\n          ? html`\n              <span>${currentLabel}</span>\n              <span class=\"mx-2 text-fg-muted\">→</span>\n              <span class=\"font-semibold\">${latestLabel}</span>\n            `\n          : html`<span>${currentLabel}</span>`}\n      </p>\n    </div>\n  `;\n};\n\nexport const UpdateModal = ({\n  visible = false,\n  onClose = () => {},\n  currentVersion = \"\",\n  currentOpenclawVersion = \"\",\n  version = \"\",\n  latestOpenclawVersion = \"\",\n  updateStrategy = null,\n  onUpdate = () => {},\n  updating = false,\n}) => {\n  const requestedTag = useMemo(() => getReleaseTagFromVersion(version), [version]);\n  const shouldLoadReleaseNotes =\n    visible &&\n    String(version || \"\").trim() &&\n    String(currentVersion || \"\").trim() &&\n    String(version || \"\").trim() !== String(currentVersion || \"\").trim();\n  const canApplyUpdate =\n    updateStrategy?.action === \"self-update\" ||\n    updateStrategy?.action === \"managed-update\";\n  const [loadingNotes, setLoadingNotes] = useState(false);\n  const [notesError, setNotesError] = useState(\"\");\n  const [notesData, setNotesData] = useState(null);\n\n  useEffect(() => {\n    if (!visible) return;\n    if (!shouldLoadReleaseNotes) {\n      setLoadingNotes(false);\n      setNotesError(\"\");\n      setNotesData(null);\n      return;\n    }\n    let isActive = true;\n    const loadNotes = async () => {\n      setLoadingNotes(true);\n      setNotesError(\"\");\n      try {\n        const data = await fetchAlphaclawReleaseNotes(requestedTag);\n        if (!isActive) return;\n        if (!data?.ok) {\n          setNotesError(data?.error || \"Could not load release notes\");\n          setNotesData(null);\n          return;\n        }\n        setNotesData(data);\n      } catch (err) {\n        if (!isActive) return;\n        setNotesError(err?.message || \"Could not load release notes\");\n        setNotesData(null);\n      } finally {\n        if (!isActive) return;\n        setLoadingNotes(false);\n      }\n    };\n    loadNotes();\n    return () => {\n      isActive = false;\n    };\n  }, [visible, requestedTag, shouldLoadReleaseNotes]);\n\n  const effectiveTag = String(notesData?.tag || requestedTag || \"\").trim();\n  const effectiveReleaseUrl =\n    String(notesData?.htmlUrl || \"\").trim() || getReleaseUrl(effectiveTag);\n  const publishedAtLabel = formatPublishedAt(notesData?.publishedAt);\n  const releaseBody = String(notesData?.body || \"\").trim();\n  const releasePreviewHtml = useMemo(\n    () =>\n      marked.parse(releaseBody, {\n        gfm: true,\n        breaks: true,\n      }),\n    [releaseBody],\n  );\n  const strategyLabel = String(updateStrategy?.label || \"\").trim();\n  const strategyDescription = String(updateStrategy?.description || \"\").trim();\n  const strategySteps = Array.isArray(updateStrategy?.steps)\n    ? updateStrategy.steps\n    : [];\n  const templateRepoUrl = String(updateStrategy?.templateRepoUrl || \"\").trim();\n  const showStrategyDetails =\n    updateStrategy?.provider === \"apex\" &&\n    updateStrategy?.action === \"managed-update\"\n      ? false\n      : Boolean(strategyDescription || strategySteps.length > 0 || templateRepoUrl);\n  const primaryActionUrl = String(updateStrategy?.primaryActionUrl || \"\").trim();\n  const primaryLabel = canApplyUpdate\n    ? String(updateStrategy?.primaryActionLabel || \"\").trim() || \"Update now\"\n    : \"Done\";\n  const handlePrimaryAction = () => {\n    if (canApplyUpdate) {\n      onUpdate();\n      return;\n    }\n    if (primaryActionUrl) {\n      try {\n        window.open(primaryActionUrl, \"_blank\", \"noopener,noreferrer\");\n      } catch {}\n    }\n    onClose();\n  };\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      panelClassName=\"relative bg-modal border border-border rounded-xl p-5 w-full max-w-3xl max-h-[92vh] overflow-hidden flex flex-col gap-4\"\n    >\n      <button\n        type=\"button\"\n        onclick=${onClose}\n        class=\"absolute top-5 right-5 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n        aria-label=\"Close modal\"\n      >\n        <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n      </button>\n      <div class=\"space-y-1 pr-10\">\n        <h3 class=\"text-sm font-semibold\">Update available</h3>\n        <p class=\"text-xs text-fg-muted\">\n          ${strategyLabel\n            ? `Detected deployment target: ${strategyLabel}`\n            : \"Review the latest bundled versions before updating.\"}\n        </p>\n      </div>\n\n      <div class=\"grid gap-2 sm:grid-cols-2\">\n        <${VersionSummaryRow}\n          label=\"AlphaClaw\"\n          currentVersion=${currentVersion}\n          latestVersion=${version}\n        />\n        <${VersionSummaryRow}\n          label=\"OpenClaw\"\n          currentVersion=${currentOpenclawVersion}\n          latestVersion=${latestOpenclawVersion || currentOpenclawVersion}\n        />\n      </div>\n\n      ${shouldLoadReleaseNotes\n        ? html`\n            ${publishedAtLabel\n              ? html`<p class=\"text-xs text-fg-muted\">Published ${publishedAtLabel}</p>`\n              : null}\n            <div class=\"ac-surface-inset border border-border rounded-lg p-2 overflow-auto min-h-[220px] max-h-[52vh]\">\n              ${loadingNotes\n                ? html`\n                    <div class=\"min-h-[200px] flex items-center justify-center text-fg-muted\">\n                      <span class=\"inline-flex items-center gap-2 text-sm\">\n                        <${LoadingSpinner} className=\"h-4 w-4\" />\n                        Loading release notes...\n                      </span>\n                    </div>\n                  `\n                : notesError\n                  ? html`\n                      <div class=\"space-y-2\">\n                        <p class=\"text-sm text-status-error\">${notesError}</p>\n                        <a\n                          class=\"ac-tip-link text-xs\"\n                          href=${effectiveReleaseUrl}\n                          target=\"_blank\"\n                          rel=\"noreferrer\"\n                          >View release on GitHub</a\n                        >\n                      </div>\n                    `\n                  : releaseBody\n                    ? html`<div\n                        class=\"file-viewer-preview release-notes-preview\"\n                        dangerouslySetInnerHTML=${{ __html: releasePreviewHtml }}\n                      ></div>`\n                    : html`\n                        <div class=\"space-y-2\">\n                          <p class=\"text-sm text-body\">\n                            No release notes were published for this tag.\n                          </p>\n                          <a\n                            class=\"ac-tip-link text-xs\"\n                            href=${effectiveReleaseUrl}\n                            target=\"_blank\"\n                            rel=\"noreferrer\"\n                            >Open release on GitHub</a\n                          >\n                        </div>\n                      `}\n            </div>\n          `\n        : null}\n\n      ${showStrategyDetails &&\n      html`\n        <div class=\"ac-surface-inset border border-border rounded-lg p-3 space-y-2\">\n          ${strategyDescription\n            ? html`<p class=\"text-sm text-body\">${strategyDescription}</p>`\n            : null}\n          ${strategySteps.length > 0\n            ? html`\n                <ol class=\"space-y-2 text-sm text-body list-decimal list-outside ml-6 pl-0\">\n                  ${strategySteps.map(\n                    (step) => html`<li key=${step}>${step}</li>`,\n                  )}\n                </ol>\n              `\n            : null}\n          ${templateRepoUrl\n            ? html`\n                <a\n                  class=\"ac-tip-link text-xs block mt-3\"\n                  href=${templateRepoUrl}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  >View deployment template</a\n                >\n              `\n            : null}\n        </div>\n      `}\n\n      <div class=\"flex items-center justify-end gap-2 pt-1\">\n        <${ActionButton}\n          onClick=${onClose}\n          tone=\"ghost\"\n          idleLabel=\"Later\"\n          disabled=${updating}\n        />\n        <${ActionButton}\n          onClick=${handlePrimaryAction}\n          tone=${canApplyUpdate ? \"warning\" : \"neutral\"}\n          idleLabel=${primaryLabel}\n          loadingLabel=${canApplyUpdate ? \"Updating...\" : primaryLabel}\n          loading=${canApplyUpdate && updating}\n          disabled=${canApplyUpdate ? loadingNotes : false}\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/usage-tab/constants.js",
    "content": "export const kColorPalette = [\n  \"#7dd3fc\",\n  \"#22d3ee\",\n  \"#fbbf24\",\n  \"#34d399\",\n  \"#fb7185\",\n  \"#a78bfa\",\n  \"#f472b6\",\n  \"#60a5fa\",\n  \"#4ade80\",\n  \"#f97316\",\n];\n\nexport const kBadgeToneClass = {\n  cyan: \"border-cyan-400/30 text-status-info bg-cyan-400/10\",\n  blue: \"border-blue-400/30 text-blue-300 bg-blue-400/10\",\n  purple: \"border-purple-400/30 text-purple-300 bg-purple-400/10\",\n  gray: \"border-gray-400/30 text-fg-muted bg-gray-400/10\",\n};\n\nexport const kRangeOptions = [\n  { label: \"7d\", value: 7 },\n  { label: \"30d\", value: 30 },\n  { label: \"90d\", value: 90 },\n];\n\nexport const kDefaultUsageDays = 30;\nexport const kDefaultUsageMetric = \"tokens\";\nexport const kDefaultUsageBreakdown = \"model\";\nexport const kUsageDaysUiSettingKey = \"usageDays\";\nexport const kUsageMetricUiSettingKey = \"usageMetric\";\nexport const kUsageBreakdownUiSettingKey = \"usageBreakdown\";\nexport const kUsageSourceOrder = [\"chat\", \"hooks\", \"cron\"];\n\nexport const kUsageBreakdownOptions = [\n  { label: \"Model breakdown\", value: \"model\" },\n  { label: \"Type breakdown\", value: \"source\" },\n  { label: \"Agent breakdown\", value: \"agent\" },\n];\n"
  },
  {
    "path": "lib/public/js/components/usage-tab/formatters.js",
    "content": "import { kColorPalette } from \"./constants.js\";\n\nexport const toLocalDayKey = (value) => {\n  const d = value instanceof Date ? value : new Date(value ?? Date.now());\n  const year = d.getFullYear();\n  const month = String(d.getMonth() + 1).padStart(2, \"0\");\n  const day = String(d.getDate()).padStart(2, \"0\");\n  return `${year}-${month}-${day}`;\n};\n\nexport const toChartColor = (key) => {\n  const raw = String(key || \"\");\n  let hash = 0;\n  for (let index = 0; index < raw.length; index += 1) {\n    hash = ((hash << 5) - hash + raw.charCodeAt(index)) | 0;\n  }\n  return kColorPalette[Math.abs(hash) % kColorPalette.length];\n};\n\nexport const renderSourceLabel = (source) => {\n  if (source === \"hooks\") return \"Hooks\";\n  if (source === \"cron\") return \"Cron\";\n  return \"Chat\";\n};\n\nexport const renderBreakdownLabel = (value, breakdown) => {\n  const normalizedBreakdown = String(breakdown || \"model\");\n  const raw = String(value || \"\").trim();\n  if (!raw) return \"Unknown\";\n  if (normalizedBreakdown === \"source\") {\n    return renderSourceLabel(raw);\n  }\n  if (normalizedBreakdown === \"agent\") {\n    return raw === \"unknown\" ? \"Unknown agent\" : raw;\n  }\n  return raw;\n};\n"
  },
  {
    "path": "lib/public/js/components/usage-tab/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../action-button.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { OverviewSection } from \"./overview-section.js\";\nimport { SessionsSection } from \"./sessions-section.js\";\nimport { useUsageTab } from \"./use-usage-tab.js\";\n\nconst html = htm.bind(h);\n\nexport const UsageTab = ({ sessionId = \"\" }) => {\n  const { state, actions } = useUsageTab({ sessionId });\n\n  const handleToggleSession = (itemSessionId, isOpen) => {\n    if (isOpen) {\n      actions.setExpandedSessionIds((currentValue) =>\n        currentValue.includes(itemSessionId) ? currentValue : [...currentValue, itemSessionId],\n      );\n      if (!state.sessionDetailById[itemSessionId] && !state.loadingDetailById[itemSessionId]) {\n        actions.loadSessionDetail(itemSessionId);\n      }\n      return;\n    }\n    actions.setExpandedSessionIds((currentValue) =>\n      currentValue.filter((value) => value !== itemSessionId),\n    );\n  };\n\n  return html`\n    <div class=\"space-y-4\">\n      <${PageHeader}\n        title=\"Usage\"\n        actions=${html`\n          <${ActionButton}\n            onClick=${actions.loadSummary}\n            loading=${state.loadingSummary}\n            tone=\"secondary\"\n            size=\"sm\"\n            idleLabel=\"Refresh\"\n            loadingMode=\"inline\"\n          />\n        `}\n      />\n      ${state.error\n        ? html`<div class=\"text-xs text-status-error bg-status-error-bg border border-status-error-border rounded px-3 py-2\">\n            ${state.error}\n          </div>`\n        : null}\n      ${state.loadingSummary && !state.summary\n        ? html`<div class=\"text-sm text-[var(--text-muted)]\">Loading usage summary...</div>`\n        : html`\n            <${OverviewSection}\n              summary=${state.summary}\n              periodSummary=${state.periodSummary}\n              metric=${state.metric}\n              breakdown=${state.breakdown}\n              days=${state.days}\n              overviewCanvasRef=${state.overviewCanvasRef}\n              onDaysChange=${actions.setDays}\n              onMetricChange=${actions.setMetric}\n              onBreakdownChange=${actions.setBreakdown}\n            />\n          `}\n      <${SessionsSection}\n        sessions=${state.sessions}\n        loadingSessions=${state.loadingSessions}\n        expandedSessionIds=${state.expandedSessionIds}\n        loadingDetailById=${state.loadingDetailById}\n        sessionDetailById=${state.sessionDetailById}\n        onToggleSession=${handleToggleSession}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/usage-tab/overview-section.js",
    "content": "import { h } from \"preact\";\nimport { useEffect, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport {\n  formatCompactNumber,\n  formatInteger,\n  formatUsd,\n} from \"../../lib/format.js\";\nimport { SegmentedControl } from \"../segmented-control.js\";\nimport {\n  kRangeOptions,\n  kUsageBreakdownOptions,\n  kUsageSourceOrder,\n} from \"./constants.js\";\nimport { renderSourceLabel } from \"./formatters.js\";\n\nconst html = htm.bind(h);\n\nconst formatCountLabel = (value, singular, plural) => {\n  const count = Number(value || 0);\n  const label = count === 1 ? singular : plural;\n  return `${formatInteger(count)} ${label}`;\n};\n\nconst formatPercent = (ratio) => `${(Number(ratio || 0) * 100).toFixed(1)}%`;\n\nconst getCacheHitRateValueClass = (ratio) => {\n  const percent = Number(ratio || 0) * 100;\n  if (percent <= 0) return \"text-body\";\n  if (percent >= 70) return \"text-status-success\";\n  if (percent >= 40) return \"text-amber-300\";\n  return \"text-status-error-muted\";\n};\n\nconst getOverviewMetrics = (summary) => {\n  const totals = summary?.totals || {};\n  const cacheReadTokens = Number(totals.cacheReadTokens || 0);\n  const cacheWriteTokens = Number(totals.cacheWriteTokens || 0);\n  const inputTokens = Number(totals.inputTokens || 0);\n  const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens;\n  const turnCount = Number(totals.turnCount || 0);\n  const totalTokens = Number(totals.totalTokens || 0);\n  const totalCost = Number(totals.totalCost || 0);\n  return {\n    cacheHitRate: promptTokens > 0 ? cacheReadTokens / promptTokens : 0,\n    cacheReadTokens,\n    promptTokens,\n    avgTokensPerTurn: turnCount > 0 ? totalTokens / turnCount : 0,\n    avgCostPerTurn: turnCount > 0 ? totalCost / turnCount : 0,\n    turnCount,\n  };\n};\n\nconst SummaryCard = ({ title, tokens, cost }) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <h3 class=\"card-label text-xs\">${title}</h3>\n    <div class=\"text-lg font-semibold mt-1\">\n      ${formatInteger(tokens)}\n      <span class=\"text-xs text-[var(--text-muted)] ml-1\">tokens</span>\n    </div>\n    <div class=\"text-xs text-[var(--text-muted)] mt-1\">${formatUsd(cost)}</div>\n  </div>\n`;\n\nconst MetricCard = ({\n  title,\n  value,\n  detail = \"\",\n  valueClass = \"\",\n  valueSuffix = \"\",\n}) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <h3 class=\"card-label text-xs\">${title}</h3>\n    <div class=${`text-lg font-semibold mt-1 ${valueClass}`.trim()}>\n      ${value}\n      ${valueSuffix\n        ? html`<span class=\"text-xs text-[var(--text-muted)] ml-1\">${valueSuffix}</span>`\n        : null}\n    </div>\n    <div class=\"text-xs text-[var(--text-muted)] mt-1\">${detail}</div>\n  </div>\n`;\n\nconst AgentCostDistribution = ({ summary }) => {\n  const agents = Array.isArray(summary?.costByAgent?.agents)\n    ? summary.costByAgent.agents\n    : [];\n  const missingPricingModels = Array.from(\n    new Set(\n      (summary?.daily || [])\n        .flatMap((dayRow) => dayRow?.models || [])\n        .filter(\n          (modelRow) =>\n            !modelRow?.pricingFound && Number(modelRow?.totalTokens || 0) > 0,\n        )\n        .map(\n          (modelRow) =>\n            String(modelRow?.model || \"unknown\").trim() || \"unknown\",\n        ),\n    ),\n  ).sort((leftValue, rightValue) => leftValue.localeCompare(rightValue));\n  const missingPricingPreview = missingPricingModels.slice(0, 3).join(\", \");\n  const hasMoreMissingPricingModels = missingPricingModels.length > 3;\n  const missingPricingLabel = missingPricingModels.length\n    ? hasMoreMissingPricingModels\n      ? `${missingPricingPreview}, +${missingPricingModels.length - 3} more`\n      : missingPricingPreview\n    : \"\";\n  const [selectedAgent, setSelectedAgent] = useState(() =>\n    String(agents[0]?.agent || \"\"),\n  );\n  useEffect(() => {\n    if (agents.length === 0) {\n      if (selectedAgent) setSelectedAgent(\"\");\n      return;\n    }\n    const hasSelectedAgent = agents.some(\n      (row) => String(row.agent || \"\") === selectedAgent,\n    );\n    if (!hasSelectedAgent) setSelectedAgent(String(agents[0]?.agent || \"\"));\n  }, [agents, selectedAgent]);\n  const selectedAgentRow =\n    agents.find((row) => String(row.agent || \"\") === selectedAgent) ||\n    agents[0] ||\n    null;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      ${agents.length === 0\n        ? html`\n            <div\n              class=\"flex flex-wrap items-start sm:items-center justify-between gap-3 mb-3\"\n            >\n              <h2 class=\"card-label text-xs\">Estimated cost breakdown</h2>\n            </div>\n            <p class=\"text-xs text-fg-muted\">\n              No agent usage recorded for this range.\n            </p>\n          `\n        : html`\n            <div class=\"space-y-3\">\n              <div\n                class=\"flex flex-wrap items-start sm:items-center justify-between gap-3\"\n              >\n                <h2 class=\"card-label text-xs\">Estimated cost breakdown</h2>\n                <div\n                  class=\"inline-flex flex-wrap items-center gap-3 text-xs text-fg-muted\"\n                >\n                  <label\n                    class=\"inline-flex items-center gap-2 text-xs text-fg-muted\"\n                  >\n                    <select\n                      class=\"bg-field border border-border rounded-lg text-xs px-2.5 py-1.5 text-body focus:border-fg-muted\"\n                      value=${String(selectedAgentRow?.agent || \"\")}\n                      onChange=${(e) =>\n                        setSelectedAgent(String(e.currentTarget?.value || \"\"))}\n                    >\n                      ${agents.map(\n                        (agentRow) => html`\n                          <option value=${String(agentRow.agent || \"\")}>\n                            ${String(agentRow.agent || \"unknown\")}\n                          </option>\n                        `,\n                      )}\n                    </select>\n                  </label>\n                </div>\n              </div>\n              <div class=\"grid grid-cols-1 sm:grid-cols-3 gap-2\">\n                ${kUsageSourceOrder.map((sourceName) => {\n                  const sourceRow = (\n                    selectedAgentRow?.sourceBreakdown || []\n                  ).find((row) => String(row.source || \"\") === sourceName) || {\n                    source: sourceName,\n                    totalCost: 0,\n                    totalTokens: 0,\n                    turnCount: 0,\n                  };\n                  return html`\n                    <div class=\"ac-surface-inset px-2.5 py-2\">\n                      <p class=\"text-[11px] text-fg-muted\">\n                        ${renderSourceLabel(sourceRow.source)}\n                      </p>\n                      <p class=\"text-xs text-body mt-0.5\">\n                        ${formatUsd(sourceRow.totalCost)}\n                      </p>\n                      <p class=\"text-[11px] text-fg-muted mt-0.5\">\n                        ${formatInteger(sourceRow.totalTokens)} tok ·\n                        ${formatCountLabel(\n                          sourceRow.turnCount,\n                          \"turn\",\n                          \"turns\",\n                        )}\n                      </p>\n                    </div>\n                  `;\n                })}\n              </div>\n            </div>\n          `}\n      ${missingPricingModels.length\n        ? html`\n            <div class=\"mt-3\">\n              <p class=\"text-[11px] text-fg-muted\">\n                <span>\n                  . Missing model pricing for ${missingPricingModels.length}\n                  ${missingPricingModels.length === 1 ? \"model\" : \"models\"}:\n                  ${missingPricingLabel}.\n                </span>\n              </p>\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n\nexport const OverviewSection = ({\n  summary = null,\n  periodSummary,\n  metric = \"tokens\",\n  breakdown = \"model\",\n  days = 30,\n  overviewCanvasRef,\n  onDaysChange = () => {},\n  onMetricChange = () => {},\n  onBreakdownChange = () => {},\n}) => {\n  const overviewMetrics = getOverviewMetrics(summary);\n\n  return html`\n    <div class=\"space-y-4\">\n      <div class=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n        <${SummaryCard}\n          title=\"Today\"\n          tokens=${periodSummary.today.tokens}\n          cost=${periodSummary.today.cost}\n        />\n        <${SummaryCard}\n          title=\"Last 7 days\"\n          tokens=${periodSummary.week.tokens}\n          cost=${periodSummary.week.cost}\n        />\n        <${SummaryCard}\n          title=\"Last 30 days\"\n          tokens=${periodSummary.month.tokens}\n          cost=${periodSummary.month.cost}\n        />\n      </div>\n      <div class=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n        <${MetricCard}\n          title=\"Cache hit rate\"\n          value=${formatPercent(overviewMetrics.cacheHitRate)}\n          valueClass=${getCacheHitRateValueClass(overviewMetrics.cacheHitRate)}\n          detail=${`${formatCompactNumber(overviewMetrics.cacheReadTokens)} cached · ${formatCompactNumber(overviewMetrics.promptTokens)} prompt`}\n        />\n        <${MetricCard}\n          title=\"Avg tokens per turn\"\n          value=${formatCompactNumber(overviewMetrics.avgTokensPerTurn)}\n          valueSuffix=\"tokens\"\n          detail=${`${formatCountLabel(overviewMetrics.turnCount, \"turn\", \"turns\")} last ${days} days`}\n        />\n        <${MetricCard}\n          title=\"Avg cost per turn\"\n          value=${formatUsd(overviewMetrics.avgCostPerTurn)}\n          detail=${`${formatCountLabel(overviewMetrics.turnCount, \"turn\", \"turns\")} last ${days} days`}\n        />\n      </div>\n      <div class=\"bg-surface border border-border rounded-xl p-4\">\n        <div\n          class=\"flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3\"\n        >\n          <label class=\"inline-flex items-center gap-2\">\n            <select\n              class=\"bg-field border border-border rounded-lg text-xs px-2.5 py-1.5 text-body focus:border-fg-muted\"\n              value=${breakdown}\n              onChange=${(event) =>\n                onBreakdownChange(String(event.currentTarget?.value || \"model\"))}\n              aria-label=\"Usage chart breakdown\"\n            >\n              ${kUsageBreakdownOptions.map(\n                (option) => html`\n                  <option value=${option.value}>${option.label}</option>\n                `,\n              )}\n            </select>\n          </label>\n          <div class=\"flex items-center gap-2\">\n            <${SegmentedControl}\n              options=${kRangeOptions.map((option) => ({\n                label: option.label,\n                value: option.value,\n              }))}\n              value=${days}\n              onChange=${onDaysChange}\n            />\n            <${SegmentedControl}\n              options=${[\n                { label: \"tokens\", value: \"tokens\" },\n                { label: \"cost\", value: \"cost\" },\n              ]}\n              value=${metric}\n              onChange=${onMetricChange}\n            />\n          </div>\n        </div>\n        <div style=${{ height: \"280px\" }}>\n          <canvas ref=${overviewCanvasRef}></canvas>\n        </div>\n      </div>\n      <${AgentCostDistribution} summary=${summary} />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/usage-tab/sessions-section.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport {\n  formatDurationCompactMs,\n  formatInteger,\n  formatLocaleDateTimeWithTodayTime,\n  formatUsd,\n} from \"../../lib/format.js\";\nimport { kBadgeToneClass } from \"./constants.js\";\n\nconst html = htm.bind(h);\n\nconst formatCountLabel = (value, singular, plural) => {\n  const count = Number(value || 0);\n  const label = count === 1 ? singular : plural;\n  return `${formatInteger(count)} ${label}`;\n};\n\nconst SessionBadges = ({ session }) => {\n  const labels = session?.labels;\n  if (!Array.isArray(labels) || labels.length === 0) {\n    const fallback = String(session?.sessionKey || session?.sessionId || \"\");\n    return html`<span class=\"truncate\">${fallback}</span>`;\n  }\n  return html`\n    <span class=\"inline-flex items-center gap-1.5 flex-wrap\">\n      ${labels.map(\n        (badge) => html`\n          <span\n            class=${`inline-flex items-center px-1.5 py-0.5 rounded border text-[11px] leading-tight ${kBadgeToneClass[badge.tone] || kBadgeToneClass.gray}`}\n          >\n            ${badge.label}\n          </span>\n        `,\n      )}\n    </span>\n  `;\n};\n\nconst SessionInlineDetail = ({\n  item,\n  expandedSessionIds,\n  loadingDetailById,\n  sessionDetailById,\n}) => {\n  const itemSessionId = String(item.sessionId || \"\");\n  const isExpanded = expandedSessionIds.includes(itemSessionId);\n  if (!isExpanded) return null;\n  const detail = sessionDetailById[itemSessionId];\n  const loadingDetail = !!loadingDetailById[itemSessionId];\n  if (loadingDetail) {\n    return html`\n      <div class=\"ac-history-body\">\n        <p class=\"text-xs text-fg-muted\">Loading session detail...</p>\n      </div>\n    `;\n  }\n  if (!detail) {\n    return html`\n      <div class=\"ac-history-body\">\n        <p class=\"text-xs text-fg-muted\">Session detail not available.</p>\n      </div>\n    `;\n  }\n  const sessionKeyValue = String(\n    detail.sessionKey || item.sessionKey || detail.sessionId || item.sessionId || \"\",\n  ).trim();\n  return html`\n    <div class=\"ac-history-body space-y-3 border-0 pt-0 mt-0\">\n      <div>\n        <p class=\"text-[11px] text-fg-muted mb-1\">Session key</p>\n        <p class=\"text-xs text-body font-mono break-all\">${sessionKeyValue || \"n/a\"}</p>\n      </div>\n      <div class=\"mt-1.5\">\n        <p class=\"text-[11px] text-fg-muted mb-1\">Model breakdown</p>\n        ${(detail.modelBreakdown || []).length === 0\n          ? html`<p class=\"text-xs text-fg-muted\">No model usage recorded.</p>`\n          : html`\n              <div class=\"space-y-1.5\">\n                ${(detail.modelBreakdown || []).map(\n                  (row) => html`\n                    <div class=\"flex items-center justify-between gap-3 text-xs px-1 py-0.5 rounded hover:bg-surface transition-colors\">\n                      <span class=\"text-body truncate\">${row.model || \"unknown\"}</span>\n                      <span class=\"inline-flex items-center gap-3 text-fg-muted shrink-0\">\n                        <span>${formatInteger(row.totalTokens)} tok</span>\n                        <span>${formatUsd(row.totalCost)}</span>\n                        <span>${formatCountLabel(row.turnCount, \"turn\", \"turns\")}</span>\n                      </span>\n                    </div>\n                  `,\n                )}\n              </div>\n            `}\n      </div>\n      <div>\n        <p class=\"text-[11px] text-fg-muted mb-1\">Tool usage</p>\n        ${(detail.toolUsage || []).length === 0\n          ? html`<p class=\"text-xs text-fg-muted\">No tool calls recorded.</p>`\n          : html`\n              <div class=\"space-y-1.5\">\n                ${(detail.toolUsage || []).map(\n                  (row) => html`\n                    <div class=\"flex items-center justify-between gap-3 text-xs px-1 py-0.5 rounded hover:bg-surface transition-colors\">\n                      <span class=\"text-body truncate\">${row.toolName}</span>\n                      <span class=\"inline-flex items-center gap-3 text-fg-muted shrink-0\">\n                        <span>${formatCountLabel(row.callCount, \"call\", \"calls\")}</span>\n                        <span>${(Number(row.errorRate || 0) * 100).toFixed(1)}% err</span>\n                        <span>${formatDurationCompactMs(row.avgDurationMs)}</span>\n                      </span>\n                    </div>\n                  `,\n                )}\n              </div>\n            `}\n      </div>\n    </div>\n  `;\n};\n\nexport const SessionsSection = ({\n  sessions = [],\n  loadingSessions = false,\n  expandedSessionIds = [],\n  loadingDetailById = {},\n  sessionDetailById = {},\n  onToggleSession = () => {},\n}) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <h2 class=\"card-label text-xs mb-3\">Sessions</h2>\n    <div class=\"ac-history-list\">\n      ${sessions.length === 0\n        ? html`<p class=\"text-xs text-fg-muted\">\n            ${loadingSessions ? \"Loading sessions...\" : \"No sessions recorded yet.\"}\n          </p>`\n        : sessions.map(\n            (item) => html`\n              <details\n                class=\"ac-history-item\"\n                open=${expandedSessionIds.includes(String(item.sessionId || \"\"))}\n                ontoggle=${(e) => {\n                  const itemSessionId = String(item.sessionId || \"\");\n                  const isOpen = !!e.currentTarget?.open;\n                  onToggleSession(itemSessionId, isOpen);\n                }}\n              >\n                <summary class=\"ac-history-summary hover:bg-surface transition-colors\">\n                  <div class=\"ac-history-summary-row\">\n                    <span class=\"inline-flex items-center gap-2 min-w-0\">\n                      <span class=\"ac-history-toggle shrink-0\" aria-hidden=\"true\">▸</span>\n                      <${SessionBadges} session=${item} />\n                    </span>\n                    <span class=\"inline-flex items-center gap-3 shrink-0 text-xs text-fg-muted\">\n                      <span>${formatInteger(item.totalTokens)} tok</span>\n                      <span>${formatUsd(item.totalCost)}</span>\n                      <span>\n                        ${formatLocaleDateTimeWithTodayTime(item.lastActivityMs, {\n                          fallback: \"n/a\",\n                          valueIsEpochMs: true,\n                        })}\n                      </span>\n                    </span>\n                  </div>\n                </summary>\n                <${SessionInlineDetail}\n                  item=${item}\n                  expandedSessionIds=${expandedSessionIds}\n                  loadingDetailById=${loadingDetailById}\n                  sessionDetailById=${sessionDetailById}\n                />\n              </details>\n            `,\n          )}\n    </div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/usage-tab/use-usage-tab.js",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"preact/hooks\";\nimport Chart from \"chart.js/auto\";\nimport {\n  fetchUsageSessionDetail,\n  fetchUsageSessions,\n  fetchUsageSummary,\n} from \"../../lib/api.js\";\nimport {\n  formatChartBucketLabel,\n  formatInteger,\n  formatUsd,\n} from \"../../lib/format.js\";\nimport { readUiSettings, writeUiSettings } from \"../../lib/ui-settings.js\";\nimport {\n  kDefaultUsageBreakdown,\n  kDefaultUsageDays,\n  kDefaultUsageMetric,\n  kUsageBreakdownUiSettingKey,\n  kUsageDaysUiSettingKey,\n  kUsageMetricUiSettingKey,\n} from \"./constants.js\";\nimport { renderBreakdownLabel, toChartColor, toLocalDayKey } from \"./formatters.js\";\n\nexport const useUsageTab = ({ sessionId = \"\" }) => {\n  const [days, setDays] = useState(() => {\n    const settings = readUiSettings();\n    const parsedDays = Number.parseInt(\n      String(settings[kUsageDaysUiSettingKey] ?? \"\"),\n      10,\n    );\n    return [7, 30, 90].includes(parsedDays) ? parsedDays : kDefaultUsageDays;\n  });\n  const [metric, setMetric] = useState(() => {\n    const settings = readUiSettings();\n    return settings[kUsageMetricUiSettingKey] === \"cost\"\n      ? \"cost\"\n      : kDefaultUsageMetric;\n  });\n  const [breakdown, setBreakdown] = useState(() => {\n    const settings = readUiSettings();\n    const configured = String(settings[kUsageBreakdownUiSettingKey] || \"\").trim();\n    return configured === \"source\" || configured === \"agent\"\n      ? configured\n      : kDefaultUsageBreakdown;\n  });\n  const [summary, setSummary] = useState(null);\n  const [sessions, setSessions] = useState([]);\n  const [sessionDetailById, setSessionDetailById] = useState({});\n  const [loadingSummary, setLoadingSummary] = useState(false);\n  const [loadingSessions, setLoadingSessions] = useState(false);\n  const [loadingDetailById, setLoadingDetailById] = useState({});\n  const [expandedSessionIds, setExpandedSessionIds] = useState(() =>\n    sessionId ? [String(sessionId)] : [],\n  );\n  const [error, setError] = useState(\"\");\n  const overviewCanvasRef = useRef(null);\n  const overviewChartRef = useRef(null);\n\n  const loadSummary = useCallback(async () => {\n    setLoadingSummary(true);\n    setError(\"\");\n    try {\n      const data = await fetchUsageSummary(days);\n      setSummary(data.summary || null);\n    } catch (err) {\n      setError(err.message || \"Could not load usage summary\");\n    } finally {\n      setLoadingSummary(false);\n    }\n  }, [days]);\n\n  const loadSessions = useCallback(async () => {\n    setLoadingSessions(true);\n    try {\n      const data = await fetchUsageSessions(100);\n      setSessions(Array.isArray(data.sessions) ? data.sessions : []);\n    } catch (err) {\n      setError(err.message || \"Could not load sessions\");\n    } finally {\n      setLoadingSessions(false);\n    }\n  }, []);\n\n  const loadSessionDetail = useCallback(async (selectedSessionId) => {\n    const safeSessionId = String(selectedSessionId || \"\").trim();\n    if (!safeSessionId) return;\n    setLoadingDetailById((currentValue) => ({\n      ...currentValue,\n      [safeSessionId]: true,\n    }));\n    try {\n      const detailPayload = await fetchUsageSessionDetail(safeSessionId);\n      setSessionDetailById((currentValue) => ({\n        ...currentValue,\n        [safeSessionId]: detailPayload.detail || null,\n      }));\n    } catch (err) {\n      setError(err.message || \"Could not load session detail\");\n    } finally {\n      setLoadingDetailById((currentValue) => ({\n        ...currentValue,\n        [safeSessionId]: false,\n      }));\n    }\n  }, []);\n\n  useEffect(() => {\n    loadSummary();\n  }, [loadSummary]);\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings[kUsageDaysUiSettingKey] = days;\n    settings[kUsageMetricUiSettingKey] = metric;\n    settings[kUsageBreakdownUiSettingKey] = breakdown;\n    writeUiSettings(settings);\n  }, [days, metric, breakdown]);\n\n  useEffect(() => {\n    loadSessions();\n  }, [loadSessions]);\n\n  useEffect(() => {\n    const safeSessionId = String(sessionId || \"\").trim();\n    if (!safeSessionId) return;\n    setExpandedSessionIds((currentValue) =>\n      currentValue.includes(safeSessionId)\n        ? currentValue\n        : [...currentValue, safeSessionId],\n    );\n    if (\n      !sessionDetailById[safeSessionId] &&\n      !loadingDetailById[safeSessionId]\n    ) {\n      loadSessionDetail(safeSessionId);\n    }\n  }, [sessionId, sessionDetailById, loadingDetailById, loadSessionDetail]);\n\n  const periodSummary = useMemo(() => {\n    const rows = Array.isArray(summary?.daily) ? summary.daily : [];\n    const now = new Date();\n    const dayKey = toLocalDayKey(now);\n    const weekStart = toLocalDayKey(\n      new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),\n    );\n    const monthStart = toLocalDayKey(\n      new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),\n    );\n    const zero = { tokens: 0, cost: 0 };\n    return rows.reduce(\n      (acc, row) => {\n        const tokens = Number(row.totalTokens || 0);\n        const cost = Number(row.totalCost || 0);\n        if (String(row.date) === dayKey) {\n          acc.today.tokens += tokens;\n          acc.today.cost += cost;\n        }\n        if (String(row.date) >= weekStart) {\n          acc.week.tokens += tokens;\n          acc.week.cost += cost;\n        }\n        if (String(row.date) >= monthStart) {\n          acc.month.tokens += tokens;\n          acc.month.cost += cost;\n        }\n        return acc;\n      },\n      {\n        today: { ...zero },\n        week: { ...zero },\n        month: { ...zero },\n      },\n    );\n  }, [summary]);\n\n  const overviewDatasets = useMemo(() => {\n    const rows = Array.isArray(summary?.daily) ? summary.daily : [];\n    const allBreakdownKeys = new Set();\n    const totalsByBreakdownKey = new Map();\n    const breakdownRowKey =\n      breakdown === \"source\" ? \"sources\" : breakdown === \"agent\" ? \"agents\" : \"models\";\n    const breakdownValueKey =\n      breakdown === \"source\" ? \"source\" : breakdown === \"agent\" ? \"agent\" : \"model\";\n    for (const dayRow of rows) {\n      for (const breakdownRow of dayRow[breakdownRowKey] || []) {\n        const bucketKey = String(breakdownRow[breakdownValueKey] || \"unknown\");\n        allBreakdownKeys.add(bucketKey);\n        totalsByBreakdownKey.set(\n          bucketKey,\n          Number(totalsByBreakdownKey.get(bucketKey) || 0) +\n            Number(\n              metric === \"cost\"\n                ? breakdownRow.totalCost || 0\n                : breakdownRow.totalTokens || 0,\n            ),\n        );\n      }\n    }\n    const labels = rows.map((row) =>\n      formatChartBucketLabel(String(row.date || \"\"), {\n        range: days <= 7 ? \"7d\" : \"30d\",\n        valueType: \"day-key\",\n      }));\n    const orderedBreakdownKeys = Array.from(allBreakdownKeys).sort(\n      (leftValue, rightValue) => {\n        const leftTotal = Number(totalsByBreakdownKey.get(leftValue) || 0);\n        const rightTotal = Number(totalsByBreakdownKey.get(rightValue) || 0);\n        if (rightTotal !== leftTotal) return rightTotal - leftTotal;\n        return leftValue.localeCompare(rightValue);\n      },\n    );\n    const datasets = orderedBreakdownKeys.map((bucketKey) => ({\n      label: bucketKey,\n      data: rows.map((row) => {\n        const found = (row[breakdownRowKey] || []).find(\n          (breakdownRow) =>\n            String(breakdownRow[breakdownValueKey] || \"\") === bucketKey,\n        );\n        if (!found) return 0;\n        return metric === \"cost\"\n          ? Number(found.totalCost || 0)\n          : Number(found.totalTokens || 0);\n      }),\n      backgroundColor: toChartColor(`${breakdown}:${bucketKey}`),\n    }));\n    return { labels, datasets };\n  }, [summary, metric, breakdown, days]);\n\n  useEffect(() => {\n    const canvas = overviewCanvasRef.current;\n    if (!canvas || !Chart) return;\n    if (overviewChartRef.current) {\n      overviewChartRef.current.destroy();\n      overviewChartRef.current = null;\n    }\n    overviewChartRef.current = new Chart(canvas, {\n      type: \"bar\",\n      data: overviewDatasets,\n      options: {\n        responsive: true,\n        maintainAspectRatio: false,\n        interaction: { mode: \"index\", intersect: false },\n        scales: {\n          x: { stacked: true, ticks: { color: \"rgba(156,163,175,1)\" } },\n          y: {\n            stacked: true,\n            ticks: {\n              color: \"rgba(156,163,175,1)\",\n              callback: (v) =>\n                metric === \"cost\"\n                  ? `$${Number(v).toFixed(2)}`\n                  : formatInteger(v),\n            },\n          },\n        },\n        plugins: {\n          legend: {\n            labels: {\n              color: \"rgba(209,213,219,1)\",\n              boxWidth: 10,\n              boxHeight: 10,\n            },\n          },\n          tooltip: {\n            callbacks: {\n              label: (context) => {\n                const value = Number(context.parsed.y || 0);\n                const label = renderBreakdownLabel(context.dataset.label, breakdown);\n                return metric === \"cost\"\n                  ? `${label}: ${formatUsd(value)}`\n                  : `${label}: ${formatInteger(value)} tokens`;\n              },\n            },\n          },\n        },\n      },\n    });\n    return () => {\n      if (overviewChartRef.current) {\n        overviewChartRef.current.destroy();\n        overviewChartRef.current = null;\n      }\n    };\n  }, [overviewDatasets, metric, breakdown]);\n\n  return {\n    state: {\n      days,\n      metric,\n      breakdown,\n      summary,\n      sessions,\n      sessionDetailById,\n      loadingSummary,\n      loadingSessions,\n      loadingDetailById,\n      expandedSessionIds,\n      error,\n      periodSummary,\n      overviewCanvasRef,\n    },\n    actions: {\n      setDays,\n      setMetric,\n      setBreakdown,\n      loadSummary,\n      loadSessionDetail,\n      setExpandedSessionIds,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/console/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { FileCopyLineIcon } from \"../../icons.js\";\nimport {\n  kWatchdogConsoleTabLogs,\n  kWatchdogConsoleTabTerminal,\n} from \"../helpers.js\";\nimport { WatchdogTerminal } from \"../terminal/index.js\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogConsoleCard = ({\n  activeConsoleTab = kWatchdogConsoleTabLogs,\n  stickToBottom = true,\n  onSetStickToBottom = () => {},\n  onSelectConsoleTab = () => {},\n  connectingTerminal = false,\n  terminalConnected = false,\n  terminalEnded = false,\n  terminalStatusText = \"\",\n  terminalUiSettling = false,\n  onRestartTerminalSession = () => {},\n  logsRef = null,\n  logs = \"\",\n  loadingLogs = true,\n  copyingAll = false,\n  terminalPanelRef = null,\n  terminalHostRef = null,\n  terminalInstanceRef = null,\n  logsPanelHeightPx = 320,\n  onCopyAll = () => {},\n}) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <div class=\"flex items-center justify-between gap-2 mb-3\">\n      <div\n        class=\"inline-flex items-center rounded-lg border border-border bg-field p-0.5\"\n      >\n        <button\n          type=\"button\"\n          class=${`px-2.5 py-1 text-xs rounded-md ${activeConsoleTab === kWatchdogConsoleTabLogs ? \"bg-surface text-bright\" : \"text-fg-muted hover:text-body\"}`}\n          onClick=${() => onSelectConsoleTab(kWatchdogConsoleTabLogs)}\n        >\n          Logs\n        </button>\n        <button\n          type=\"button\"\n          class=${`px-2.5 py-1 text-xs rounded-md ${activeConsoleTab === kWatchdogConsoleTabTerminal ? \"bg-surface text-bright\" : \"text-fg-muted hover:text-body\"}`}\n          onClick=${() => onSelectConsoleTab(kWatchdogConsoleTabTerminal)}\n        >\n          Terminal\n        </button>\n      </div>\n      <div class=\"flex items-center gap-2\">\n        ${activeConsoleTab === kWatchdogConsoleTabLogs\n          ? html`\n              <label class=\"inline-flex items-center gap-2 text-xs text-fg-muted\">\n                <input\n                  type=\"checkbox\"\n                  checked=${stickToBottom}\n                  onchange=${(event) =>\n                    onSetStickToBottom(!!event.currentTarget?.checked)}\n                />\n                Stick to bottom\n              </label>\n            `\n          : html`\n              <div class=\"flex items-center gap-2 pr-1\">\n                ${terminalUiSettling\n                  ? null\n                  : html`\n                      <span class=\"text-xs text-fg-muted\">\n                        ${connectingTerminal\n                          ? \"Connecting...\"\n                          : terminalEnded\n                            ? \"Session ended\"\n                            : terminalConnected\n                              ? \"Connected\"\n                              : terminalStatusText || \"Disconnected\"}\n                      </span>\n                      ${connectingTerminal || terminalConnected\n                        ? null\n                        : html`\n                            <button\n                              type=\"button\"\n                              class=\"ac-btn-secondary text-xs px-2.5 py-1 rounded-lg\"\n                              onClick=${onRestartTerminalSession}\n                            >\n                              New session\n                            </button>\n                          `}\n                    `}\n              </div>\n            `}\n      </div>\n    </div>\n    <div class=${activeConsoleTab === kWatchdogConsoleTabLogs ? \"\" : \"hidden\"}>\n      <pre\n        ref=${logsRef}\n        class=\"watchdog-logs-panel bg-field border border-border rounded-lg p-3 overflow-auto text-xs text-body whitespace-pre-wrap break-words\"\n        style=${{ height: `${logsPanelHeightPx}px` }}\n      >\n${loadingLogs ? \"Loading logs...\" : logs || \"No logs yet.\"}</pre\n      >\n      <div class=\"mt-3 flex justify-end\">\n        <button\n          type=\"button\"\n          class=${`ac-btn-secondary text-xs px-2.5 py-1 rounded-lg inline-flex items-center gap-1.5 ${copyingAll ? \"opacity-50 cursor-not-allowed\" : \"\"}`}\n          onClick=${onCopyAll}\n          disabled=${copyingAll}\n        >\n          <${FileCopyLineIcon} className=\"w-3.5 h-3.5\" />\n          ${copyingAll ? \"Copying...\" : \"Copy all\"}\n        </button>\n      </div>\n    </div>\n    <div\n      class=${activeConsoleTab === kWatchdogConsoleTabTerminal\n        ? \"space-y-2\"\n        : \"hidden\"}\n    >\n      <${WatchdogTerminal}\n        panelRef=${terminalPanelRef}\n        hostRef=${terminalHostRef}\n        terminalInstanceRef=${terminalInstanceRef}\n        panelHeightPx=${logsPanelHeightPx}\n      />\n    </div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/console/use-console.js",
    "content": "import { useEffect, useRef, useState } from \"preact/hooks\";\nimport { fetchWatchdogLogs } from \"../../../lib/api.js\";\nimport { copyTextToClipboard } from \"../../../lib/clipboard.js\";\nimport { readUiSettings, writeUiSettings } from \"../../../lib/ui-settings.js\";\nimport { showToast } from \"../../toast.js\";\nimport {\n  clampWatchdogLogsPanelHeight,\n  formatWatchdogCopyAllText,\n  kWatchdogConsoleTabLogs,\n  kWatchdogConsoleTabTerminal,\n  kWatchdogConsoleTabUiSettingKey,\n  kWatchdogLogsPanelHeightUiSettingKey,\n  normalizeWatchdogConsoleTab,\n  readCssHeightPx,\n} from \"../helpers.js\";\nimport { useWatchdogTerminal } from \"../terminal/use-terminal.js\";\n\nexport const useWatchdogConsole = ({\n} = {}) => {\n  const [logs, setLogs] = useState(\"\");\n  const [loadingLogs, setLoadingLogs] = useState(true);\n  const [copyingAll, setCopyingAll] = useState(false);\n  const [stickToBottom, setStickToBottom] = useState(true);\n  const [activeConsoleTab, setActiveConsoleTab] = useState(() => {\n    const settings = readUiSettings();\n    return normalizeWatchdogConsoleTab(settings?.[kWatchdogConsoleTabUiSettingKey]);\n  });\n  const [logsPanelHeightPx, setLogsPanelHeightPx] = useState(() => {\n    const settings = readUiSettings();\n    return clampWatchdogLogsPanelHeight(\n      settings?.[kWatchdogLogsPanelHeightUiSettingKey],\n    );\n  });\n  const logsRef = useRef(null);\n  const terminalPanelRef = useRef(null);\n  const terminalHostRef = useRef(null);\n  const terminal = useWatchdogTerminal({\n    active: activeConsoleTab === kWatchdogConsoleTabTerminal,\n    panelRef: terminalPanelRef,\n    hostRef: terminalHostRef,\n  });\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings[kWatchdogConsoleTabUiSettingKey] =\n      normalizeWatchdogConsoleTab(activeConsoleTab);\n    writeUiSettings(settings);\n  }, [activeConsoleTab]);\n\n  useEffect(() => {\n    let active = true;\n    let timer = null;\n    const pollLogs = async () => {\n      try {\n        const text = await fetchWatchdogLogs(65536);\n        if (!active) return;\n        setLogs(text || \"\");\n        setLoadingLogs(false);\n      } catch {\n        if (!active) return;\n        setLoadingLogs(false);\n      }\n      if (!active) return;\n      timer = setTimeout(pollLogs, 3000);\n    };\n    pollLogs();\n    return () => {\n      active = false;\n      if (timer) clearTimeout(timer);\n    };\n  }, []);\n\n  useEffect(() => {\n    const logsElement = logsRef.current;\n    if (!logsElement || !stickToBottom) return;\n    logsElement.scrollTop = logsElement.scrollHeight;\n  }, [logs, stickToBottom]);\n\n  useEffect(() => {\n    const panelElement =\n      activeConsoleTab === kWatchdogConsoleTabLogs\n        ? logsRef.current\n        : terminalPanelRef.current;\n    if (!panelElement || typeof ResizeObserver === \"undefined\") return () => {};\n    let saveTimer = null;\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries?.[0];\n      const nextHeight = clampWatchdogLogsPanelHeight(\n        readCssHeightPx(entry?.target),\n      );\n      setLogsPanelHeightPx((currentValue) =>\n        Math.abs(currentValue - nextHeight) >= 1 ? nextHeight : currentValue,\n      );\n      if (saveTimer) window.clearTimeout(saveTimer);\n      saveTimer = window.setTimeout(() => {\n        const settings = readUiSettings();\n        settings[kWatchdogLogsPanelHeightUiSettingKey] = nextHeight;\n        writeUiSettings(settings);\n      }, 120);\n      if (activeConsoleTab === kWatchdogConsoleTabTerminal) {\n        window.requestAnimationFrame(() => {\n          terminal.fitNow();\n        });\n      }\n    });\n    observer.observe(panelElement);\n    return () => {\n      observer.disconnect();\n      if (saveTimer) window.clearTimeout(saveTimer);\n    };\n  }, [activeConsoleTab]);\n\n  const handleSelectConsoleTab = (nextTab = kWatchdogConsoleTabLogs) => {\n    const normalizedTab = normalizeWatchdogConsoleTab(nextTab);\n    if (normalizedTab === kWatchdogConsoleTabTerminal) {\n      terminal.prepareForActivate();\n    } else {\n      terminal.clearSettling();\n    }\n    setActiveConsoleTab(normalizedTab);\n  };\n\n  const onRestartTerminalSession = () => {\n    terminal.restartSession();\n    setActiveConsoleTab(kWatchdogConsoleTabTerminal);\n  };\n\n  const handleCopyAll = async () => {\n    if (copyingAll) return;\n    setCopyingAll(true);\n    try {\n      const text = formatWatchdogCopyAllText({\n        logs,\n      });\n      const copied = await copyTextToClipboard(text);\n      if (!copied) {\n        throw new Error(\"Could not copy watchdog export\");\n      }\n      showToast(\"Copied watchdog logs\", \"success\");\n    } catch (error) {\n      showToast(error.message || \"Could not copy watchdog export\", \"error\");\n    } finally {\n      setCopyingAll(false);\n    }\n  };\n\n  return {\n    logs,\n    loadingLogs,\n    copyingAll,\n    stickToBottom,\n    setStickToBottom,\n    activeConsoleTab,\n    handleSelectConsoleTab,\n    logsPanelHeightPx,\n    logsRef,\n    terminalPanelRef,\n    terminalHostRef,\n    onRestartTerminalSession,\n    onCopyAll: handleCopyAll,\n    ...terminal,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/helpers.js",
    "content": "export const kWatchdogConsoleTabLogs = \"logs\";\nexport const kWatchdogConsoleTabTerminal = \"terminal\";\nexport const kWatchdogConsoleTabUiSettingKey = \"watchdogConsoleTab\";\nexport const kWatchdogLogsPanelHeightUiSettingKey = \"watchdogLogsPanelHeightPx\";\nexport const kWatchdogLogsPanelDefaultHeightPx = 320;\nexport const kWatchdogLogsPanelMinHeightPx = 160;\nexport const kXtermCssUrl = \"/css/vendor/xterm.css\";\nexport const kWatchdogTerminalWsPath = \"/api/watchdog/terminal/ws\";\n\nlet xtermModulesPromise = null;\n\nexport const loadXtermModules = () => {\n  if (!xtermModulesPromise) {\n    xtermModulesPromise = Promise.all([import(\"@xterm/xterm\"), import(\"@xterm/addon-fit\")]).then(\n      ([xtermModule, fitAddonModule]) => {\n        const Terminal =\n          xtermModule?.Terminal || xtermModule?.default?.Terminal || null;\n        const FitAddon =\n          fitAddonModule?.FitAddon || fitAddonModule?.default?.FitAddon || null;\n        if (typeof Terminal !== \"function\") {\n          throw new Error(\"Xterm Terminal export not found\");\n        }\n        if (typeof FitAddon !== \"function\") {\n          throw new Error(\"Xterm FitAddon export not found\");\n        }\n        return { Terminal, FitAddon };\n      },\n    );\n  }\n  return xtermModulesPromise;\n};\n\nexport const ensureXtermStylesheet = () => {\n  if (typeof document === \"undefined\") return;\n  if (document.getElementById(\"ac-xterm-css\")) return;\n  const link = document.createElement(\"link\");\n  link.id = \"ac-xterm-css\";\n  link.rel = \"stylesheet\";\n  link.href = kXtermCssUrl;\n  document.head.appendChild(link);\n};\n\nexport const fitTerminalWhenVisible = ({\n  panel = null,\n  fitAddon = null,\n  minWidthPx = 120,\n  minHeightPx = 80,\n} = {}) => {\n  if (!panel || !fitAddon) return false;\n  const panelWidth = Number(panel.clientWidth || 0);\n  const panelHeight = Number(panel.clientHeight || 0);\n  if (panelWidth < minWidthPx || panelHeight < minHeightPx) return false;\n  fitAddon.fit();\n  return true;\n};\n\nexport const normalizeWatchdogConsoleTab = (value) =>\n  value === kWatchdogConsoleTabTerminal\n    ? kWatchdogConsoleTabTerminal\n    : kWatchdogConsoleTabLogs;\n\nexport const clampWatchdogLogsPanelHeight = (value) => {\n  const parsed = Number(value);\n  const normalized = Number.isFinite(parsed)\n    ? Math.round(parsed)\n    : kWatchdogLogsPanelDefaultHeightPx;\n  return Math.max(kWatchdogLogsPanelMinHeightPx, normalized);\n};\n\nexport const readCssHeightPx = (element) => {\n  if (!element) return 0;\n  const computedHeight = Number.parseFloat(\n    window.getComputedStyle(element).height || \"0\",\n  );\n  return Number.isFinite(computedHeight) ? computedHeight : 0;\n};\n\nexport const formatBytes = (bytes) => {\n  if (bytes == null) return \"—\";\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;\n  if (bytes < 1024 * 1024 * 1024)\n    return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;\n  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;\n};\n\nexport const getIncidentStatusTone = (event) => {\n  const eventType = String(event?.eventType || \"\")\n    .trim()\n    .toLowerCase();\n  const status = String(event?.status || \"\")\n    .trim()\n    .toLowerCase();\n  if (status === \"failed\") {\n    return {\n      dotClass: \"bg-red-500/90\",\n      label: \"Failed\",\n    };\n  }\n  if (status === \"ok\" && eventType === \"health_check\") {\n    return {\n      dotClass: \"bg-green-500/90\",\n      label: \"Healthy\",\n    };\n  }\n  if (status === \"warn\" || status === \"warning\") {\n    return {\n      dotClass: \"bg-yellow-400/90\",\n      label: \"Warning\",\n    };\n  }\n  return {\n    dotClass: \"bg-gray-500/70\",\n    label: \"Unknown\",\n  };\n};\n\nexport const formatWatchdogCopyAllText = ({\n  logs = \"\",\n  generatedAt = null,\n} = {}) => {\n  const sections = [];\n  const generatedAtLabel =\n    generatedAt instanceof Date && !Number.isNaN(generatedAt.getTime())\n      ? generatedAt.toISOString()\n      : new Date().toISOString();\n\n  sections.push(`# AlphaClaw Watchdog Export`);\n  sections.push(`Generated at: ${generatedAtLabel}`);\n\n  sections.push(`## Gateway Logs`);\n  sections.push(String(logs || \"\").trim() || \"No logs yet.\");\n\n  return sections.join(\"\\n\\n\").trim();\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/incidents/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { getIncidentStatusTone } from \"../helpers.js\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogIncidentsCard = ({\n  events = [],\n  onRefresh = () => {},\n}) => html`\n  <div class=\"bg-surface border border-border rounded-xl p-4\">\n    <div class=\"flex items-center justify-between gap-2 mb-3\">\n      <h2 class=\"card-label\">Recent incidents</h2>\n      <button class=\"text-xs text-fg-muted hover:text-body\" onclick=${onRefresh}>\n        Refresh\n      </button>\n    </div>\n    <div class=\"ac-history-list\">\n      ${events.length === 0 &&\n      html`<p class=\"text-xs text-fg-muted\">No incidents recorded.</p>`}\n      ${events.map((event) => {\n        const tone = getIncidentStatusTone(event);\n        return html`\n          <details class=\"ac-history-item\">\n            <summary class=\"ac-history-summary\">\n              <div class=\"ac-history-summary-row\">\n                <span class=\"inline-flex items-center gap-2 min-w-0\">\n                  <span class=\"ac-history-toggle shrink-0\" aria-hidden=\"true\"\n                    >▸</span\n                  >\n                  <span class=\"truncate\">\n                    ${event.createdAt || \"\"} · ${event.eventType || \"event\"} ·\n                    ${event.status || \"unknown\"}\n                  </span>\n                </span>\n                <span\n                  class=${`h-2.5 w-2.5 shrink-0 rounded-full ${tone.dotClass}`}\n                  title=${tone.label}\n                  aria-label=${tone.label}\n                ></span>\n              </div>\n            </summary>\n            <div class=\"ac-history-body text-xs text-fg-muted\">\n              <div>Source: ${event.source || \"unknown\"}</div>\n              <pre class=\"mt-2 bg-field rounded p-2 whitespace-pre-wrap break-words\">\n${typeof event.details === \"string\"\n                  ? event.details\n                  : JSON.stringify(event.details || {}, null, 2)}</pre\n              >\n            </div>\n          </details>\n        `;\n      })}\n    </div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/incidents/use-incidents.js",
    "content": "import { useEffect } from \"preact/hooks\";\nimport { usePolling } from \"../../../hooks/usePolling.js\";\nimport { fetchWatchdogEvents } from \"../../../lib/api.js\";\n\nexport const useWatchdogIncidents = ({\n  restartSignal = 0,\n  onRefreshStatuses = () => {},\n} = {}) => {\n  const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000);\n\n  useEffect(() => {\n    if (!restartSignal) return;\n    onRefreshStatuses();\n    eventsPoll.refresh();\n    const t1 = setTimeout(() => {\n      onRefreshStatuses();\n      eventsPoll.refresh();\n    }, 1200);\n    const t2 = setTimeout(() => {\n      onRefreshStatuses();\n      eventsPoll.refresh();\n    }, 3500);\n    return () => {\n      clearTimeout(t1);\n      clearTimeout(t2);\n    };\n  }, [restartSignal, onRefreshStatuses, eventsPoll.refresh]);\n\n  return {\n    events: eventsPoll.data?.events || [],\n    refreshEvents: eventsPoll.refresh,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Gateway } from \"../gateway.js\";\nimport { useWatchdogTab } from \"./use-watchdog-tab.js\";\nimport { WatchdogResourcesCard } from \"./resources/index.js\";\nimport { WatchdogSettingsCard } from \"./settings/index.js\";\nimport { WatchdogConsoleCard } from \"./console/index.js\";\nimport { WatchdogIncidentsCard } from \"./incidents/index.js\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogTab = ({\n  gatewayStatus = null,\n  openclawVersion = null,\n  watchdogStatus = null,\n  onRefreshStatuses = () => {},\n  restartingGateway = false,\n  onRestartGateway,\n  restartSignal = 0,\n}) => {\n  const state = useWatchdogTab({\n    watchdogStatus,\n    onRefreshStatuses,\n    restartSignal,\n  });\n\n  return html`\n    <div class=\"space-y-4\">\n      <${Gateway}\n        status=${gatewayStatus}\n        openclawVersion=${openclawVersion}\n        restarting=${restartingGateway}\n        onRestart=${onRestartGateway}\n        watchdogStatus=${state.currentWatchdogStatus}\n        onRepair=${state.onRepair}\n        repairing=${state.isRepairInProgress}\n      />\n\n      <${WatchdogResourcesCard}\n        resources=${state.resources}\n        memoryExpanded=${state.memoryExpanded}\n        onSetMemoryExpanded=${state.setMemoryExpanded}\n      />\n\n      <${WatchdogSettingsCard}\n        settings=${state.settings}\n        savingSettings=${state.savingSettings}\n        onToggleAutoRepair=${state.onToggleAutoRepair}\n        onToggleNotifications=${state.onToggleNotifications}\n      />\n\n      <${WatchdogConsoleCard}\n        activeConsoleTab=${state.activeConsoleTab}\n        stickToBottom=${state.stickToBottom}\n        onSetStickToBottom=${state.setStickToBottom}\n        onSelectConsoleTab=${state.handleSelectConsoleTab}\n        connectingTerminal=${state.connectingTerminal}\n        terminalConnected=${state.terminalConnected}\n        terminalEnded=${state.terminalEnded}\n        terminalStatusText=${state.terminalStatusText}\n        terminalUiSettling=${state.terminalUiSettling}\n        onRestartTerminalSession=${state.onRestartTerminalSession}\n        logsRef=${state.logsRef}\n        logs=${state.logs}\n        loadingLogs=${state.loadingLogs}\n        copyingAll=${state.copyingAll}\n        terminalPanelRef=${state.terminalPanelRef}\n        terminalHostRef=${state.terminalHostRef}\n        terminalInstanceRef=${state.terminalInstanceRef}\n        logsPanelHeightPx=${state.logsPanelHeightPx}\n        onCopyAll=${state.onCopyAll}\n      />\n\n      <${WatchdogIncidentsCard}\n        events=${state.events}\n        onRefresh=${state.refreshEvents}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/resource-bar.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nconst barColor = (percent) => {\n  if (percent == null) return \"bg-gray-600\";\n  return \"bg-cyan-400\";\n};\n\nexport const ResourceBar = ({\n  label,\n  percent,\n  detail,\n  segments = null,\n  expanded = false,\n  onToggle = null,\n}) => html`\n  <div\n    class=${onToggle ? \"cursor-pointer group\" : \"\"}\n    onclick=${onToggle || undefined}\n  >\n    <span\n      class=${`text-xs text-fg-muted ${onToggle ? \"group-hover:text-body transition-colors\" : \"\"}`}\n      >${label}</span\n    >\n    <div\n      class=${`h-0.5 w-full bg-white/15 rounded-full overflow-hidden mt-1.5 flex ${onToggle ? \"group-hover:bg-white/10 transition-colors\" : \"\"}`}\n    >\n      ${expanded && segments\n        ? segments.map(\n            (seg) => html`\n              <div\n                class=\"h-full\"\n                style=${{\n                  width: `${Math.min(100, seg.percent ?? 0)}%`,\n                  backgroundColor: seg.color,\n                  transition:\n                    \"width 0.8s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.5s ease\",\n                }}\n              ></div>\n            `,\n          )\n        : html`\n            <div\n              class=${`h-full rounded-full ${barColor(percent)}`}\n              style=${{\n                width: `${Math.min(100, percent ?? 0)}%`,\n                transition:\n                  \"width 0.8s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.5s ease\",\n              }}\n            ></div>\n          `}\n    </div>\n    <div class=\"flex flex-wrap items-center gap-x-3 mt-2.5\">\n      <span class=\"text-xs text-fg-muted font-mono flex-1\">${detail}</span>\n      ${expanded &&\n      segments &&\n      segments\n        .filter((segment) => segment.label)\n        .map(\n          (segment) => html`\n            <span\n              class=\"inline-flex items-center gap-1 text-xs text-fg-muted font-mono\"\n            >\n              <span\n                class=\"inline-block w-1.5 h-1.5 rounded-full\"\n                style=${{ backgroundColor: segment.color }}\n              ></span>\n              ${segment.label}\n            </span>\n          `,\n        )}\n    </div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/resources/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { formatBytes } from \"../helpers.js\";\nimport { ResourceBar } from \"../resource-bar.js\";\nimport { Tooltip } from \"../../tooltip.js\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogResourcesCard = ({\n  resources = null,\n  memoryExpanded = false,\n  onSetMemoryExpanded = () => {},\n}) => {\n  if (!resources) return null;\n  const diskLabel = resources.disk?.path\n    ? html`\n        <${Tooltip}\n          text=${resources.disk.path}\n          widthClass=\"w-auto max-w-80 whitespace-normal break-all\"\n        >\n          <span class=\"inline-block cursor-help\">Disk</span>\n        </${Tooltip}>\n      `\n    : \"Disk\";\n  const memorySegments = (() => {\n    const processes = resources.processes;\n    const totalBytes = resources.memory?.totalBytes;\n    const usedBytes = resources.memory?.usedBytes;\n    if (!processes || !totalBytes || !usedBytes) return null;\n    const segments = [];\n    let trackedBytes = 0;\n    if (processes.gateway?.rssBytes != null) {\n      trackedBytes += processes.gateway.rssBytes;\n      segments.push({\n        percent: (processes.gateway.rssBytes / totalBytes) * 100,\n        color: \"#22d3ee\",\n        label: `Gateway ${formatBytes(processes.gateway.rssBytes)}`,\n      });\n    }\n    if (processes.alphaclaw?.rssBytes != null) {\n      trackedBytes += processes.alphaclaw.rssBytes;\n      segments.push({\n        percent: (processes.alphaclaw.rssBytes / totalBytes) * 100,\n        color: \"#a78bfa\",\n        label: `AlphaClaw ${formatBytes(processes.alphaclaw.rssBytes)}`,\n      });\n    }\n    const otherBytes = Math.max(0, usedBytes - trackedBytes);\n    if (otherBytes > 0) {\n      segments.push({\n        percent: (otherBytes / totalBytes) * 100,\n        color: \"#4b5563\",\n        label: `Other ${formatBytes(otherBytes)}`,\n      });\n    }\n    return segments.length ? segments : null;\n  })();\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      ${memoryExpanded\n        ? html`\n            <${ResourceBar}\n              label=\"Memory\"\n              detail=${`${formatBytes(resources.memory?.usedBytes)} / ${formatBytes(resources.memory?.totalBytes)}`}\n              percent=${resources.memory?.percent}\n              expanded=${true}\n              onToggle=${() => onSetMemoryExpanded(false)}\n              segments=${memorySegments}\n            />\n          `\n        : html`\n            <div class=\"grid grid-cols-1 sm:grid-cols-3 gap-4\">\n              <${ResourceBar}\n                label=\"Memory\"\n                percent=${resources.memory?.percent}\n                detail=${`${formatBytes(resources.memory?.usedBytes)} / ${formatBytes(resources.memory?.totalBytes)}`}\n                onToggle=${() => onSetMemoryExpanded(true)}\n              />\n              <${ResourceBar}\n                label=${diskLabel}\n                percent=${resources.disk?.percent}\n                detail=${`${formatBytes(resources.disk?.usedBytes)} / ${formatBytes(resources.disk?.totalBytes)}`}\n              />\n              <${ResourceBar}\n                label=${`CPU${resources.cpu?.cores ? ` (${resources.cpu.cores} vCPU)` : \"\"}`}\n                percent=${resources.cpu?.percent}\n                detail=${resources.cpu?.percent != null\n                  ? `${resources.cpu.percent}%`\n                  : \"—\"}\n              />\n            </div>\n          `}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/resources/use-resources.js",
    "content": "import { useState } from \"preact/hooks\";\nimport { usePolling } from \"../../../hooks/usePolling.js\";\nimport { fetchWatchdogResources } from \"../../../lib/api.js\";\n\nexport const useWatchdogResources = () => {\n  const resourcesPoll = usePolling(() => fetchWatchdogResources(), 5000);\n  const [memoryExpanded, setMemoryExpanded] = useState(false);\n  return {\n    resources: resourcesPoll.data?.resources || null,\n    memoryExpanded,\n    setMemoryExpanded,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/settings/index.js",
    "content": "import { h } from \"preact\";\nimport { useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { InfoTooltip } from \"../../info-tooltip.js\";\nimport { ToggleSwitch } from \"../../toggle-switch.js\";\nimport { showToast } from \"../../toast.js\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogSettingsCard = ({\n  settings = {},\n  savingSettings = false,\n  onToggleAutoRepair = () => {},\n  onToggleNotifications = () => {},\n}) => {\n  const [testing, setTesting] = useState(false);\n  const [testResult, setTestResult] = useState(null);\n\n  const handleTestNotification = async () => {\n    setTesting(true);\n    setTestResult(null);\n    try {\n      const res = await fetch(\"/api/watchdog/test-notification\", { method: \"POST\" });\n      const data = await res.json();\n      if (!data?.ok) {\n        setTestResult(data);\n        return;\n      }\n\n      const channels = data.result?.channels || data.result || {};\n      const parts = [];\n      for (const channel of [\"telegram\", \"discord\", \"slack\"]) {\n        const ch = channels[channel];\n        if (!ch || ch.skipped) continue;\n        if (ch.sent > 0) parts.push(`${channel}: ${ch.sent} sent`);\n        if (ch.failed > 0) parts.push(`${channel}: ${ch.failed} failed`);\n      }\n\n      if (parts.length === 0) {\n        showToast(\"No channels configured\", \"warning\");\n        return;\n      }\n\n      const hasFailures = parts.some((part) => part.includes(\"failed\"));\n      showToast(\n        hasFailures ? parts.join(\", \") : `Test notification sent: ${parts.join(\", \")}`,\n        hasFailures ? \"warning\" : \"success\",\n      );\n    } catch (err) {\n      setTestResult({ ok: false, error: err.message });\n    } finally {\n      setTesting(false);\n    }\n  };\n\n  const formatResult = (result) => {\n    if (!result) return null;\n    return html`<span class=\"text-status-error-muted text-xs\">\n      ${result.error || \"Failed\"}\n    </span>`;\n  };\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <div class=\"inline-flex items-center gap-2 text-xs text-fg-muted\">\n          <span>Auto-repair</span>\n          <${InfoTooltip}\n            text=\"Automatically runs OpenClaw doctor repair when watchdog detects gateway health failures or crash loops.\"\n          />\n        </div>\n        <${ToggleSwitch}\n          checked=${!!settings.autoRepair}\n          disabled=${savingSettings}\n          onChange=${onToggleAutoRepair}\n          label=\"\"\n        />\n      </div>\n      <div class=\"flex items-center justify-between gap-3 mt-3\">\n        <div class=\"inline-flex items-center gap-2 text-xs text-fg-muted\">\n          <span>Notifications</span>\n          <${InfoTooltip}\n            text=\"Sends channel notices for watchdog alerts and auto-repair outcomes.\"\n          />\n        </div>\n        <div class=\"flex items-center gap-2\">\n          <button\n            class=${`text-xs px-2 py-1 rounded-lg ac-btn-ghost disabled:opacity-50 disabled:cursor-not-allowed ${\n              settings.notificationsEnabled ? \"\" : \"invisible pointer-events-none\"\n            }`}\n            onClick=${handleTestNotification}\n            disabled=${testing || savingSettings || !settings.notificationsEnabled}\n            aria-hidden=${!settings.notificationsEnabled}\n            tabIndex=${settings.notificationsEnabled ? 0 : -1}\n          >\n            ${testing ? \"Sending...\" : \"Test\"}\n          </button>\n          <${ToggleSwitch}\n            checked=${!!settings.notificationsEnabled}\n            disabled=${savingSettings}\n            onChange=${onToggleNotifications}\n            label=\"\"\n          />\n        </div>\n      </div>\n      ${testResult\n        ? html`<div class=\"mt-2\">${formatResult(testResult)}</div>`\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/settings/use-settings.js",
    "content": "import { useEffect, useState } from \"preact/hooks\";\nimport {\n  fetchWatchdogSettings,\n  triggerWatchdogRepair,\n  updateWatchdogSettings,\n} from \"../../../lib/api.js\";\nimport { showToast } from \"../../toast.js\";\n\nexport const useWatchdogSettings = ({\n  watchdogStatus = null,\n  onRefreshStatuses = () => {},\n  onRefreshIncidents = () => {},\n} = {}) => {\n  const [settings, setSettings] = useState({\n    autoRepair: false,\n    notificationsEnabled: true,\n  });\n  const [savingSettings, setSavingSettings] = useState(false);\n  const [repairing, setRepairing] = useState(false);\n  const isRepairInProgress =\n    repairing || !!(watchdogStatus || {})?.operationInProgress;\n\n  useEffect(() => {\n    let active = true;\n    const loadSettings = async () => {\n      try {\n        const data = await fetchWatchdogSettings();\n        if (!active) return;\n        setSettings(\n          data.settings || {\n            autoRepair: false,\n            notificationsEnabled: true,\n          },\n        );\n      } catch (error) {\n        if (!active) return;\n        showToast(error.message || \"Could not load watchdog settings\", \"error\");\n      }\n    };\n    loadSettings();\n    return () => {\n      active = false;\n    };\n  }, []);\n\n  const onToggleAutoRepair = async (nextValue) => {\n    if (savingSettings) return;\n    setSavingSettings(true);\n    try {\n      const data = await updateWatchdogSettings({ autoRepair: !!nextValue });\n      setSettings(\n        data.settings || {\n          ...settings,\n          autoRepair: !!nextValue,\n        },\n      );\n      onRefreshStatuses();\n      showToast(`Auto-repair ${nextValue ? \"enabled\" : \"disabled\"}`, \"success\");\n    } catch (error) {\n      showToast(error.message || \"Could not update auto-repair\", \"error\");\n    } finally {\n      setSavingSettings(false);\n    }\n  };\n\n  const onToggleNotifications = async (nextValue) => {\n    if (savingSettings) return;\n    setSavingSettings(true);\n    try {\n      const data = await updateWatchdogSettings({\n        notificationsEnabled: !!nextValue,\n      });\n      setSettings(\n        data.settings || {\n          ...settings,\n          notificationsEnabled: !!nextValue,\n        },\n      );\n      onRefreshStatuses();\n      showToast(\n        `Notifications ${nextValue ? \"enabled\" : \"disabled\"}`,\n        \"success\",\n      );\n    } catch (error) {\n      showToast(error.message || \"Could not update notifications\", \"error\");\n    } finally {\n      setSavingSettings(false);\n    }\n  };\n\n  const onRepair = async () => {\n    if (isRepairInProgress) return;\n    setRepairing(true);\n    try {\n      const data = await triggerWatchdogRepair();\n      if (!data.ok) throw new Error(data.error || \"Repair failed\");\n      showToast(\"Repair triggered\", \"success\");\n      setTimeout(() => {\n        onRefreshStatuses();\n        onRefreshIncidents();\n      }, 800);\n    } catch (error) {\n      showToast(error.message || \"Could not run repair\", \"error\");\n    } finally {\n      setRepairing(false);\n    }\n  };\n\n  return {\n    settings,\n    savingSettings,\n    isRepairInProgress,\n    onToggleAutoRepair,\n    onToggleNotifications,\n    onRepair,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/terminal/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\n\nconst html = htm.bind(h);\n\nexport const WatchdogTerminal = ({\n  panelRef = null,\n  hostRef = null,\n  terminalInstanceRef = null,\n  panelHeightPx = 320,\n}) => html`\n  <div\n    ref=${panelRef}\n    class=\"watchdog-logs-panel bg-field border border-border rounded-lg p-3 overflow-hidden\"\n    style=${{ height: `${panelHeightPx}px` }}\n    onClick=${() => terminalInstanceRef?.current?.focus()}\n  >\n    <div ref=${hostRef} class=\"watchdog-terminal-host w-full h-full\"></div>\n  </div>\n`;\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/terminal/use-terminal.js",
    "content": "import { useEffect, useRef, useState } from \"preact/hooks\";\nimport { closeWatchdogTerminalSession } from \"../../../lib/api.js\";\nimport { showToast } from \"../../toast.js\";\nimport {\n  ensureXtermStylesheet,\n  fitTerminalWhenVisible,\n  kWatchdogTerminalWsPath,\n  loadXtermModules,\n} from \"../helpers.js\";\n\nconst waitForTerminalHost = ({\n  hostRef = null,\n  panelRef = null,\n  maxFrames = 8,\n} = {}) =>\n  new Promise((resolve, reject) => {\n    let framesRemaining = Math.max(1, Number(maxFrames) || 1);\n\n    const check = () => {\n      if (hostRef?.current && panelRef?.current) {\n        resolve({\n          hostElement: hostRef.current,\n          panelElement: panelRef.current,\n        });\n        return;\n      }\n      framesRemaining -= 1;\n      if (framesRemaining <= 0) {\n        reject(new Error(\"Terminal host not ready\"));\n        return;\n      }\n      window.requestAnimationFrame(check);\n    };\n\n    check();\n  });\n\nexport const useWatchdogTerminal = ({\n  active = false,\n  panelRef = null,\n  hostRef = null,\n} = {}) => {\n  const [connectingTerminal, setConnectingTerminal] = useState(false);\n  const [terminalConnected, setTerminalConnected] = useState(false);\n  const [terminalEnded, setTerminalEnded] = useState(false);\n  const [terminalStatusText, setTerminalStatusText] = useState(\"\");\n  const [terminalUiSettling, setTerminalUiSettling] = useState(false);\n  const [terminalSessionId, setTerminalSessionId] = useState(\"\");\n  const [terminalReconnectToken, setTerminalReconnectToken] = useState(0);\n  const terminalInstanceRef = useRef(null);\n  const terminalFitAddonRef = useRef(null);\n  const terminalSocketRef = useRef(null);\n  const terminalSessionIdRef = useRef(\"\");\n\n  useEffect(() => {\n    terminalSessionIdRef.current = String(terminalSessionId || \"\");\n  }, [terminalSessionId]);\n\n  useEffect(() => {\n    if (!active) return;\n    let cancelled = false;\n    let resizeTimer = null;\n    const setupTerminal = async () => {\n      try {\n        setConnectingTerminal(true);\n        ensureXtermStylesheet();\n        const { Terminal, FitAddon } = await loadXtermModules();\n        if (cancelled) return;\n        const { hostElement } = await waitForTerminalHost({\n          hostRef,\n          panelRef,\n        });\n        if (cancelled) return;\n        if (!terminalInstanceRef.current) {\n          const terminal = new Terminal({\n            cursorBlink: true,\n            fontFamily:\n              \"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace\",\n            fontSize: 12,\n            lineHeight: 1.2,\n            letterSpacing: 0,\n            convertEol: false,\n            theme: {\n              background: \"rgba(0, 0, 0, 0)\",\n              foreground: \"#d1d5db\",\n              cursor: \"#67e8f9\",\n            },\n          });\n          const fitAddon = new FitAddon();\n          terminal.loadAddon(fitAddon);\n          terminal.open(hostElement);\n          fitAddon.fit();\n          terminal.attachCustomKeyEventHandler((event) => {\n            if (event.type !== \"keydown\") return true;\n            const pressedKey = String(event.key || \"\").toLowerCase();\n            if (\n              !event.metaKey ||\n              event.ctrlKey ||\n              event.altKey ||\n              event.shiftKey\n            ) {\n              return true;\n            }\n            if (pressedKey !== \"k\") return true;\n            event.preventDefault();\n            terminal.clear();\n            return false;\n          });\n          window.setTimeout(() => {\n            terminalFitAddonRef.current?.fit();\n          }, 120);\n          terminal.focus();\n          terminal.onData((data) => {\n            const socket = terminalSocketRef.current;\n            if (!socket || socket.readyState !== 1) return;\n            socket.send(\n              JSON.stringify({\n                type: \"input\",\n                data,\n              }),\n            );\n          });\n          terminalInstanceRef.current = terminal;\n          terminalFitAddonRef.current = fitAddon;\n        }\n\n        const existingSocket = terminalSocketRef.current;\n        if (existingSocket && existingSocket.readyState <= 1) {\n          setConnectingTerminal(false);\n          setTerminalUiSettling(false);\n          fitTerminalWhenVisible({\n            panel: panelRef?.current,\n            fitAddon: terminalFitAddonRef.current,\n          });\n          terminalInstanceRef.current?.focus();\n          return;\n        }\n\n        const protocol = window.location.protocol === \"https:\" ? \"wss\" : \"ws\";\n        const socket = new WebSocket(\n          `${protocol}://${window.location.host}${kWatchdogTerminalWsPath}`,\n        );\n        terminalSocketRef.current = socket;\n        socket.onopen = () => {\n          if (cancelled) return;\n          setConnectingTerminal(false);\n          setTerminalUiSettling(false);\n          setTerminalConnected(true);\n          setTerminalEnded(false);\n          setTerminalStatusText(\"Connected\");\n          fitTerminalWhenVisible({\n            panel: panelRef?.current,\n            fitAddon: terminalFitAddonRef.current,\n          });\n          terminalInstanceRef.current?.focus();\n        };\n        socket.onmessage = (event) => {\n          let payload = null;\n          try {\n            payload = JSON.parse(String(event.data || \"\"));\n          } catch {\n            return;\n          }\n          const type = String(payload?.type || \"\");\n          if (type === \"session\") {\n            const sessionId = String(payload?.session?.id || \"\");\n            if (sessionId) setTerminalSessionId(sessionId);\n            setTerminalStatusText(\"Connected\");\n            return;\n          }\n          if (type === \"output\") {\n            terminalInstanceRef.current?.write(String(payload?.data || \"\"));\n            return;\n          }\n          if (type === \"exit\") {\n            setTerminalEnded(true);\n            setTerminalConnected(false);\n            setTerminalStatusText(\"Session ended\");\n          }\n        };\n        socket.onclose = () => {\n          if (cancelled) return;\n          setConnectingTerminal(false);\n          setTerminalUiSettling(false);\n          setTerminalConnected(false);\n          if (!terminalEnded) setTerminalStatusText(\"Disconnected\");\n        };\n        socket.onerror = () => {\n          if (cancelled) return;\n          setConnectingTerminal(false);\n          setTerminalUiSettling(false);\n          setTerminalConnected(false);\n          setTerminalStatusText(\"Connection error\");\n          showToast(\"Watchdog terminal connection failed\", \"error\");\n        };\n      } catch (error) {\n        if (cancelled) return;\n        setConnectingTerminal(false);\n        setTerminalUiSettling(false);\n        setTerminalConnected(false);\n        setTerminalStatusText(\"Terminal failed to load\");\n        console.error(\n          `[watchdog-terminal] initialization failed: ${error?.message || \"unknown error\"}`,\n          error,\n        );\n        showToast(\"Could not initialize terminal\", \"error\");\n      }\n    };\n    setupTerminal();\n\n    const onResize = () => {\n      if (resizeTimer) window.clearTimeout(resizeTimer);\n      resizeTimer = window.setTimeout(() => {\n        fitTerminalWhenVisible({\n          panel: panelRef?.current,\n          fitAddon: terminalFitAddonRef.current,\n        });\n      }, 60);\n    };\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      cancelled = true;\n      if (resizeTimer) window.clearTimeout(resizeTimer);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [active, terminalEnded, terminalReconnectToken, panelRef, hostRef]);\n\n  useEffect(\n    () => () => {\n      const activeSessionId = String(terminalSessionIdRef.current || \"\");\n      if (activeSessionId) {\n        closeWatchdogTerminalSession(activeSessionId).catch(() => {});\n      }\n      const socket = terminalSocketRef.current;\n      if (socket && socket.readyState <= 1) socket.close();\n      terminalSocketRef.current = null;\n      terminalFitAddonRef.current = null;\n      if (terminalInstanceRef.current) {\n        terminalInstanceRef.current.dispose();\n      }\n      terminalInstanceRef.current = null;\n    },\n    [],\n  );\n\n  const prepareForActivate = () => {\n    const hasOpenSocket =\n      !!terminalSocketRef.current && terminalSocketRef.current.readyState <= 1;\n    if (hasOpenSocket && terminalConnected) {\n      setTerminalUiSettling(false);\n      setConnectingTerminal(false);\n      return;\n    }\n    setTerminalUiSettling(true);\n    setConnectingTerminal(true);\n  };\n\n  const clearSettling = () => {\n    setTerminalUiSettling(false);\n  };\n\n  const restartSession = () => {\n    const activeSessionId = String(terminalSessionId || \"\");\n    if (activeSessionId) {\n      closeWatchdogTerminalSession(activeSessionId).catch(() => {});\n    }\n    const socket = terminalSocketRef.current;\n    if (socket && socket.readyState <= 1) socket.close();\n    terminalSocketRef.current = null;\n    terminalInstanceRef.current?.clear();\n    setConnectingTerminal(true);\n    setTerminalUiSettling(true);\n    setTerminalEnded(false);\n    setTerminalConnected(false);\n    setTerminalSessionId(\"\");\n    setTerminalStatusText(\"Connecting...\");\n    setTerminalReconnectToken((value) => value + 1);\n  };\n\n  const fitNow = () => {\n    fitTerminalWhenVisible({\n      panel: panelRef?.current,\n      fitAddon: terminalFitAddonRef.current,\n    });\n  };\n\n  return {\n    connectingTerminal,\n    terminalConnected,\n    terminalEnded,\n    terminalStatusText,\n    terminalUiSettling,\n    terminalInstanceRef,\n    fitNow,\n    prepareForActivate,\n    clearSettling,\n    restartSession,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/watchdog-tab/use-watchdog-tab.js",
    "content": "import { useWatchdogConsole } from \"./console/use-console.js\";\nimport { useWatchdogIncidents } from \"./incidents/use-incidents.js\";\nimport { useWatchdogResources } from \"./resources/use-resources.js\";\nimport { useWatchdogSettings } from \"./settings/use-settings.js\";\n\nexport const useWatchdogTab = ({\n  watchdogStatus = null,\n  onRefreshStatuses = () => {},\n  restartSignal = 0,\n} = {}) => {\n  const currentWatchdogStatus = watchdogStatus || {};\n  const incidents = useWatchdogIncidents({\n    restartSignal,\n    onRefreshStatuses,\n  });\n  const resources = useWatchdogResources();\n  const settings = useWatchdogSettings({\n    watchdogStatus: currentWatchdogStatus,\n    onRefreshStatuses,\n    onRefreshIncidents: incidents.refreshEvents,\n  });\n  const consoleState = useWatchdogConsole();\n\n  return {\n    currentWatchdogStatus,\n    events: incidents.events,\n    refreshEvents: incidents.refreshEvents,\n    resources: resources.resources,\n    memoryExpanded: resources.memoryExpanded,\n    setMemoryExpanded: resources.setMemoryExpanded,\n    settings: settings.settings,\n    savingSettings: settings.savingSettings,\n    onToggleAutoRepair: settings.onToggleAutoRepair,\n    onToggleNotifications: settings.onToggleNotifications,\n    onRepair: settings.onRepair,\n    isRepairInProgress: settings.isRepairInProgress,\n    logs: consoleState.logs,\n    loadingLogs: consoleState.loadingLogs,\n    copyingAll: consoleState.copyingAll,\n    stickToBottom: consoleState.stickToBottom,\n    setStickToBottom: consoleState.setStickToBottom,\n    activeConsoleTab: consoleState.activeConsoleTab,\n    handleSelectConsoleTab: consoleState.handleSelectConsoleTab,\n    connectingTerminal: consoleState.connectingTerminal,\n    terminalConnected: consoleState.terminalConnected,\n    terminalEnded: consoleState.terminalEnded,\n    terminalStatusText: consoleState.terminalStatusText,\n    terminalUiSettling: consoleState.terminalUiSettling,\n    onRestartTerminalSession: consoleState.onRestartTerminalSession,\n    logsPanelHeightPx: consoleState.logsPanelHeightPx,\n    logsRef: consoleState.logsRef,\n    terminalPanelRef: consoleState.terminalPanelRef,\n    terminalHostRef: consoleState.terminalHostRef,\n    terminalInstanceRef: consoleState.terminalInstanceRef,\n    onCopyAll: consoleState.onCopyAll,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/create-webhook-modal/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport {\n  kNoDestinationSessionValue,\n  useDestinationSessionSelection,\n} from \"../../../hooks/use-destination-session-selection.js\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { CloseIcon } from \"../../icons.js\";\nimport { ModalShell } from \"../../modal-shell.js\";\nimport { PageHeader } from \"../../page-header.js\";\nimport { SessionSelectField } from \"../../session-select-field.js\";\n\nconst html = htm.bind(h);\n\nexport const CreateWebhookModal = ({\n  visible,\n  name,\n  mode = \"webhook\",\n  onModeChange = () => {},\n  onNameChange = () => {},\n  canCreate = false,\n  creating = false,\n  onCreate = () => {},\n  onClose = () => {},\n}) => {\n  const {\n    sessions: selectableSessions,\n    loading: loadingSessions,\n    error: destinationLoadError,\n    destinationSessionKey,\n    setDestinationSessionKey,\n    selectedDestination,\n  } = useDestinationSessionSelection({\n    enabled: visible,\n    resetKey: String(visible),\n  });\n\n  const normalized = String(name || \"\")\n    .trim()\n    .toLowerCase();\n  const previewName = normalized || \"{name}\";\n  const previewPath = `/hooks/${previewName}`;\n  const previewUrl =\n    mode === \"oauth\"\n      ? `${window.location.origin}/oauth/{id}`\n      : `${window.location.origin}${previewPath}`;\n  if (!visible) return null;\n\n  return html`\n    <${ModalShell}\n      visible=${visible}\n      onClose=${onClose}\n      panelClassName=\"bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4\"\n    >\n      <${PageHeader}\n        title=\"Create Webhook\"\n        actions=${html`\n          <button\n            type=\"button\"\n            onclick=${onClose}\n            class=\"h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary\"\n            aria-label=\"Close modal\"\n          >\n            <${CloseIcon} className=\"w-3.5 h-3.5 text-body\" />\n          </button>\n        `}\n      />\n      <div class=\"space-y-2\">\n        <p class=\"text-xs text-fg-muted\">Endpoint mode</p>\n        <div class=\"flex items-center gap-2\">\n          <button\n            class=\"text-xs px-2 py-1 rounded border transition-colors ${mode ===\n            \"webhook\"\n              ? \"border-cyan-400 text-status-info bg-cyan-400/10\"\n              : \"border-border text-fg-muted hover:text-body\"}\"\n            onclick=${() => onModeChange(\"webhook\")}\n          >\n            Webhook\n          </button>\n          <button\n            class=\"text-xs px-2 py-1 rounded border transition-colors ${mode ===\n            \"oauth\"\n              ? \"border-cyan-400 text-status-info bg-cyan-400/10\"\n              : \"border-border text-fg-muted hover:text-body\"}\"\n            onclick=${() => onModeChange(\"oauth\")}\n          >\n            OAuth Callback\n          </button>\n        </div>\n      </div>\n      <div class=\"space-y-2\">\n        <p class=\"text-xs text-fg-muted\">Name</p>\n        <input\n          type=\"text\"\n          value=${name}\n          placeholder=\"fathom\"\n          onInput=${(e) => onNameChange(e.target.value)}\n          onKeyDown=${(e) => {\n            if (e.key === \"Enter\" && canCreate && !creating) {\n              onCreate(selectedDestination, mode);\n            }\n            if (e.key === \"Escape\") onClose();\n          }}\n          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\"\n        />\n      </div>\n      <${SessionSelectField}\n        label=\"Deliver to\"\n        sessions=${selectableSessions}\n        selectedSessionKey=${destinationSessionKey}\n        onChangeSessionKey=${setDestinationSessionKey}\n        disabled=${loadingSessions || creating}\n        loading=${loadingSessions}\n        error=${destinationLoadError}\n        allowNone=${true}\n        noneValue=${kNoDestinationSessionValue}\n        noneLabel=\"Default\"\n        emptyStateText=\"No paired chat sessions found yet. You can still create the webhook without a default destination.\"\n        loadingLabel=\"Loading destinations...\"\n      />\n      <div class=\"border border-border rounded-lg overflow-hidden\">\n        <table class=\"w-full text-xs\">\n          <tbody>\n            <tr class=\"border-b border-border\">\n              <td class=\"w-24 px-3 py-2 text-fg-muted\">Path</td>\n              <td class=\"px-3 py-2 text-body font-mono\">\n                <code>${previewPath}</code>\n              </td>\n            </tr>\n            <tr class=\"border-b border-border\">\n              <td class=\"w-24 px-3 py-2 text-fg-muted\">URL</td>\n              <td class=\"px-3 py-2 text-body font-mono break-all\">\n                <code>${previewUrl}</code>\n              </td>\n            </tr>\n            <tr>\n              <td class=\"w-24 px-3 py-2 text-fg-muted\">Transform</td>\n              <td class=\"px-3 py-2 text-body font-mono\">\n                <code>hooks/transforms/${previewName}/${previewName}-transform.mjs</code>\n              </td>\n            </tr>\n          </tbody>\n        </table>\n      </div>\n      ${mode === \"oauth\"\n        ? html`\n            <div class=\"space-y-1\">\n              <p class=\"text-xs text-fg-muted\">\n                For OAuth providers that can't send auth headers. AlphaClaw\n                injects webhook auth before forwarding to /hooks/{name}.\n              </p>\n            </div>\n          `\n        : null}\n      <div class=\"pt-1 flex items-center justify-end gap-2\">\n        <${ActionButton}\n          onClick=${onClose}\n          tone=\"secondary\"\n          size=\"md\"\n          idleLabel=\"Cancel\"\n          className=\"px-4 py-2 rounded-lg text-sm\"\n        />\n        <${ActionButton}\n          onClick=${() => onCreate(selectedDestination, mode)}\n          disabled=${!canCreate || creating}\n          loading=${creating}\n          tone=\"primary\"\n          size=\"md\"\n          idleLabel=\"Create\"\n          loadingLabel=\"Creating...\"\n          className=\"px-4 py-2 rounded-lg text-sm\"\n        />\n      </div>\n    </${ModalShell}>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/helpers.js",
    "content": "import {\n  formatLocaleDateTime,\n  formatLocaleDateTimeWithTodayTime,\n} from \"../../lib/format.js\";\n\nexport const kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nexport const kStatusFilters = [\"all\", \"success\", \"error\"];\n\nexport const formatDateTime = (value) => {\n  return formatLocaleDateTime(value, { fallback: \"—\" });\n};\n\nexport const formatLastReceived = (value) => {\n  return formatLocaleDateTimeWithTodayTime(value, { fallback: \"—\" });\n};\n\nexport const formatBytes = (size) => {\n  const bytes = Number(size || 0);\n  if (!Number.isFinite(bytes) || bytes <= 0) return \"0B\";\n  if (bytes < 1024) return `${bytes}B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;\n};\n\nexport const healthClassName = (health) => {\n  if (health === \"red\") return \"bg-red-500\";\n  if (health === \"yellow\") return \"bg-yellow-500\";\n  return \"bg-green-500\";\n};\n\nexport const getRequestStatusTone = (status) => {\n  if (status === \"success\") {\n    return {\n      dotClass: \"bg-green-500/90\",\n      textClass: \"text-status-success-muted/90\",\n    };\n  }\n  if (status === \"error\") {\n    return {\n      dotClass: \"bg-red-500/90\",\n      textClass: \"text-status-error-muted\",\n    };\n  }\n  return {\n    dotClass: \"bg-gray-500/70\",\n    textClass: \"text-fg-muted\",\n  };\n};\n\nexport const formatAgentFallbackName = (agentId = \"\") =>\n  String(agentId || \"\")\n    .trim()\n    .split(/[-_\\s]+/)\n    .filter(Boolean)\n    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n    .join(\" \") || \"Main Agent\";\n\nexport const jsonPretty = (value) => {\n  if (typeof value === \"string\") {\n    try {\n      const parsed = JSON.parse(value);\n      return JSON.stringify(parsed, null, 2);\n    } catch {\n      return value;\n    }\n  }\n  try {\n    return JSON.stringify(value || {}, null, 2);\n  } catch {\n    return String(value || \"\");\n  }\n};\n\nexport const buildWebhookDebugMessage = ({\n  hookName = \"\",\n  webhook = null,\n  request = null,\n}) => {\n  const hookPath =\n    String(webhook?.path || \"\").trim() ||\n    (hookName ? `/hooks/${hookName}` : \"/hooks/unknown\");\n  const gatewayStatus =\n    request?.gatewayStatus == null ? \"n/a\" : String(request.gatewayStatus);\n  return [\n    \"Investigate this failed webhook request and share findings before fixing anything.\",\n    \"Reply with your diagnosis first, including the likely root cause, any relevant risks, and what you would change if I approve a fix.\",\n    \"\",\n    `Webhook: ${hookPath}`,\n    `Request ID: ${String(request?.id || \"unknown\")}`,\n    `Time: ${String(request?.createdAt || \"unknown\")}`,\n    `Method: ${String(request?.method || \"unknown\")}`,\n    `Source IP: ${String(request?.sourceIp || \"unknown\")}`,\n    `Gateway status: ${gatewayStatus}`,\n    `Transform path: ${String(webhook?.transformPath || \"unknown\")}`,\n    `Payload truncated: ${request?.payloadTruncated ? \"yes\" : \"no\"}`,\n    \"\",\n    \"Headers:\",\n    jsonPretty(request?.headers),\n    \"\",\n    \"Payload:\",\n    jsonPretty(request?.payload),\n    \"\",\n    \"Gateway response:\",\n    jsonPretty(request?.gatewayBody),\n  ].join(\"\\n\");\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/index.js",
    "content": "import { h } from \"preact\";\nimport { useCallback, useMemo, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { createWebhook } from \"../../lib/api.js\";\nimport { ActionButton } from \"../action-button.js\";\nimport { PageHeader } from \"../page-header.js\";\nimport { showToast } from \"../toast.js\";\nimport { CreateWebhookModal } from \"./create-webhook-modal/index.js\";\nimport { kNamePattern } from \"./helpers.js\";\nimport { WebhookDetail } from \"./webhook-detail/index.js\";\nimport { WebhookList } from \"./webhook-list/index.js\";\n\nconst html = htm.bind(h);\n\nexport const Webhooks = ({\n  selectedHookName = \"\",\n  onSelectHook = () => {},\n  onBackToList = () => {},\n  onRestartRequired = () => {},\n  onOpenFile = () => {},\n}) => {\n  const [isCreating, setIsCreating] = useState(false);\n  const [newName, setNewName] = useState(\"\");\n  const [createMode, setCreateMode] = useState(\"webhook\");\n  const [creating, setCreating] = useState(false);\n\n  const canCreate = useMemo(() => {\n    const name = String(newName || \"\")\n      .trim()\n      .toLowerCase();\n    return kNamePattern.test(name);\n  }, [newName]);\n\n  const handleCreate = useCallback(\n    async (destination = null, mode = \"webhook\") => {\n      const candidateName = String(newName || \"\")\n        .trim()\n        .toLowerCase();\n      if (!kNamePattern.test(candidateName)) {\n        showToast(\n          \"Name must be lowercase letters, numbers, and hyphens\",\n          \"error\",\n        );\n        return;\n      }\n      if (creating) return;\n      setCreating(true);\n      try {\n        const data = await createWebhook(candidateName, {\n          destination,\n          oauthCallback: mode === \"oauth\",\n        });\n        setIsCreating(false);\n        setNewName(\"\");\n        setCreateMode(\"webhook\");\n        onSelectHook(candidateName);\n        if (data.restartRequired) onRestartRequired(true);\n        if (mode === \"oauth\" && data?.webhook?.oauthCallbackUrl) {\n          showToast(\"Webhook + OAuth callback created\", \"success\");\n        } else {\n          showToast(\"Webhook created\", \"success\");\n        }\n        if (data.syncWarning) {\n          showToast(`Created, but git-sync failed: ${data.syncWarning}`, \"warning\");\n        }\n      } catch (err) {\n        showToast(err.message || \"Could not create webhook\", \"error\");\n      } finally {\n        setCreating(false);\n      }\n    },\n    [creating, newName, onRestartRequired, onSelectHook],\n  );\n\n  return html`\n    <div class=\"space-y-4\">\n      <${PageHeader}\n        title=\"Webhooks\"\n        leading=${selectedHookName\n          ? html`\n              <button\n                class=\"flex items-center gap-1.5 text-sm text-fg-muted hover:text-body transition-colors\"\n                onclick=${onBackToList}\n              >\n                <svg\n                  width=\"16\"\n                  height=\"16\"\n                  viewBox=\"0 0 16 16\"\n                  fill=\"currentColor\"\n                >\n                  <path\n                    d=\"M10.354 3.354a.5.5 0 00-.708-.708l-5 5a.5.5 0 000 .708l5 5a.5.5 0 00.708-.708L5.707 8l4.647-4.646z\"\n                  />\n                </svg>\n                Back\n              </button>\n            `\n          : null}\n        actions=${selectedHookName\n          ? null\n          : html`\n              <${ActionButton}\n                onClick=${() => {\n                  setCreateMode(\"webhook\");\n                  setIsCreating((open) => !open);\n                }}\n                tone=\"secondary\"\n                size=\"sm\"\n                idleLabel=\"Create new\"\n                className=\"px-3 py-1.5\"\n              />\n            `}\n      />\n\n      ${selectedHookName\n        ? html`\n            <${WebhookDetail}\n              selectedHookName=${selectedHookName}\n              onBackToList=${onBackToList}\n              onRestartRequired=${onRestartRequired}\n              onOpenFile=${onOpenFile}\n            />\n          `\n        : html`\n            <${WebhookList}\n              onSelectHook=${(name) => {\n                onSelectHook(name);\n              }}\n            />\n          `}\n\n      <${CreateWebhookModal}\n        visible=${isCreating && !selectedHookName}\n        name=${newName}\n        mode=${createMode}\n        onModeChange=${setCreateMode}\n        onNameChange=${setNewName}\n        canCreate=${canCreate}\n        creating=${creating}\n        onCreate=${handleCreate}\n        onClose=${() => {\n          setIsCreating(false);\n          setCreateMode(\"webhook\");\n        }}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/request-history/index.js",
    "content": "import { h } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { sendAgentMessage } from \"../../../lib/api.js\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { AgentSendModal } from \"../../agent-send-modal.js\";\nimport { showToast } from \"../../toast.js\";\nimport {\n  buildWebhookDebugMessage,\n  formatBytes,\n  formatLastReceived,\n  getRequestStatusTone,\n  jsonPretty,\n  kStatusFilters,\n} from \"../helpers.js\";\nimport { useRequestHistory } from \"./use-request-history.js\";\n\nconst html = htm.bind(h);\n\nexport const RequestHistory = ({\n  selectedHookName = \"\",\n  effectiveAuthMode = \"headers\",\n  webhookUrl = \"\",\n  webhookUrlWithQueryToken = \"\",\n  bearerTokenValue = \"\",\n  selectedWebhook = null,\n  refreshNonce = 0,\n}) => {\n  const { state, actions } = useRequestHistory({\n    selectedHookName,\n    effectiveAuthMode,\n    webhookUrl,\n    webhookUrlWithQueryToken,\n    bearerTokenValue,\n    refreshNonce,\n  });\n\n  const {\n    requests,\n    statusFilter,\n    expandedRows,\n    replayingRequestId,\n    debugLoadingRequestId,\n    debugRequest,\n  } = state;\n\n  const debugAgentMessage = useMemo(\n    () =>\n      buildWebhookDebugMessage({\n        hookName: selectedHookName,\n        webhook: selectedWebhook,\n        request: debugRequest,\n      }),\n    [debugRequest, selectedHookName, selectedWebhook],\n  );\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <h3 class=\"card-label\">Request history</h3>\n        <div class=\"flex items-center gap-2\">\n          ${kStatusFilters.map(\n            (filter) => html`\n              <button\n                class=\"text-xs px-2 py-1 rounded border ${statusFilter === filter\n                  ? \"border-cyan-400 text-status-info bg-cyan-400/10\"\n                  : \"border-border text-fg-muted hover:text-body\"}\"\n                onclick=${() => actions.handleSetStatusFilter(filter)}\n              >\n                ${filter}\n              </button>\n            `,\n          )}\n        </div>\n      </div>\n\n      ${requests.length === 0\n        ? html`<p class=\"text-sm text-fg-muted\">No requests logged yet.</p>`\n        : html`\n            <div class=\"ac-history-list\">\n              ${requests.map((item) => {\n                const statusTone = getRequestStatusTone(item.status);\n                return html`\n                  <details\n                    class=\"ac-history-item\"\n                    open=${expandedRows.has(item.id)}\n                    ontoggle=${(e) =>\n                      actions.handleRequestRowToggle(item.id, !!e.currentTarget?.open)}\n                  >\n                    <summary class=\"ac-history-summary\">\n                      <div class=\"ac-history-summary-row\">\n                        <span class=\"inline-flex items-center gap-2 min-w-0\">\n                          <span class=\"ac-history-toggle shrink-0\" aria-hidden=\"true\"\n                            >▸</span\n                          >\n                          <span class=\"truncate text-xs text-body\">\n                            ${formatLastReceived(item.createdAt)}\n                          </span>\n                        </span>\n                        <span class=\"inline-flex items-center gap-2 shrink-0\">\n                          <span class=\"text-xs text-fg-muted\"\n                            >${formatBytes(item.payloadSize)}</span\n                          >\n                          <span class=${`text-xs font-medium ${statusTone.textClass}`}\n                            >${item.gatewayStatus || \"n/a\"}</span\n                          >\n                          <span class=\"inline-flex items-center\">\n                            <span\n                              class=${`h-2.5 w-2.5 rounded-full ${statusTone.dotClass}`}\n                              title=${item.status || \"unknown\"}\n                              aria-label=${item.status || \"unknown\"}\n                            ></span>\n                          </span>\n                        </span>\n                      </div>\n                    </summary>\n                    ${expandedRows.has(item.id)\n                      ? html`\n                          <div class=\"ac-history-body space-y-3\">\n                            <div>\n                              <p class=\"text-[11px] text-fg-muted mb-1\">Headers</p>\n                              <pre\n                                class=\"text-xs bg-field border border-border rounded p-2 overflow-auto\"\n                              >\n${jsonPretty(item.headers)}</pre\n                              >\n                              <div class=\"mt-2 flex justify-start gap-2\">\n                                <button\n                                  class=\"h-7 text-xs px-2.5 rounded-lg ac-btn-secondary\"\n                                  onclick=${() =>\n                                    actions.handleCopyRequestField(\n                                      jsonPretty(item.headers),\n                                      \"Headers\",\n                                    )}\n                                >\n                                  Copy\n                                </button>\n                              </div>\n                            </div>\n                            <div>\n                              <p class=\"text-[11px] text-fg-muted mb-1\">\n                                Payload ${item.payloadTruncated ? \"(truncated)\" : \"\"}\n                              </p>\n                              <pre\n                                class=\"text-xs bg-field border border-border rounded p-2 overflow-auto\"\n                              >\n${jsonPretty(item.payload)}</pre\n                              >\n                              <div class=\"mt-2 flex justify-start gap-2\">\n                                <button\n                                  class=\"h-7 text-xs px-2.5 rounded-lg ac-btn-secondary\"\n                                  onclick=${() =>\n                                    actions.handleCopyRequestField(\n                                      item.payload,\n                                      \"Payload\",\n                                    )}\n                                >\n                                  Copy\n                                </button>\n                                <button\n                                  class=\"h-7 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60\"\n                                  onclick=${() => actions.handleReplayRequest(item)}\n                                  disabled=${item.payloadTruncated ||\n                                  replayingRequestId === item.id}\n                                  title=${item.payloadTruncated\n                                    ? \"Cannot replay truncated payload\"\n                                    : \"Replay this payload\"}\n                                >\n                                  ${replayingRequestId === item.id\n                                    ? \"Replaying...\"\n                                    : \"Replay\"}\n                                </button>\n                              </div>\n                            </div>\n                            <div>\n                              <p class=\"text-[11px] text-fg-muted mb-1\">\n                                Gateway response (${item.gatewayStatus || \"n/a\"})\n                              </p>\n                              <pre\n                                class=\"text-xs bg-field border border-border rounded p-2 overflow-auto\"\n                              >\n${jsonPretty(item.gatewayBody)}</pre\n                              >\n                              <div class=\"mt-2 flex justify-start gap-2\">\n                                <button\n                                  class=\"h-7 text-xs px-2.5 rounded-lg ac-btn-secondary\"\n                                  onclick=${() =>\n                                    actions.handleCopyRequestField(\n                                      item.gatewayBody,\n                                      \"Gateway response\",\n                                    )}\n                                >\n                                  Copy\n                                </button>\n                                ${item.status === \"error\"\n                                  ? html`<${ActionButton}\n                                      onClick=${() =>\n                                        actions.handleAskAgentToDebug(item)}\n                                      loading=${debugLoadingRequestId === item.id}\n                                      tone=\"primary\"\n                                      size=\"sm\"\n                                      idleLabel=\"Ask agent to debug\"\n                                      loadingLabel=\"Loading...\"\n                                      className=\"h-7 px-2.5\"\n                                    />`\n                                  : null}\n                              </div>\n                            </div>\n                          </div>\n                        `\n                      : null}\n                  </details>\n                `;\n              })}\n            </div>\n          `}\n      <${AgentSendModal}\n        visible=${!!debugRequest}\n        title=\"Ask agent to debug\"\n        messageLabel=\"Debug request\"\n        messageRows=${12}\n        initialMessage=${debugAgentMessage}\n        resetKey=${String(debugRequest?.id || \"\")}\n        submitLabel=\"Send debug request\"\n        loadingLabel=\"Sending...\"\n        onClose=${() => actions.setDebugRequest(null)}\n        onSubmit=${async ({ selectedSessionKey, message }) => {\n          try {\n            await sendAgentMessage({\n              message,\n              sessionKey: selectedSessionKey,\n            });\n            showToast(\"Debug request sent to agent\", \"success\");\n            return true;\n          } catch (err) {\n            showToast(err.message || \"Could not send debug request\", \"error\");\n            return false;\n          }\n        }}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/request-history/use-request-history.js",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"preact/hooks\";\nimport {\n  fetchWebhookRequest,\n  fetchWebhookRequests,\n} from \"../../../lib/api.js\";\nimport { usePolling } from \"../../../hooks/usePolling.js\";\nimport { showToast } from \"../../toast.js\";\n\nexport const useRequestHistory = ({\n  selectedHookName = \"\",\n  effectiveAuthMode = \"headers\",\n  webhookUrl = \"\",\n  webhookUrlWithQueryToken = \"\",\n  bearerTokenValue = \"\",\n  refreshNonce = 0,\n}) => {\n  const [statusFilter, setStatusFilter] = useState(\"all\");\n  const [expandedRows, setExpandedRows] = useState(() => new Set());\n  const [replayingRequestId, setReplayingRequestId] = useState(null);\n  const [debugLoadingRequestId, setDebugLoadingRequestId] = useState(null);\n  const [debugRequest, setDebugRequest] = useState(null);\n\n  const requestsPoll = usePolling(\n    async () => {\n      if (!selectedHookName) return { requests: [] };\n      const data = await fetchWebhookRequests(selectedHookName, {\n        limit: 25,\n        offset: 0,\n        status: statusFilter,\n      });\n      return data;\n    },\n    5000,\n    { enabled: !!selectedHookName },\n  );\n\n  const requests = requestsPoll.data?.requests || [];\n\n  useEffect(() => {\n    if (!selectedHookName) return;\n    requestsPoll.refresh();\n  }, [refreshNonce, requestsPoll.refresh, selectedHookName]);\n\n  const handleRequestRowToggle = useCallback((id, isOpen) => {\n    setExpandedRows((prev) => {\n      const next = new Set(prev);\n      if (isOpen) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n  }, []);\n\n  const handleSetStatusFilter = useCallback(\n    (filter) => {\n      setStatusFilter(filter);\n      setExpandedRows(new Set());\n      setTimeout(() => requestsPoll.refresh(), 0);\n    },\n    [requestsPoll.refresh],\n  );\n\n  const resetState = useCallback(() => {\n    setStatusFilter(\"all\");\n    setExpandedRows(new Set());\n    setDebugRequest(null);\n    setDebugLoadingRequestId(null);\n    setReplayingRequestId(null);\n  }, []);\n\n  const handleCopyRequestField = useCallback(async (value, label) => {\n    try {\n      await navigator.clipboard.writeText(String(value || \"\"));\n      showToast(`${label} copied`, \"success\");\n    } catch {\n      showToast(\n        `Could not copy ${String(label || \"value\").toLowerCase()}`,\n        \"error\",\n      );\n    }\n  }, []);\n\n  const requestUrl = useMemo(() => {\n    return effectiveAuthMode === \"query\" ? webhookUrlWithQueryToken : webhookUrl;\n  }, [effectiveAuthMode, webhookUrl, webhookUrlWithQueryToken]);\n\n  const requestHeaders = useMemo(() => {\n    const headers = { \"Content-Type\": \"application/json\" };\n    if (effectiveAuthMode === \"headers\") {\n      headers.Authorization = bearerTokenValue;\n    }\n    return headers;\n  }, [bearerTokenValue, effectiveAuthMode]);\n\n  const handleReplayRequest = useCallback(\n    async (item) => {\n      if (!item || replayingRequestId === item.id) return;\n      if (item.payloadTruncated) {\n        showToast(\"Cannot replay a truncated payload\", \"warning\");\n        return;\n      }\n      setReplayingRequestId(item.id);\n      try {\n        const response = await fetch(requestUrl, {\n          method: \"POST\",\n          headers: requestHeaders,\n          body: String(item.payload || \"\"),\n        });\n        const bodyText = await response.text();\n        let body = null;\n        try {\n          body = bodyText ? JSON.parse(bodyText) : null;\n        } catch {\n          body = null;\n        }\n        const errorMessage =\n          body?.ok === false\n            ? body?.error || \"Webhook rejected\"\n            : !response.ok\n              ? body?.error || bodyText || `HTTP ${response.status}`\n              : \"\";\n        if (errorMessage) {\n          showToast(`Replay failed: ${errorMessage}`, \"error\");\n          return;\n        }\n        showToast(\"Request replayed\", \"success\");\n        setTimeout(() => requestsPoll.refresh(), 0);\n      } catch (err) {\n        showToast(err.message || \"Could not replay request\", \"error\");\n      } finally {\n        setReplayingRequestId(null);\n      }\n    },\n    [replayingRequestId, requestHeaders, requestUrl, requestsPoll.refresh],\n  );\n\n  const handleAskAgentToDebug = useCallback(\n    async (item) => {\n      if (!selectedHookName || !item?.id || debugLoadingRequestId === item.id)\n        return;\n      try {\n        setDebugLoadingRequestId(item.id);\n        const data = await fetchWebhookRequest(selectedHookName, item.id);\n        setDebugRequest(data?.request || item);\n      } catch (err) {\n        showToast(err.message || \"Could not load webhook request details\", \"error\");\n      } finally {\n        setDebugLoadingRequestId(null);\n      }\n    },\n    [debugLoadingRequestId, selectedHookName],\n  );\n\n  return {\n    state: {\n      requests,\n      statusFilter,\n      expandedRows,\n      replayingRequestId,\n      debugLoadingRequestId,\n      debugRequest,\n    },\n    actions: {\n      refreshRequests: requestsPoll.refresh,\n      handleRequestRowToggle,\n      handleSetStatusFilter,\n      handleReplayRequest,\n      handleCopyRequestField,\n      handleAskAgentToDebug,\n      setDebugRequest,\n      resetState,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/webhook-detail/index.js",
    "content": "import { h } from \"preact\";\nimport { useCallback, useState } from \"preact/hooks\";\nimport htm from \"htm\";\nimport { ActionButton } from \"../../action-button.js\";\nimport { Badge } from \"../../badge.js\";\nimport { ConfirmDialog } from \"../../confirm-dialog.js\";\nimport { showToast } from \"../../toast.js\";\nimport { kNoDestinationSessionValue } from \"../../../hooks/use-destination-session-selection.js\";\nimport {\n  getSessionDisplayLabel,\n  getSessionRowKey,\n} from \"../../../lib/session-keys.js\";\nimport { formatDateTime } from \"../helpers.js\";\nimport { RequestHistory } from \"../request-history/index.js\";\nimport { useWebhookDetail } from \"./use-webhook-detail.js\";\n\nconst html = htm.bind(h);\n\nexport const WebhookDetail = ({\n  selectedHookName = \"\",\n  onBackToList = () => {},\n  onRestartRequired = () => {},\n  onOpenFile = () => {},\n}) => {\n  const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0);\n  const handleTestWebhookSent = useCallback(() => {\n    setHistoryRefreshNonce((value) => value + 1);\n  }, []);\n  const { state, actions } = useWebhookDetail({\n    selectedHookName,\n    onBackToList,\n    onRestartRequired,\n    onTestWebhookSent: handleTestWebhookSent,\n  });\n\n  const {\n    authMode,\n    selectedWebhook,\n    isWebhookLoading,\n    webhookLoadError,\n    selectedWebhookManaged,\n    selectedDeliveryAgentName,\n    selectedDeliveryChannel,\n    selectableSessions,\n    loadingDestinationSessions,\n    destinationLoadError,\n    destinationSessionKey,\n    destinationDirty,\n    savingDestination,\n    webhookUrl,\n    oauthCallbackUrl,\n    hasOauthCallback,\n    webhookUrlWithQueryToken,\n    authHeaderValue,\n    bearerTokenValue,\n    effectiveAuthMode,\n    activeCurlCommand,\n    deleting,\n    showDeleteConfirm,\n    deleteTransformDir,\n    sendingTestWebhook,\n    rotatingOauthCallback,\n    showRotateOauthConfirm,\n  } = state;\n\n  return html`\n    <div class=\"space-y-4\">\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-4\">\n        <div>\n          <h2 class=\"font-semibold text-sm\">\n            ${selectedWebhook?.path || `/hooks/${selectedHookName}`}\n          </h2>\n        </div>\n\n        ${isWebhookLoading\n          ? html`<div class=\"bg-field border border-border rounded-lg p-3\">\n              <p class=\"text-xs text-fg-muted\">Loading webhook details...</p>\n            </div>`\n          : webhookLoadError\n            ? html`<div class=\"bg-field border border-border rounded-lg p-3\">\n                <p class=\"text-xs text-status-error\">\n                  ${webhookLoadError?.message || \"Could not load webhook details\"}\n                </p>\n              </div>`\n            : hasOauthCallback\n              ? null\n              : html`<div class=\"bg-field border border-border rounded-lg p-3 space-y-4\">\n              ${selectedWebhookManaged\n                ? null\n                : html`\n                    <div class=\"space-y-2\">\n                      <p class=\"text-xs text-fg-muted\">Auth mode</p>\n                      <div class=\"flex items-center gap-2\">\n                        <button\n                          class=\"text-xs px-2 py-1 rounded border transition-colors ${authMode ===\n                          \"headers\"\n                            ? \"border-cyan-400 text-status-info bg-cyan-400/10\"\n                            : \"border-border text-fg-muted hover:text-body\"}\"\n                          onclick=${() => actions.setAuthMode(\"headers\")}\n                        >\n                          Headers\n                        </button>\n                        <button\n                          class=\"text-xs px-2 py-1 rounded border transition-colors ${authMode ===\n                          \"query\"\n                            ? \"border-cyan-400 text-status-info bg-cyan-400/10\"\n                            : \"border-border text-fg-muted hover:text-body\"}\"\n                          onclick=${() => actions.setAuthMode(\"query\")}\n                        >\n                          Query string\n                        </button>\n                      </div>\n                    </div>\n                  `}\n              <div class=\"space-y-2\">\n                <p class=\"text-xs text-fg-muted\">Webhook URL</p>\n                <div class=\"flex items-center gap-2\">\n                  <input\n                    type=\"text\"\n                    readonly\n                    value=${effectiveAuthMode === \"query\"\n                      ? webhookUrlWithQueryToken\n                      : webhookUrl}\n                    class=\"h-8 flex-1 bg-field border border-border rounded-lg px-3 text-xs text-body outline-none font-mono\"\n                  />\n                  <button\n                    class=\"h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0\"\n                    onclick=${async () => {\n                      try {\n                        await navigator.clipboard.writeText(\n                          effectiveAuthMode === \"query\"\n                            ? webhookUrlWithQueryToken\n                            : webhookUrl,\n                        );\n                        showToast(\"Webhook URL copied\", \"success\");\n                      } catch {\n                        showToast(\"Could not copy URL\", \"error\");\n                      }\n                    }}\n                  >\n                    Copy\n                  </button>\n                </div>\n              </div>\n              ${selectedWebhookManaged\n                ? null\n                : effectiveAuthMode === \"headers\"\n                  ? html`\n                      <div class=\"space-y-2\">\n                        <p class=\"text-xs text-fg-muted\">Auth headers</p>\n                        <div class=\"flex items-center gap-2\">\n                          <input\n                            type=\"text\"\n                            readonly\n                            value=${authHeaderValue}\n                            class=\"h-8 flex-1 bg-field border border-border rounded-lg px-3 text-xs text-body outline-none font-mono\"\n                          />\n                          <button\n                            class=\"h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0\"\n                            onclick=${async () => {\n                              try {\n                                await navigator.clipboard.writeText(\n                                  bearerTokenValue,\n                                );\n                              showToast(\"Bearer token copied\", \"success\");\n                              } catch {\n                              showToast(\"Could not copy bearer token\", \"error\");\n                              }\n                            }}\n                          >\n                            Copy\n                          </button>\n                        </div>\n                      </div>\n                    `\n                  : html`\n                      <p class=\"text-xs text-status-warning\">\n                        Always use auth headers when possible. Query string is\n                        less secure.\n                      </p>\n                    `}\n            </div>`}\n\n        ${isWebhookLoading || webhookLoadError || selectedWebhookManaged || !hasOauthCallback\n          ? null\n          : html`\n              <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n                <div class=\"flex items-center gap-2\">\n                  <p class=\"text-xs text-fg-muted\">OAuth Callback URL</p>\n                  ${hasOauthCallback\n                    ? html`<${Badge} tone=\"neutral\">OAuth alias</${Badge}>`\n                    : null}\n                </div>\n                <div class=\"flex items-center gap-2\">\n                  <input\n                    type=\"text\"\n                    readonly\n                    value=${hasOauthCallback ? oauthCallbackUrl : \"Not enabled\"}\n                    class=\"h-8 flex-1 bg-field border border-border rounded-lg px-3 text-xs text-body outline-none font-mono\"\n                  />\n                  ${hasOauthCallback\n                    ? html`\n                        <button\n                          class=\"h-8 text-xs px-2.5 rounded-lg ac-btn-secondary shrink-0\"\n                          onclick=${async () => {\n                            try {\n                              await navigator.clipboard.writeText(\n                                oauthCallbackUrl,\n                              );\n                              showToast(\"OAuth callback URL copied\", \"success\");\n                            } catch {\n                              showToast(\"Could not copy URL\", \"error\");\n                            }\n                          }}\n                        >\n                          Copy\n                        </button>\n                      `\n                    : null}\n                </div>\n                <div class=\"flex items-center justify-start gap-3 flex-wrap\">\n                  <div class=\"flex items-center gap-2\">\n                    <button\n                      class=\"h-8 text-xs px-2.5 rounded-lg ac-btn-secondary disabled:opacity-60\"\n                      onclick=${() => {\n                        if (rotatingOauthCallback) return;\n                        actions.setShowRotateOauthConfirm(true);\n                      }}\n                      disabled=${rotatingOauthCallback}\n                    >\n                      ${rotatingOauthCallback ? \"Rotating...\" : \"Rotate\"}\n                    </button>\n                  </div>\n                  <p class=\"text-xs text-status-warning\">\n                    Keep this URL private. Rotate if exposed.\n                  </p>\n                </div>\n              </div>\n            `}\n\n        <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n          ${selectedWebhookManaged\n            ? html`\n                <p class=\"text-xs text-fg-muted\">Deliver to</p>\n                <p class=\"text-xs text-body font-mono\">\n                  ${selectedDeliveryAgentName}${\" \"}\n                  <span class=\"text-xs text-fg-muted font-mono\"\n                    >(${selectedDeliveryChannel})</span\n                  >\n                </p>\n              `\n            : html`\n                <p class=\"text-xs text-fg-muted\">Deliver to</p>\n                <div class=\"flex items-center gap-2\">\n                  <select\n                    value=${destinationSessionKey || kNoDestinationSessionValue}\n                    onInput=${(event) => {\n                      const nextValue = String(event.currentTarget?.value || \"\");\n                      actions.setDestinationSessionKey(\n                        nextValue === kNoDestinationSessionValue ? \"\" : nextValue,\n                      );\n                    }}\n                    disabled=${loadingDestinationSessions || savingDestination}\n                    class=\"h-8 flex-1 bg-field border border-border rounded-lg px-3 text-xs text-body focus:border-fg-muted\"\n                  >\n                    <option value=${kNoDestinationSessionValue}>Default</option>\n                    ${loadingDestinationSessions\n                      ? html`<option value=\"\" disabled>Loading...</option>`\n                      : selectableSessions.map(\n                          (sessionRow) => html`\n                            <option value=${getSessionRowKey(sessionRow)}>\n                              ${String(\n                                getSessionDisplayLabel(sessionRow) ||\n                                getSessionRowKey(sessionRow) ||\n                                \"Session\",\n                              )}\n                            </option>\n                          `,\n                        )}\n                  </select>\n                  <${ActionButton}\n                    onClick=${actions.handleSaveDestination}\n                    disabled=${!destinationDirty || savingDestination}\n                    loading=${savingDestination}\n                    tone=\"secondary\"\n                    size=\"sm\"\n                    idleLabel=\"Save\"\n                    loadingLabel=\"Saving...\"\n                    className=\"px-2.5 py-1\"\n                  />\n                </div>\n                ${destinationLoadError\n                  ? html`<p class=\"text-xs text-status-error-muted\">${destinationLoadError}</p>`\n                  : null}\n              `}\n        </div>\n\n        <div class=\"bg-field border border-border rounded-lg p-3 space-y-2\">\n          <p class=\"text-xs text-fg-muted\">Test webhook</p>\n          <div class=\"flex flex-col gap-2 sm:flex-row sm:items-center\">\n            <input\n              type=\"text\"\n              readonly\n              value=${activeCurlCommand}\n              class=\"h-8 w-full sm:flex-1 sm:min-w-0 bg-field border border-border rounded-lg px-3 text-xs text-body outline-none font-mono overflow-x-auto scrollbar-hidden\"\n            />\n            <div class=\"grid grid-cols-2 gap-2 w-full sm:w-auto sm:flex sm:items-center\">\n              <button\n                class=\"h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0\"\n                onclick=${async () => {\n                  try {\n                    await navigator.clipboard.writeText(activeCurlCommand);\n                    showToast(\"curl command copied\", \"success\");\n                  } catch {\n                    showToast(\"Could not copy curl command\", \"error\");\n                  }\n                }}\n              >\n                Copy\n              </button>\n              <button\n                class=\"h-8 text-xs px-2.5 rounded-lg ac-btn-secondary w-full sm:w-auto sm:shrink-0 disabled:opacity-60\"\n                onclick=${actions.handleSendTestWebhook}\n                disabled=${sendingTestWebhook}\n              >\n                ${sendingTestWebhook ? \"Sending...\" : \"Send\"}\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"bg-field border border-border rounded-lg p-3\">\n          <div class=\"flex items-center gap-2 text-xs text-body\">\n            <span class=\"text-fg-muted\">Transform:</span>\n            ${selectedWebhook?.transformPath\n              ? html`<button\n                  type=\"button\"\n                  class=\"ac-tip-link flex-1 min-w-0 truncate block text-left font-mono\"\n                  title=${selectedWebhook.transformPath}\n                  onclick=${() => onOpenFile(selectedWebhook.transformPath)}\n                >\n                  ${selectedWebhook.transformPath}\n                </button>`\n              : html`<code class=\"flex-1 min-w-0 truncate block\">—</code>`}\n            <span\n              class=${`ml-auto inline-flex items-center gap-1 px-1.5 py-0.5 rounded border font-sans ${\n                selectedWebhook?.transformExists\n                  ? \"border-green-500/30 text-status-success bg-green-500/10\"\n                  : \"border-yellow-500/30 text-status-warning bg-yellow-500/10\"\n              }`}\n            >\n              <span class=\"font-sans text-sm leading-none\">\n                ${selectedWebhook?.transformExists ? \"✓\" : \"!\"}\n              </span>\n              ${selectedWebhook?.transformExists ? null : html`<span>missing</span>`}\n            </span>\n          </div>\n        </div>\n\n        <div class=\"flex items-center justify-between gap-3\">\n          <p class=\"text-xs text-fg-dim\">\n            Created: ${formatDateTime(selectedWebhook?.createdAt)}\n          </p>\n          ${selectedWebhookManaged\n            ? null\n            : html`<${ActionButton}\n                onClick=${() => {\n                  if (deleting) return;\n                  actions.setDeleteTransformDir(true);\n                  actions.setShowDeleteConfirm(true);\n                }}\n                disabled=${deleting}\n                loading=${deleting}\n                tone=\"danger\"\n                size=\"sm\"\n                idleLabel=\"Delete\"\n                loadingLabel=\"Deleting...\"\n                className=\"shrink-0 px-2.5 py-1\"\n              />`}\n        </div>\n      </div>\n\n      ${selectedWebhookManaged && !isWebhookLoading && !webhookLoadError\n        ? html`\n            <div class=\"rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3\">\n              <p class=\"text-xs text-status-warning\">\n                This webhook is managed by Gmail Watch setup and cannot be\n                deleted or edited from this page.\n              </p>\n            </div>\n          `\n        : null}\n      <${RequestHistory}\n        selectedHookName=${selectedHookName}\n        selectedWebhook=${selectedWebhook}\n        effectiveAuthMode=${effectiveAuthMode}\n        webhookUrl=${webhookUrl}\n        webhookUrlWithQueryToken=${webhookUrlWithQueryToken}\n        bearerTokenValue=${bearerTokenValue}\n        refreshNonce=${historyRefreshNonce}\n      />\n      <${ConfirmDialog}\n        visible=${showRotateOauthConfirm &&\n        !!selectedHookName &&\n        !selectedWebhookManaged &&\n        hasOauthCallback}\n        title=\"Rotate OAuth callback?\"\n        message=\"Rotating will generate a new callback URL and immediately invalidate the current URL.\"\n        confirmLabel=\"Rotate callback URL\"\n        confirmLoadingLabel=\"Rotating...\"\n        confirmLoading=${rotatingOauthCallback}\n        cancelLabel=\"Cancel\"\n        onCancel=${() => {\n          if (rotatingOauthCallback) return;\n          actions.setShowRotateOauthConfirm(false);\n        }}\n        onConfirm=${actions.handleRotateOauthCallback}\n      />\n      <${ConfirmDialog}\n        visible=${showDeleteConfirm &&\n        !!selectedHookName &&\n        !selectedWebhookManaged}\n        title=\"Delete webhook?\"\n        message=${`This removes \"/hooks/${selectedHookName}\" from openclaw.json.`}\n        details=${html`\n          <div class=\"rounded-lg border border-border bg-field p-3\">\n            <label class=\"flex items-center gap-2 text-xs text-body select-none\">\n              <input\n                type=\"checkbox\"\n                checked=${deleteTransformDir}\n                onInput=${(event) =>\n                  actions.setDeleteTransformDir(!!event.target.checked)}\n              />\n              Also delete <code>hooks/transforms/${selectedHookName}</code>\n            </label>\n          </div>\n        `}\n        confirmLabel=\"Delete webhook\"\n        confirmLoadingLabel=\"Deleting...\"\n        confirmLoading=${deleting}\n        cancelLabel=\"Cancel\"\n        onCancel=${() => {\n          if (deleting) return;\n          actions.setDeleteTransformDir(true);\n          actions.setShowDeleteConfirm(false);\n        }}\n        onConfirm=${actions.handleDeleteConfirmed}\n      />\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/webhook-detail/use-webhook-detail.js",
    "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"preact/hooks\";\nimport {\n  deleteWebhook,\n  fetchAgents,\n  fetchWebhookDetail,\n  rotateWebhookOauthCallback,\n  updateWebhookDestination,\n} from \"../../../lib/api.js\";\nimport {\n  useDestinationSessionSelection,\n} from \"../../../hooks/use-destination-session-selection.js\";\nimport { useCachedFetch } from \"../../../hooks/use-cached-fetch.js\";\nimport {\n  getAgentIdFromSessionKey,\n  getSessionRowKey,\n} from \"../../../lib/session-keys.js\";\nimport { showToast } from \"../../toast.js\";\nimport { formatAgentFallbackName } from \"../helpers.js\";\n\nconst getWebhookDestination = (webhook = null) => {\n  const channel = String(webhook?.channel || \"\").trim();\n  const to = String(webhook?.to || \"\").trim();\n  if (!channel || !to) return null;\n  const agentId = String(webhook?.agentId || \"\").trim();\n  return {\n    channel,\n    to,\n    ...(agentId ? { agentId } : {}),\n  };\n};\n\nconst findDestinationSessionKey = (sessions = [], webhook = null) => {\n  const destination = getWebhookDestination(webhook);\n  if (!destination) return \"\";\n  const destinationAgentId = String(destination?.agentId || \"\").trim();\n  const matchingSession = sessions.find((sessionRow) => {\n    const channel = String(sessionRow?.replyChannel || \"\").trim();\n    const to = String(sessionRow?.replyTo || \"\").trim();\n    const agentId = getAgentIdFromSessionKey(getSessionRowKey(sessionRow));\n    const agentMatches = destinationAgentId ? agentId === destinationAgentId : true;\n    return (\n      channel === destination.channel &&\n      to === destination.to &&\n      agentMatches\n    );\n  });\n  return String(matchingSession?.key || \"\").trim();\n};\n\nconst areDestinationsEqual = (left = null, right = null) => {\n  if (!left && !right) return true;\n  if (!left || !right) return false;\n  return (\n    String(left.channel || \"\").trim() === String(right.channel || \"\").trim() &&\n    String(left.to || \"\").trim() === String(right.to || \"\").trim() &&\n    String(left.agentId || \"\").trim() === String(right.agentId || \"\").trim()\n  );\n};\n\nexport const useWebhookDetail = ({\n  selectedHookName = \"\",\n  onBackToList = () => {},\n  onRestartRequired = () => {},\n  onTestWebhookSent = () => {},\n}) => {\n  const [authMode, setAuthMode] = useState(\"headers\");\n  const [deleting, setDeleting] = useState(false);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [deleteTransformDir, setDeleteTransformDir] = useState(true);\n  const [rotatingOauthCallback, setRotatingOauthCallback] = useState(false);\n  const [showRotateOauthConfirm, setShowRotateOauthConfirm] = useState(false);\n  const [sendingTestWebhook, setSendingTestWebhook] = useState(false);\n  const [savingDestination, setSavingDestination] = useState(false);\n\n  const detailCacheKey = useMemo(\n    () => `/api/webhooks/${encodeURIComponent(String(selectedHookName || \"\"))}`,\n    [selectedHookName],\n  );\n  const detailFetchState = useCachedFetch(\n    detailCacheKey,\n    async () => {\n      if (!selectedHookName) return null;\n      const data = await fetchWebhookDetail(selectedHookName);\n      return data.webhook || null;\n    },\n    {\n      enabled: !!selectedHookName,\n      maxAgeMs: 15000,\n    },\n  );\n  const agentsFetchState = useCachedFetch(\"/api/agents\", fetchAgents, {\n    enabled: true,\n    maxAgeMs: 30000,\n  });\n\n  const agents = Array.isArray(agentsFetchState.data?.agents)\n    ? agentsFetchState.data.agents\n    : [];\n  const agentNameById = useMemo(\n    () =>\n      new Map(\n        agents.map((agent) => [\n          String(agent?.id || \"\").trim(),\n          String(agent?.name || \"\").trim() || formatAgentFallbackName(agent?.id),\n        ]),\n      ),\n    [agents],\n  );\n\n  const selectedWebhook = detailFetchState.data;\n  const isWebhookLoading = !!selectedHookName && detailFetchState.loading;\n  const webhookLoadError = detailFetchState.error;\n  const selectedWebhookManaged = Boolean(selectedWebhook?.managed);\n  const selectedDeliveryAgentId =\n    String(selectedWebhook?.agentId || \"main\").trim() || \"main\";\n  const selectedDeliveryAgentName =\n    agentNameById.get(selectedDeliveryAgentId) ||\n    formatAgentFallbackName(selectedDeliveryAgentId);\n  const selectedDeliveryChannel =\n    String(selectedWebhook?.channel || \"last\").trim() || \"last\";\n  const destinationResetKey = useMemo(\n    () =>\n      [\n        selectedHookName,\n        selectedWebhook?.agentId,\n        selectedWebhook?.channel,\n        selectedWebhook?.to,\n      ]\n        .map((value) => String(value || \"\").trim())\n        .join(\"|\"),\n    [\n      selectedHookName,\n      selectedWebhook?.agentId,\n      selectedWebhook?.channel,\n      selectedWebhook?.to,\n    ],\n  );\n  const {\n    sessions: selectableSessions,\n    loading: loadingDestinationSessions,\n    error: destinationLoadError,\n    destinationSessionKey,\n    setDestinationSessionKey,\n    selectedDestination,\n  } = useDestinationSessionSelection({\n    enabled: !!selectedHookName && !selectedWebhookManaged,\n    resetKey: destinationResetKey,\n  });\n\n  const webhookUrl = selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;\n  const oauthCallbackUrl = String(selectedWebhook?.oauthCallbackUrl || \"\").trim();\n  const hasOauthCallback = !!oauthCallbackUrl;\n  const oauthCallbackTestUrl = useMemo(() => {\n    if (!hasOauthCallback) return \"\";\n    try {\n      const url = new URL(oauthCallbackUrl);\n      if (!url.searchParams.has(\"code\")) {\n        url.searchParams.set(\"code\", \"TEST_AUTH_CODE\");\n      }\n      if (!url.searchParams.has(\"state\")) {\n        url.searchParams.set(\"state\", \"TEST_STATE\");\n      }\n      if (!url.searchParams.has(\"message\")) {\n        url.searchParams.set(\"message\", \"OAuth callback test\");\n      }\n      return url.toString();\n    } catch {\n      const separator = oauthCallbackUrl.includes(\"?\") ? \"&\" : \"?\";\n      return `${oauthCallbackUrl}${separator}code=TEST_AUTH_CODE&state=TEST_STATE&message=OAuth%20callback%20test`;\n    }\n  }, [hasOauthCallback, oauthCallbackUrl]);\n  const webhookUrlWithQueryToken =\n    selectedWebhook?.queryStringUrl ||\n    `${webhookUrl}${webhookUrl.includes(\"?\") ? \"&\" : \"?\"}token=<WEBHOOK_TOKEN>`;\n\n  const derivedTokenFromQuery = useMemo(() => {\n    try {\n      const parsed = new URL(webhookUrlWithQueryToken);\n      return String(parsed.searchParams.get(\"token\") || \"\").trim();\n    } catch {\n      return \"\";\n    }\n  }, [webhookUrlWithQueryToken]);\n\n  const authHeaderValue =\n    selectedWebhook?.authHeaderValue ||\n    (derivedTokenFromQuery\n      ? `Authorization: Bearer ${derivedTokenFromQuery}`\n      : \"Authorization: Bearer <WEBHOOK_TOKEN>\");\n  const bearerTokenValue = authHeaderValue.startsWith(\"Authorization: \")\n    ? authHeaderValue.slice(\"Authorization: \".length)\n    : authHeaderValue;\n\n  const webhookTestPayload = useMemo(() => {\n    if (\n      String(selectedHookName || \"\")\n        .trim()\n        .toLowerCase() === \"gmail\"\n    ) {\n      return {\n        payload: {\n          account: \"test@gmail.com\",\n          messages: [\n            {\n              id: \"test-message-1\",\n              from: \"alerts@example.com\",\n              to: [\"test@gmail.com\"],\n              subject: \"Test Gmail webhook event\",\n              snippet:\n                \"This is a simulated Gmail message payload for webhook testing.\",\n              receivedAt: new Date().toISOString(),\n            },\n          ],\n        },\n      };\n    }\n    return {\n      source: \"manual-test\",\n      message: `This is a test of the ${selectedHookName || \"webhook\"} webhook.`,\n    };\n  }, [selectedHookName]);\n\n  const webhookTestPayloadJson = JSON.stringify(webhookTestPayload);\n  const curlCommandHeaders =\n    `curl -X POST \"${webhookUrl}\" ` +\n    `-H \"Content-Type: application/json\" ` +\n    `-H \"${authHeaderValue}\" ` +\n    `-d '${webhookTestPayloadJson}'`;\n  const curlCommandQuery =\n    `curl -X POST \"${webhookUrlWithQueryToken}\" ` +\n    `-H \"Content-Type: application/json\" ` +\n    `-d '${webhookTestPayloadJson}'`;\n  const curlCommandOauth = `curl -X GET \"${oauthCallbackTestUrl}\"`;\n\n  const effectiveAuthMode = selectedWebhookManaged ? \"headers\" : authMode;\n  const activeCurlCommand = hasOauthCallback\n    ? curlCommandOauth\n    : effectiveAuthMode === \"query\"\n      ? curlCommandQuery\n      : curlCommandHeaders;\n\n  const refreshDetail = useCallback(() => {\n    detailFetchState.refresh({ force: true });\n    agentsFetchState.refresh({ force: true });\n  }, [agentsFetchState.refresh, detailFetchState.refresh]);\n\n  useEffect(() => {\n    if (!selectedHookName || selectedWebhookManaged || !selectedWebhook) return;\n    if (!Array.isArray(selectableSessions) || selectableSessions.length <= 0) {\n      setDestinationSessionKey(\"\");\n      return;\n    }\n    const nextKey = findDestinationSessionKey(selectableSessions, selectedWebhook);\n    setDestinationSessionKey(nextKey);\n  }, [\n    selectableSessions,\n    selectedHookName,\n    selectedWebhook,\n    selectedWebhookManaged,\n    setDestinationSessionKey,\n  ]);\n\n  const currentDestination = useMemo(\n    () => getWebhookDestination(selectedWebhook),\n    [selectedWebhook],\n  );\n  const destinationDirty = useMemo(\n    () => !areDestinationsEqual(currentDestination, selectedDestination),\n    [currentDestination, selectedDestination],\n  );\n\n  const handleSaveDestination = useCallback(async () => {\n    if (\n      !selectedHookName ||\n      selectedWebhookManaged ||\n      savingDestination ||\n      !destinationDirty\n    ) {\n      return;\n    }\n    setSavingDestination(true);\n    try {\n      const data = await updateWebhookDestination(selectedHookName, {\n        destination: selectedDestination || null,\n      });\n      if (data?.restartRequired) {\n        onRestartRequired(true);\n      }\n      if (data?.syncWarning) {\n        showToast(`Updated, but git-sync failed: ${data.syncWarning}`, \"warning\");\n      }\n      showToast(\"Webhook destination updated\", \"success\");\n      refreshDetail();\n    } catch (err) {\n      showToast(err.message || \"Could not update webhook destination\", \"error\");\n    } finally {\n      setSavingDestination(false);\n    }\n  }, [\n    destinationDirty,\n    onRestartRequired,\n    refreshDetail,\n    savingDestination,\n    selectedDestination,\n    selectedHookName,\n    selectedWebhookManaged,\n  ]);\n\n  const handleSendTestWebhook = useCallback(async () => {\n    if (!selectedHookName || sendingTestWebhook) return;\n    setSendingTestWebhook(true);\n    try {\n      const response = hasOauthCallback\n        ? await fetch(oauthCallbackTestUrl, {\n            method: \"GET\",\n          })\n        : await fetch(\n            effectiveAuthMode === \"query\" ? webhookUrlWithQueryToken : webhookUrl,\n            {\n              method: \"POST\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n                ...(effectiveAuthMode === \"headers\"\n                  ? { Authorization: bearerTokenValue }\n                  : {}),\n              },\n              body: webhookTestPayloadJson,\n            },\n          );\n      onTestWebhookSent();\n      const bodyText = await response.text();\n      let body = null;\n      try {\n        body = bodyText ? JSON.parse(bodyText) : null;\n      } catch {\n        body = null;\n      }\n      const errorMessage =\n        body?.ok === false\n          ? body?.error || \"Webhook rejected\"\n          : !response.ok\n            ? body?.error || bodyText || `HTTP ${response.status}`\n            : \"\";\n      if (errorMessage) {\n        showToast(`Test webhook failed: ${errorMessage}`, \"error\");\n        return;\n      }\n      showToast(\"Test webhook sent\", \"success\");\n    } catch (err) {\n      showToast(err.message || \"Could not send test webhook\", \"error\");\n    } finally {\n      setSendingTestWebhook(false);\n    }\n  }, [\n    bearerTokenValue,\n    effectiveAuthMode,\n    hasOauthCallback,\n    oauthCallbackTestUrl,\n    onTestWebhookSent,\n    selectedHookName,\n    sendingTestWebhook,\n    webhookTestPayloadJson,\n    webhookUrl,\n    webhookUrlWithQueryToken,\n  ]);\n\n  const handleDeleteConfirmed = useCallback(async () => {\n    if (!selectedHookName || deleting) return;\n    setDeleting(true);\n    try {\n      const data = await deleteWebhook(selectedHookName, {\n        deleteTransformDir,\n      });\n      if (data.restartRequired) onRestartRequired(true);\n      onBackToList();\n      setShowDeleteConfirm(false);\n      setDeleteTransformDir(true);\n      showToast(\"Webhook removed\", \"success\");\n      if (data.deletedTransformDir) {\n        showToast(\"Transform directory deleted\", \"success\");\n      }\n      if (data.syncWarning) {\n        showToast(`Deleted, but git-sync failed: ${data.syncWarning}`, \"warning\");\n      }\n      refreshDetail();\n    } catch (err) {\n      showToast(err.message || \"Could not delete webhook\", \"error\");\n    } finally {\n      setDeleting(false);\n    }\n  }, [\n    deleteTransformDir,\n    deleting,\n    onBackToList,\n    onRestartRequired,\n    refreshDetail,\n    selectedHookName,\n  ]);\n\n  const handleRotateOauthCallback = useCallback(async () => {\n    if (!selectedHookName || rotatingOauthCallback) return;\n    setRotatingOauthCallback(true);\n    try {\n      await rotateWebhookOauthCallback(selectedHookName);\n      showToast(\"OAuth callback rotated\", \"success\");\n      setShowRotateOauthConfirm(false);\n      refreshDetail();\n    } catch (err) {\n      showToast(err.message || \"Could not rotate OAuth callback\", \"error\");\n    } finally {\n      setRotatingOauthCallback(false);\n    }\n  }, [refreshDetail, rotatingOauthCallback, selectedHookName]);\n\n  return {\n    state: {\n      authMode,\n      selectedWebhook,\n      isWebhookLoading,\n      webhookLoadError,\n      selectedWebhookManaged,\n      selectedDeliveryAgentName,\n      selectedDeliveryChannel,\n      selectableSessions,\n      loadingDestinationSessions,\n      destinationLoadError,\n      destinationSessionKey,\n      destinationDirty,\n      savingDestination,\n      webhookUrl,\n      oauthCallbackUrl,\n      hasOauthCallback,\n      webhookUrlWithQueryToken,\n      authHeaderValue,\n      bearerTokenValue,\n      effectiveAuthMode,\n      activeCurlCommand,\n      deleting,\n      showDeleteConfirm,\n      deleteTransformDir,\n      rotatingOauthCallback,\n      showRotateOauthConfirm,\n      sendingTestWebhook,\n    },\n    actions: {\n      refreshDetail,\n      setAuthMode,\n      setDestinationSessionKey,\n      setShowDeleteConfirm,\n      setDeleteTransformDir,\n      setShowRotateOauthConfirm,\n      handleSaveDestination,\n      handleDeleteConfirmed,\n      handleRotateOauthCallback,\n      handleSendTestWebhook,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/webhook-list/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { Badge } from \"../../badge.js\";\nimport { formatLastReceived, healthClassName } from \"../helpers.js\";\nimport { useWebhookList } from \"./use-webhook-list.js\";\n\nconst html = htm.bind(h);\n\nexport const WebhookList = ({\n  onSelectHook = () => {},\n}) => {\n  const { state, actions } = useWebhookList({ onSelectHook });\n\n  const { webhooks, isListLoading } = state;\n\n  return html`\n    <div class=\"bg-surface border border-border rounded-xl p-4 space-y-4\">\n      ${isListLoading\n        ? html`<p class=\"text-xs text-fg-muted\">Loading webhooks...</p>`\n        : null}\n      ${!isListLoading && webhooks.length === 0\n        ? html`<p class=\"text-sm text-fg-muted\">\n            No webhooks configured yet. Create one to get started.\n          </p>`\n        : null}\n      ${webhooks.length > 0\n        ? html`\n            <div class=\"overflow-auto\">\n              <table class=\"w-full text-sm\">\n                <thead>\n                  <tr class=\"text-left text-xs text-fg-muted border-b border-border\">\n                    <th class=\"pb-2 pr-3\">Path</th>\n                    <th class=\"pb-2 pr-3\">Last received</th>\n                    <th class=\"pb-2 pr-3\">Errors</th>\n                    <th class=\"pb-2 pr-3\">Health</th>\n                    <th class=\"pb-2 pr-3\">Type</th>\n                  </tr>\n                </thead>\n                <tbody>\n                  <tr aria-hidden=\"true\">\n                    <td class=\"h-2 p-0\" colspan=\"5\"></td>\n                  </tr>\n                  ${webhooks.map(\n                    (item) => html`\n                      <tr\n                        class=\"group cursor-pointer\"\n                        onclick=${() => actions.handleSelectHook(item.name)}\n                      >\n                        <td\n                          class=\"px-3 py-2.5 group-hover:bg-surface first:rounded-l-lg transition-colors\"\n                        >\n                          <code>${item.path || `/hooks/${item.name}`}</code>\n                        </td>\n                        <td\n                          class=\"px-3 py-2.5 text-xs text-fg-muted group-hover:bg-surface transition-colors\"\n                        >\n                          ${formatLastReceived(item.lastReceived)}\n                        </td>\n                        <td\n                          class=\"px-3 py-2.5 text-xs group-hover:bg-surface transition-colors\"\n                        >\n                          ${item.errorCount || 0}\n                        </td>\n                        <td\n                          class=\"px-3 py-2.5 group-hover:bg-surface last:rounded-r-lg transition-colors\"\n                        >\n                          <span\n                            class=\"inline-block w-2.5 h-2.5 rounded-full ${healthClassName(\n                              item.health,\n                            )}\"\n                            title=${item.health}\n                          />\n                        </td>\n                        <td\n                          class=\"px-3 py-2.5 text-xs text-fg-muted group-hover:bg-surface transition-colors\"\n                        >\n                          ${item.managed\n                            ? html`<span\n                                class=\"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] bg-cyan-500/10 text-status-info\"\n                                >Managed</span\n                              >`\n                            : item.oauthCallbackEnabled\n                              ? html`<${Badge} tone=\"neutral\">OAuth</${Badge}>`\n                              : html`<${Badge} tone=\"neutral\">Custom</${Badge}>`}\n                        </td>\n                      </tr>\n                    `,\n                  )}\n                </tbody>\n              </table>\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/webhooks/webhook-list/use-webhook-list.js",
    "content": "import { useCallback } from \"preact/hooks\";\nimport { usePolling } from \"../../../hooks/usePolling.js\";\nimport { fetchWebhooks } from \"../../../lib/api.js\";\n\nexport const useWebhookList = ({\n  onSelectHook = () => {},\n}) => {\n  const listPoll = usePolling(fetchWebhooks, 15000);\n\n  const webhooks = listPoll.data?.webhooks || [];\n  const isListLoading = !listPoll.data && !listPoll.error;\n\n  const handleSelectHook = useCallback(\n    (name) => {\n      onSelectHook(name);\n    },\n    [onSelectHook],\n  );\n\n  return {\n    state: {\n      webhooks,\n      isListLoading,\n    },\n    actions: {\n      refreshList: listPoll.refresh,\n      handleSelectHook,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/components/welcome/index.js",
    "content": "import { h } from \"preact\";\nimport htm from \"htm\";\nimport { kWelcomeGroups } from \"../onboarding/welcome-config.js\";\nimport { WelcomePreStep } from \"../onboarding/welcome-pre-step.js\";\nimport { WelcomeImportStep } from \"../onboarding/welcome-import-step.js\";\nimport { WelcomePlaceholderReviewStep } from \"../onboarding/welcome-placeholder-review-step.js\";\nimport { WelcomeSecretReviewStep } from \"../onboarding/welcome-secret-review-step.js\";\nimport { WelcomeHeader } from \"../onboarding/welcome-header.js\";\nimport { WelcomeSetupStep } from \"../onboarding/welcome-setup-step.js\";\nimport { WelcomeFormStep } from \"../onboarding/welcome-form-step.js\";\nimport { WelcomePairingStep } from \"../onboarding/welcome-pairing-step.js\";\nimport { useWelcome } from \"./use-welcome.js\";\n\nconst html = htm.bind(h);\n\nexport const Welcome = ({ onComplete, acVersion }) => {\n  const { state, actions } = useWelcome({ onComplete });\n\n  return html`\n    <div class=\"max-w-lg w-full space-y-5\">\n      <${WelcomeHeader}\n        groups=${kWelcomeGroups}\n        step=${state.step}\n        isPreStep=${state.isPreStep}\n        isSetupStep=${state.isSetupStep}\n        isPairingStep=${state.isPairingStep}\n        stepNumber=${state.stepNumber}\n        activeStepLabel=${state.activeStepLabel}\n      />\n\n      <div class=\"bg-surface border border-border rounded-xl p-4 space-y-3\">\n        ${state.isPreStep\n          ? html`<${WelcomePreStep} onSelectFlow=${actions.handleSelectFlow} />`\n          : state.isImportStep\n          ? html`<${WelcomeImportStep}\n              scanResult=${state.importScanResult}\n              scanning=${state.importScanning}\n              error=${state.importError}\n              onApprove=${actions.handleImportApprove}\n              onShowSecretReview=${actions.handleShowSecretReview}\n              onBack=${actions.handleImportBack}\n            />`\n          : state.isSecretReviewStep\n            ? html`<${WelcomeSecretReviewStep}\n                secrets=${state.importScanResult?.secrets || []}\n                onApprove=${actions.handleImportApprove}\n                onBack=${actions.handleSecretReviewBack}\n                loading=${state.importScanning}\n                error=${state.importError}\n              />`\n            : state.isPlaceholderReviewStep\n              ? html`<${WelcomePlaceholderReviewStep}\n                  placeholderReview=${state.placeholderReview}\n                  vals=${state.vals}\n                  setValue=${actions.setValue}\n                  onContinue=${actions.handlePlaceholderReviewContinue}\n                />`\n              : state.isSetupStep\n                ? html`<${WelcomeSetupStep}\n                    error=${state.setupError}\n                    loading=${state.loading}\n                    onRetry=${actions.handleSubmit}\n                    onBack=${actions.goBackFromSetupError}\n                  />`\n                : state.isPairingStep\n                  ? html`<${WelcomePairingStep}\n                      channel=${state.selectedPairingChannel}\n                      pairings=${state.pairingRequestsPoll.data || []}\n                      loading=${!state.pairingStatusPoll.data}\n                      error=${state.pairingError}\n                      onApprove=${actions.handlePairingApprove}\n                      onReject=${actions.handlePairingReject}\n                      canFinish=${state.pairingComplete || state.canFinishPairing}\n                      onContinue=${actions.finishOnboarding}\n                      onSkip=${actions.finishOnboarding}\n                    />`\n                  : html`\n                      <${WelcomeFormStep}\n                        activeGroup=${state.activeGroup}\n                        vals=${state.vals}\n                        hasAi=${state.hasAi}\n                        setValue=${actions.setValue}\n                        modelOptions=${state.modelOptions}\n                        modelsLoading=${state.modelsLoading}\n                        modelsError=${state.modelsError}\n                        canToggleFullCatalog=${state.canToggleFullCatalog}\n                        showAllModels=${state.showAllModels}\n                        setShowAllModels=${actions.setShowAllModels}\n                        selectedProvider=${state.selectedProvider}\n                        codexLoading=${state.codexLoading}\n                        codexStatus=${state.codexStatus}\n                        startCodexAuth=${actions.startCodexAuth}\n                        handleCodexDisconnect=${actions.handleCodexDisconnect}\n                        codexAuthStarted=${state.codexAuthStarted}\n                        codexAuthWaiting=${state.codexAuthWaiting}\n                        codexManualInput=${state.codexManualInput}\n                        setCodexManualInput=${actions.setCodexManualInput}\n                        completeCodexAuth=${actions.completeCodexAuth}\n                        codexExchanging=${state.codexExchanging}\n                        visibleAiFieldKeys=${state.visibleAiFieldKeys}\n                        error=${state.formError}\n                        step=${state.step}\n                        totalGroups=${kWelcomeGroups.length}\n                        goBack=${actions.goBack}\n                        goNext=${actions.goNext}\n                        loading=${state.loading}\n                        githubStepLoading=${state.githubStepLoading}\n                        handleSubmit=${actions.handleSubmit}\n                      />\n                    `}\n      </div>\n      ${acVersion\n        ? html`\n            <div class=\"text-center text-xs text-fg-muted font-mono mt-8\">\n              v${acVersion}\n            </div>\n          `\n        : null}\n    </div>\n  `;\n};\n"
  },
  {
    "path": "lib/public/js/components/welcome/use-welcome.js",
    "content": "import { useCallback, useEffect, useState } from \"preact/hooks\";\nimport {\n  runOnboard,\n  verifyGithubOnboardingRepo,\n  scanImportRepo,\n  applyImport,\n  fetchModels,\n} from \"../../lib/api.js\";\nimport { useCachedFetch } from \"../../hooks/use-cached-fetch.js\";\nimport { usePolling } from \"../../hooks/usePolling.js\";\nimport {\n  getModelProvider,\n  getAuthProviderFromModelProvider,\n  getFeaturedModels,\n  getVisibleAiFieldKeys,\n  kProviderAuthFields,\n} from \"../../lib/model-config.js\";\nimport {\n  getInitialOnboardingModelKey,\n  getModelCatalogModels,\n  isModelCatalogRefreshing,\n  kModelCatalogCacheKey,\n  kModelCatalogPollIntervalMs,\n  preloadModelCatalog,\n} from \"../../lib/model-catalog.js\";\nimport {\n  kWelcomeGroups,\n  getWelcomeGroupError,\n  findFirstInvalidWelcomeGroup,\n  isValidGithubRepoInput,\n  kGithubFlowFresh,\n  kGithubFlowImport,\n  kGithubTargetRepoModeCreate,\n  kGithubTargetRepoModeExistingEmpty,\n  kRepoModeNew,\n  kRepoModeExisting,\n} from \"../onboarding/welcome-config.js\";\nimport { getPreferredPairingChannel } from \"../onboarding/pairing-utils.js\";\nimport {\n  kOnboardingStorageKey,\n  kPairingChannelKey,\n  useWelcomeStorage,\n} from \"../onboarding/use-welcome-storage.js\";\nimport { useWelcomeCodex } from \"../onboarding/use-welcome-codex.js\";\nimport { useWelcomePairing } from \"../onboarding/use-welcome-pairing.js\";\nimport { buildApprovedImportVals } from \"../onboarding/welcome-secret-review-utils.js\";\n\nconst kMaxOnboardingVars = 64;\nconst kMaxEnvKeyLength = 128;\nconst kMaxEnvValueLength = 4096;\nexport const kImportStepId = \"import\";\nexport const kSecretReviewStepId = \"secret-review\";\nexport const kPlaceholderReviewStepId = \"placeholder-review\";\nconst kImportSubstepKey = \"_IMPORT_SUBSTEP\";\nconst kImportPlaceholderReviewKey = \"_IMPORT_PLACEHOLDER_REVIEW\";\nconst kImportPlaceholderSkipConfirmedKey = \"_IMPORT_PLACEHOLDER_SKIP_CONFIRMED\";\n\nconst normalizeOnboardingVals = (currentVals = {}) => {\n  let didChange = false;\n  const normalizedEntries = Object.entries(currentVals).map(([key, value]) => {\n    const normalizedValue = typeof value === \"string\" ? value.trim() : value;\n    if (normalizedValue !== value) didChange = true;\n    return [key, normalizedValue];\n  });\n  return {\n    normalizedVals: didChange ? Object.fromEntries(normalizedEntries) : currentVals,\n    didChange,\n  };\n};\n\nconst normalizePlaceholderReview = (review) => {\n  if (!review || !Array.isArray(review.vars) || review.vars.length === 0) {\n    return { found: false, count: 0, vars: [] };\n  }\n  return {\n    found: true,\n    count:\n      typeof review.count === \"number\" ? review.count : review.vars.length,\n    vars: review.vars\n      .map((item) => ({\n        key: String(item?.key || \"\").trim(),\n        status: String(item?.status || \"missing\").trim() || \"missing\",\n      }))\n      .filter((item) => item.key),\n  };\n};\n\nexport const useWelcome = ({ onComplete }) => {\n  const kSetupStepIndex = kWelcomeGroups.length;\n  const kPairingStepIndex = kSetupStepIndex + 1;\n  const {\n    vals,\n    setVals,\n    setValue: setStoredValue,\n    step,\n    setStep,\n    setupError,\n    setSetupError,\n  } = useWelcomeStorage({\n    kSetupStepIndex,\n    kPairingStepIndex,\n  });\n  const [models, setModels] = useState([]);\n  const [modelsLoading, setModelsLoading] = useState(true);\n  const [modelsError, setModelsError] = useState(null);\n  const [modelsRefreshing, setModelsRefreshing] = useState(false);\n  const [showAllModels, setShowAllModels] = useState(false);\n  const [loading, setLoading] = useState(false);\n  const [githubStepLoading, setGithubStepLoading] = useState(false);\n  const [formError, setFormError] = useState(null);\n  const {\n    codexStatus,\n    codexLoading,\n    codexManualInput,\n    setCodexManualInput,\n    codexExchanging,\n    codexAuthStarted,\n    codexAuthWaiting,\n    startCodexAuth,\n    completeCodexAuth,\n    handleCodexDisconnect,\n  } = useWelcomeCodex({ setFormError });\n  const [importStep, setImportStepState] = useState(() => {\n    const storedStep = String(vals[kImportSubstepKey] || \"\").trim();\n    return storedStep === kPlaceholderReviewStepId\n      ? storedStep\n      : null;\n  });\n  const [importTempDir, setImportTempDir] = useState(null);\n  const [importScanResult, setImportScanResult] = useState(null);\n  const [importScanning, setImportScanning] = useState(false);\n  const [importError, setImportError] = useState(null);\n  const modelsFetchState = useCachedFetch(kModelCatalogCacheKey, fetchModels, {\n    maxAgeMs: 30000,\n  });\n  const modelsPoll = usePolling(fetchModels, kModelCatalogPollIntervalMs, {\n    enabled: modelsRefreshing,\n    pauseWhenHidden: true,\n    cacheKey: kModelCatalogCacheKey,\n  });\n\n  useEffect(() => {\n    // Warm the real catalog immediately so the AI step usually opens ready.\n    preloadModelCatalog().catch(() => {});\n  }, []);\n\n  const setValue = (key, value) => {\n    if (formError) setFormError(null);\n    setStoredValue(key, value);\n  };\n\n  const setImportStep = (nextStep) => {\n    setImportStepState(nextStep);\n    setVals((prev) => ({\n      ...prev,\n      [kImportSubstepKey]:\n        nextStep === kPlaceholderReviewStepId ? nextStep : \"\",\n    }));\n  };\n\n  const clearPlaceholderReview = () => {\n    setVals((prev) => ({\n      ...prev,\n      [kImportPlaceholderReviewKey]: null,\n      [kImportPlaceholderSkipConfirmedKey]: false,\n    }));\n  };\n\n  const applyModelCatalog = useCallback((payload) => {\n    const list = getModelCatalogModels(payload);\n    if (!payload) return;\n    const isRefreshing = isModelCatalogRefreshing(payload);\n    const isFallbackRefresh =\n      String(payload?.source || \"\") === \"fallback\" && isRefreshing;\n    setModels(list);\n    setModelsRefreshing(isRefreshing);\n    setModelsError(\n      list.length > 0\n        ? isFallbackRefresh\n          ? \"Loading full model catalog...\"\n          : null\n        : \"No models found\",\n    );\n    const defaultModelKey = getInitialOnboardingModelKey({\n      catalog: list,\n      currentModelKey: vals.MODEL_KEY,\n    });\n    if (!vals.MODEL_KEY && defaultModelKey) {\n      setVals((prev) => ({ ...prev, MODEL_KEY: defaultModelKey }));\n    }\n  }, [setVals, vals.MODEL_KEY]);\n\n  useEffect(() => {\n    applyModelCatalog(modelsFetchState.data);\n  }, [applyModelCatalog, modelsFetchState.data]);\n\n  useEffect(() => {\n    applyModelCatalog(modelsPoll.data);\n  }, [applyModelCatalog, modelsPoll.data]);\n\n  useEffect(() => {\n    const hasModels = getModelCatalogModels(modelsFetchState.data).length > 0;\n    setModelsLoading(\n      (modelsFetchState.loading || modelsPoll.isPolling) && !hasModels,\n    );\n  }, [modelsFetchState.data, modelsFetchState.loading, modelsPoll.isPolling]);\n\n  useEffect(() => {\n    if (!modelsFetchState.error) return;\n    setModelsError(\"Failed to load models\");\n    setModelsLoading(false);\n  }, [modelsFetchState.error]);\n\n  const getValidationContext = (currentVals = {}) => {\n    const currentSelectedProvider = getModelProvider(\n      String(currentVals.MODEL_KEY || \"\").trim(),\n    );\n    const currentSelectedAuthProvider =\n      getAuthProviderFromModelProvider(currentSelectedProvider);\n    const currentProviderAuthFields =\n      kProviderAuthFields[currentSelectedAuthProvider] || [];\n    const currentHasAi =\n      currentSelectedProvider === \"openai-codex\"\n        ? !!codexStatus.connected\n        : currentProviderAuthFields.some((field) =>\n            !!String(currentVals[field.key] || \"\").trim(),\n          );\n\n    return {\n      hasAi: currentHasAi,\n      selectedProvider: currentSelectedProvider,\n      codexLoading,\n    };\n  };\n\n  const validationContext = getValidationContext(vals);\n  const { selectedProvider, hasAi } = validationContext;\n  const placeholderReview = normalizePlaceholderReview(\n    vals[kImportPlaceholderReviewKey],\n  );\n  const featuredModels = getFeaturedModels(models);\n  const baseModelOptions = showAllModels\n    ? models\n    : featuredModels.length > 0\n      ? featuredModels\n      : models;\n  const selectedModelOption = models.find(\n    (model) => model.key === vals.MODEL_KEY,\n  );\n  const modelOptions =\n    selectedModelOption &&\n    !baseModelOptions.some((model) => model.key === selectedModelOption.key)\n      ? [...baseModelOptions, selectedModelOption]\n      : baseModelOptions;\n  const canToggleFullCatalog =\n    featuredModels.length > 0 && models.length > featuredModels.length;\n  const visibleAiFieldKeys = getVisibleAiFieldKeys(selectedProvider);\n  const isPreStep = step === -1;\n  const isSetupStep = step === kSetupStepIndex;\n  const isPairingStep = step === kPairingStepIndex;\n  const activeGroup = step >= 0 && step < kSetupStepIndex ? kWelcomeGroups[step] : null;\n  const selectedPairingChannel = String(\n    vals[kPairingChannelKey] || getPreferredPairingChannel(vals),\n  );\n  const {\n    pairingStatusPoll,\n    pairingRequestsPoll,\n    pairingChannels,\n    canFinishPairing,\n    pairingError,\n    pairingComplete,\n    handlePairingApprove,\n    handlePairingReject,\n    resetPairingState,\n  } = useWelcomePairing({\n    isPairingStep,\n    selectedPairingChannel,\n  });\n\n  const handleSubmit = async () => {\n    const { normalizedVals, didChange } = normalizeOnboardingVals(vals);\n    if (didChange) setVals(normalizedVals);\n    const submitValidationContext = getValidationContext(normalizedVals);\n    const invalidGroup = findFirstInvalidWelcomeGroup(\n      normalizedVals,\n      submitValidationContext,\n    );\n    if (invalidGroup) {\n      setFormError(\n        getWelcomeGroupError(\n          invalidGroup.id,\n          normalizedVals,\n          submitValidationContext,\n        ),\n      );\n      setSetupError(null);\n      setStep(kWelcomeGroups.findIndex((group) => group.id === invalidGroup.id));\n      return;\n    }\n    if (loading) return;\n    const vars = Object.entries(normalizedVals)\n      .filter(\n        ([key]) => key !== \"MODEL_KEY\" && !String(key || \"\").startsWith(\"_\"),\n      )\n      .filter(([, value]) => value)\n      .map(([key, value]) => ({ key, value }));\n    const preflightError = (() => {\n      if (!normalizedVals.MODEL_KEY || !String(normalizedVals.MODEL_KEY).includes(\"/\")) {\n        return \"A model selection is required\";\n      }\n      if (vars.length > kMaxOnboardingVars) {\n        return `Too many environment variables (max ${kMaxOnboardingVars})`;\n      }\n      for (const entry of vars) {\n        const key = String(entry?.key || \"\");\n        const value = String(entry?.value || \"\");\n        if (!key) return \"Each variable must include a key\";\n        if (key.length > kMaxEnvKeyLength) {\n          return `Variable key is too long: ${key.slice(0, 32)}...`;\n        }\n        if (value.length > kMaxEnvValueLength) {\n          return `Value too long for ${key} (max ${kMaxEnvValueLength} chars)`;\n        }\n      }\n      if (\n        !normalizedVals.GITHUB_TOKEN ||\n        !isValidGithubRepoInput(normalizedVals.GITHUB_WORKSPACE_REPO)\n      ) {\n        return 'Target repo must be in \"owner/repo\" format.';\n      }\n      if (\n        (normalizedVals._GITHUB_FLOW || kGithubFlowFresh) === kGithubFlowImport &&\n        !isValidGithubRepoInput(normalizedVals._GITHUB_SOURCE_REPO)\n      ) {\n        return 'Source repo must be in \"owner/repo\" format.';\n      }\n      return \"\";\n    })();\n    if (preflightError) {\n      setFormError(preflightError);\n      setSetupError(null);\n      setStep(\n        Math.max(\n          0,\n          kWelcomeGroups.findIndex((group) => group.id === \"github\"),\n        ),\n      );\n      return;\n    }\n    setStep(kSetupStepIndex);\n    setLoading(true);\n    setFormError(null);\n    setSetupError(null);\n    resetPairingState();\n\n    const wasImport =\n      (normalizedVals._GITHUB_FLOW || kGithubFlowFresh) === kGithubFlowImport;\n    try {\n      const result = await runOnboard(vars, normalizedVals.MODEL_KEY, {\n        importMode: wasImport,\n      });\n      if (!result.ok) throw new Error(result.error || \"Onboarding failed\");\n      const pairingChannel = getPreferredPairingChannel(normalizedVals);\n      if (!pairingChannel) {\n        throw new Error(\n          \"No channel credential configured for pairing.\",\n        );\n      }\n      setVals((prev) => ({\n        ...prev,\n        [kPairingChannelKey]: pairingChannel,\n      }));\n      setLoading(false);\n      setStep(kPairingStepIndex);\n      resetPairingState();\n      setSetupError(null);\n    } catch (err) {\n      console.error(\"Onboard error:\", err);\n      setSetupError(err.message || \"Onboarding failed\");\n      setLoading(false);\n    }\n  };\n\n  const finishOnboarding = () => {\n    localStorage.removeItem(kOnboardingStorageKey);\n    onComplete();\n  };\n\n  const goBack = () => {\n    if (isSetupStep) return;\n    setFormError(null);\n    setStep((prev) => Math.max(-1, prev - 1));\n  };\n\n  const goBackFromSetupError = () => {\n    setLoading(false);\n    setSetupError(null);\n    setStep(kWelcomeGroups.length - 1);\n  };\n\n  const goNext = async () => {\n    const { normalizedVals, didChange } = normalizeOnboardingVals(vals);\n    if (didChange) setVals(normalizedVals);\n    if (!activeGroup) return;\n    const stepValidationContext = getValidationContext(normalizedVals);\n    const stepValidationError = getWelcomeGroupError(\n      activeGroup.id,\n      normalizedVals,\n      stepValidationContext,\n    );\n    if (stepValidationError) {\n      setFormError(stepValidationError);\n      return;\n    }\n    setFormError(null);\n    if (activeGroup.id === \"github\") {\n      const githubFlow = normalizedVals._GITHUB_FLOW || kGithubFlowFresh;\n      const targetRepoMode =\n        githubFlow === kGithubFlowImport\n          ? kGithubTargetRepoModeCreate\n          : normalizedVals._GITHUB_TARGET_REPO_MODE ||\n            kGithubTargetRepoModeCreate;\n      const targetVerifyMode =\n        targetRepoMode === kGithubTargetRepoModeExistingEmpty\n          ? kRepoModeExisting\n          : kRepoModeNew;\n      const sourceRepo =\n        githubFlow === kGithubFlowImport\n          ? normalizedVals._GITHUB_SOURCE_REPO\n          : normalizedVals.GITHUB_WORKSPACE_REPO;\n      setGithubStepLoading(true);\n      clearPlaceholderReview();\n      try {\n        if (githubFlow === kGithubFlowImport) {\n          const sourceResult = await verifyGithubOnboardingRepo(\n            sourceRepo,\n            normalizedVals.GITHUB_TOKEN,\n            kRepoModeExisting,\n          );\n          if (!sourceResult?.ok) {\n            setFormError(sourceResult?.error || \"GitHub source verification failed\");\n            return;\n          }\n          if (sourceResult.repoIsEmpty) {\n            setFormError(\n              \"That source repository is empty. Use Start fresh if you want AlphaClaw to bootstrap a new setup there.\",\n            );\n            return;\n          }\n          const targetResult = await verifyGithubOnboardingRepo(\n            normalizedVals.GITHUB_WORKSPACE_REPO,\n            normalizedVals.GITHUB_TOKEN,\n            kRepoModeNew,\n          );\n          if (!targetResult?.ok) {\n            setFormError(targetResult?.error || \"GitHub target verification failed\");\n            return;\n          }\n          if (\n            targetRepoMode === kGithubTargetRepoModeCreate &&\n            targetResult.repoExists\n          ) {\n            setFormError(\n              \"That target repository already exists. Choose Use existing empty repo or pick a new target repo name.\",\n            );\n            return;\n          }\n          if (\n            targetRepoMode === kGithubTargetRepoModeExistingEmpty &&\n            !targetResult.repoExists\n          ) {\n            setFormError(\n              \"That target repository does not exist yet. Choose Create new repo or enter an existing empty target repo.\",\n            );\n            return;\n          }\n          if (sourceResult.tempDir && !sourceResult.repoIsEmpty) {\n            setImportTempDir(sourceResult.tempDir);\n            setImportStep(kImportStepId);\n            setImportScanning(true);\n            setImportError(null);\n            try {\n              const scanResult = await scanImportRepo(sourceResult.tempDir);\n              if (!scanResult?.ok) {\n                setImportError(scanResult?.error || \"Import scan failed\");\n                setImportScanning(false);\n                return;\n              }\n              setImportScanResult(scanResult);\n            } catch (scanErr) {\n              setImportError(scanErr?.message || \"Import scan failed\");\n            } finally {\n              setImportScanning(false);\n            }\n            return;\n          }\n        }\n        const targetResult = await verifyGithubOnboardingRepo(\n          normalizedVals.GITHUB_WORKSPACE_REPO,\n          normalizedVals.GITHUB_TOKEN,\n          targetVerifyMode,\n        );\n        if (!targetResult?.ok) {\n          setFormError(targetResult?.error || \"GitHub verification failed\");\n          return;\n        }\n        if (\n          targetRepoMode === kGithubTargetRepoModeCreate &&\n          targetResult.repoExists\n        ) {\n          setFormError(\n            \"That target repository already exists. Choose Use existing empty repo or pick a new target repo name.\",\n          );\n          return;\n        }\n        if (\n          targetRepoMode === kGithubTargetRepoModeExistingEmpty &&\n          !targetResult.repoExists\n        ) {\n          setFormError(\n            \"That target repository does not exist yet. Choose Create new repo or enter an existing empty target repo.\",\n          );\n          return;\n        }\n      } catch (err) {\n        setFormError(err?.message || \"GitHub verification failed\");\n        return;\n      } finally {\n        setGithubStepLoading(false);\n      }\n    }\n    setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1));\n  };\n\n  const handleImportApprove = async (approvedSecrets = []) => {\n    setImportScanning(true);\n    setImportError(null);\n    try {\n      const skipSecretExtraction = approvedSecrets.length === 0;\n      const approvedImportVals = buildApprovedImportVals(approvedSecrets);\n      const result = await applyImport({\n        tempDir: importTempDir,\n        approvedSecrets,\n        skipSecretExtraction,\n        githubRepo: vals.GITHUB_WORKSPACE_REPO,\n        githubToken: vals.GITHUB_TOKEN,\n      });\n      if (!result?.ok) {\n        setImportError(result?.error || \"Import failed\");\n        setImportScanning(false);\n        return;\n      }\n      const nextPlaceholderReview = normalizePlaceholderReview(\n        result.placeholderReview,\n      );\n      setVals((prev) => ({\n        ...prev,\n        ...approvedImportVals,\n        ...(result.preFill || {}),\n        [kImportPlaceholderReviewKey]: nextPlaceholderReview,\n        [kImportPlaceholderSkipConfirmedKey]: false,\n      }));\n      if (nextPlaceholderReview.found) {\n        setImportStep(kPlaceholderReviewStepId);\n        return;\n      }\n      clearPlaceholderReview();\n      setImportStep(null);\n      setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1));\n    } catch (err) {\n      setImportError(err?.message || \"Import failed\");\n    } finally {\n      setImportScanning(false);\n    }\n  };\n\n  const handleShowSecretReview = () => {\n    setImportStep(kSecretReviewStepId);\n  };\n\n  const handleSecretReviewBack = () => {\n    setImportStep(kImportStepId);\n  };\n\n  const handleImportBack = () => {\n    setImportStep(null);\n    setImportTempDir(null);\n    setImportScanResult(null);\n    setImportError(null);\n    clearPlaceholderReview();\n  };\n\n  const handlePlaceholderReviewContinue = () => {\n    clearPlaceholderReview();\n    setImportStep(null);\n    setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1));\n  };\n\n  const handleSelectFlow = (flow) => {\n    setValue(\"_GITHUB_FLOW\", flow);\n    setStep(0);\n  };\n\n  const isImportStep = importStep === kImportStepId;\n  const isSecretReviewStep = importStep === kSecretReviewStepId;\n  const isPlaceholderReviewStep = importStep === kPlaceholderReviewStepId;\n  const activeStepLabel = isPreStep\n    ? \"Getting Started\"\n    : isImportStep\n    ? \"Import\"\n    : isSecretReviewStep\n      ? \"Review Secrets\"\n      : isPlaceholderReviewStep\n        ? \"Review Env Vars\"\n        : isSetupStep\n          ? \"Initializing\"\n          : isPairingStep\n            ? \"Pairing\"\n            : activeGroup?.title || \"Setup\";\n  const stepNumber =\n    isPreStep\n      ? 0\n      : isImportStep || isSecretReviewStep || isPlaceholderReviewStep\n      ? step + 1\n      : isSetupStep\n        ? kWelcomeGroups.length + 1\n        : isPairingStep\n          ? kWelcomeGroups.length + 2\n          : step + 1;\n\n  return {\n    state: {\n      vals,\n      step,\n      setupError,\n      modelsLoading,\n      modelsError,\n      showAllModels,\n      loading,\n      githubStepLoading,\n      formError,\n      importScanResult,\n      importScanning,\n      importError,\n      selectedProvider,\n      modelOptions,\n      canToggleFullCatalog,\n      visibleAiFieldKeys,\n      hasAi,\n      isPreStep,\n      isSetupStep,\n      isPairingStep,\n      activeGroup,\n      selectedPairingChannel,\n      placeholderReview,\n      isImportStep,\n      isSecretReviewStep,\n      isPlaceholderReviewStep,\n      activeStepLabel,\n      stepNumber,\n      codexStatus,\n      codexLoading,\n      codexManualInput,\n      codexExchanging,\n      codexAuthStarted,\n      codexAuthWaiting,\n      pairingStatusPoll,\n      pairingRequestsPoll,\n      pairingChannels,\n      canFinishPairing,\n      pairingError,\n      pairingComplete,\n    },\n    actions: {\n      setVals,\n      setValue,\n      setShowAllModels,\n      setCodexManualInput,\n      startCodexAuth,\n      completeCodexAuth,\n      handleCodexDisconnect,\n      handleSubmit,\n      finishOnboarding,\n      goBack,\n      goBackFromSetupError,\n      goNext,\n      handleSelectFlow,\n      handleImportApprove,\n      handleShowSecretReview,\n      handleSecretReviewBack,\n      handleImportBack,\n      handlePlaceholderReviewContinue,\n      handlePairingApprove,\n      handlePairingReject,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/use-app-shell-controller.js",
    "content": "import { useState, useEffect, useCallback } from \"preact/hooks\";\nimport {\n  fetchStatus,\n  fetchOnboardStatus,\n  fetchAuthStatus,\n  fetchAlphaclawVersion,\n  updateAlphaclaw,\n  fetchRestartStatus,\n  dismissRestartStatus,\n  restartGateway,\n  fetchWatchdogStatus,\n  fetchDoctorStatus,\n  subscribeStatusEvents,\n} from \"../lib/api.js\";\nimport { shouldRequireRestartForBrowsePath } from \"../lib/browse-restart-policy.js\";\nimport { usePolling } from \"./usePolling.js\";\nimport { showToast } from \"../components/toast.js\";\n\nexport const useAppShellController = ({ location = \"\" } = {}) => {\n  const kInitialStatusPollDelayMs = 5000;\n  const [onboarded, setOnboarded] = useState(null);\n  const [authEnabled, setAuthEnabled] = useState(false);\n  const [acVersion, setAcVersion] = useState(null);\n  const [acCurrentOpenclawVersion, setAcCurrentOpenclawVersion] = useState(null);\n  const [acLatest, setAcLatest] = useState(null);\n  const [acLatestOpenclawVersion, setAcLatestOpenclawVersion] = useState(null);\n  const [acHasUpdate, setAcHasUpdate] = useState(false);\n  const [acUpdateStrategy, setAcUpdateStrategy] = useState(null);\n  const [acUpdating, setAcUpdating] = useState(false);\n  const [restartRequired, setRestartRequired] = useState(false);\n  const [browseRestartRequired, setBrowseRestartRequired] = useState(false);\n  const [restartingGateway, setRestartingGateway] = useState(false);\n  const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0);\n  const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000);\n  const [statusPollingGraceElapsed, setStatusPollingGraceElapsed] = useState(false);\n  const [statusStreamConnected, setStatusStreamConnected] = useState(false);\n  const [statusStreamStatus, setStatusStreamStatus] = useState(null);\n  const [statusStreamWatchdog, setStatusStreamWatchdog] = useState(null);\n  const [statusStreamDoctor, setStatusStreamDoctor] = useState(null);\n\n  const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {\n    enabled:\n      onboarded === true && !statusStreamConnected && statusPollingGraceElapsed,\n    cacheKey: \"/api/status\",\n  });\n  const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {\n    enabled:\n      onboarded === true && !statusStreamConnected && statusPollingGraceElapsed,\n    cacheKey: \"/api/watchdog/status\",\n  });\n  const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {\n    enabled:\n      onboarded === true && !statusStreamConnected && statusPollingGraceElapsed,\n    cacheKey: \"/api/doctor/status\",\n  });\n  const sharedStatus = statusStreamStatus || sharedStatusPoll.data || null;\n  const sharedWatchdogStatus =\n    statusStreamWatchdog || sharedWatchdogPoll.data?.status || null;\n  const sharedDoctorStatus =\n    statusStreamDoctor || sharedDoctorPoll.data?.status || null;\n  const isAnyRestartRequired = restartRequired || browseRestartRequired;\n\n  const refreshSharedStatuses = useCallback(() => {\n    sharedStatusPoll.refresh();\n    sharedWatchdogPoll.refresh();\n    sharedDoctorPoll.refresh();\n  }, [sharedDoctorPoll.refresh, sharedStatusPoll.refresh, sharedWatchdogPoll.refresh]);\n\n  useEffect(() => {\n    fetchOnboardStatus()\n      .then((data) => setOnboarded(data.onboarded))\n      .catch(() => setOnboarded(false));\n    fetchAuthStatus()\n      .then((data) => setAuthEnabled(!!data.authEnabled))\n      .catch(() => {});\n  }, []);\n\n  useEffect(() => {\n    if (onboarded !== true) {\n      setStatusPollingGraceElapsed(false);\n      return () => {};\n    }\n    const timerId = setTimeout(() => {\n      setStatusPollingGraceElapsed(true);\n    }, kInitialStatusPollDelayMs);\n    return () => {\n      clearTimeout(timerId);\n    };\n  }, [onboarded]);\n\n  useEffect(() => {\n    if (onboarded !== true) return;\n    let disposed = false;\n    const startStream = () => {\n      if (disposed) return;\n      try {\n        return subscribeStatusEvents({\n          onOpen: () => {\n            if (disposed) return;\n            setStatusStreamConnected(true);\n          },\n          onMessage: (payload = {}) => {\n            if (disposed) return;\n            if (payload.status && typeof payload.status === \"object\") {\n              setStatusStreamStatus(payload.status);\n            }\n            if (payload.watchdogStatus && typeof payload.watchdogStatus === \"object\") {\n              setStatusStreamWatchdog(payload.watchdogStatus);\n            }\n            if (payload.doctorStatus && typeof payload.doctorStatus === \"object\") {\n              setStatusStreamDoctor(payload.doctorStatus);\n            }\n          },\n          onError: () => {\n            if (disposed) return;\n            setStatusStreamConnected(false);\n          },\n        });\n      } catch {\n        setStatusStreamConnected(false);\n        return null;\n      }\n    };\n    let cleanup = startStream();\n    return () => {\n      disposed = true;\n      setStatusStreamConnected(false);\n      if (typeof cleanup === \"function\") {\n        cleanup();\n      }\n    };\n  }, [onboarded]);\n\n  useEffect(() => {\n    if (!onboarded) return;\n    let active = true;\n    const check = async (refresh = false) => {\n      try {\n        const data = await fetchAlphaclawVersion(refresh);\n        if (!active) return;\n        setAcVersion(data.currentVersion || null);\n        setAcCurrentOpenclawVersion(data.currentOpenclawVersion || null);\n        setAcLatest(data.latestVersion || null);\n        setAcLatestOpenclawVersion(data.latestOpenclawVersion || null);\n        setAcHasUpdate(!!data.hasUpdate);\n        setAcUpdateStrategy(data.updateStrategy || null);\n      } catch {}\n    };\n    check(true);\n    const id = setInterval(() => check(false), 5 * 60 * 1000);\n    return () => {\n      active = false;\n      clearInterval(id);\n    };\n  }, [onboarded]);\n\n  const refreshRestartStatus = useCallback(async () => {\n    if (!onboarded) return;\n    try {\n      const data = await fetchRestartStatus();\n      setRestartRequired(!!data.restartRequired);\n      setRestartingGateway(!!data.restartInProgress);\n    } catch {}\n  }, [onboarded]);\n\n  useEffect(() => {\n    if (!onboarded) return;\n    refreshRestartStatus();\n  }, [onboarded, refreshRestartStatus]);\n\n  useEffect(() => {\n    if (onboarded !== true) return;\n    const inStatusView =\n      location.startsWith(\"/general\") || location.startsWith(\"/watchdog\");\n    const gatewayStatus = sharedStatus?.gateway ?? null;\n    const watchdogHealth = String(sharedWatchdogStatus?.health || \"\").toLowerCase();\n    const watchdogLifecycle = String(sharedWatchdogStatus?.lifecycle || \"\").toLowerCase();\n    const shouldFastPollWatchdog =\n      watchdogHealth === \"unknown\" ||\n      watchdogLifecycle === \"restarting\" ||\n      watchdogLifecycle === \"stopped\" ||\n      !!sharedWatchdogStatus?.operationInProgress;\n    const shouldFastPollGateway = !gatewayStatus || gatewayStatus !== \"running\";\n    const nextCadenceMs =\n      inStatusView && (shouldFastPollWatchdog || shouldFastPollGateway) ? 2000 : 15000;\n    setStatusPollCadenceMs((currentCadenceMs) =>\n      currentCadenceMs === nextCadenceMs ? currentCadenceMs : nextCadenceMs,\n    );\n  }, [\n    location,\n    onboarded,\n    sharedStatus?.gateway,\n    sharedWatchdogStatus?.health,\n    sharedWatchdogStatus?.lifecycle,\n    sharedWatchdogStatus?.operationInProgress,\n  ]);\n\n  useEffect(() => {\n    if (!onboarded || (!restartRequired && !restartingGateway)) return;\n    const id = setInterval(refreshRestartStatus, 2000);\n    return () => clearInterval(id);\n  }, [onboarded, restartRequired, restartingGateway, refreshRestartStatus]);\n\n  useEffect(() => {\n    const handleBrowseFileSaved = (event) => {\n      const savedPath = String(event?.detail?.path || \"\");\n      if (!shouldRequireRestartForBrowsePath(savedPath)) return;\n      setBrowseRestartRequired(true);\n    };\n    window.addEventListener(\"alphaclaw:browse-file-saved\", handleBrowseFileSaved);\n    return () => {\n      window.removeEventListener(\"alphaclaw:browse-file-saved\", handleBrowseFileSaved);\n    };\n  }, []);\n  useEffect(() => {\n    const handleRestartRequired = () => setRestartRequired(true);\n    window.addEventListener(\"alphaclaw:restart-required\", handleRestartRequired);\n    return () => {\n      window.removeEventListener(\"alphaclaw:restart-required\", handleRestartRequired);\n    };\n  }, []);\n\n  const handleGatewayRestart = useCallback(async () => {\n    if (restartingGateway) return;\n    setRestartingGateway(true);\n    try {\n      const data = await restartGateway();\n      if (!data?.ok) throw new Error(data?.error || \"Gateway restart failed\");\n      setRestartRequired(!!data.restartRequired);\n      setBrowseRestartRequired(false);\n      setGatewayRestartSignal(Date.now());\n      refreshSharedStatuses();\n      showToast(\"Gateway restarted\", \"success\");\n      setTimeout(refreshRestartStatus, 800);\n    } catch (err) {\n      showToast(err.message || \"Restart failed\", \"error\");\n      setTimeout(refreshRestartStatus, 800);\n    } finally {\n      setRestartingGateway(false);\n    }\n  }, [refreshRestartStatus, refreshSharedStatuses, restartingGateway]);\n\n  const handleAcUpdate = useCallback(async () => {\n    if (acUpdating) return;\n    setAcUpdating(true);\n    try {\n      const data = await updateAlphaclaw();\n      if (data.ok) {\n        showToast(\n          data.managedUpdate\n            ? \"Deployment update started — reconnecting...\"\n            : \"AlphaClaw updated — restarting...\",\n          \"success\",\n        );\n        setTimeout(() => window.location.reload(), data.managedUpdate ? 8000 : 5000);\n      } else {\n        showToast(data.error || \"AlphaClaw update failed\", \"error\");\n        setAcUpdating(false);\n      }\n    } catch (err) {\n      showToast(err.message || \"Could not update AlphaClaw\", \"error\");\n      setAcUpdating(false);\n    }\n  }, [acUpdating]);\n\n  const dismissRestartBanner = useCallback(async () => {\n    setRestartRequired(false);\n    setBrowseRestartRequired(false);\n    try {\n      await dismissRestartStatus();\n      await refreshRestartStatus();\n    } catch (err) {\n      showToast(err.message || \"Could not dismiss restart banner\", \"error\");\n      await refreshRestartStatus();\n    }\n  }, [refreshRestartStatus]);\n\n  return {\n    state: {\n      acHasUpdate,\n      acLatest,\n      acLatestOpenclawVersion,\n      acCurrentOpenclawVersion,\n      acUpdateStrategy,\n      acUpdating,\n      acVersion,\n      authEnabled,\n      gatewayRestartSignal,\n      isAnyRestartRequired,\n      onboarded,\n      restartingGateway,\n      sharedDoctorStatus,\n      sharedStatus,\n      sharedWatchdogStatus,\n    },\n    actions: {\n      handleAcUpdate,\n      handleGatewayRestart,\n      handleOnboardingComplete: () => setOnboarded(true),\n      refreshSharedStatuses,\n      dismissRestartBanner,\n      setRestartRequired,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/use-app-shell-ui.js",
    "content": "import { useState, useEffect, useRef, useCallback } from \"preact/hooks\";\nimport { readUiSettings, writeUiSettings } from \"../lib/ui-settings.js\";\n\nconst kDefaultSidebarWidthPx = 220;\nconst kSidebarMinWidthPx = 180;\nconst kSidebarMaxWidthPx = 460;\n\nconst clampSidebarWidth = (value) =>\n  Math.max(kSidebarMinWidthPx, Math.min(kSidebarMaxWidthPx, value));\n\nexport const useAppShellUi = () => {\n  const appShellRef = useRef(null);\n  const menuRef = useRef(null);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [sidebarWidthPx, setSidebarWidthPx] = useState(() => {\n    const settings = readUiSettings();\n    if (!Number.isFinite(settings.sidebarWidthPx)) return kDefaultSidebarWidthPx;\n    return clampSidebarWidth(settings.sidebarWidthPx);\n  });\n  const [isResizingSidebar, setIsResizingSidebar] = useState(false);\n  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);\n  const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false);\n\n  const closeMenu = useCallback((event) => {\n    if (menuRef.current && !menuRef.current.contains(event.target)) {\n      setMenuOpen(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    if (menuOpen) {\n      document.addEventListener(\"click\", closeMenu, true);\n      return () => document.removeEventListener(\"click\", closeMenu, true);\n    }\n  }, [closeMenu, menuOpen]);\n\n  useEffect(() => {\n    if (!mobileSidebarOpen) return;\n    const previousOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = previousOverflow;\n    };\n  }, [mobileSidebarOpen]);\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings.sidebarWidthPx = sidebarWidthPx;\n    writeUiSettings(settings);\n  }, [sidebarWidthPx]);\n\n  const resizeSidebarWithClientX = useCallback((clientX) => {\n    const shellElement = appShellRef.current;\n    if (!shellElement) return;\n    const shellBounds = shellElement.getBoundingClientRect();\n    const nextWidth = clampSidebarWidth(Math.round(clientX - shellBounds.left));\n    setSidebarWidthPx(nextWidth);\n  }, []);\n\n  const onSidebarResizerPointerDown = useCallback((event) => {\n    event.preventDefault();\n    setIsResizingSidebar(true);\n    resizeSidebarWithClientX(event.clientX);\n  }, [resizeSidebarWithClientX]);\n\n  useEffect(() => {\n    if (!isResizingSidebar) return () => {};\n    const onPointerMove = (event) => resizeSidebarWithClientX(event.clientX);\n    const onPointerUp = () => setIsResizingSidebar(false);\n    window.addEventListener(\"pointermove\", onPointerMove);\n    window.addEventListener(\"pointerup\", onPointerUp);\n    const previousUserSelect = document.body.style.userSelect;\n    const previousCursor = document.body.style.cursor;\n    document.body.style.userSelect = \"none\";\n    document.body.style.cursor = \"col-resize\";\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n      document.body.style.userSelect = previousUserSelect;\n      document.body.style.cursor = previousCursor;\n    };\n  }, [isResizingSidebar, resizeSidebarWithClientX]);\n\n  const handlePaneScroll = useCallback((event) => {\n    const nextScrolled = event.currentTarget.scrollTop > 0;\n    setMobileTopbarScrolled((currentScrolled) =>\n      currentScrolled === nextScrolled ? currentScrolled : nextScrolled,\n    );\n  }, []);\n\n  return {\n    refs: {\n      appShellRef,\n      menuRef,\n    },\n    state: {\n      isResizingSidebar,\n      menuOpen,\n      mobileSidebarOpen,\n      mobileTopbarScrolled,\n      sidebarWidthPx,\n    },\n    actions: {\n      closeMobileSidebar: () => setMobileSidebarOpen(false),\n      handlePaneScroll,\n      onSidebarResizerPointerDown,\n      onToggleMenu: () => setMenuOpen((open) => !open),\n      setMenuOpen,\n      setMobileSidebarOpen,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/use-browse-navigation.js",
    "content": "import { useState, useEffect, useRef, useCallback } from \"preact/hooks\";\nimport { readUiSettings, writeUiSettings } from \"../lib/ui-settings.js\";\nimport { kDefaultUiTab, getSelectedNavId, kNavSections } from \"../lib/app-navigation.js\";\nimport { buildBrowseRoute, normalizeBrowsePath, parseBrowseRoute } from \"../lib/browse-route.js\";\n\nconst kBrowseLastPathUiSettingKey = \"browseLastPath\";\nconst kLastMenuRouteUiSettingKey = \"lastMenuRoute\";\n\nexport const useBrowseNavigation = ({\n  location = \"\",\n  setLocation = () => {},\n  onCloseMobileSidebar = () => {},\n} = {}) => {\n  const [sidebarTab, setSidebarTab] = useState(() => {\n    if (location.startsWith(\"/browse\")) return \"browse\";\n    if (location.startsWith(\"/chat\")) return \"chat\";\n    return \"menu\";\n  });\n  const [lastBrowsePath, setLastBrowsePath] = useState(() => {\n    const settings = readUiSettings();\n    return typeof settings[kBrowseLastPathUiSettingKey] === \"string\"\n      ? settings[kBrowseLastPathUiSettingKey]\n      : \"\";\n  });\n  const [lastMenuRoute, setLastMenuRoute] = useState(() => {\n    const settings = readUiSettings();\n    const storedRoute = settings[kLastMenuRouteUiSettingKey];\n    if (\n      typeof storedRoute === \"string\" &&\n      storedRoute.startsWith(\"/\") &&\n      !storedRoute.startsWith(\"/browse\") &&\n      !storedRoute.startsWith(\"/agents\") &&\n      !storedRoute.startsWith(\"/chat\")\n    ) {\n      return storedRoute;\n    }\n    return `/${kDefaultUiTab}`;\n  });\n  const [browsePreviewPath, setBrowsePreviewPath] = useState(\"\");\n  const routeHistoryRef = useRef([]);\n\n  const {\n    activeBrowsePath,\n    browseLineEndTarget,\n    browseLineTarget,\n    browseViewerMode,\n    isBrowseRoute,\n    selectedBrowsePath,\n  } = parseBrowseRoute({\n    location,\n    browsePreviewPath,\n  });\n\n  const selectedNavId = getSelectedNavId({\n    isBrowseRoute,\n    location,\n  });\n\n  // Derive sidebar tab only from `location`. Avoid optimistic setSidebarTab + this effect\n  // fighting (e.g. chat tab selected while hash is still /general → pane never mounts).\n  useEffect(() => {\n    setSidebarTab(() => {\n      if (location.startsWith(\"/browse\")) return \"browse\";\n      if (location.startsWith(\"/chat\")) return \"chat\";\n      return \"menu\";\n    });\n  }, [location]);\n\n  useEffect(() => {\n    if (location.startsWith(\"/browse\")) return;\n    setBrowsePreviewPath(\"\");\n  }, [location]);\n\n  useEffect(() => {\n    const historyStack = routeHistoryRef.current;\n    const lastEntry = historyStack[historyStack.length - 1];\n    if (lastEntry === location) return;\n    historyStack.push(location);\n    if (historyStack.length > 100) {\n      historyStack.shift();\n    }\n  }, [location]);\n\n  useEffect(() => {\n    if (location.startsWith(\"/browse\")) return;\n    if (location.startsWith(\"/chat\")) return;\n    if (location.startsWith(\"/telegram\")) return;\n    setLastMenuRoute((currentRoute) =>\n      currentRoute === location ? currentRoute : location,\n    );\n  }, [location]);\n\n  useEffect(() => {\n    if (!isBrowseRoute) return;\n    if (!selectedBrowsePath) return;\n    setLastBrowsePath((currentPath) =>\n      currentPath === selectedBrowsePath ? currentPath : selectedBrowsePath,\n    );\n  }, [isBrowseRoute, selectedBrowsePath]);\n\n  useEffect(() => {\n    const handleBrowseGitSynced = () => {\n      if (!isBrowseRoute || browseViewerMode !== \"diff\") return;\n      const activePath = String(selectedBrowsePath || \"\").trim();\n      if (!activePath) return;\n      setLocation(buildBrowseRoute(activePath, { view: \"edit\" }));\n    };\n    window.addEventListener(\"alphaclaw:browse-git-synced\", handleBrowseGitSynced);\n    return () => {\n      window.removeEventListener(\"alphaclaw:browse-git-synced\", handleBrowseGitSynced);\n    };\n  }, [browseViewerMode, isBrowseRoute, selectedBrowsePath, setLocation]);\n\n  useEffect(() => {\n    const settings = readUiSettings();\n    settings[kBrowseLastPathUiSettingKey] = lastBrowsePath;\n    settings[kLastMenuRouteUiSettingKey] = lastMenuRoute;\n    writeUiSettings(settings);\n  }, [lastBrowsePath, lastMenuRoute]);\n\n  const navigateToSubScreen = useCallback((screen) => {\n    setLocation(`/${screen}`);\n    onCloseMobileSidebar();\n  }, [onCloseMobileSidebar, setLocation]);\n\n  const handleBrowsePreviewFile = useCallback((nextPreviewPath) => {\n    const normalizedPreviewPath = normalizeBrowsePath(nextPreviewPath);\n    setBrowsePreviewPath(normalizedPreviewPath);\n  }, []);\n\n  const navigateToBrowseFile = useCallback((relativePath, options = {}) => {\n    const normalizedTargetPath = normalizeBrowsePath(relativePath);\n    const selectingDirectory =\n      !!options.directory || String(relativePath || \"\").trim().endsWith(\"/\");\n    const shouldPreservePreview = selectingDirectory && !!options.preservePreview;\n    const activePath = normalizeBrowsePath(\n      browsePreviewPath || selectedBrowsePath || \"\",\n    );\n    const nextPreviewPath =\n      shouldPreservePreview && activePath && activePath !== normalizedTargetPath\n        ? activePath\n        : \"\";\n    setBrowsePreviewPath(nextPreviewPath);\n    const routeOptions = selectingDirectory\n      ? { ...options, view: \"edit\" }\n      : options;\n    setLocation(buildBrowseRoute(normalizedTargetPath, routeOptions));\n    onCloseMobileSidebar();\n  }, [browsePreviewPath, onCloseMobileSidebar, selectedBrowsePath, setLocation]);\n\n  const handleSelectSidebarTab = useCallback((nextTab) => {\n    if (nextTab === \"menu\" && location.startsWith(\"/browse\")) {\n      setBrowsePreviewPath(\"\");\n      setLocation(lastMenuRoute || `/${kDefaultUiTab}`);\n      return;\n    }\n    if (nextTab === \"menu\" && location.startsWith(\"/chat\")) {\n      setLocation(lastMenuRoute || `/${kDefaultUiTab}`);\n      return;\n    }\n    if (nextTab === \"browse\" && !location.startsWith(\"/browse\")) {\n      setLocation(buildBrowseRoute(lastBrowsePath));\n      return;\n    }\n    if (nextTab === \"chat\" && !location.startsWith(\"/chat\")) {\n      setLocation(\"/chat\");\n    }\n  }, [lastBrowsePath, lastMenuRoute, location, setLocation]);\n\n  const handleSelectNavItem = useCallback((itemId) => {\n    setLocation(`/${itemId}`);\n    onCloseMobileSidebar();\n  }, [onCloseMobileSidebar, setLocation]);\n\n  const exitSubScreen = useCallback(() => {\n    setLocation(`/${kDefaultUiTab}`);\n    onCloseMobileSidebar();\n  }, [onCloseMobileSidebar, setLocation]);\n\n  return {\n    state: {\n      activeBrowsePath,\n      browseLineEndTarget,\n      browseLineTarget,\n      browsePreviewPath,\n      browseViewerMode,\n      isBrowseRoute,\n      routeHistoryRef,\n      selectedBrowsePath,\n      selectedNavId,\n      sidebarTab,\n    },\n    actions: {\n      buildBrowseRoute,\n      clearBrowsePreview: () => setBrowsePreviewPath(\"\"),\n      exitSubScreen,\n      handleBrowsePreviewFile,\n      handleSelectNavItem,\n      handleSelectSidebarTab,\n      navigateToBrowseFile,\n      navigateToSubScreen,\n    },\n    constants: {\n      kNavSections,\n    },\n  };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/use-cached-fetch.js",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"preact/hooks\";\nimport { cachedFetch, getCached } from \"../lib/api-cache.js\";\n\nexport const useCachedFetch = (\n  key,\n  fetcher,\n  {\n    enabled = true,\n    maxAgeMs = 15000,\n    staleWhileRevalidate = true,\n  } = {},\n) => {\n  const normalizedKey = useMemo(() => String(key || \"\"), [key]);\n  const initialCachedData = useMemo(() => getCached(normalizedKey), [normalizedKey]);\n  const [data, setData] = useState(initialCachedData);\n  const [loading, setLoading] = useState(initialCachedData === null);\n  const [error, setError] = useState(null);\n\n  useEffect(() => {\n    setData(getCached(normalizedKey));\n  }, [normalizedKey]);\n\n  const refresh = useCallback(\n    async ({ force = false } = {}) => {\n      if (!enabled) return getCached(normalizedKey);\n      if (getCached(normalizedKey) === null) {\n        setLoading(true);\n      }\n      try {\n        const next = await cachedFetch(normalizedKey, fetcher, {\n          maxAgeMs,\n          force,\n          staleWhileRevalidate,\n          onRevalidate: (revalidatedData) => {\n            setData(revalidatedData);\n            setError(null);\n          },\n        });\n        setData(next);\n        setError(null);\n        return next;\n      } catch (err) {\n        setError(err);\n        throw err;\n      } finally {\n        setLoading(false);\n      }\n    },\n    [enabled, fetcher, maxAgeMs, normalizedKey, staleWhileRevalidate],\n  );\n\n  useEffect(() => {\n    if (!enabled) return;\n    refresh().catch(() => {});\n  }, [enabled, refresh]);\n\n  return {\n    data,\n    error,\n    loading,\n    refresh,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/use-destination-session-selection.js",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"preact/hooks\";\nimport { useAgentSessions } from \"./useAgentSessions.js\";\nimport {\n  getDestinationFromSession,\n  getSessionRowKey,\n  kDestinationSessionFilter,\n} from \"../lib/session-keys.js\";\n\nexport const kNoDestinationSessionValue = \"__none__\";\n\nexport const useDestinationSessionSelection = ({\n  enabled = false,\n  resetKey = \"\",\n} = {}) => {\n  const [manualSessionKey, setManualSessionKey] = useState(\"\");\n  const [hasManualSelection, setHasManualSelection] = useState(false);\n  const {\n    sessions,\n    selectedSessionKey,\n    setSelectedSessionKey,\n    loading,\n    error,\n  } = useAgentSessions({\n    enabled,\n    filter: kDestinationSessionFilter,\n  });\n\n  useEffect(() => {\n    if (!enabled) return;\n    setManualSessionKey(\"\");\n    setHasManualSelection(false);\n  }, [enabled, resetKey]);\n\n  const preferredSessionKey = useMemo(() => {\n    const matchingPreferredSession = sessions.find(\n      (sessionRow) =>\n        getSessionRowKey(sessionRow) === String(selectedSessionKey || \"\").trim(),\n    );\n    return String(\n      getSessionRowKey(matchingPreferredSession) || getSessionRowKey(sessions[0]),\n    ).trim();\n  }, [sessions, selectedSessionKey]);\n\n  const effectiveSessionKey = hasManualSelection\n    ? manualSessionKey\n    : preferredSessionKey;\n\n  const selectedSession = useMemo(\n    () =>\n      sessions.find(\n        (sessionRow) =>\n          getSessionRowKey(sessionRow) === String(effectiveSessionKey || \"\").trim(),\n      ) || null,\n    [effectiveSessionKey, sessions],\n  );\n\n  const selectedDestination = useMemo(\n    () => getDestinationFromSession(selectedSession),\n    [selectedSession],\n  );\n\n  const setDestinationSessionKey = useCallback((key) => {\n    const normalizedKey = String(key || \"\");\n    setManualSessionKey(normalizedKey);\n    setHasManualSelection(true);\n    setSelectedSessionKey(normalizedKey);\n  }, [setSelectedSessionKey]);\n\n  return {\n    sessions,\n    loading,\n    error,\n    destinationSessionKey: effectiveSessionKey,\n    setDestinationSessionKey,\n    selectedDestinationSession: selectedSession,\n    selectedDestination,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/use-hash-location.js",
    "content": "import { useState, useEffect, useCallback } from \"preact/hooks\";\nimport { kDefaultUiTab } from \"../lib/app-navigation.js\";\n\nconst getHashPath = () => {\n  const hash = window.location.hash.replace(/^#/, \"\");\n  if (!hash) return `/${kDefaultUiTab}`;\n  return hash.startsWith(\"/\") ? hash : `/${hash}`;\n};\n\nexport const useHashLocation = () => {\n  const [location, setLocationState] = useState(getHashPath);\n\n  useEffect(() => {\n    const onHashChange = () => setLocationState(getHashPath());\n    window.addEventListener(\"hashchange\", onHashChange);\n    return () => window.removeEventListener(\"hashchange\", onHashChange);\n  }, []);\n\n  const setLocation = useCallback((to) => {\n    const normalized = to.startsWith(\"/\") ? to : `/${to}`;\n    const nextHash = `#${normalized}`;\n    if (window.location.hash !== nextHash) {\n      window.location.hash = normalized;\n      return;\n    }\n    setLocationState(normalized);\n  }, []);\n\n  return [location, setLocation];\n};\n\nexport const getHashRouterPath = getHashPath;\n"
  },
  {
    "path": "lib/public/js/hooks/useAgentSessions.js",
    "content": "import { useState, useEffect, useMemo, useCallback } from \"preact/hooks\";\nimport { fetchAgentSessions } from \"../lib/api.js\";\nimport {\n  kAgentSessionsCacheKey,\n  kAgentLastSessionKey,\n} from \"../lib/storage-keys.js\";\nimport {\n  getSessionRowKey,\n  isDestinationSessionKey,\n  sortSessionsByPriority,\n} from \"../lib/session-keys.js\";\n\nconst readCachedSessions = () => {\n  try {\n    const raw = localStorage.getItem(kAgentSessionsCacheKey);\n    if (!raw) return [];\n    const parsed = JSON.parse(raw);\n    return Array.isArray(parsed) ? parsed : [];\n  } catch {\n    return [];\n  }\n};\n\nconst writeCachedSessions = (sessions) => {\n  try {\n    localStorage.setItem(kAgentSessionsCacheKey, JSON.stringify(sessions));\n  } catch {}\n};\n\nconst readLastSessionKey = () => {\n  try {\n    return localStorage.getItem(kAgentLastSessionKey) || \"\";\n  } catch {\n    return \"\";\n  }\n};\n\nconst writeLastSessionKey = (key) => {\n  try {\n    localStorage.setItem(kAgentLastSessionKey, String(key || \"\"));\n  } catch {}\n};\n\nconst pickPreferredSession = (sessions, lastKey) => {\n  if (lastKey) {\n    const lastMatch = sessions.find((row) => getSessionRowKey(row) === lastKey);\n    if (lastMatch) return lastMatch;\n  }\n  return (\n    sessions.find((row) => getSessionRowKey(row).toLowerCase() === \"agent:main:main\") ||\n    sessions.find((row) => {\n      return isDestinationSessionKey(getSessionRowKey(row));\n    }) ||\n    sessions[0] ||\n    null\n  );\n};\n\n/**\n * Shared hook for agent session selection with localStorage caching.\n *\n * @param {object} options\n * @param {boolean} options.enabled - Whether to load sessions (tie to modal visibility, etc.)\n * @param {(sessions: Array) => Array} [options.filter] - Optional filter applied to the session list before exposing it.\n * @returns {{ sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading, error }}\n */\nexport const useAgentSessions = ({ enabled = false, filter } = {}) => {\n  const [allSessions, setAllSessions] = useState([]);\n  const [selectedSessionKey, setSelectedSessionKeyState] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState(\"\");\n\n  const setSelectedSessionKey = useCallback((key) => {\n    const normalized = String(key || \"\");\n    setSelectedSessionKeyState(normalized);\n    writeLastSessionKey(normalized);\n  }, []);\n\n  useEffect(() => {\n    if (!enabled) return;\n    let active = true;\n\n    const cached = readCachedSessions();\n    const lastKey = readLastSessionKey();\n    if (cached.length > 0) {\n      setAllSessions(cached);\n      const preferred = pickPreferredSession(cached, lastKey);\n      setSelectedSessionKeyState(getSessionRowKey(preferred));\n    }\n\n    const load = async () => {\n      try {\n        if (cached.length === 0) setLoading(true);\n        setError(\"\");\n        const data = await fetchAgentSessions();\n        if (!active) return;\n        const nextSessions = Array.isArray(data?.sessions) ? data.sessions : [];\n        setAllSessions(nextSessions);\n        writeCachedSessions(nextSessions);\n        if (cached.length === 0 || !lastKey) {\n          const preferred = pickPreferredSession(nextSessions, lastKey);\n          setSelectedSessionKeyState(getSessionRowKey(preferred));\n        }\n      } catch (err) {\n        if (!active) return;\n        if (cached.length === 0) {\n          setAllSessions([]);\n          setSelectedSessionKeyState(\"\");\n          setError(err.message || \"Could not load agent sessions\");\n        }\n      } finally {\n        if (active) setLoading(false);\n      }\n    };\n    load();\n    return () => {\n      active = false;\n    };\n  }, [enabled]);\n\n  const sessions = useMemo(\n    () => sortSessionsByPriority(filter ? allSessions.filter(filter) : allSessions),\n    [allSessions, filter],\n  );\n\n  useEffect(() => {\n    if (!enabled) return;\n    if (sessions.length === 0) {\n      if (selectedSessionKey) setSelectedSessionKeyState(\"\");\n      return;\n    }\n    const hasSelectedSession = sessions.some(\n      (row) => getSessionRowKey(row) === String(selectedSessionKey || \"\"),\n    );\n    if (hasSelectedSession) return;\n    const preferred = pickPreferredSession(sessions, readLastSessionKey());\n    setSelectedSessionKeyState(getSessionRowKey(preferred));\n  }, [enabled, sessions, selectedSessionKey]);\n\n  const selectedSession = useMemo(\n    () => sessions.find((row) => getSessionRowKey(row) === selectedSessionKey) || null,\n    [sessions, selectedSessionKey],\n  );\n\n  return { sessions, selectedSessionKey, setSelectedSessionKey, selectedSession, loading, error };\n};\n"
  },
  {
    "path": "lib/public/js/hooks/usePolling.js",
    "content": "import { useState, useEffect, useCallback, useRef } from \"preact/hooks\";\nimport { getCached, setCached } from \"../lib/api-cache.js\";\n\nexport const usePolling = (\n  fetcher,\n  interval,\n  {\n    enabled = true,\n    pauseWhenHidden = true,\n    cacheKey = \"\",\n    dedupeInFlight = false,\n  } = {},\n) => {\n  const normalizedCacheKey = String(cacheKey || \"\");\n  const [data, setData] = useState(() =>\n    normalizedCacheKey ? getCached(normalizedCacheKey) : null,\n  );\n  const [error, setError] = useState(null);\n  const [isPolling, setIsPolling] = useState(false);\n  const fetcherRef = useRef(fetcher);\n  const inFlightRefreshRef = useRef(null);\n  const activeRefreshCountRef = useRef(0);\n  const nextRefreshIdRef = useRef(0);\n  const latestRefreshIdRef = useRef(0);\n  fetcherRef.current = fetcher;\n\n  const refresh = useCallback(async ({ force = false } = {}) => {\n    if (dedupeInFlight && inFlightRefreshRef.current && !force) {\n      return inFlightRefreshRef.current;\n    }\n    const refreshId = nextRefreshIdRef.current + 1;\n    nextRefreshIdRef.current = refreshId;\n    latestRefreshIdRef.current = refreshId;\n    activeRefreshCountRef.current += 1;\n    setIsPolling(true);\n    const refreshPromise = Promise.resolve().then(async () => {\n      try {\n        const result = await fetcherRef.current();\n        if (latestRefreshIdRef.current === refreshId) {\n          if (normalizedCacheKey) {\n            setCached(normalizedCacheKey, result);\n          }\n          setData(result);\n          setError(null);\n        }\n        return result;\n      } catch (err) {\n        if (latestRefreshIdRef.current === refreshId) {\n          setError(err);\n        }\n        return null;\n      } finally {\n        activeRefreshCountRef.current = Math.max(\n          0,\n          activeRefreshCountRef.current - 1,\n        );\n        setIsPolling(activeRefreshCountRef.current > 0);\n        if (inFlightRefreshRef.current === refreshPromise) {\n          inFlightRefreshRef.current = null;\n        }\n      }\n    });\n    if (dedupeInFlight) {\n      inFlightRefreshRef.current = refreshPromise;\n    }\n    return refreshPromise;\n  }, [dedupeInFlight, normalizedCacheKey]);\n\n  useEffect(() => {\n    if (!normalizedCacheKey) return;\n    const cached = getCached(normalizedCacheKey);\n    if (cached !== null) {\n      setData(cached);\n    }\n  }, [normalizedCacheKey]);\n\n  useEffect(() => {\n    if (!enabled) return;\n    if (pauseWhenHidden && typeof document !== \"undefined\" && document.hidden) {\n      return undefined;\n    }\n    refresh();\n    const intervalId = setInterval(refresh, interval);\n    return () => clearInterval(intervalId);\n  }, [enabled, interval, pauseWhenHidden, refresh]);\n\n  useEffect(() => {\n    if (!enabled || !pauseWhenHidden || typeof document === \"undefined\") return;\n    const handleVisibilityChange = () => {\n      if (!document.hidden) {\n        refresh();\n      }\n    };\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n  }, [enabled, pauseWhenHidden, refresh]);\n\n  return { data, error, refresh, isPolling };\n};\n"
  },
  {
    "path": "lib/public/js/lib/agent-identity.js",
    "content": "const kNonEmojiPattern = /[A-Za-z0-9:]/;\n\nexport const sanitizeAgentEmoji = (rawEmoji) => {\n  const trimmed = String(rawEmoji ?? \"\").trim();\n  if (!trimmed) return \"\";\n  if (kNonEmojiPattern.test(trimmed)) return \"\";\n  return trimmed;\n};\n"
  },
  {
    "path": "lib/public/js/lib/api-cache.js",
    "content": "const kApiCache = new Map();\nconst kInFlightByKey = new Map();\n\nconst nowMs = () => Date.now();\n\nconst isFresh = (entry, maxAgeMs) => {\n  if (!entry) return false;\n  return nowMs() - Number(entry.fetchedAt || 0) < Number(maxAgeMs || 0);\n};\n\nexport const getCached = (key = \"\") => {\n  const normalizedKey = String(key || \"\");\n  if (!normalizedKey) return null;\n  return kApiCache.get(normalizedKey)?.data ?? null;\n};\n\nexport const setCached = (key = \"\", data = null) => {\n  const normalizedKey = String(key || \"\");\n  if (!normalizedKey) return data;\n  kApiCache.set(normalizedKey, {\n    data,\n    fetchedAt: nowMs(),\n  });\n  return data;\n};\n\nexport const invalidateCache = (key = \"\") => {\n  const normalizedKey = String(key || \"\");\n  if (!normalizedKey) return;\n  kApiCache.delete(normalizedKey);\n  kInFlightByKey.delete(normalizedKey);\n};\n\nexport const cachedFetch = async (\n  key,\n  fetcher,\n  {\n    maxAgeMs = 15000,\n    force = false,\n    staleWhileRevalidate = true,\n    onRevalidate = null,\n  } = {},\n) => {\n  const normalizedKey = String(key || \"\");\n  if (!normalizedKey || typeof fetcher !== \"function\") {\n    return fetcher();\n  }\n\n  const entry = kApiCache.get(normalizedKey);\n  if (!force && isFresh(entry, maxAgeMs)) {\n    return entry.data;\n  }\n\n  if (!force && staleWhileRevalidate && entry) {\n    if (!kInFlightByKey.has(normalizedKey)) {\n      const backgroundPromise = Promise.resolve()\n        .then(() => fetcher())\n        .then((result) => {\n          setCached(normalizedKey, result);\n          if (typeof onRevalidate === \"function\") {\n            onRevalidate(result);\n          }\n          return result;\n        })\n        .finally(() => {\n          kInFlightByKey.delete(normalizedKey);\n        });\n      kInFlightByKey.set(normalizedKey, backgroundPromise);\n    }\n    return entry.data;\n  }\n\n  if (kInFlightByKey.has(normalizedKey)) {\n    return kInFlightByKey.get(normalizedKey);\n  }\n\n  const requestPromise = Promise.resolve()\n    .then(() => fetcher())\n    .then((result) => {\n      setCached(normalizedKey, result);\n      return result;\n    })\n    .finally(() => {\n      kInFlightByKey.delete(normalizedKey);\n    });\n  kInFlightByKey.set(normalizedKey, requestPromise);\n  return requestPromise;\n};\n"
  },
  {
    "path": "lib/public/js/lib/api.js",
    "content": "import { subscribeToSse } from \"./sse.js\";\n\nconst kClientTimeZoneHeader = \"x-client-timezone\";\n\nconst getBrowserTimeZone = () => {\n  try {\n    return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || \"\";\n  } catch {\n    return \"\";\n  }\n};\n\nexport const authFetch = async (url, opts = {}) => {\n  const nextOptions = { ...opts };\n  const headers = new Headers(opts?.headers || {});\n  if (!headers.has(kClientTimeZoneHeader)) {\n    const browserTimeZone = getBrowserTimeZone();\n    if (browserTimeZone) {\n      headers.set(kClientTimeZoneHeader, browserTimeZone);\n    }\n  }\n  nextOptions.headers = headers;\n  const res = await fetch(url, nextOptions);\n  if (res.status === 401) {\n    try {\n      window.localStorage?.clear?.();\n    } catch {}\n    window.location.href = \"/setup\";\n    throw new Error(\"Unauthorized\");\n  }\n  return res;\n};\n\nexport const subscribeStatusEvents = ({\n  onMessage = () => {},\n  onOpen = () => {},\n  onError = () => {},\n} = {}) => {\n  if (typeof window?.EventSource !== \"function\") {\n    throw new Error(\"Server events are not supported in this browser\");\n  }\n  const source = new window.EventSource(\"/api/events/status\", {\n    withCredentials: true,\n  });\n  const handleStatus = (event) => {\n    let payload = {};\n    try {\n      payload = event?.data ? JSON.parse(event.data) : {};\n    } catch {}\n    onMessage(payload || {});\n  };\n  source.addEventListener(\"status\", handleStatus);\n  source.onopen = () => onOpen();\n  source.onerror = (event) => onError(event);\n  return () => {\n    source.removeEventListener(\"status\", handleStatus);\n    source.onopen = null;\n    source.onerror = null;\n    source.close();\n  };\n};\n\nexport async function fetchStatus() {\n  const res = await authFetch(\"/api/status\");\n  return res.json();\n}\n\nexport async function fetchPairings() {\n  const res = await authFetch(\"/api/pairings\");\n  return res.json();\n}\n\nexport async function approvePairing(id, channel, accountId = \"\") {\n  const res = await authFetch(`/api/pairings/${id}/approve`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ channel, accountId }),\n  });\n  return res.json();\n}\n\nexport async function rejectPairing(id, channel, accountId = \"\") {\n  const res = await authFetch(`/api/pairings/${id}/reject`, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ channel, accountId }),\n  });\n  return parseJsonOrThrow(res, \"Could not reject pairing\");\n}\n\nexport async function fetchGoogleAccounts() {\n  const res = await authFetch(\"/api/google/accounts\");\n  return res.json();\n}\n\nexport async function fetchGoogleStatus(accountId = \"\") {\n  const params = new URLSearchParams();\n  if (accountId) params.set(\"accountId\", String(accountId));\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/google/status${suffix}`);\n  return res.json();\n}\n\nexport async function fetchGoogleCredentials({\n  accountId = \"\",\n  client = \"\",\n} = {}) {\n  const params = new URLSearchParams();\n  if (accountId) params.set(\"accountId\", String(accountId));\n  if (client) params.set(\"client\", String(client));\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/google/credentials${suffix}`);\n  return res.json();\n}\n\nexport async function checkGoogleApis(accountId = \"\") {\n  const params = new URLSearchParams();\n  if (accountId) params.set(\"accountId\", String(accountId));\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/google/check${suffix}`);\n  return res.json();\n}\n\nexport async function saveGoogleCredentials({\n  clientId,\n  clientSecret,\n  email,\n  services = [],\n  client = \"default\",\n  personal = false,\n  accountId = \"\",\n}) {\n  const res = await authFetch(\"/api/google/credentials\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      clientId,\n      clientSecret,\n      email,\n      services,\n      client,\n      personal,\n      accountId,\n    }),\n  });\n  return res.json();\n}\n\nexport async function saveGoogleAccount({\n  email,\n  services = [],\n  client = \"default\",\n  personal = false,\n  accountId = \"\",\n}) {\n  const res = await authFetch(\"/api/google/accounts\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ email, services, client, personal, accountId }),\n  });\n  return res.json();\n}\n\nexport async function disconnectGoogle(accountId = \"\") {\n  const res = await authFetch(\"/api/google/disconnect\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ accountId }),\n  });\n  return res.json();\n}\n\nexport const fetchGmailConfig = async () => {\n  const res = await authFetch(\"/api/gmail/config\");\n  return parseJsonOrThrow(res, \"Could not load Gmail watch config\");\n};\n\nexport const saveGmailConfig = async ({\n  client = \"default\",\n  topicPath = \"\",\n  projectId = \"\",\n  regeneratePushToken = false,\n} = {}) => {\n  const res = await authFetch(\"/api/gmail/config\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      client,\n      topicPath,\n      projectId,\n      regeneratePushToken,\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not save Gmail watch config\");\n};\n\nexport const startGmailWatch = async (accountId, { destination = null } = {}) => {\n  const res = await authFetch(\"/api/gmail/watch/start\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      accountId: String(accountId || \"\"),\n      ...(destination ? { destination } : {}),\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not start Gmail watch\");\n};\n\nexport const stopGmailWatch = async (accountId) => {\n  const res = await authFetch(\"/api/gmail/watch/stop\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ accountId: String(accountId || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not stop Gmail watch\");\n};\n\nexport const renewGmailWatch = async ({\n  accountId = \"\",\n  force = true,\n} = {}) => {\n  const res = await authFetch(\"/api/gmail/watch/renew\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      accountId: String(accountId || \"\"),\n      force: Boolean(force),\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not renew Gmail watch\");\n};\n\nexport const fetchAgentSessions = async () => {\n  const res = await authFetch(\"/api/agent/sessions\");\n  return parseJsonOrThrow(res, \"Could not load agent sessions\");\n};\n\nexport const fetchDoctorStatus = async () => {\n  const res = await authFetch(\"/api/doctor/status\");\n  return parseJsonOrThrow(res, \"Could not load Doctor status\");\n};\n\nexport const startDoctorRun = async () => {\n  const res = await authFetch(\"/api/doctor/run\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({}),\n  });\n  return parseJsonOrThrow(res, \"Could not start Doctor run\");\n};\n\nexport const importDoctorResult = async (rawOutput = \"\") => {\n  const res = await authFetch(\"/api/doctor/import\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ rawOutput: String(rawOutput || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not import Doctor result\");\n};\n\nexport const fetchDoctorRuns = async (limit = 10) => {\n  const params = new URLSearchParams({ limit: String(limit) });\n  const res = await authFetch(`/api/doctor/runs?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load Doctor runs\");\n};\n\nexport const fetchDoctorCards = async ({ runId = \"all\" } = {}) => {\n  const params = new URLSearchParams();\n  if (String(runId || \"\").trim()) params.set(\"runId\", String(runId || \"\"));\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/doctor/cards${suffix}`);\n  return parseJsonOrThrow(res, \"Could not load Doctor findings\");\n};\n\nexport const fetchDoctorRun = async (runId) => {\n  const res = await authFetch(\n    `/api/doctor/runs/${encodeURIComponent(String(runId || \"\"))}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load Doctor run\");\n};\n\nexport const fetchDoctorRunCards = async (runId) => {\n  const res = await authFetch(\n    `/api/doctor/runs/${encodeURIComponent(String(runId || \"\"))}/cards`,\n  );\n  return parseJsonOrThrow(res, \"Could not load Doctor cards\");\n};\n\nexport const updateDoctorCardStatus = async ({ cardId, status }) => {\n  const res = await authFetch(\n    `/api/doctor/cards/${encodeURIComponent(String(cardId || \"\"))}/status`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ status: String(status || \"\") }),\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not update Doctor card status\");\n};\n\nexport const sendDoctorCardFix = async ({\n  cardId,\n  sessionId = \"\",\n  replyChannel = \"\",\n  replyTo = \"\",\n  prompt = \"\",\n} = {}) => {\n  const res = await authFetch(\n    `/api/doctor/findings/${encodeURIComponent(String(cardId || \"\"))}/fix`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        sessionId: String(sessionId || \"\"),\n        replyChannel: String(replyChannel || \"\"),\n        replyTo: String(replyTo || \"\"),\n        prompt: String(prompt || \"\"),\n      }),\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not send Doctor fix request\");\n};\n\nexport const sendAgentMessage = async ({\n  message = \"\",\n  sessionKey = \"\",\n} = {}) => {\n  const res = await authFetch(\"/api/agent/message\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      message: String(message || \"\"),\n      sessionKey: String(sessionKey || \"\"),\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not send message to agent\");\n};\n\nexport async function restartGateway() {\n  const res = await authFetch(\"/api/gateway/restart\", { method: \"POST\" });\n  return parseJsonOrThrow(res, \"Could not restart gateway\");\n}\n\nexport async function fetchRestartStatus() {\n  const res = await authFetch(\"/api/restart-status\");\n  return parseJsonOrThrow(res, \"Could not load restart status\");\n}\n\nexport async function dismissRestartStatus() {\n  const res = await authFetch(\"/api/restart-status/dismiss\", {\n    method: \"POST\",\n  });\n  return parseJsonOrThrow(res, \"Could not dismiss restart status\");\n}\n\nexport async function fetchWatchdogStatus() {\n  const res = await authFetch(\"/api/watchdog/status\");\n  return parseJsonOrThrow(res, \"Could not load watchdog status\");\n}\n\nexport async function fetchUsageSummary(days = 30) {\n  const params = new URLSearchParams({ days: String(days) });\n  const res = await authFetch(`/api/usage/summary?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load usage summary\");\n}\n\nexport async function fetchUsageSessions(limit = 50) {\n  const params = new URLSearchParams({ limit: String(limit) });\n  const res = await authFetch(`/api/usage/sessions?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load usage sessions\");\n}\n\nexport async function fetchUsageSessionDetail(sessionId) {\n  const res = await authFetch(\n    `/api/usage/sessions/${encodeURIComponent(String(sessionId || \"\"))}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load usage session detail\");\n}\n\nexport async function fetchUsageSessionTimeSeries(sessionId, maxPoints = 100) {\n  const params = new URLSearchParams({ maxPoints: String(maxPoints) });\n  const safeSessionId = encodeURIComponent(String(sessionId || \"\"));\n  const res = await authFetch(\n    `/api/usage/sessions/${safeSessionId}/timeseries?${params.toString()}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load usage time series\");\n}\n\nexport async function fetchWatchdogEvents(limit = 20) {\n  const res = await authFetch(\n    `/api/watchdog/events?limit=${encodeURIComponent(String(limit))}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load watchdog events\");\n}\n\nexport async function fetchWatchdogLogs(tail = 65536) {\n  const res = await authFetch(\n    `/api/watchdog/logs?tail=${encodeURIComponent(String(tail))}`,\n  );\n  if (!res.ok) throw new Error(\"Could not load watchdog logs\");\n  return res.text();\n}\n\nexport async function createWatchdogTerminalSession() {\n  const res = await authFetch(\"/api/watchdog/terminal/session\", {\n    method: \"POST\",\n  });\n  return parseJsonOrThrow(res, \"Could not start watchdog terminal\");\n}\n\nexport async function fetchWatchdogTerminalOutput(sessionId, cursor = 0) {\n  const params = new URLSearchParams({\n    sessionId: String(sessionId || \"\"),\n    cursor: String(cursor || 0),\n  });\n  const res = await authFetch(`/api/watchdog/terminal/output?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not read watchdog terminal output\");\n}\n\nexport async function sendWatchdogTerminalInput(sessionId, input = \"\") {\n  const res = await authFetch(\"/api/watchdog/terminal/input\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      sessionId: String(sessionId || \"\"),\n      input: String(input || \"\"),\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not send watchdog terminal input\");\n}\n\nexport async function closeWatchdogTerminalSession(sessionId) {\n  const res = await authFetch(\"/api/watchdog/terminal/close\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      sessionId: String(sessionId || \"\"),\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not close watchdog terminal\");\n}\n\nexport async function triggerWatchdogRepair() {\n  const res = await authFetch(\"/api/watchdog/repair\", { method: \"POST\" });\n  return parseJsonOrThrow(res, \"Could not trigger watchdog repair\");\n}\n\nexport async function fetchWatchdogResources() {\n  const res = await authFetch(\"/api/watchdog/resources\");\n  return parseJsonOrThrow(res, \"Could not load system resources\");\n}\n\nexport async function fetchWatchdogSettings() {\n  const res = await authFetch(\"/api/watchdog/settings\");\n  return parseJsonOrThrow(res, \"Could not load watchdog settings\");\n}\n\nexport async function updateWatchdogSettings(settings) {\n  const res = await authFetch(\"/api/watchdog/settings\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(settings || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not update watchdog settings\");\n}\n\nexport async function fetchDashboardUrl() {\n  const res = await authFetch(\"/api/gateway/dashboard\");\n  return parseJsonOrThrow(res, \"Could not load dashboard URL\");\n}\n\nexport async function fetchAlphaclawVersion(refresh = false) {\n  const query = refresh ? \"?refresh=1\" : \"\";\n  const res = await authFetch(`/api/alphaclaw/version${query}`);\n  return res.json();\n}\n\nexport async function fetchAlphaclawReleaseNotes(tag = \"\") {\n  const normalizedTag = String(tag || \"\").trim();\n  const query = normalizedTag\n    ? `?${new URLSearchParams({ tag: normalizedTag }).toString()}`\n    : \"\";\n  try {\n    const res = await authFetch(`/api/alphaclaw/release-notes${query}`);\n    return await parseJsonOrThrow(res, \"Could not load release notes\");\n  } catch {\n    const endpoint = normalizedTag\n      ? `https://api.github.com/repos/chrysb/alphaclaw/releases/tags/${encodeURIComponent(normalizedTag)}`\n      : \"https://api.github.com/repos/chrysb/alphaclaw/releases/latest\";\n    const res = await fetch(endpoint, {\n      headers: { Accept: \"application/vnd.github+json\" },\n    });\n    const text = await res.text();\n    let data = null;\n    try {\n      data = text ? JSON.parse(text) : null;\n    } catch {\n      throw new Error(text || \"Could not load release notes\");\n    }\n    if (!res.ok) {\n      throw new Error(data?.message || text || \"Could not load release notes\");\n    }\n    return {\n      ok: true,\n      tag: String(data?.tag_name || normalizedTag || \"\"),\n      name: String(data?.name || \"\"),\n      body: String(data?.body || \"\"),\n      htmlUrl: String(data?.html_url || \"\"),\n      publishedAt: String(data?.published_at || \"\"),\n    };\n  }\n}\n\nexport async function updateAlphaclaw() {\n  const res = await authFetch(\"/api/alphaclaw/update\", { method: \"POST\" });\n  return res.json();\n}\n\nexport async function fetchSyncCron() {\n  const res = await authFetch(\"/api/sync-cron\");\n  const text = await res.text();\n  let data;\n  try {\n    data = text ? JSON.parse(text) : {};\n  } catch {\n    throw new Error(text || \"Could not parse sync cron response\");\n  }\n  if (!res.ok) {\n    throw new Error(data.error || text || `HTTP ${res.status}`);\n  }\n  return data;\n}\n\nexport async function updateSyncCron(payload) {\n  const res = await authFetch(\"/api/sync-cron\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload),\n  });\n  const text = await res.text();\n  let data;\n  try {\n    data = text ? JSON.parse(text) : {};\n  } catch {\n    throw new Error(text || \"Could not parse sync cron response\");\n  }\n  if (!res.ok) {\n    throw new Error(data.error || text || `HTTP ${res.status}`);\n  }\n  return data;\n}\n\nexport async function fetchCronJobs({ sortBy = \"nextRunAtMs\", sortDir = \"asc\" } = {}) {\n  const params = new URLSearchParams();\n  if (sortBy) params.set(\"sortBy\", String(sortBy));\n  if (sortDir) params.set(\"sortDir\", String(sortDir));\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/cron/jobs${suffix}`);\n  return parseJsonOrThrow(res, \"Could not load cron jobs\");\n}\n\nexport async function fetchCronStatus() {\n  const res = await authFetch(\"/api/cron/status\");\n  return parseJsonOrThrow(res, \"Could not load cron status\");\n}\n\nexport async function fetchCronJobRuns(\n  id,\n  {\n    limit = 20,\n    offset = 0,\n    status = \"all\",\n    deliveryStatus = \"all\",\n    sortDir = \"desc\",\n    query = \"\",\n  } = {},\n) {\n  const params = new URLSearchParams({\n    limit: String(limit),\n    offset: String(offset),\n    status: String(status || \"all\"),\n    deliveryStatus: String(deliveryStatus || \"all\"),\n    sortDir: String(sortDir || \"desc\"),\n  });\n  if (String(query || \"\").trim()) params.set(\"query\", String(query).trim());\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/cron/jobs/${safeId}/runs?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load cron run history\");\n}\n\nexport async function fetchCronJobUsage(id, { days = 30 } = {}) {\n  const params = new URLSearchParams({ days: String(days) });\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/cron/jobs/${safeId}/usage?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load cron job usage\");\n}\n\nexport async function fetchCronJobTrends(id, { range = \"7d\" } = {}) {\n  const params = new URLSearchParams({ range: String(range || \"7d\") });\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/cron/jobs/${safeId}/trends?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load cron job trends\");\n}\n\nexport async function fetchCronBulkUsage({ days = 30 } = {}) {\n  const params = new URLSearchParams({ days: String(days) });\n  const res = await authFetch(`/api/cron/usage/bulk?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load cron usage overview\");\n}\n\nexport async function fetchCronBulkRuns({\n  sinceMs = 0,\n  limitPerJob = 20,\n  status = \"all\",\n  deliveryStatus = \"all\",\n  sortDir = \"desc\",\n} = {}) {\n  const params = new URLSearchParams({\n    sinceMs: String(sinceMs || 0),\n    limitPerJob: String(limitPerJob || 20),\n    status: String(status || \"all\"),\n    deliveryStatus: String(deliveryStatus || \"all\"),\n    sortDir: String(sortDir || \"desc\"),\n  });\n  const res = await authFetch(`/api/cron/runs/bulk?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load cron run outcomes\");\n}\n\nexport async function triggerCronJobRun(id) {\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/cron/jobs/${safeId}/run`, { method: \"POST\" });\n  return parseJsonOrThrow(res, \"Could not trigger cron job run\");\n}\n\nexport async function setCronJobEnabled(id, enabled) {\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const action = enabled ? \"enable\" : \"disable\";\n  const res = await authFetch(`/api/cron/jobs/${safeId}/${action}`, {\n    method: \"POST\",\n  });\n  return parseJsonOrThrow(res, \"Could not update cron job state\");\n}\n\nexport async function updateCronJobPrompt(id, message) {\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/cron/jobs/${safeId}/prompt`, {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ message: String(message || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not update cron prompt\");\n}\n\nexport async function updateCronJobRouting(\n  id,\n  {\n    sessionTarget = \"\",\n    wakeMode = \"\",\n    deliveryMode = \"\",\n    deliveryChannel = \"\",\n    deliveryTo = \"\",\n  } = {},\n) {\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/cron/jobs/${safeId}/routing`, {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      sessionTarget: String(sessionTarget || \"\"),\n      wakeMode: String(wakeMode || \"\"),\n      deliveryMode: String(deliveryMode || \"\"),\n      deliveryChannel: String(deliveryChannel || \"\"),\n      deliveryTo: String(deliveryTo || \"\"),\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not update cron routing\");\n}\n\nexport async function fetchDevicePairings() {\n  const res = await authFetch(\"/api/devices\");\n  return res.json();\n}\n\nexport async function approveDevice(id) {\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/devices/${safeId}/approve`, { method: \"POST\" });\n  return parseJsonOrThrow(res, \"Could not approve device\");\n}\n\nexport async function rejectDevice(id) {\n  const safeId = encodeURIComponent(String(id || \"\"));\n  const res = await authFetch(`/api/devices/${safeId}/reject`, { method: \"POST\" });\n  return parseJsonOrThrow(res, \"Could not reject device\");\n}\n\nexport const fetchNodesStatus = async () => {\n  const res = await authFetch(\"/api/nodes\");\n  return parseJsonOrThrow(res, \"Could not load nodes\");\n};\n\nexport const approveNode = async (nodeId) => {\n  const safeNodeId = encodeURIComponent(String(nodeId || \"\"));\n  const res = await authFetch(`/api/nodes/${safeNodeId}/approve`, {\n    method: \"POST\",\n  });\n  return parseJsonOrThrow(res, \"Could not approve node\");\n};\n\nexport const removeNode = async (nodeId) => {\n  const safeNodeId = encodeURIComponent(String(nodeId || \"\"));\n  const res = await authFetch(`/api/nodes/${safeNodeId}`, {\n    method: \"DELETE\",\n  });\n  return parseJsonOrThrow(res, \"Could not remove node\");\n};\n\nexport const routeExecToNode = async (nodeId) => {\n  const safeNodeId = encodeURIComponent(String(nodeId || \"\"));\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), 20000);\n  try {\n    const res = await authFetch(`/api/nodes/${safeNodeId}/route`, {\n      method: \"POST\",\n      signal: controller.signal,\n    });\n    return parseJsonOrThrow(res, \"Could not route execution to node\");\n  } catch (error) {\n    if (String(error?.name || \"\") === \"AbortError\") {\n      throw new Error(\"Routing timed out. Gateway may be restarting or unavailable.\");\n    }\n    throw error;\n  } finally {\n    clearTimeout(timeoutId);\n  }\n};\n\nexport const fetchNodeConnectInfo = async () => {\n  const res = await authFetch(\"/api/nodes/connect-info\");\n  return parseJsonOrThrow(res, \"Could not load connect info\");\n};\n\nexport const fetchNodeBrowserStatusForNode = async (nodeId, profile = \"user\") => {\n  const safeNodeId = encodeURIComponent(String(nodeId || \"\"));\n  const params = new URLSearchParams({ profile: String(profile || \"user\") });\n  const res = await authFetch(\n    `/api/nodes/${safeNodeId}/browser-status?${params.toString()}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load node browser status\");\n};\n\nexport const fetchNodeExecConfig = async () => {\n  const res = await authFetch(\"/api/nodes/exec-config\");\n  return parseJsonOrThrow(res, \"Could not load node exec config\");\n};\n\nexport const saveNodeExecConfig = async (payload) => {\n  const res = await authFetch(\"/api/nodes/exec-config\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not save node exec config\");\n};\n\nexport const fetchNodeExecApprovals = async () => {\n  const res = await authFetch(\"/api/nodes/exec-approvals\");\n  return parseJsonOrThrow(res, \"Could not load node exec approvals\");\n};\n\nexport const addNodeExecAllowlistPattern = async (pattern) => {\n  const res = await authFetch(\"/api/nodes/exec-approvals/allowlist\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ pattern: String(pattern || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not add allowlist pattern\");\n};\n\nexport const removeNodeExecAllowlistPattern = async (entryId) => {\n  const safeEntryId = encodeURIComponent(String(entryId || \"\"));\n  const res = await authFetch(`/api/nodes/exec-approvals/allowlist/${safeEntryId}`, {\n    method: \"DELETE\",\n  });\n  return parseJsonOrThrow(res, \"Could not remove allowlist pattern\");\n};\n\nexport const fetchAuthStatus = async () => {\n  const res = await authFetch(\"/api/auth/status\");\n  return res.json();\n};\n\nexport const logout = async () => {\n  const res = await authFetch(\"/api/auth/logout\", { method: \"POST\" });\n  return res.json();\n};\n\nexport async function fetchOnboardStatus() {\n  const res = await authFetch(\"/api/onboard/status\");\n  return res.json();\n}\n\nexport async function runOnboard(vars, modelKey, { importMode = false } = {}) {\n  const res = await authFetch(\"/api/onboard\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ vars, modelKey, importMode }),\n  });\n  return res.json();\n}\n\nexport async function verifyGithubOnboardingRepo(repo, token, mode = \"new\") {\n  const res = await authFetch(\"/api/onboard/github/verify\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ repo, token, mode }),\n  });\n  return res.json();\n}\n\nexport async function scanImportRepo(tempDir) {\n  const res = await authFetch(\"/api/onboard/import/scan\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ tempDir }),\n  });\n  return res.json();\n}\n\nexport async function applyImport({\n  tempDir,\n  approvedSecrets = [],\n  skipSecretExtraction = false,\n  githubRepo = \"\",\n  githubToken = \"\",\n}) {\n  const res = await authFetch(\"/api/onboard/import/apply\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      tempDir,\n      approvedSecrets,\n      skipSecretExtraction,\n      githubRepo,\n      githubToken,\n    }),\n  });\n  return res.json();\n}\n\nexport const fetchModels = async () => {\n  const res = await authFetch(\"/api/models\");\n  return res.json();\n};\n\nexport const fetchModelStatus = async () => {\n  const res = await authFetch(\"/api/models/status\");\n  return res.json();\n};\n\nexport const setPrimaryModel = async (modelKey) => {\n  const res = await authFetch(\"/api/models/set\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ modelKey }),\n  });\n  return res.json();\n};\n\nexport const fetchModelsConfig = async ({ agentId } = {}) => {\n  const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : \"\";\n  const res = await authFetch(`/api/models/config${qs}`);\n  return res.json();\n};\n\nexport const saveModelsConfig = async ({\n  primary,\n  configuredModels,\n  profiles,\n  authOrder,\n  agentId,\n} = {}) => {\n  const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : \"\";\n  const res = await authFetch(`/api/models/config${qs}`, {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ primary, configuredModels, profiles, authOrder }),\n  });\n  return res.json();\n};\n\nexport const fetchAuthProfiles = async () => {\n  const res = await authFetch(\"/api/models/auth\");\n  return res.json();\n};\n\nexport const upsertAuthProfile = async (profileId, credential) => {\n  const res = await authFetch(\n    `/api/models/auth/${encodeURIComponent(profileId)}`,\n    {\n      method: \"PUT\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(credential),\n    },\n  );\n  return res.json();\n};\n\nexport const deleteAuthProfile = async (profileId) => {\n  const res = await authFetch(\n    `/api/models/auth/${encodeURIComponent(profileId)}`,\n    {\n      method: \"DELETE\",\n    },\n  );\n  return res.json();\n};\n\nexport const fetchAgents = async () => {\n  const res = await authFetch(\"/api/agents\");\n  return parseJsonOrThrow(res, \"Could not load agents\");\n};\n\nexport const fetchChannelAccounts = async () => {\n  const res = await authFetch(\"/api/channels/accounts\");\n  return parseJsonOrThrow(res, \"Could not load channel accounts\");\n};\n\nexport const fetchChannelAccountToken = async ({\n  provider = \"\",\n  accountId = \"default\",\n} = {}) => {\n  const params = new URLSearchParams({\n    provider: String(provider || \"\"),\n    accountId: String(accountId || \"default\"),\n  });\n  const res = await authFetch(`/api/channels/accounts/token?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load channel token\");\n};\n\nexport const createChannelAccount = async (payload) => {\n  const res = await authFetch(\"/api/channels/accounts\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not create channel account\");\n};\n\nexport const createChannelAccountJob = async (payload) => {\n  const res = await authFetch(\"/api/channels/accounts/jobs\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not start channel account operation\");\n};\n\nexport const subscribeOperationEvents = ({\n  operationId = \"\",\n  onMessage = () => {},\n  onError = () => {},\n}) =>\n  subscribeToSse({\n    url: `/api/operations/${encodeURIComponent(String(operationId || \"\"))}/events`,\n    onMessage,\n    onError,\n  });\n\nexport const updateChannelAccount = async (payload) => {\n  const res = await authFetch(\"/api/channels/accounts\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not update channel account\");\n};\n\nexport const deleteChannelAccount = async (payload) => {\n  const res = await authFetch(\"/api/channels/accounts\", {\n    method: \"DELETE\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not delete channel account\");\n};\n\nexport const runChannelAccountLogin = async (payload) => {\n  const res = await authFetch(\"/api/channels/accounts/login\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not run channel login\");\n};\n\nexport const fetchChannelAccountLoginStatus = async ({\n  provider = \"\",\n  accountId = \"default\",\n} = {}) => {\n  const params = new URLSearchParams({\n    provider: String(provider || \"\"),\n    accountId: String(accountId || \"default\"),\n  });\n  const res = await authFetch(\n    `/api/channels/accounts/login-status?${params.toString()}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load channel login status\");\n};\n\nexport const fetchAgent = async (agentId) => {\n  const res = await authFetch(`/api/agents/${encodeURIComponent(String(agentId || \"\"))}`);\n  return parseJsonOrThrow(res, \"Could not load agent\");\n};\n\nexport const fetchAgentWorkspaceSize = async (agentId) => {\n  const res = await authFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}/workspace-size`,\n  );\n  return parseJsonOrThrow(res, \"Could not load workspace size\");\n};\n\nexport const fetchAgentBindings = async (agentId) => {\n  const res = await authFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}/bindings`,\n  );\n  return parseJsonOrThrow(res, \"Could not load agent bindings\");\n};\n\nexport const createAgent = async (payload) => {\n  const res = await authFetch(\"/api/agents\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not create agent\");\n};\n\nexport const updateAgent = async (agentId, payload) => {\n  const res = await authFetch(`/api/agents/${encodeURIComponent(String(agentId || \"\"))}`, {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify(payload || {}),\n  });\n  return parseJsonOrThrow(res, \"Could not update agent\");\n};\n\nexport const addAgentBinding = async (agentId, payload) => {\n  const res = await authFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}/bindings`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload || {}),\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not add agent binding\");\n};\n\nexport const removeAgentBinding = async (agentId, payload) => {\n  const res = await authFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}/bindings`,\n    {\n      method: \"DELETE\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(payload || {}),\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not remove agent binding\");\n};\n\nexport const deleteAgent = async (agentId, { keepWorkspace = true } = {}) => {\n  const query = new URLSearchParams({\n    keepWorkspace: keepWorkspace ? \"true\" : \"false\",\n  });\n  const res = await authFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}?${query.toString()}`,\n    { method: \"DELETE\" },\n  );\n  return parseJsonOrThrow(res, \"Could not delete agent\");\n};\n\nexport const setDefaultAgent = async (agentId) => {\n  const res = await authFetch(\n    `/api/agents/${encodeURIComponent(String(agentId || \"\"))}/default`,\n    { method: \"POST\" },\n  );\n  return parseJsonOrThrow(res, \"Could not set default agent\");\n};\n\nexport const fetchCodexStatus = async () => {\n  const res = await authFetch(\"/api/codex/status\");\n  return res.json();\n};\n\nexport const disconnectCodex = async () => {\n  const res = await authFetch(\"/api/codex/disconnect\", { method: \"POST\" });\n  return res.json();\n};\n\nexport const exchangeCodexOAuth = async (input) => {\n  const res = await authFetch(\"/api/codex/exchange\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ input }),\n  });\n  return res.json();\n};\n\nexport async function fetchEnvVars() {\n  const res = await authFetch(\"/api/env\");\n  return res.json();\n}\n\nexport async function saveEnvVars(vars) {\n  const res = await authFetch(\"/api/env\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ vars }),\n  });\n  const text = await res.text();\n  let data;\n  try {\n    data = text ? JSON.parse(text) : {};\n  } catch {\n    throw new Error(text || \"Could not parse env save response\");\n  }\n  if (!res.ok) {\n    throw new Error(data.error || text || `HTTP ${res.status}`);\n  }\n  return data;\n}\n\nconst parseJsonOrThrow = async (res, fallbackError) => {\n  const text = await res.text();\n  let data;\n  try {\n    data = text ? JSON.parse(text) : {};\n  } catch {\n    throw new Error(text || fallbackError);\n  }\n  if (!res.ok || data?.ok === false) {\n    throw new Error(data.error || text || `HTTP ${res.status}`);\n  }\n  return data;\n};\n\nexport async function fetchWebhooks() {\n  const res = await authFetch(\"/api/webhooks\");\n  return parseJsonOrThrow(res, \"Could not load webhooks\");\n}\n\nexport async function fetchWebhookDetail(name) {\n  const res = await authFetch(`/api/webhooks/${encodeURIComponent(name)}`);\n  return parseJsonOrThrow(res, \"Could not load webhook detail\");\n}\n\nexport async function createWebhook(\n  name,\n  { destination = null, oauthCallback = false } = {},\n) {\n  const res = await authFetch(\"/api/webhooks\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({\n      name,\n      ...(destination ? { destination } : {}),\n      oauthCallback: !!oauthCallback,\n    }),\n  });\n  return parseJsonOrThrow(res, \"Could not create webhook\");\n}\n\nexport async function deleteWebhook(name, { deleteTransformDir = false } = {}) {\n  const res = await authFetch(`/api/webhooks/${encodeURIComponent(name)}`, {\n    method: \"DELETE\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ deleteTransformDir: !!deleteTransformDir }),\n  });\n  return parseJsonOrThrow(res, \"Could not delete webhook\");\n}\n\nexport async function updateWebhookDestination(name, { destination = null } = {}) {\n  const res = await authFetch(\n    `/api/webhooks/${encodeURIComponent(name)}/destination`,\n    {\n      method: \"PUT\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        destination,\n      }),\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not update webhook destination\");\n}\n\nexport async function createWebhookOauthCallback(name) {\n  const res = await authFetch(\n    `/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,\n    {\n      method: \"POST\",\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not enable OAuth callback\");\n}\n\nexport async function rotateWebhookOauthCallback(name) {\n  const res = await authFetch(\n    `/api/webhooks/${encodeURIComponent(name)}/oauth-callback/rotate`,\n    {\n      method: \"POST\",\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not rotate OAuth callback\");\n}\n\nexport async function deleteWebhookOauthCallback(name) {\n  const res = await authFetch(\n    `/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,\n    {\n      method: \"DELETE\",\n    },\n  );\n  return parseJsonOrThrow(res, \"Could not delete OAuth callback\");\n}\n\nexport async function fetchWebhookRequests(\n  name,\n  { limit = 50, offset = 0, status = \"all\" } = {},\n) {\n  const params = new URLSearchParams({\n    limit: String(limit),\n    offset: String(offset),\n    status: String(status || \"all\"),\n  });\n  const res = await authFetch(\n    `/api/webhooks/${encodeURIComponent(name)}/requests?${params.toString()}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load webhook requests\");\n}\n\nexport async function fetchWebhookRequest(name, id) {\n  const res = await authFetch(\n    `/api/webhooks/${encodeURIComponent(name)}/requests/${encodeURIComponent(String(id))}`,\n  );\n  return parseJsonOrThrow(res, \"Could not load webhook request\");\n}\n\nexport const fetchBrowseTree = async (depth = 10) => {\n  const params = new URLSearchParams({ depth: String(depth) });\n  const res = await authFetch(`/api/browse/tree?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load file tree\");\n};\n\nexport const fetchFileContent = async (filePath) => {\n  const params = new URLSearchParams({ path: String(filePath || \"\") });\n  const res = await authFetch(`/api/browse/read?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load file content\");\n};\n\nexport const saveFileContent = async (filePath, content) => {\n  const normalizedPath = String(filePath || \"\");\n  const normalizedContent = typeof content === \"string\" ? content : String(content ?? \"\");\n  const res = await authFetch(\"/api/browse/write\", {\n    method: \"PUT\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ path: normalizedPath, content: normalizedContent }),\n  });\n  return parseJsonOrThrow(res, \"Could not save file\");\n};\n\nexport const createBrowseFile = async (filePath) => {\n  const res = await authFetch(\"/api/browse/create-file\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ path: String(filePath || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not create file\");\n};\n\nexport const createBrowseFolder = async (folderPath) => {\n  const res = await authFetch(\"/api/browse/create-folder\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ path: String(folderPath || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not create folder\");\n};\n\nexport const moveBrowsePath = async (from, to) => {\n  const res = await authFetch(\"/api/browse/move\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ from: String(from || \"\"), to: String(to || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not move path\");\n};\n\nexport const deleteBrowseFile = async (filePath) => {\n  const res = await authFetch(\"/api/browse/delete\", {\n    method: \"DELETE\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ path: String(filePath || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not delete file\");\n};\n\nexport const downloadBrowseFile = async (filePath) => {\n  const params = new URLSearchParams({ path: String(filePath || \"\") });\n  const res = await authFetch(`/api/browse/download?${params.toString()}`);\n  if (!res.ok) {\n    const errorText = await res.text();\n    throw new Error(errorText || \"Could not download file\");\n  }\n  const fileBlob = await res.blob();\n  const urlApi = window?.URL || URL;\n  if (!urlApi?.createObjectURL || !urlApi?.revokeObjectURL) {\n    throw new Error(\"Download is not supported in this browser\");\n  }\n  const downloadUrl = urlApi.createObjectURL(fileBlob);\n  const fileName =\n    String(filePath || \"\")\n      .split(\"/\")\n      .filter(Boolean)\n      .pop() || \"download\";\n  try {\n    const downloadLink = document.createElement(\"a\");\n    downloadLink.href = downloadUrl;\n    downloadLink.download = fileName;\n    document.body?.appendChild(downloadLink);\n    downloadLink.click();\n    downloadLink.remove();\n  } finally {\n    urlApi.revokeObjectURL(downloadUrl);\n  }\n  return { ok: true };\n};\n\nexport const restoreBrowseFile = async (filePath) => {\n  const res = await authFetch(\"/api/browse/restore\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ path: String(filePath || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not restore file\");\n};\n\nexport const fetchBrowseGitSummary = async () => {\n  const res = await authFetch(\"/api/browse/git-summary\");\n  return parseJsonOrThrow(res, \"Could not load git summary\");\n};\n\nexport const fetchBrowseFileDiff = async (filePath) => {\n  const params = new URLSearchParams({ path: String(filePath || \"\") });\n  const res = await authFetch(`/api/browse/git-diff?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load file diff\");\n};\n\nexport const fetchBrowseSqliteTable = async ({\n  filePath,\n  table,\n  limit = 50,\n  offset = 0,\n}) => {\n  const params = new URLSearchParams({\n    path: String(filePath || \"\"),\n    table: String(table || \"\"),\n    limit: String(limit),\n    offset: String(offset),\n  });\n  const res = await authFetch(`/api/browse/sqlite-table?${params.toString()}`);\n  return parseJsonOrThrow(res, \"Could not load sqlite table data\");\n};\n\nexport const syncBrowseChanges = async (message = \"\") => {\n  const res = await authFetch(\"/api/browse/git-sync\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ message: String(message || \"\") }),\n  });\n  return parseJsonOrThrow(res, \"Could not sync changes\");\n};\n"
  },
  {
    "path": "lib/public/js/lib/app-navigation.js",
    "content": "export const kDefaultUiTab = \"general\";\n\nexport const kNavSections = [\n  {\n    label: \"Setup\",\n    items: [\n      { id: \"general\", label: \"General\" },\n    ],\n  },\n  {\n    label: \"Monitoring\",\n    items: [\n      { id: \"cron\", label: \"Cron\" },\n      { id: \"usage\", label: \"Usage\" },\n      { id: \"doctor\", label: \"Doctor\" },\n      { id: \"watchdog\", label: \"Watchdog\" },\n    ],\n  },\n  {\n    label: \"Config\",\n    items: [\n      { id: \"models\", label: \"Models\" },\n      { id: \"envars\", label: \"Envars\" },\n      { id: \"webhooks\", label: \"Webhooks\" },\n      { id: \"nodes\", label: \"Nodes\" },\n    ],\n  },\n];\n\nexport const getSelectedNavId = ({ isBrowseRoute = false, location = \"\" } = {}) => {\n  if (isBrowseRoute) return \"browse\";\n  if (location.startsWith(\"/telegram\")) return \"\";\n  if (location.startsWith(\"/chat\")) return \"\";\n  if (location.startsWith(\"/models\")) return \"models\";\n  if (location.startsWith(\"/agents\")) return \"agents\";\n  if (location.startsWith(\"/providers\")) return \"models\";\n  if (location.startsWith(\"/watchdog\")) return \"watchdog\";\n  if (location.startsWith(\"/cron\")) return \"cron\";\n  if (location.startsWith(\"/usage\")) return \"usage\";\n  if (location.startsWith(\"/doctor\")) return \"doctor\";\n  if (location.startsWith(\"/nodes\")) return \"nodes\";\n  if (location.startsWith(\"/envars\")) return \"envars\";\n  if (location.startsWith(\"/webhooks\")) return \"webhooks\";\n  return kDefaultUiTab;\n};\n"
  },
  {
    "path": "lib/public/js/lib/browse-draft-state.js",
    "content": "import {\n  kFileDraftStorageKeyPrefix,\n  kDraftIndexStorageKey,\n} from \"./storage-keys.js\";\n\nexport { kFileDraftStorageKeyPrefix, kDraftIndexStorageKey };\nexport const kDraftIndexChangedEventName = \"alphaclaw:browse-draft-index-changed\";\n\nconst getStorage = (storage) => storage || window.localStorage;\n\nexport const getFileDraftStorageKey = (filePath) =>\n  `${kFileDraftStorageKeyPrefix}${String(filePath || \"\").trim()}`;\n\nexport const readStoredFileDraft = (filePath, storage) => {\n  try {\n    if (!filePath) return \"\";\n    const localStorage = getStorage(storage);\n    const draft = localStorage.getItem(getFileDraftStorageKey(filePath));\n    return typeof draft === \"string\" ? draft : \"\";\n  } catch {\n    return \"\";\n  }\n};\n\nexport const writeStoredFileDraft = (filePath, content, storage) => {\n  try {\n    if (!filePath) return;\n    const localStorage = getStorage(storage);\n    localStorage.setItem(getFileDraftStorageKey(filePath), String(content || \"\"));\n  } catch {}\n};\n\nexport const clearStoredFileDraft = (filePath, storage) => {\n  try {\n    if (!filePath) return;\n    const localStorage = getStorage(storage);\n    localStorage.removeItem(getFileDraftStorageKey(filePath));\n  } catch {}\n};\n\nexport const readDraftIndex = (storage) => {\n  try {\n    const localStorage = getStorage(storage);\n    const rawValue = localStorage.getItem(kDraftIndexStorageKey);\n    if (!rawValue) return new Set();\n    const parsedValue = JSON.parse(rawValue);\n    if (!Array.isArray(parsedValue)) return new Set();\n    return new Set(\n      parsedValue.map((entry) => String(entry || \"\").trim()).filter(Boolean),\n    );\n  } catch {\n    return new Set();\n  }\n};\n\nexport const writeDraftIndex = (draftPaths, options = {}) => {\n  const { storage, dispatchEvent } = options;\n  try {\n    const localStorage = getStorage(storage);\n    const normalizedPaths = Array.from(draftPaths).sort((left, right) =>\n      left.localeCompare(right),\n    );\n    localStorage.setItem(kDraftIndexStorageKey, JSON.stringify(normalizedPaths));\n    if (dispatchEvent) {\n      dispatchEvent(\n        new CustomEvent(kDraftIndexChangedEventName, {\n          detail: { paths: normalizedPaths },\n        }),\n      );\n    }\n  } catch {}\n};\n\nexport const updateDraftIndex = (filePath, hasDraft, options = {}) => {\n  const { storage, dispatchEvent } = options;\n  if (!filePath) return;\n  const normalizedPath = String(filePath || \"\").trim();\n  if (!normalizedPath) return;\n  const nextDraftPaths = readDraftIndex(storage);\n  if (hasDraft) nextDraftPaths.add(normalizedPath);\n  else nextDraftPaths.delete(normalizedPath);\n  writeDraftIndex(nextDraftPaths, { storage, dispatchEvent });\n};\n\nexport const readStoredDraftPaths = (storage) => {\n  try {\n    const localStorage = getStorage(storage);\n    const draftIndex = readDraftIndex(localStorage);\n    if (draftIndex.size > 0) return draftIndex;\n    const nextDraftPaths = new Set();\n    for (let index = 0; index < localStorage.length; index += 1) {\n      const key = localStorage.key(index) || \"\";\n      if (key.startsWith(kFileDraftStorageKeyPrefix)) {\n        const path = key.slice(kFileDraftStorageKeyPrefix.length).trim();\n        if (path) nextDraftPaths.add(path);\n      }\n    }\n    if (nextDraftPaths.size > 0) {\n      writeDraftIndex(nextDraftPaths, { storage: localStorage });\n    }\n    return nextDraftPaths;\n  } catch {\n    return new Set();\n  }\n};\n"
  },
  {
    "path": "lib/public/js/lib/browse-file-policies.js",
    "content": "const kBrowseFilePoliciesUrl = new URL(\n  \"../../shared/browse-file-policies.json\",\n  import.meta.url,\n);\n\nlet kBrowseFilePolicies = {\n  protectedPaths: [],\n  lockedPaths: [],\n};\ntry {\n  const policyResponse = await fetch(kBrowseFilePoliciesUrl);\n  if (policyResponse.ok) {\n    const policyJson = await policyResponse.json();\n    if (policyJson && typeof policyJson === \"object\") {\n      kBrowseFilePolicies = policyJson;\n    }\n  }\n} catch {}\n\nexport const kProtectedBrowsePaths = new Set(\n  Array.isArray(kBrowseFilePolicies?.protectedPaths)\n    ? kBrowseFilePolicies.protectedPaths\n    : [],\n);\n\nexport const kLockedBrowsePaths = new Set(\n  Array.isArray(kBrowseFilePolicies?.lockedPaths)\n    ? kBrowseFilePolicies.lockedPaths\n    : [],\n);\n\nexport const normalizeBrowsePolicyPath = (inputPath) =>\n  String(inputPath || \"\")\n    .replaceAll(\"\\\\\", \"/\")\n    .replace(/^\\.\\/+/, \"\")\n    .replace(/^\\/+/, \"\")\n    .trim()\n    .toLowerCase();\n\nexport const matchesBrowsePolicyPath = (policyPathSet, normalizedPath) => {\n  const safeNormalizedPath = String(normalizedPath || \"\").trim();\n  if (!safeNormalizedPath) return false;\n  for (const policyPath of policyPathSet) {\n    if (\n      safeNormalizedPath === policyPath ||\n      safeNormalizedPath.endsWith(`/${policyPath}`) ||\n      safeNormalizedPath.startsWith(`${policyPath}/`) ||\n      safeNormalizedPath.includes(`/${policyPath}/`)\n    ) {\n      return true;\n    }\n  }\n  return false;\n};\n"
  },
  {
    "path": "lib/public/js/lib/browse-restart-policy.js",
    "content": "const kBrowseRestartRequiredRules = [\n  { type: \"file\", path: \"openclaw.json\" },\n  { type: \"directory\", path: \"hooks/transforms\" },\n];\n\nconst normalizeRestartRulePath = (value) =>\n  String(value || \"\")\n    .trim()\n    .replace(/^\\/+|\\/+$/g, \"\");\n\nconst matchesBrowseRestartRequiredRule = (path, rule) => {\n  const normalizedPath = normalizeRestartRulePath(path);\n  if (!normalizedPath) return false;\n  if (!rule || typeof rule !== \"object\") return false;\n  const type = String(rule.type || \"\").toLowerCase();\n  const targetPath = normalizeRestartRulePath(rule.path);\n  if (!targetPath) return false;\n  if (type === \"directory\") {\n    return normalizedPath === targetPath || normalizedPath.startsWith(`${targetPath}/`);\n  }\n  if (type === \"file\") {\n    return normalizedPath === targetPath;\n  }\n  return false;\n};\n\nexport const shouldRequireRestartForBrowsePath = (path) =>\n  kBrowseRestartRequiredRules.some((rule) => matchesBrowseRestartRequiredRule(path, rule));\n"
  },
  {
    "path": "lib/public/js/lib/browse-route.js",
    "content": "export const normalizeBrowsePath = (value) => String(value || \"\").replace(/^\\/+|\\/+$/g, \"\");\n\nexport const buildBrowseRoute = (relativePath, options = {}) => {\n  const view = String(options?.view || \"edit\");\n  const encodedPath = String(relativePath || \"\")\n    .split(\"/\")\n    .filter(Boolean)\n    .map((segment) => encodeURIComponent(segment))\n    .join(\"/\");\n  const baseRoute = encodedPath ? `/browse/${encodedPath}` : \"/browse\";\n  const params = new URLSearchParams();\n  if (view === \"diff\" && encodedPath) params.set(\"view\", \"diff\");\n  if (options.line) params.set(\"line\", String(options.line));\n  if (options.lineEnd) params.set(\"lineEnd\", String(options.lineEnd));\n  const query = params.toString();\n  return query ? `${baseRoute}?${query}` : baseRoute;\n};\n\nexport const parseBrowseRoute = ({ location = \"\", browsePreviewPath = \"\" } = {}) => {\n  const isBrowseRoute = location.startsWith(\"/browse\");\n  const browseRoutePath = isBrowseRoute ? String(location || \"\").split(\"?\")[0] : \"\";\n  const browseRouteQuery =\n    isBrowseRoute && String(location || \"\").includes(\"?\")\n      ? String(location || \"\").split(\"?\").slice(1).join(\"?\")\n      : \"\";\n  const selectedBrowsePath = isBrowseRoute\n    ? browseRoutePath\n        .replace(/^\\/browse\\/?/, \"\")\n        .split(\"/\")\n        .filter(Boolean)\n        .map((segment) => {\n          try {\n            return decodeURIComponent(segment);\n          } catch {\n            return segment;\n          }\n        })\n        .join(\"/\")\n    : \"\";\n  const activeBrowsePath = browsePreviewPath || selectedBrowsePath;\n  const browseQueryParams = isBrowseRoute ? new URLSearchParams(browseRouteQuery) : null;\n  const browseViewerMode =\n    !browsePreviewPath && browseQueryParams?.get(\"view\") === \"diff\"\n      ? \"diff\"\n      : \"edit\";\n  const browseLineTarget = Number.parseInt(browseQueryParams?.get(\"line\") || \"\", 10) || 0;\n  const browseLineEndTarget = Number.parseInt(browseQueryParams?.get(\"lineEnd\") || \"\", 10) || 0;\n\n  return {\n    activeBrowsePath,\n    browseLineEndTarget,\n    browseLineTarget,\n    browseViewerMode,\n    isBrowseRoute,\n    selectedBrowsePath,\n  };\n};\n"
  },
  {
    "path": "lib/public/js/lib/channel-accounts.js",
    "content": "export const resolveChannelAccountLabel = ({\n  channelId,\n  account = {},\n  providerLabel = \"\",\n}) => {\n  const fallbackProviderLabel = channelId\n    ? channelId.charAt(0).toUpperCase() + channelId.slice(1)\n    : \"Channel\";\n  const resolvedProviderLabel = String(providerLabel || \"\").trim()\n    || fallbackProviderLabel;\n  const configuredName = String(account?.name || \"\").trim();\n  if (configuredName) return configuredName;\n  const accountId = String(account?.id || \"\").trim();\n  if (!accountId || accountId === \"default\") return resolvedProviderLabel;\n  return `${resolvedProviderLabel} ${accountId}`;\n};\n\nexport const isImplicitDefaultAccount = ({ accountId, boundAgentId }) =>\n  String(accountId || \"\").trim() === \"default\" &&\n  !String(boundAgentId || \"\").trim();\n"
  },
  {
    "path": "lib/public/js/lib/channel-create-operation.js",
    "content": "import {\n  createChannelAccount,\n  createChannelAccountJob,\n  subscribeOperationEvents,\n} from \"./api.js\";\n\nexport const createChannelAccountWithProgress = async ({\n  payload = {},\n  onPhase = () => {},\n}) => {\n  onPhase(\"Loading...\");\n  if (typeof window?.EventSource !== \"function\") {\n    return createChannelAccount(payload);\n  }\n  const startResult = await createChannelAccountJob(payload);\n  const operationId = String(startResult?.operationId || \"\").trim();\n  if (!operationId) {\n    throw new Error(\"Could not start channel creation operation\");\n  }\n  return new Promise((resolve, reject) => {\n    let settleCalled = false;\n    let activePhase = \"\";\n    let activePhaseAtMs = 0;\n    let deferredPhase = null;\n    let deferredTimer = null;\n    const kPhaseMinimumVisibleMs = {\n      restarting: 1200,\n    };\n    const clearDeferredTimer = () => {\n      if (!deferredTimer) return;\n      clearTimeout(deferredTimer);\n      deferredTimer = null;\n    };\n    const applyPhase = ({ phase = \"\", label = \"\" } = {}) => {\n      const nextPhase = String(phase || \"\").trim();\n      const nextLabel = String(label || \"\").trim();\n      if (!nextLabel) return;\n      const minVisibleMs = Number(kPhaseMinimumVisibleMs[activePhase] || 0);\n      const elapsedMs = activePhaseAtMs > 0 ? Date.now() - activePhaseAtMs : 0;\n      if (\n        minVisibleMs > 0 &&\n        nextPhase &&\n        nextPhase !== activePhase &&\n        elapsedMs < minVisibleMs\n      ) {\n        deferredPhase = { phase: nextPhase, label: nextLabel };\n        clearDeferredTimer();\n        deferredTimer = setTimeout(() => {\n          deferredTimer = null;\n          const next = deferredPhase;\n          deferredPhase = null;\n          if (!next) return;\n          applyPhase(next);\n        }, minVisibleMs - elapsedMs);\n        return;\n      }\n      clearDeferredTimer();\n      deferredPhase = null;\n      onPhase(nextLabel);\n      activePhase = nextPhase;\n      activePhaseAtMs = Date.now();\n    };\n    const closeWithCleanup = () => {\n      clearDeferredTimer();\n      close();\n    };\n    const close = subscribeOperationEvents({\n      operationId,\n      onMessage: (entry) => {\n        const eventName = String(entry?.event || \"\").trim();\n        if (eventName === \"phase\") {\n          applyPhase({\n            phase: String(entry?.data?.phase || \"\").trim(),\n            label: String(entry?.data?.label || \"\").trim(),\n          });\n          return;\n        }\n        if (eventName === \"done\") {\n          if (settleCalled) return;\n          settleCalled = true;\n          closeWithCleanup();\n          resolve(entry?.data || {});\n          return;\n        }\n        if (eventName === \"error\") {\n          if (settleCalled) return;\n          settleCalled = true;\n          closeWithCleanup();\n          reject(\n            new Error(String(entry?.data?.error || \"Could not create channel\")),\n          );\n        }\n      },\n      onError: () => {\n        if (settleCalled) return;\n        settleCalled = true;\n        closeWithCleanup();\n        reject(new Error(\"Channel operation stream disconnected\"));\n      },\n    });\n  });\n};\n"
  },
  {
    "path": "lib/public/js/lib/channel-provider-availability.js",
    "content": "const kSingleAccountChannelProviders = new Set([\"discord\", \"whatsapp\"]);\n\nconst hasConfiguredAccounts = ({ configuredChannelMap, provider }) => {\n  const channelEntry = configuredChannelMap instanceof Map\n    ? configuredChannelMap.get(String(provider || \"\").trim())\n    : null;\n  return (\n    Array.isArray(channelEntry?.accounts) &&\n    channelEntry.accounts.length > 0\n  );\n};\n\nexport const isSingleAccountChannelProvider = (provider = \"\") =>\n  kSingleAccountChannelProviders.has(String(provider || \"\").trim());\n\nexport const isChannelProviderDisabledForAdd = ({\n  configuredChannelMap = new Map(),\n  provider = \"\",\n} = {}) => {\n  if (!isSingleAccountChannelProvider(provider)) return false;\n  return hasConfiguredAccounts({ configuredChannelMap, provider });\n};\n"
  },
  {
    "path": "lib/public/js/lib/clipboard.js",
    "content": "export const copyTextToClipboard = async (value) => {\n  const text = String(value || \"\");\n  if (!text) return false;\n\n  try {\n    if (navigator?.clipboard?.writeText) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n  } catch {}\n\n  let fallbackElement = null;\n  let appendedFallbackElement = false;\n  try {\n    if (\n      !document?.createElement ||\n      !document?.body?.appendChild ||\n      !document?.body?.removeChild ||\n      typeof document.execCommand !== \"function\"\n    ) {\n      return false;\n    }\n\n    fallbackElement = document.createElement(\"textarea\");\n    fallbackElement.value = text;\n    fallbackElement.setAttribute(\"readonly\", \"\");\n    fallbackElement.style.position = \"fixed\";\n    fallbackElement.style.opacity = \"0\";\n    document.body.appendChild(fallbackElement);\n    appendedFallbackElement = true;\n    fallbackElement.select();\n    return document.execCommand(\"copy\");\n  } catch {\n    return false;\n  } finally {\n    if (fallbackElement && appendedFallbackElement) {\n      document.body.removeChild(fallbackElement);\n    }\n  }\n};\n"
  },
  {
    "path": "lib/public/js/lib/codex-oauth-window.js",
    "content": "const kCodexAuthStartPath = \"/auth/codex/start\";\nconst kCodexAuthWindowName = \"codex-auth\";\nconst kCodexAuthPopupFeatures = \"popup=yes,width=640,height=780\";\nconst kCodexAuthCallbackMessageType = \"callback-input\";\n\nexport const openCodexAuthWindow = () => {\n  const popup = window.open(\n    kCodexAuthStartPath,\n    kCodexAuthWindowName,\n    kCodexAuthPopupFeatures,\n  );\n  if (!popup || popup.closed) {\n    window.location.href = kCodexAuthStartPath;\n    return null;\n  }\n  return popup;\n};\n\nexport const isCodexAuthCallbackMessage = (value) =>\n  value?.codex === kCodexAuthCallbackMessageType &&\n  typeof value.input === \"string\" &&\n  value.input.trim().length > 0;\n"
  },
  {
    "path": "lib/public/js/lib/file-highlighting.js",
    "content": "export {\n  formatFrontmatterValue,\n  getFileSyntaxKind,\n  highlightEditorLines,\n  parseFrontmatter,\n} from \"./syntax-highlighters/index.js\";\n"
  },
  {
    "path": "lib/public/js/lib/file-tree-utils.js",
    "content": "export const collectAncestorFolderPaths = (targetPath) => {\n  const normalizedPath = String(targetPath || \"\")\n    .split(\"/\")\n    .map((segment) => segment.trim())\n    .filter(Boolean);\n  if (normalizedPath.length <= 1) return [];\n  const ancestors = [];\n  for (let index = 0; index < normalizedPath.length - 1; index += 1) {\n    ancestors.push(normalizedPath.slice(0, index + 1).join(\"/\"));\n  }\n  return ancestors;\n};\n"
  },
  {
    "path": "lib/public/js/lib/format.js",
    "content": "const kIntegerFormatter = new Intl.NumberFormat(\"en-US\");\nconst kCompactNumberFormatter = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  minimumFractionDigits: 1,\n  maximumFractionDigits: 1,\n});\nconst kUsdFormatter = new Intl.NumberFormat(\"en-US\", {\n  style: \"currency\",\n  currency: \"USD\",\n  minimumFractionDigits: 2,\n  maximumFractionDigits: 3,\n});\n\nconst toDateValue = (\n  value,\n  { valueIsUnixSeconds = false, valueIsEpochMs = false } = {},\n) => {\n  if (value == null || value === \"\") return null;\n  if (value instanceof Date) return value;\n  if (valueIsUnixSeconds) return new Date(Number(value) * 1000);\n  if (valueIsEpochMs) return new Date(Number(value));\n  return new Date(value);\n};\n\nconst isSameDay = (left, right) =>\n  left.getFullYear() === right.getFullYear() &&\n  left.getMonth() === right.getMonth() &&\n  left.getDate() === right.getDate();\n\nexport const formatInteger = (value) =>\n  kIntegerFormatter.format(Number(value || 0));\n\nexport const formatCompactNumber = (value) => {\n  const numberValue = Number(value || 0);\n  if (!Number.isFinite(numberValue)) return \"0\";\n  if (Math.abs(numberValue) < 1000) return formatInteger(numberValue);\n  return kCompactNumberFormatter.format(numberValue);\n};\n\nexport const formatBytes = (value) => {\n  const bytes = Number(value || 0);\n  if (!Number.isFinite(bytes) || bytes <= 0) return \"0 B\";\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  let unitIndex = 0;\n  let nextValue = bytes;\n  while (nextValue >= 1024 && unitIndex < units.length - 1) {\n    nextValue /= 1024;\n    unitIndex += 1;\n  }\n  const precision = nextValue >= 100 || unitIndex === 0 ? 0 : nextValue >= 10 ? 1 : 2;\n  return `${nextValue.toFixed(precision)} ${units[unitIndex]}`;\n};\n\nexport const formatUsd = (value) => kUsdFormatter.format(Number(value || 0));\n\nexport const formatLocaleDateTime = (\n  value,\n  { fallback = \"—\", valueIsUnixSeconds = false, valueIsEpochMs = false } = {},\n) => {\n  try {\n    const dateValue = toDateValue(value, { valueIsUnixSeconds, valueIsEpochMs });\n    if (!dateValue || Number.isNaN(dateValue.getTime())) return fallback;\n    return dateValue.toLocaleString();\n  } catch {\n    return fallback;\n  }\n};\n\nexport const formatLocaleDateTimeWithTodayTime = (\n  value,\n  {\n    fallback = \"—\",\n    valueIsUnixSeconds = false,\n    valueIsEpochMs = false,\n  } = {},\n) => {\n  try {\n    const dateValue = toDateValue(value, { valueIsUnixSeconds, valueIsEpochMs });\n    if (!dateValue || Number.isNaN(dateValue.getTime())) return fallback;\n    return isSameDay(dateValue, new Date())\n      ? dateValue.toLocaleTimeString()\n      : dateValue.toLocaleString();\n  } catch {\n    return fallback;\n  }\n};\n\nexport const formatDurationCompactMs = (value) => {\n  const ms = Number(value || 0);\n  if (!Number.isFinite(ms) || ms <= 0) return \"0s\";\n  if (ms < 1000) return `${Math.round(ms)}ms`;\n  const totalSeconds = Math.round(ms / 1000);\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = totalSeconds % 60;\n  if (minutes <= 0) return `${seconds}s`;\n  return `${minutes}m ${seconds}s`;\n};\n\nconst parseDayKeyToLocalDate = (dayKey = \"\") => {\n  const rawValue = String(dayKey || \"\").trim();\n  const match = rawValue.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n  if (!match) return null;\n  const year = Number.parseInt(match[1], 10);\n  const monthIndex = Number.parseInt(match[2], 10) - 1;\n  const dayOfMonth = Number.parseInt(match[3], 10);\n  if (\n    !Number.isFinite(year) ||\n    !Number.isFinite(monthIndex) ||\n    !Number.isFinite(dayOfMonth)\n  ) {\n    return null;\n  }\n  return new Date(year, monthIndex, dayOfMonth);\n};\n\nexport const formatChartBucketLabel = (\n  value,\n  { range = \"7d\", valueType = \"epoch-ms\" } = {},\n) => {\n  let dateValue = null;\n  if (valueType === \"day-key\") {\n    dateValue = parseDayKeyToLocalDate(value);\n  } else if (valueType === \"epoch-ms\") {\n    const numericValue = Number(value);\n    dateValue = Number.isFinite(numericValue) ? new Date(numericValue) : null;\n  } else {\n    dateValue = toDateValue(value);\n  }\n  if (!dateValue || Number.isNaN(dateValue.getTime())) return String(value ?? \"\");\n  const normalizedRange = String(range || \"\").trim().toLowerCase();\n  if (normalizedRange === \"24h\") {\n    return dateValue.toLocaleTimeString([], {\n      hour: \"numeric\",\n    });\n  }\n  if (normalizedRange === \"7d\") {\n    return dateValue.toLocaleDateString([], {\n      weekday: \"short\",\n      month: \"numeric\",\n      day: \"numeric\",\n    });\n  }\n  return dateValue.toLocaleDateString([], {\n    month: \"numeric\",\n    day: \"numeric\",\n  });\n};\n"
  },
  {
    "path": "lib/public/js/lib/model-catalog.js",
    "content": "import { fetchModels } from \"./api.js\";\nimport { cachedFetch } from \"./api-cache.js\";\nimport { getFeaturedModels } from \"./model-config.js\";\n\nexport const kModelCatalogCacheKey = \"/api/models\";\nexport const kModelCatalogPollIntervalMs = 3000;\n\nexport const getModelCatalogModels = (payload) =>\n  Array.isArray(payload?.models) ? payload.models : [];\n\nexport const isModelCatalogRefreshing = (payload) =>\n  Boolean(payload?.refreshing);\n\nexport const preloadModelCatalog = ({\n  force = true,\n  maxAgeMs = 30000,\n} = {}) =>\n  cachedFetch(kModelCatalogCacheKey, fetchModels, {\n    force,\n    maxAgeMs,\n  });\n\nexport const getInitialOnboardingModelKey = ({\n  catalog = [],\n  currentModelKey = \"\",\n} = {}) => {\n  const normalizedCurrent = String(currentModelKey || \"\").trim();\n  if (normalizedCurrent) return normalizedCurrent;\n  const featuredModels = getFeaturedModels(catalog);\n  return String(featuredModels[0]?.key || catalog[0]?.key || \"\");\n};\n"
  },
  {
    "path": "lib/public/js/lib/model-config.js",
    "content": "export const getModelProvider = (modelKey) => String(modelKey || \"\").split(\"/\")[0] || \"\";\n\nexport const getAuthProviderFromModelProvider = (provider) => {\n  const normalized = String(provider || \"\").trim();\n  if (normalized === \"openai-codex\") return \"openai\";\n  if (normalized === \"volcengine-plan\") return \"volcengine\";\n  if (normalized === \"byteplus-plan\") return \"byteplus\";\n  return normalized;\n};\n\nexport const kFeaturedModelDefs = [\n  {\n    label: \"Opus 4.7\",\n    preferredKeys: [\"anthropic/claude-opus-4-7\"],\n  },\n  {\n    label: \"Opus 4.6\",\n    preferredKeys: [\"anthropic/claude-opus-4-6\"],\n  },\n  {\n    label: \"Sonnet 4.6\",\n    preferredKeys: [\"anthropic/claude-sonnet-4-6\"],\n  },\n  {\n    label: \"Codex 5.3\",\n    preferredKeys: [\"openai-codex/gpt-5.3-codex\"],\n  },\n  {\n    label: \"GPT-5.5\",\n    preferredKeys: [\"openai-codex/gpt-5.5\"],\n  },\n  {\n    label: \"Gemini 3.1 Pro\",\n    preferredKeys: [\"google/gemini-3.1-pro-preview\"],\n  },\n];\n\nexport const getFeaturedModels = (allModels) => {\n  const picked = [];\n  const used = new Set();\n  kFeaturedModelDefs.forEach((def) => {\n    const found = def.preferredKeys\n      .map((key) => allModels.find((model) => model.key === key))\n      .find(Boolean);\n    if (!found || used.has(found.key)) return;\n    picked.push({ ...found, featuredLabel: def.label });\n    used.add(found.key);\n  });\n  return picked;\n};\n\nexport const kProviderAuthFields = {\n  anthropic: [\n    {\n      key: \"ANTHROPIC_API_KEY\",\n      label: \"Anthropic API Key\",\n      url: \"https://console.anthropic.com\",\n      linkText: \"Get key\",\n      placeholder: \"sk-ant-...\",\n    },\n    {\n      key: \"ANTHROPIC_TOKEN\",\n      label: \"Anthropic Setup Token\",\n      hint: \"From claude setup-token (uses your Claude subscription)\",\n      linkText: \"Get token\",\n      placeholder: \"Token...\",\n    },\n  ],\n  openai: [\n    {\n      key: \"OPENAI_API_KEY\",\n      label: \"OpenAI API Key\",\n      url: \"https://platform.openai.com\",\n      linkText: \"Get key\",\n      placeholder: \"sk-...\",\n    },\n  ],\n  google: [\n    {\n      key: \"GEMINI_API_KEY\",\n      label: \"Gemini API Key\",\n      url: \"https://aistudio.google.com\",\n      linkText: \"Get key\",\n      placeholder: \"AI...\",\n    },\n  ],\n  opencode: [\n    {\n      key: \"OPENCODE_API_KEY\",\n      label: \"OpenCode API Key\",\n      placeholder: \"oc-...\",\n    },\n  ],\n  openrouter: [\n    {\n      key: \"OPENROUTER_API_KEY\",\n      label: \"OpenRouter API Key\",\n      url: \"https://openrouter.ai\",\n      linkText: \"Get key\",\n      placeholder: \"sk-or-...\",\n    },\n  ],\n  zai: [\n    {\n      key: \"ZAI_API_KEY\",\n      label: \"Z.AI API Key\",\n      placeholder: \"zai-...\",\n    },\n  ],\n  \"vercel-ai-gateway\": [\n    {\n      key: \"AI_GATEWAY_API_KEY\",\n      label: \"AI Gateway API Key\",\n      placeholder: \"aigw_...\",\n    },\n  ],\n  kilocode: [\n    {\n      key: \"KILOCODE_API_KEY\",\n      label: \"KiloCode API Key\",\n      placeholder: \"kilo_...\",\n    },\n  ],\n  xai: [\n    {\n      key: \"XAI_API_KEY\",\n      label: \"xAI API Key\",\n      placeholder: \"xai-...\",\n    },\n  ],\n  mistral: [\n    {\n      key: \"MISTRAL_API_KEY\",\n      label: \"Mistral API Key\",\n      url: \"https://console.mistral.ai\",\n      linkText: \"Get key\",\n      placeholder: \"sk-...\",\n    },\n  ],\n  voyage: [\n    {\n      key: \"VOYAGE_API_KEY\",\n      label: \"Voyage API Key\",\n      url: \"https://dash.voyageai.com\",\n      linkText: \"Get key\",\n      placeholder: \"pa-...\",\n    },\n  ],\n  groq: [\n    {\n      key: \"GROQ_API_KEY\",\n      label: \"Groq API Key\",\n      url: \"https://console.groq.com\",\n      linkText: \"Get key\",\n      placeholder: \"gsk_...\",\n    },\n  ],\n  cerebras: [\n    {\n      key: \"CEREBRAS_API_KEY\",\n      label: \"Cerebras API Key\",\n      placeholder: \"csk-...\",\n    },\n  ],\n  moonshot: [\n    {\n      key: \"MOONSHOT_API_KEY\",\n      label: \"Moonshot API Key\",\n      placeholder: \"sk-...\",\n    },\n  ],\n  \"kimi-coding\": [\n    {\n      key: \"KIMI_API_KEY\",\n      label: \"Kimi API Key\",\n      placeholder: \"sk-...\",\n    },\n  ],\n  volcengine: [\n    {\n      key: \"VOLCANO_ENGINE_API_KEY\",\n      label: \"Volcano Engine API Key\",\n      placeholder: \"ve-...\",\n    },\n  ],\n  byteplus: [\n    {\n      key: \"BYTEPLUS_API_KEY\",\n      label: \"BytePlus API Key\",\n      placeholder: \"bp-...\",\n    },\n  ],\n  synthetic: [\n    {\n      key: \"SYNTHETIC_API_KEY\",\n      label: \"Synthetic API Key\",\n      placeholder: \"syn-...\",\n    },\n  ],\n  minimax: [\n    {\n      key: \"MINIMAX_API_KEY\",\n      label: \"MiniMax API Key\",\n      placeholder: \"minimax-...\",\n    },\n  ],\n  deepgram: [\n    {\n      key: \"DEEPGRAM_API_KEY\",\n      label: \"Deepgram API Key\",\n      url: \"https://console.deepgram.com\",\n      linkText: \"Get key\",\n      placeholder: \"dg-...\",\n    },\n  ],\n  vllm: [\n    {\n      key: \"VLLM_API_KEY\",\n      label: \"vLLM API Key\",\n      placeholder: \"vllm-local\",\n    },\n  ],\n};\n\nexport const kProviderLabels = {\n  anthropic: \"Anthropic\",\n  openai: \"OpenAI\",\n  google: \"Gemini\",\n  opencode: \"OpenCode Zen\",\n  openrouter: \"OpenRouter\",\n  zai: \"Z.AI\",\n  \"vercel-ai-gateway\": \"Vercel AI Gateway\",\n  kilocode: \"Kilo Gateway\",\n  xai: \"xAI\",\n  mistral: \"Mistral\",\n  cerebras: \"Cerebras\",\n  moonshot: \"Moonshot\",\n  \"kimi-coding\": \"Kimi Coding\",\n  volcengine: \"Volcano Engine\",\n  byteplus: \"BytePlus\",\n  synthetic: \"Synthetic\",\n  minimax: \"MiniMax\",\n  voyage: \"Voyage\",\n  groq: \"Groq\",\n  deepgram: \"Deepgram\",\n  vllm: \"vLLM\",\n};\n\nexport const kProviderOrder = [\n  \"anthropic\",\n  \"openai\",\n  \"google\",\n  \"zai\",\n  \"xai\",\n  \"openrouter\",\n  \"opencode\",\n  \"kilocode\",\n  \"vercel-ai-gateway\",\n  \"minimax\",\n  \"moonshot\",\n  \"kimi-coding\",\n  \"volcengine\",\n  \"byteplus\",\n  \"synthetic\",\n  \"mistral\",\n  \"cerebras\",\n  \"voyage\",\n  \"groq\",\n  \"deepgram\",\n  \"vllm\",\n];\n\nexport const kCoreProviders = new Set([\"anthropic\", \"openai\", \"google\", \"openrouter\"]);\n\nexport const kProviderFeatures = {\n  anthropic: [\"Agent Model\"],\n  openai: [\"Agent Model\", \"Embeddings\", \"Audio\"],\n  google: [\"Agent Model\", \"Embeddings\", \"Audio\"],\n  opencode: [\"Agent Model\"],\n  openrouter: [\"Agent Model\"],\n  zai: [\"Agent Model\"],\n  \"vercel-ai-gateway\": [\"Agent Model\"],\n  kilocode: [\"Agent Model\"],\n  xai: [\"Agent Model\"],\n  mistral: [\"Agent Model\", \"Embeddings\", \"Audio\"],\n  cerebras: [\"Agent Model\"],\n  moonshot: [\"Agent Model\"],\n  \"kimi-coding\": [\"Agent Model\"],\n  volcengine: [\"Agent Model\"],\n  byteplus: [\"Agent Model\"],\n  synthetic: [\"Agent Model\"],\n  minimax: [\"Agent Model\"],\n  voyage: [\"Embeddings\"],\n  groq: [\"Agent Model\", \"Audio\"],\n  deepgram: [\"Audio\"],\n  vllm: [\"Agent Model\"],\n};\n\nexport const kFeatureDefs = [\n  {\n    id: \"embeddings\",\n    label: \"Memory Embeddings\",\n    tag: \"Embeddings\",\n    providers: [\"openai\", \"google\", \"voyage\", \"mistral\"],\n  },\n  {\n    id: \"audio\",\n    label: \"Audio Transcription\",\n    tag: \"Audio\",\n    hasDefault: true,\n    providers: [\"openai\", \"groq\", \"deepgram\", \"google\", \"mistral\"],\n  },\n];\n\nexport const getVisibleAiFieldKeys = (provider) => {\n  if (provider === \"openai-codex\") return new Set();\n  const authProvider = getAuthProviderFromModelProvider(provider);\n  const fields = kProviderAuthFields[authProvider] || [];\n  return new Set(fields.map((field) => field.key));\n};\n\nexport const kAllAiAuthFields = Object.values(kProviderAuthFields)\n  .flat()\n  .filter((field, idx, arr) => arr.findIndex((item) => item.key === field.key) === idx);\n"
  },
  {
    "path": "lib/public/js/lib/session-keys.js",
    "content": "export const getNormalizedSessionKey = (sessionKey = \"\") =>\n  String(sessionKey || \"\").trim();\n\nexport const getSessionRowKey = (sessionRow = null) =>\n  getNormalizedSessionKey(sessionRow?.key || sessionRow?.sessionKey || \"\");\n\nexport const getAgentIdFromSessionKey = (sessionKey = \"\") => {\n  const normalizedSessionKey = getNormalizedSessionKey(sessionKey);\n  const agentMatch = normalizedSessionKey.match(/^agent:([^:]+):/);\n  return String(agentMatch?.[1] || \"\").trim();\n};\n\nexport const isDestinationSessionKey = (sessionKey = \"\") => {\n  const normalizedSessionKey = getNormalizedSessionKey(sessionKey).toLowerCase();\n  return (\n    normalizedSessionKey.includes(\":direct:\") ||\n    normalizedSessionKey.includes(\":group:\")\n  );\n};\n\nexport const kDestinationSessionFilter = (sessionRow) =>\n  !!(\n    String(sessionRow?.replyChannel || \"\").trim() &&\n    String(sessionRow?.replyTo || \"\").trim()\n  ) || isDestinationSessionKey(getSessionRowKey(sessionRow));\n\nconst kSessionPriority = {\n  destination: 0,\n  other: 1,\n};\n\nexport const getSessionPriority = (sessionRow = null) =>\n  isDestinationSessionKey(getSessionRowKey(sessionRow))\n    ? kSessionPriority.destination\n    : kSessionPriority.other;\n\nexport const sortSessionsByPriority = (sessions = []) =>\n  [...(Array.isArray(sessions) ? sessions : [])].sort((leftRow, rightRow) => {\n    const priorityDiff = getSessionPriority(leftRow) - getSessionPriority(rightRow);\n    if (priorityDiff !== 0) return priorityDiff;\n    const updatedAtDiff =\n      Number(rightRow?.updatedAt || 0) - Number(leftRow?.updatedAt || 0);\n    if (updatedAtDiff !== 0) return updatedAtDiff;\n    return getSessionRowKey(leftRow).localeCompare(getSessionRowKey(rightRow));\n  });\n\nexport const getDestinationFromSession = (sessionRow = null) => {\n  const channel = String(sessionRow?.replyChannel || \"\").trim();\n  const to = String(sessionRow?.replyTo || \"\").trim();\n  if (!channel || !to) return null;\n  const agentId = getAgentIdFromSessionKey(getSessionRowKey(sessionRow));\n  return {\n    channel,\n    to,\n    ...(agentId ? { agentId } : {}),\n  };\n};\n\n/** Matches server `parseChannelFromSessionKey` for icon routing when `channel` is absent (cached rows). */\nexport const parseChannelFromSessionKey = (sessionKey = \"\") => {\n  const k = String(sessionKey || \"\");\n  if (k.includes(\":telegram:\")) return \"telegram\";\n  if (k.includes(\":discord:\")) return \"discord\";\n  if (k.includes(\":slack:\")) return \"slack\";\n  return \"\";\n};\n\nconst getTopicIdsFromSessionKey = (sessionKey = \"\") => {\n  const normalizedSessionKey = getNormalizedSessionKey(sessionKey);\n  const topicMatch = normalizedSessionKey.match(\n    /:telegram:group:([^:]+):topic:([^:]+)$/,\n  );\n  return {\n    groupId: String(topicMatch?.[1] || \"\").trim(),\n    topicId: String(topicMatch?.[2] || \"\").trim(),\n  };\n};\n\nexport const getSessionKind = (sessionKey = \"\") => {\n  const normalizedSessionKey = getNormalizedSessionKey(sessionKey);\n  if (!normalizedSessionKey) return \"other\";\n  if (normalizedSessionKey === \"main\" || normalizedSessionKey.endsWith(\":main\")) {\n    return \"main\";\n  }\n  if (/:telegram:group:([^:]+):topic:([^:]+)$/.test(normalizedSessionKey)) {\n    return \"topic\";\n  }\n  if (normalizedSessionKey.includes(\":slash:\")) return \"slash\";\n  if (normalizedSessionKey.includes(\":subagent:\")) return \"subagent\";\n  if (/:direct:([^:]+)$/.test(normalizedSessionKey)) return \"direct\";\n  return \"other\";\n};\n\nexport const getSessionDisplayLabel = (sessionRow = null) => {\n  const key = getSessionRowKey(sessionRow);\n  const kind = getSessionKind(key);\n  if (kind === \"main\") return \"Main Thread\";\n\n  const doctorMatch = key.match(/(?:^|:)doctor:(\\d+)$/);\n  if (doctorMatch) return `Doctor Run #${doctorMatch[1]}`;\n  if (/(?:^|:)doctor(?::|$)/.test(key)) return \"Doctor Run\";\n\n  if (kind === \"topic\") {\n    const { groupId, topicId } = getTopicIdsFromSessionKey(key);\n    const topicName = String(sessionRow?.topicName || \"\").trim();\n    const groupName = String(sessionRow?.groupName || \"\").trim();\n    const topicLabel = topicName || (topicId ? `Topic ${topicId}` : \"Topic\");\n    const groupLabel = groupName || groupId;\n    return groupLabel ? `${topicLabel} - ${groupLabel}` : topicLabel;\n  }\n\n  if (kind === \"direct\") {\n    const directMatch = key.match(/:direct:([^:]+)$/);\n    const directTarget = String(directMatch?.[1] || \"\").trim();\n    if (parseChannelFromSessionKey(key) === \"telegram\") {\n      return \"Direct message\";\n    }\n    return directTarget ? `Direct ${directTarget}` : \"Direct\";\n  }\n\n  return key || \"Session\";\n};\n\n/** Channel id for platform icons; prefers API `channel`, else parses from key / replyChannel. */\nexport const getSessionChannelForIcon = (sessionRow = null) => {\n  const fromRow = String(sessionRow?.channel || \"\").trim();\n  if (fromRow) return fromRow;\n  const fromReply = String(sessionRow?.replyChannel || \"\").trim();\n  if (fromReply) return fromReply;\n  return parseChannelFromSessionKey(getSessionRowKey(sessionRow));\n};\n"
  },
  {
    "path": "lib/public/js/lib/sse.js",
    "content": "const parseEventPayload = (value) => {\n  if (typeof value !== \"string\" || !value.trim()) return {};\n  try {\n    return JSON.parse(value);\n  } catch {\n    return {};\n  }\n};\n\nexport const subscribeToSse = ({\n  url = \"\",\n  onMessage = () => {},\n  onError = () => {},\n}) => {\n  if (typeof window?.EventSource !== \"function\") {\n    throw new Error(\"Server events are not supported in this browser\");\n  }\n  const source = new window.EventSource(String(url || \"\"), { withCredentials: true });\n  const handlePhase = (event) => {\n    onMessage({\n      event: \"phase\",\n      data: parseEventPayload(event?.data || \"\"),\n    });\n  };\n  const handleDone = (event) => {\n    onMessage({\n      event: \"done\",\n      data: parseEventPayload(event?.data || \"\"),\n    });\n  };\n  const handleFailure = (event) => {\n    onMessage({\n      event: \"error\",\n      data: parseEventPayload(event?.data || \"\"),\n    });\n  };\n  const handleError = (event) => {\n    onError(event);\n  };\n  source.addEventListener(\"phase\", handlePhase);\n  source.addEventListener(\"done\", handleDone);\n  source.addEventListener(\"error\", handleFailure);\n  source.onerror = handleError;\n  return () => {\n    source.removeEventListener(\"phase\", handlePhase);\n    source.removeEventListener(\"done\", handleDone);\n    source.removeEventListener(\"error\", handleFailure);\n    source.onerror = null;\n    source.close();\n  };\n};\n"
  },
  {
    "path": "lib/public/js/lib/storage-keys.js",
    "content": "// Centralized localStorage key registry.\n// All standalone localStorage keys used by the Setup UI should be defined here\n// so they stay discoverable, consistently prefixed, and free of collisions.\n//\n// Naming convention: \"alphaclaw.<area>.<purpose>\"\n\n// --- UI settings (single JSON blob containing sub-keys) ---\nexport const kUiSettingsStorageKey = \"alphaclaw.ui.settings\";\nexport const kThemeStorageKey = \"alphaclaw.ui.theme\";\n\n// --- Browse / file viewer ---\nexport const kFileViewerModeStorageKey = \"alphaclaw.browse.viewerMode\";\nexport const kEditorSelectionStorageKey = \"alphaclaw.browse.editorSelection\";\nexport const kExpandedFoldersStorageKey = \"alphaclaw.browse.expandedFolders\";\n\n// --- Browse / drafts ---\nexport const kFileDraftStorageKeyPrefix = \"alphaclaw.browse.draft.\";\nexport const kDraftIndexStorageKey = \"alphaclaw.browse.draftIndex\";\n\n// --- Onboarding ---\nexport const kOnboardingStorageKey = \"alphaclaw.onboarding.state\";\n\n// --- Telegram workspace ---\nexport const kTelegramWorkspaceStorageKey = \"alphaclaw.telegram.workspaceState\";\nexport const kTelegramWorkspaceCacheKey = \"alphaclaw.telegram.workspaceCache\";\n\n// --- Agent sessions (shared across session pickers) ---\n// Bump version when session row shape changes so stale cache is not reused.\nexport const kAgentSessionsCacheKey = \"alphaclaw.agent.sessionsCache.v3\";\nexport const kAgentLastSessionKey = \"alphaclaw.agent.lastSessionKey\";\n\n// --- Chat ---\nexport const kChatSessionDraftsStorageKey = \"alphaclaw.chat.sessionDrafts\";\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/css.js",
    "content": "import { escapeHtml } from \"./utils.js\";\n\nconst kCssNumberRegex =\n  /(^|[^\\w.#-])(-?\\d+(?:\\.\\d+)?(?:px|em|rem|vh|vw|%|deg|s|ms)?)(?=$|[^\\w-])/g;\n\nconst highlightCssTextSegment = (text) => {\n  let content = escapeHtml(text);\n  content = content.replace(/@[a-zA-Z-]+/g, '<span class=\"hl-keyword\">$&</span>');\n  content = content.replace(/#[0-9a-fA-F]{3,8}\\b/g, '<span class=\"hl-number\">$&</span>');\n  content = content.replace(kCssNumberRegex, '$1<span class=\"hl-number\">$2</span>');\n  content = content.replace(\n    /(^|[;{\\s])([a-zA-Z-]+)(\\s*:)/g,\n    '$1<span class=\"hl-attr\">$2</span>$3',\n  );\n  return content;\n};\n\nconst findClosingQuote = (source, startIndex, quote) => {\n  let index = startIndex + 1;\n  while (index < source.length) {\n    if (source[index] === \"\\\\\" && index + 1 < source.length) {\n      index += 2;\n      continue;\n    }\n    if (source[index] === quote) return index;\n    index += 1;\n  }\n  return -1;\n};\n\nconst tokenizeCssLine = (line, inBlockComment) => {\n  const source = String(line || \"\");\n  const parts = [];\n  let cursor = 0;\n  let nextInBlockComment = inBlockComment;\n\n  while (cursor < source.length) {\n    if (nextInBlockComment) {\n      const blockEnd = source.indexOf(\"*/\", cursor);\n      if (blockEnd === -1) {\n        parts.push({ kind: \"comment\", value: source.slice(cursor) });\n        return { parts, inBlockComment: true };\n      }\n      parts.push({ kind: \"comment\", value: source.slice(cursor, blockEnd + 2) });\n      cursor = blockEnd + 2;\n      nextInBlockComment = false;\n      continue;\n    }\n\n    const blockCommentIndex = source.indexOf(\"/*\", cursor);\n    const singleQuoteIndex = source.indexOf(\"'\", cursor);\n    const doubleQuoteIndex = source.indexOf('\"', cursor);\n    const indexes = [blockCommentIndex, singleQuoteIndex, doubleQuoteIndex].filter(\n      (index) => index !== -1,\n    );\n\n    if (indexes.length === 0) {\n      parts.push({ kind: \"text\", value: source.slice(cursor) });\n      break;\n    }\n\n    const nextIndex = Math.min(...indexes);\n    if (nextIndex > cursor) {\n      parts.push({ kind: \"text\", value: source.slice(cursor, nextIndex) });\n      cursor = nextIndex;\n    }\n\n    if (blockCommentIndex === nextIndex) {\n      const blockEnd = source.indexOf(\"*/\", nextIndex + 2);\n      if (blockEnd === -1) {\n        parts.push({ kind: \"comment\", value: source.slice(nextIndex) });\n        nextInBlockComment = true;\n        break;\n      }\n      parts.push({ kind: \"comment\", value: source.slice(nextIndex, blockEnd + 2) });\n      cursor = blockEnd + 2;\n      continue;\n    }\n\n    const quote = source[nextIndex];\n    const quoteEnd = findClosingQuote(source, nextIndex, quote);\n    if (quoteEnd === -1) {\n      parts.push({ kind: \"string\", value: source.slice(nextIndex) });\n      break;\n    }\n    parts.push({ kind: \"string\", value: source.slice(nextIndex, quoteEnd + 1) });\n    cursor = quoteEnd + 1;\n  }\n\n  return { parts, inBlockComment: nextInBlockComment };\n};\n\nexport const highlightCssLine = (line, state = { inBlockComment: false }) => {\n  const tokens = tokenizeCssLine(line, Boolean(state?.inBlockComment));\n  const html = tokens.parts\n    .map((part) => {\n      if (part.kind === \"comment\") {\n        return `<span class=\"hl-comment\">${escapeHtml(part.value)}</span>`;\n      }\n      if (part.kind === \"string\") {\n        return `<span class=\"hl-string\">${escapeHtml(part.value)}</span>`;\n      }\n      return highlightCssTextSegment(part.value);\n    })\n    .join(\"\");\n\n  return {\n    html,\n    state: { inBlockComment: tokens.inBlockComment },\n  };\n};\n\nexport const highlightCssContent = (content) => {\n  const lines = String(content || \"\").split(\"\\n\");\n  let state = { inBlockComment: false };\n  return lines.map((line, index) => {\n    const renderedLine = highlightCssLine(line, state);\n    state = renderedLine.state;\n    return {\n      lineNumber: index + 1,\n      html: renderedLine.html,\n    };\n  });\n};\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/frontmatter.js",
    "content": "export const parseFrontmatter = (markdown) => {\n  const value = String(markdown || \"\");\n  if (!(value.startsWith(\"---\\n\") || value === \"---\")) {\n    return { entries: [], body: value };\n  }\n  const lines = value.split(\"\\n\");\n  if (lines[0] !== \"---\") {\n    return { entries: [], body: value };\n  }\n  const closingFenceIndex = lines.findIndex(\n    (line, index) => index > 0 && line === \"---\",\n  );\n  if (closingFenceIndex === -1) {\n    return { entries: [], body: value };\n  }\n  const frontmatterLines = lines.slice(1, closingFenceIndex);\n  const bodyLines = lines.slice(closingFenceIndex + 1);\n  const entries = frontmatterLines\n    .map((line) => {\n      const separatorIndex = line.indexOf(\":\");\n      if (separatorIndex <= 0) return null;\n      const key = line.slice(0, separatorIndex).trim();\n      const rawValue = line.slice(separatorIndex + 1).trim();\n      if (!key) return null;\n      return { key, rawValue };\n    })\n    .filter((entry) => entry !== null);\n  return {\n    entries,\n    body: bodyLines.join(\"\\n\").replace(/^\\n+/, \"\"),\n  };\n};\n\nexport const formatFrontmatterValue = (rawValue) => {\n  const trimmedValue = String(rawValue || \"\").trim();\n  if (!trimmedValue) return trimmedValue;\n  if (\n    (trimmedValue.startsWith(\"{\") && trimmedValue.endsWith(\"}\")) ||\n    (trimmedValue.startsWith(\"[\") && trimmedValue.endsWith(\"]\"))\n  ) {\n    try {\n      const parsedValue = JSON.parse(trimmedValue);\n      return JSON.stringify(parsedValue, null, 2);\n    } catch {\n      return trimmedValue;\n    }\n  }\n  return trimmedValue;\n};\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/html.js",
    "content": "import { escapeHtml } from \"./utils.js\";\nimport { highlightCssLine } from \"./css.js\";\nimport { highlightJavaScriptLine } from \"./javascript.js\";\n\nconst highlightHtmlTextSegment = (text) =>\n  escapeHtml(text).replace(\n    /(&[a-zA-Z][a-zA-Z0-9]+;|&#\\d+;|&#x[0-9a-fA-F]+;)/g,\n    '<span class=\"hl-entity\">$1</span>',\n  );\n\nconst highlightHtmlAttributeValue = (valueWithSpace) => {\n  const leadingWhitespace = valueWithSpace.match(/^\\s*/)?.[0] || \"\";\n  const rawValue = valueWithSpace.slice(leadingWhitespace.length);\n  return `${escapeHtml(leadingWhitespace)}<span class=\"hl-string\">${escapeHtml(rawValue)}</span>`;\n};\n\nconst highlightHtmlAttributes = (attributesText) => {\n  const attrRegex = /([:@A-Za-z_][\\w:.-]*)(\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s\"'=<>`]+))?/g;\n  let html = \"\";\n  let cursor = 0;\n  let match = attrRegex.exec(attributesText);\n\n  while (match) {\n    const fullMatch = match[0];\n    const attrName = match[1];\n    const attrAssignment = match[2] || \"\";\n    const start = match.index;\n    const end = start + fullMatch.length;\n\n    if (start > cursor) {\n      html += escapeHtml(attributesText.slice(cursor, start));\n    }\n    html += `<span class=\"hl-attr\">${escapeHtml(attrName)}</span>`;\n\n    if (attrAssignment) {\n      const equalsIndex = attrAssignment.indexOf(\"=\");\n      if (equalsIndex !== -1) {\n        const beforeEquals = attrAssignment.slice(0, equalsIndex);\n        const afterEquals = attrAssignment.slice(equalsIndex + 1);\n        html += `${escapeHtml(beforeEquals)}<span class=\"hl-punc\">=</span>${highlightHtmlAttributeValue(afterEquals)}`;\n      } else {\n        html += escapeHtml(attrAssignment);\n      }\n    }\n\n    cursor = end;\n    match = attrRegex.exec(attributesText);\n  }\n\n  if (cursor < attributesText.length) {\n    html += escapeHtml(attributesText.slice(cursor));\n  }\n  return html;\n};\n\nconst renderHighlightedHtmlTag = (tagText) => {\n  if (/^<!--[\\s\\S]*-->$/.test(tagText) || /^<!DOCTYPE/i.test(tagText)) {\n    return `<span class=\"hl-meta\">${escapeHtml(tagText)}</span>`;\n  }\n\n  const tagMatch = tagText.match(/^<\\s*(\\/?)\\s*([A-Za-z][\\w:-]*)([\\s\\S]*?)(\\/?)\\s*>$/);\n  if (!tagMatch) {\n    return `<span class=\"hl-tag\">${escapeHtml(tagText)}</span>`;\n  }\n\n  const isClosing = tagMatch[1] === \"/\";\n  const tagName = tagMatch[2];\n  const attributesText = tagMatch[3] || \"\";\n  const isSelfClosing = tagMatch[4] === \"/\";\n  const open = isClosing ? \"&lt;/\" : \"&lt;\";\n  const attrsHtml = isClosing ? \"\" : highlightHtmlAttributes(attributesText);\n  const close = isSelfClosing ? \"/&gt;\" : \"&gt;\";\n\n  return `<span class=\"hl-punc\">${open}</span><span class=\"hl-tag\">${escapeHtml(tagName)}</span>${attrsHtml}<span class=\"hl-punc\">${close}</span>`;\n};\n\nconst renderHighlightedHtmlLine = (line) => {\n  const tokenRegex = /<!--[\\s\\S]*?-->|<!DOCTYPE[^>]*>|<\\/?[A-Za-z][^>]*>/gi;\n  const source = String(line || \"\");\n  let html = \"\";\n  let cursor = 0;\n  let match = tokenRegex.exec(source);\n\n  while (match) {\n    const token = match[0];\n    const start = match.index;\n    const end = start + token.length;\n    if (start > cursor) {\n      html += highlightHtmlTextSegment(source.slice(cursor, start));\n    }\n    html += renderHighlightedHtmlTag(token);\n    cursor = end;\n    match = tokenRegex.exec(source);\n  }\n\n  if (cursor < source.length) {\n    html += highlightHtmlTextSegment(source.slice(cursor));\n  }\n  return html;\n};\n\nconst findNextTag = (source, tagName) => {\n  const regex = new RegExp(`<\\\\/?\\\\s*${tagName}\\\\b[^>]*>`, \"ig\");\n  const match = regex.exec(source);\n  if (!match) return null;\n  return {\n    text: match[0],\n    start: match.index,\n    end: match.index + match[0].length,\n    isClosing: /^<\\s*\\//.test(match[0]),\n  };\n};\n\nconst highlightInlineSection = (line, state) => {\n  let html = \"\";\n  let cursor = 0;\n  let nextMode = state.mode;\n  let nextLanguageState = state.languageState;\n\n  while (cursor < line.length) {\n    if (nextMode === \"script\") {\n      const closeTag = findNextTag(line.slice(cursor), \"script\");\n      if (!closeTag || !closeTag.isClosing) {\n        const renderedJs = highlightJavaScriptLine(line.slice(cursor), nextLanguageState);\n        html += renderedJs.html;\n        nextLanguageState = renderedJs.state;\n        cursor = line.length;\n        break;\n      }\n      const absoluteCloseStart = cursor + closeTag.start;\n      const absoluteCloseEnd = cursor + closeTag.end;\n      const jsPart = line.slice(cursor, absoluteCloseStart);\n      const renderedJs = highlightJavaScriptLine(jsPart, nextLanguageState);\n      html += renderedJs.html;\n      html += renderHighlightedHtmlLine(line.slice(absoluteCloseStart, absoluteCloseEnd));\n      nextMode = \"html\";\n      nextLanguageState = { inBlockComment: false };\n      cursor = absoluteCloseEnd;\n      continue;\n    }\n\n    if (nextMode === \"style\") {\n      const closeTag = findNextTag(line.slice(cursor), \"style\");\n      if (!closeTag || !closeTag.isClosing) {\n        const renderedCss = highlightCssLine(line.slice(cursor), nextLanguageState);\n        html += renderedCss.html;\n        nextLanguageState = renderedCss.state;\n        cursor = line.length;\n        break;\n      }\n      const absoluteCloseStart = cursor + closeTag.start;\n      const absoluteCloseEnd = cursor + closeTag.end;\n      const cssPart = line.slice(cursor, absoluteCloseStart);\n      const renderedCss = highlightCssLine(cssPart, nextLanguageState);\n      html += renderedCss.html;\n      html += renderHighlightedHtmlLine(line.slice(absoluteCloseStart, absoluteCloseEnd));\n      nextMode = \"html\";\n      nextLanguageState = { inBlockComment: false };\n      cursor = absoluteCloseEnd;\n      continue;\n    }\n\n    const remaining = line.slice(cursor);\n    const nextScript = findNextTag(remaining, \"script\");\n    const nextStyle = findNextTag(remaining, \"style\");\n    const candidates = [nextScript, nextStyle]\n      .filter((candidate) => candidate && !candidate.isClosing)\n      .sort((left, right) => left.start - right.start);\n\n    if (candidates.length === 0) {\n      html += renderHighlightedHtmlLine(remaining);\n      cursor = line.length;\n      break;\n    }\n\n    const nextTag = candidates[0];\n    const absoluteTagStart = cursor + nextTag.start;\n    const absoluteTagEnd = cursor + nextTag.end;\n    html += renderHighlightedHtmlLine(line.slice(cursor, absoluteTagStart));\n    html += renderHighlightedHtmlLine(line.slice(absoluteTagStart, absoluteTagEnd));\n    nextMode = /<\\s*script\\b/i.test(nextTag.text) ? \"script\" : \"style\";\n    nextLanguageState = { inBlockComment: false };\n    cursor = absoluteTagEnd;\n  }\n\n  return {\n    html,\n    state: {\n      mode: nextMode,\n      languageState: nextLanguageState,\n    },\n  };\n};\n\nexport const highlightHtmlContent = (content) => {\n  const lines = String(content || \"\").split(\"\\n\");\n  let state = {\n    mode: \"html\",\n    languageState: { inBlockComment: false },\n  };\n  return lines.map((line, index) => {\n    const renderedLine = highlightInlineSection(line, state);\n    state = renderedLine.state;\n    return {\n      lineNumber: index + 1,\n      html: renderedLine.html,\n    };\n  });\n};\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/index.js",
    "content": "import { formatFrontmatterValue, parseFrontmatter } from \"./frontmatter.js\";\nimport { highlightCssContent } from \"./css.js\";\nimport { highlightHtmlContent } from \"./html.js\";\nimport { highlightJavaScriptContent } from \"./javascript.js\";\nimport { highlightJsonContent } from \"./json.js\";\nimport { highlightMarkdownContent } from \"./markdown.js\";\nimport { escapeHtml, toLineObjects } from \"./utils.js\";\n\nexport const getFileSyntaxKind = (filePath) => {\n  const normalizedPath = String(filePath || \"\").toLowerCase();\n  const pathWithoutBakSuffix = normalizedPath.replace(/(\\.bak)+$/i, \"\");\n  if (/\\.(md|markdown|mdx)$/i.test(pathWithoutBakSuffix)) return \"markdown\";\n  if (/\\.(json|jsonl)$/i.test(pathWithoutBakSuffix)) return \"json\";\n  if (/\\.(html|htm)$/i.test(pathWithoutBakSuffix)) return \"html\";\n  if (/\\.(js|mjs|cjs)$/i.test(pathWithoutBakSuffix)) return \"javascript\";\n  if (/\\.(css|scss)$/i.test(pathWithoutBakSuffix)) return \"css\";\n  return \"plain\";\n};\n\nexport const highlightEditorLines = (content, syntaxKind) => {\n  if (syntaxKind === \"markdown\") return highlightMarkdownContent(content);\n  if (syntaxKind === \"json\") return highlightJsonContent(content);\n  if (syntaxKind === \"html\") return highlightHtmlContent(content);\n  if (syntaxKind === \"javascript\") return highlightJavaScriptContent(content);\n  if (syntaxKind === \"css\") return highlightCssContent(content);\n  return toLineObjects(content, (line) => escapeHtml(line));\n};\n\nexport { formatFrontmatterValue, parseFrontmatter };\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/javascript.js",
    "content": "import { escapeHtml } from \"./utils.js\";\n\nconst kJavaScriptKeywordsRegex =\n  /\\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;\n\nconst kNumberRegex =\n  /(^|[^\\w.])(-?(?:0x[a-fA-F0-9]+|\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?))(?=$|[^\\w.])/g;\n\nconst highlightJavaScriptTextSegment = (text) => {\n  let content = escapeHtml(text);\n  content = content.replace(kJavaScriptKeywordsRegex, '<span class=\"hl-keyword\">$1</span>');\n  content = content.replace(/\\b(true|false)\\b/g, '<span class=\"hl-boolean\">$1</span>');\n  content = content.replace(/\\b(null|undefined)\\b/g, '<span class=\"hl-null\">$1</span>');\n  content = content.replace(kNumberRegex, '$1<span class=\"hl-number\">$2</span>');\n  return content;\n};\n\nconst findClosingQuote = (source, startIndex, quote) => {\n  let index = startIndex + 1;\n  while (index < source.length) {\n    if (source[index] === \"\\\\\" && index + 1 < source.length) {\n      index += 2;\n      continue;\n    }\n    if (source[index] === quote) return index;\n    index += 1;\n  }\n  return -1;\n};\n\nconst tokenizeJavaScriptLine = (line, inBlockComment) => {\n  const source = String(line || \"\");\n  const parts = [];\n  let cursor = 0;\n  let nextInBlockComment = inBlockComment;\n\n  while (cursor < source.length) {\n    if (nextInBlockComment) {\n      const blockEnd = source.indexOf(\"*/\", cursor);\n      if (blockEnd === -1) {\n        parts.push({ kind: \"comment\", value: source.slice(cursor) });\n        return { parts, inBlockComment: true };\n      }\n      parts.push({ kind: \"comment\", value: source.slice(cursor, blockEnd + 2) });\n      cursor = blockEnd + 2;\n      nextInBlockComment = false;\n      continue;\n    }\n\n    const lineCommentIndex = source.indexOf(\"//\", cursor);\n    const blockCommentIndex = source.indexOf(\"/*\", cursor);\n    const singleQuoteIndex = source.indexOf(\"'\", cursor);\n    const doubleQuoteIndex = source.indexOf('\"', cursor);\n    const templateQuoteIndex = source.indexOf(\"`\", cursor);\n    const indexes = [\n      lineCommentIndex,\n      blockCommentIndex,\n      singleQuoteIndex,\n      doubleQuoteIndex,\n      templateQuoteIndex,\n    ].filter((index) => index !== -1);\n\n    if (indexes.length === 0) {\n      parts.push({ kind: \"text\", value: source.slice(cursor) });\n      break;\n    }\n\n    const nextIndex = Math.min(...indexes);\n    if (nextIndex > cursor) {\n      parts.push({ kind: \"text\", value: source.slice(cursor, nextIndex) });\n      cursor = nextIndex;\n    }\n\n    if (lineCommentIndex === nextIndex) {\n      parts.push({ kind: \"comment\", value: source.slice(nextIndex) });\n      break;\n    }\n\n    if (blockCommentIndex === nextIndex) {\n      const blockEnd = source.indexOf(\"*/\", nextIndex + 2);\n      if (blockEnd === -1) {\n        parts.push({ kind: \"comment\", value: source.slice(nextIndex) });\n        nextInBlockComment = true;\n        break;\n      }\n      parts.push({ kind: \"comment\", value: source.slice(nextIndex, blockEnd + 2) });\n      cursor = blockEnd + 2;\n      continue;\n    }\n\n    const quote = source[nextIndex];\n    const quoteEnd = findClosingQuote(source, nextIndex, quote);\n    if (quoteEnd === -1) {\n      parts.push({ kind: \"string\", value: source.slice(nextIndex) });\n      break;\n    }\n    parts.push({ kind: \"string\", value: source.slice(nextIndex, quoteEnd + 1) });\n    cursor = quoteEnd + 1;\n  }\n\n  return { parts, inBlockComment: nextInBlockComment };\n};\n\nexport const highlightJavaScriptLine = (line, state = { inBlockComment: false }) => {\n  const tokens = tokenizeJavaScriptLine(line, Boolean(state?.inBlockComment));\n  const html = tokens.parts\n    .map((part) => {\n      if (part.kind === \"comment\") {\n        return `<span class=\"hl-comment\">${escapeHtml(part.value)}</span>`;\n      }\n      if (part.kind === \"string\") {\n        return `<span class=\"hl-string\">${escapeHtml(part.value)}</span>`;\n      }\n      return highlightJavaScriptTextSegment(part.value);\n    })\n    .join(\"\");\n  return {\n    html,\n    state: { inBlockComment: tokens.inBlockComment },\n  };\n};\n\nexport const highlightJavaScriptContent = (content) => {\n  const lines = String(content || \"\").split(\"\\n\");\n  let state = { inBlockComment: false };\n  return lines.map((line, index) => {\n    const renderedLine = highlightJavaScriptLine(line, state);\n    state = renderedLine.state;\n    return {\n      lineNumber: index + 1,\n      html: renderedLine.html,\n    };\n  });\n};\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/json.js",
    "content": "import { escapeHtml, toLineObjects } from \"./utils.js\";\n\nconst tokenizeJsonLine = (line) => {\n  const parts = [];\n  const source = String(line || \"\");\n  const stringRegex = /\"([^\"\\\\]|\\\\.)*\"/g;\n  let lastIndex = 0;\n  let match = stringRegex.exec(source);\n\n  while (match) {\n    const start = match.index;\n    const end = stringRegex.lastIndex;\n    const value = match[0];\n    const trailing = source.slice(end);\n    const isKey = /^\\s*:/.test(trailing);\n\n    if (start > lastIndex) {\n      parts.push({ kind: \"text\", value: source.slice(lastIndex, start) });\n    }\n    parts.push({ kind: isKey ? \"key\" : \"string\", value });\n    lastIndex = end;\n    match = stringRegex.exec(source);\n  }\n\n  if (lastIndex < source.length) {\n    parts.push({ kind: \"text\", value: source.slice(lastIndex) });\n  }\n\n  if (parts.length === 0) {\n    return [{ kind: \"text\", value: source }];\n  }\n  return parts;\n};\n\nconst highlightJsonTextSegment = (text) => {\n  let content = escapeHtml(text);\n  content = content.replace(\n    /(^|[^\\w.])(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)(?=$|[^\\w.])/g,\n    '$1<span class=\"hl-number\">$2</span>',\n  );\n  content = content.replace(/\\b(true|false)\\b/g, '<span class=\"hl-boolean\">$1</span>');\n  content = content.replace(/\\bnull\\b/g, '<span class=\"hl-null\">null</span>');\n  content = content.replace(/([{}\\[\\],:])/g, '<span class=\"hl-punc\">$1</span>');\n  return content;\n};\n\nconst renderHighlightedJsonLine = (line) =>\n  tokenizeJsonLine(line)\n    .map((part) => {\n      if (part.kind === \"key\") {\n        return `<span class=\"hl-key\">${escapeHtml(part.value)}</span>`;\n      }\n      if (part.kind === \"string\") {\n        return `<span class=\"hl-string\">${escapeHtml(part.value)}</span>`;\n      }\n      return highlightJsonTextSegment(part.value);\n    })\n    .join(\"\");\n\nexport const highlightJsonContent = (content) =>\n  toLineObjects(content, renderHighlightedJsonLine);\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/markdown.js",
    "content": "import { escapeHtml, toLineObjects } from \"./utils.js\";\n\nconst renderInlineMarkdown = (line) => {\n  let content = escapeHtml(line);\n  content = content.replace(/`([^`]+)`/g, '<span class=\"hl-string\">`$1`</span>');\n  content = content.replace(/\\*\\*([^*]+)\\*\\*/g, '<span class=\"hl-bold\">**$1**</span>');\n  content = content.replace(\n    /\\[([^\\]]+)\\]\\(([^)]+)\\)/g,\n    '<span class=\"hl-link\">[$1]($2)</span>',\n  );\n  return content;\n};\n\nconst renderHighlightedMarkdownLine = (line) => {\n  if (/^#{1,6}\\s/.test(line)) {\n    return `<span class=\"hl-heading\">${escapeHtml(line)}</span>`;\n  }\n  if (/^>\\s/.test(line)) {\n    return `<span class=\"hl-comment\">${escapeHtml(line)}</span>`;\n  }\n  if (/^```/.test(line)) {\n    return `<span class=\"hl-meta\">${escapeHtml(line)}</span>`;\n  }\n  if (/^\\|[-\\s|]+\\|$/.test(line)) {\n    return `<span class=\"hl-meta\">${escapeHtml(line)}</span>`;\n  }\n  if (/^\\s*[-*]\\s/.test(line)) {\n    return renderInlineMarkdown(line).replace(\n      /^(\\s*)([-*])/,\n      '$1<span class=\"hl-bullet\">$2</span>',\n    );\n  }\n  return renderInlineMarkdown(line);\n};\n\nexport const highlightMarkdownContent = (content) =>\n  toLineObjects(content, renderHighlightedMarkdownLine);\n"
  },
  {
    "path": "lib/public/js/lib/syntax-highlighters/utils.js",
    "content": "export const escapeHtml = (value) =>\n  String(value || \"\")\n    .replaceAll(\"&\", \"&amp;\")\n    .replaceAll(\"<\", \"&lt;\")\n    .replaceAll(\">\", \"&gt;\");\n\nexport const toLineObjects = (content, renderer) =>\n  String(content || \"\")\n    .split(\"\\n\")\n    .map((line, index) => ({\n      lineNumber: index + 1,\n      html: renderer(line),\n    }));\n"
  },
  {
    "path": "lib/public/js/lib/telegram-api.js",
    "content": "import { authFetch } from \"./api.js\";\n\nconst appendAccountId = (params, accountId) => {\n  const normalized = String(accountId || \"\").trim();\n  if (normalized) params.set(\"accountId\", normalized);\n};\n\nexport const verifyBot = async ({ accountId = \"\" } = {}) => {\n  const params = new URLSearchParams();\n  appendAccountId(params, accountId);\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/telegram/bot${suffix}`);\n  return res.json();\n};\n\nexport const workspace = async ({ accountId = \"\" } = {}) => {\n  const params = new URLSearchParams();\n  appendAccountId(params, accountId);\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/telegram/workspace${suffix}`);\n  return res.json();\n};\n\nexport const resetWorkspace = async ({ accountId = \"\" } = {}) => {\n  const params = new URLSearchParams();\n  appendAccountId(params, accountId);\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(`/api/telegram/workspace/reset${suffix}`, {\n    method: \"POST\",\n  });\n  return res.json();\n};\n\nexport const verifyGroup = async (groupId, { accountId = \"\" } = {}) => {\n  const res = await authFetch(\"/api/telegram/groups/verify\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ groupId, accountId }),\n  });\n  return res.json();\n};\n\nexport const listTopics = async (groupId, { accountId = \"\" } = {}) => {\n  const params = new URLSearchParams();\n  appendAccountId(params, accountId);\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(\n    `/api/telegram/groups/${encodeURIComponent(groupId)}/topics${suffix}`,\n  );\n  return res.json();\n};\n\nexport const createTopicsBulk = async (groupId, topics, { accountId = \"\" } = {}) => {\n  const res = await authFetch(\n    `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/bulk`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ topics, accountId }),\n    },\n  );\n  return res.json();\n};\n\nexport const deleteTopic = async (groupId, topicId, { accountId = \"\" } = {}) => {\n  const params = new URLSearchParams();\n  appendAccountId(params, accountId);\n  const suffix = params.toString() ? `?${params.toString()}` : \"\";\n  const res = await authFetch(\n    `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/${topicId}${suffix}`,\n    { method: \"DELETE\" },\n  );\n  return res.json();\n};\n\nexport const updateTopic = async (groupId, topicId, payload, { accountId = \"\" } = {}) => {\n  const res = await authFetch(\n    `/api/telegram/groups/${encodeURIComponent(groupId)}/topics/${encodeURIComponent(topicId)}`,\n    {\n      method: \"PUT\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ ...payload, accountId }),\n    },\n  );\n  return res.json();\n};\n\nexport const configureGroup = async (groupId, payload, { accountId = \"\" } = {}) => {\n  const res = await authFetch(\n    `/api/telegram/groups/${encodeURIComponent(groupId)}/configure`,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ ...payload, accountId }),\n    },\n  );\n  return res.json();\n};\n"
  },
  {
    "path": "lib/public/js/lib/ui-settings.js",
    "content": "import { kUiSettingsStorageKey } from \"./storage-keys.js\";\n\nexport { kUiSettingsStorageKey };\n\nconst parseSettings = (rawValue) => {\n  if (!rawValue) return {};\n  try {\n    const parsed = JSON.parse(rawValue);\n    return parsed && typeof parsed === \"object\" ? parsed : {};\n  } catch {\n    return {};\n  }\n};\n\nexport const readUiSettings = () => {\n  try {\n    const rawValue = window.localStorage.getItem(kUiSettingsStorageKey);\n    return parseSettings(rawValue);\n  } catch {\n    return {};\n  }\n};\n\nexport const writeUiSettings = (nextSettings) => {\n  try {\n    window.localStorage.setItem(\n      kUiSettingsStorageKey,\n      JSON.stringify(\n        nextSettings && typeof nextSettings === \"object\" ? nextSettings : {},\n      ),\n    );\n  } catch {}\n};\n\nexport const updateUiSettings = (updater) => {\n  const currentSettings = readUiSettings();\n  const nextSettings = updater(currentSettings);\n  writeUiSettings(nextSettings);\n  return nextSettings;\n};\n"
  },
  {
    "path": "lib/public/js/tailwind-config.js",
    "content": "// Shared Tailwind config — remaps color palette to CSS variables\n// so themes can override colors without touching component files.\ntailwind.config = {\n  theme: {\n    extend: {\n      colors: {\n        surface: \"var(--bg-sidebar)\",\n        border: \"var(--border)\",\n        body: \"var(--text)\",\n        bright: \"var(--text-bright)\",\n        \"fg-muted\": \"var(--text-muted)\",\n        \"fg-dim\": \"var(--text-dim)\",\n        field: \"var(--field-bg-contrast)\",\n        overlay: \"var(--overlay)\",\n        status: {\n          error: \"var(--status-error)\",\n          \"error-muted\": \"var(--status-error-muted)\",\n          \"error-bg\": \"var(--status-error-bg)\",\n          \"error-border\": \"var(--status-error-border)\",\n          warning: \"var(--status-warning)\",\n          \"warning-muted\": \"var(--status-warning-muted)\",\n          \"warning-bg\": \"var(--status-warning-bg)\",\n          \"warning-border\": \"var(--status-warning-border)\",\n          success: \"var(--status-success)\",\n          \"success-muted\": \"var(--status-success-muted)\",\n          \"success-bg\": \"var(--status-success-bg)\",\n          \"success-border\": \"var(--status-success-border)\",\n          info: \"var(--status-info)\",\n          \"info-muted\": \"var(--status-info-muted)\",\n          \"info-bg\": \"var(--status-info-bg)\",\n          \"info-border\": \"var(--status-info-border)\",\n        },\n      },\n      fontFamily: {\n        mono: [\"'JetBrains Mono'\", \"monospace\"],\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "lib/public/login.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>alphaclaw</title>\n    <link\n      href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap\"\n      rel=\"stylesheet\"\n    />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"./img/logo.svg\" />\n    <link rel=\"stylesheet\" href=\"./css/theme.css\" />\n    <link rel=\"stylesheet\" href=\"./css/tailwind.generated.css\" />\n    <script>\n      try {\n        var t = localStorage.getItem(\"alphaclaw.ui.theme\");\n        if (t === \"system\") t = window.matchMedia(\"(prefers-color-scheme: light)\").matches ? \"light\" : \"dark\";\n        else if (t !== \"dark\" && t !== \"light\") t = \"dark\";\n        document.documentElement.dataset.theme = t;\n      } catch {}\n    </script>\n  </head>\n  <body class=\"min-h-screen flex items-center justify-center p-4\">\n    <div class=\"max-w-sm w-full relative z-10\">\n      <div class=\"text-center mb-6\">\n        <img\n          src=\"./img/logo.svg\"\n          alt=\"alphaclaw\"\n          class=\"mx-auto mb-4\"\n          width=\"48\"\n          height=\"49\"\n        />\n        <h1 class=\"text-2xl font-semibold mb-2\">\n          <span style=\"color: var(--accent)\">alpha</span>claw\n        </h1>\n        <p style=\"color: var(--text-muted)\" class=\"text-xs mb-4\">\n          OpenClaw made easy\n        </p>\n      </div>\n      <form\n        id=\"login-form\"\n        class=\"bg-surface border border-border rounded-xl p-4 space-y-3\"\n      >\n        <input\n          id=\"password\"\n          type=\"password\"\n          placeholder=\"Password\"\n          autofocus\n          class=\"w-full bg-field border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-fg-muted\"\n          style=\"color: var(--text)\"\n        />\n        <div id=\"error\" class=\"text-status-error-muted text-xs hidden\"></div>\n        <button\n          id=\"submit-btn\"\n          type=\"submit\"\n          disabled\n          class=\"w-full text-sm font-medium px-4 py-2 rounded-lg ac-btn-cyan\"\n        >\n          Enter\n        </button>\n      </form>\n    </div>\n    <script>\n      const formEl = document.getElementById(\"login-form\");\n      const passwordEl = document.getElementById(\"password\");\n      const submitButtonEl = document.getElementById(\"submit-btn\");\n\n      const syncButtonState = () => {\n        const hasValue = passwordEl.value.length > 0;\n        submitButtonEl.disabled = !hasValue;\n      };\n\n      passwordEl.addEventListener(\"input\", syncButtonState);\n\n      const setPendingState = (isPending) => {\n        submitButtonEl.disabled = isPending;\n        submitButtonEl.classList.toggle(\"opacity-70\", isPending);\n        submitButtonEl.classList.toggle(\"cursor-not-allowed\", isPending);\n      };\n\n      formEl.addEventListener(\"submit\", async (e) => {\n        e.preventDefault();\n        const password = document.getElementById(\"password\").value;\n        const errorEl = document.getElementById(\"error\");\n        errorEl.classList.add(\"hidden\");\n        setPendingState(true);\n        try {\n          const res = await fetch(\"/api/auth/login\", {\n            method: \"POST\",\n            headers: { \"Content-Type\": \"application/json\" },\n            body: JSON.stringify({ password }),\n          });\n          const data = await res.json();\n          if (data.ok) {\n            window.location.href = \"/\";\n          } else {\n            if (res.status === 429) {\n              const retryAfterFromHeader = Number.parseInt(\n                res.headers.get(\"retry-after\") || \"0\",\n                10,\n              );\n              const retryAfterSec =\n                Number.isFinite(data.retryAfterSec) && data.retryAfterSec > 0\n                  ? data.retryAfterSec\n                  : Number.isFinite(retryAfterFromHeader) &&\n                      retryAfterFromHeader > 0\n                    ? retryAfterFromHeader\n                    : 30;\n              errorEl.textContent = `Too many attempts. Try again in ${retryAfterSec}s.`;\n            } else {\n              errorEl.textContent = data.error || \"Login failed\";\n            }\n            errorEl.classList.remove(\"hidden\");\n          }\n        } catch {\n          errorEl.textContent = \"Connection error\";\n          errorEl.classList.remove(\"hidden\");\n        } finally {\n          setPendingState(false);\n        }\n      });\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "lib/public/setup.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>alphaclaw</title>\n    <link\n      href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap\"\n      rel=\"stylesheet\"\n    />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"./img/logo.svg\" />\n    <link rel=\"stylesheet\" href=\"./css/theme.css\" />\n    <link rel=\"stylesheet\" href=\"./css/tailwind.generated.css\" />\n    <link rel=\"stylesheet\" href=\"./css/shell.css\" />\n    <link rel=\"stylesheet\" href=\"./css/explorer.css\" />\n    <link rel=\"stylesheet\" href=\"./css/agents.css\" />\n    <link rel=\"stylesheet\" href=\"./css/chat.css\" />\n    <link rel=\"stylesheet\" href=\"./css/cron.css\" />\n    <script>\n      // Apply saved theme before render to prevent flash.\n      try {\n        var t = localStorage.getItem(\"alphaclaw.ui.theme\");\n        if (t === \"system\") t = window.matchMedia(\"(prefers-color-scheme: light)\").matches ? \"light\" : \"dark\";\n        else if (t !== \"dark\" && t !== \"light\") t = \"dark\";\n        document.documentElement.dataset.theme = t;\n      } catch {}\n    </script>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./dist/app.bundle.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "lib/public/shared/browse-file-policies.json",
    "content": "{\n  \"protectedPaths\": [\n    \"openclaw.json\",\n    \"devices/paired.json\"\n  ],\n  \"lockedPaths\": [\n    \"hooks/bootstrap/agents.md\",\n    \"hooks/bootstrap/tools.md\",\n    \"skills/gog-cli\",\n    \".alphaclaw/hourly-git-sync.sh\",\n    \".alphaclaw/.cli-device-auto-approved\"\n  ]\n}\n"
  },
  {
    "path": "lib/scripts/git",
    "content": "#!/bin/bash\n# Git auth shim -- injects GITHUB_TOKEN credentials for network operations\n# inside the configured OpenClaw repo so the agent can use plain git commands.\n\nREAL_GIT_HINT=\"@@REAL_GIT@@\"\nOPENCLAW_REPO_ROOT=\"@@OPENCLAW_REPO_ROOT@@\"\nASKPASS_PATH=\"/tmp/alphaclaw-git-askpass.sh\"\n\nsame_path() {\n  local left_path right_path\n  left_path=\"$(readlink -f \"$1\" 2>/dev/null || printf '%s' \"$1\")\"\n  right_path=\"$(readlink -f \"$2\" 2>/dev/null || printf '%s' \"$2\")\"\n  [ \"$left_path\" = \"$right_path\" ]\n}\n\nresolve_real_git() {\n  local self_path candidate\n  self_path=\"$(readlink -f \"$0\" 2>/dev/null || printf '%s' \"$0\")\"\n  for candidate in \\\n    \"${ALPHACLAW_REAL_GIT:-}\" \\\n    \"$REAL_GIT_HINT\" \\\n    \"/usr/bin/git\" \\\n    \"/bin/git\" \\\n    \"/usr/libexec/git-core/git\" \\\n    \"/usr/local/bin/git.real\"\n  do\n    [ -n \"$candidate\" ] || continue\n    [ -x \"$candidate\" ] || continue\n    same_path \"$candidate\" \"$self_path\" && continue\n    printf '%s\\n' \"$candidate\"\n    return 0\n  done\n\n  if command -v which >/dev/null 2>&1; then\n    while IFS= read -r candidate; do\n      [ -n \"$candidate\" ] || continue\n      [ -x \"$candidate\" ] || continue\n      same_path \"$candidate\" \"$self_path\" && continue\n      printf '%s\\n' \"$candidate\"\n      return 0\n    done <<EOF\n$(which -a git 2>/dev/null || true)\nEOF\n  fi\n\n  return 1\n}\n\ncanonicalize_path() {\n  local target_path resolved_path\n  target_path=\"$1\"\n  [ -n \"$target_path\" ] || return 1\n\n  if [ -d \"$target_path\" ]; then\n    resolved_path=\"$(cd \"$target_path\" 2>/dev/null && pwd -P)\" || resolved_path=\"\"\n    if [ -n \"$resolved_path\" ]; then\n      printf '%s\\n' \"$resolved_path\"\n      return 0\n    fi\n  fi\n\n  resolved_path=\"$(readlink -f \"$target_path\" 2>/dev/null || true)\"\n  if [ -n \"$resolved_path\" ]; then\n    printf '%s\\n' \"$resolved_path\"\n    return 0\n  fi\n\n  printf '%s\\n' \"$target_path\"\n}\n\nresolve_effective_pwd() {\n  local effective_pwd\n  effective_pwd=\"$(pwd)\"\n\n  while [ \"$#\" -gt 0 ]; do\n    case \"$1\" in\n      -C)\n        shift\n        [ \"$#\" -gt 0 ] || break\n        case \"$1\" in\n          /*) effective_pwd=\"$1\" ;;\n          *) effective_pwd=\"$effective_pwd/$1\" ;;\n        esac\n        ;;\n      -c|--exec-path|--git-dir|--work-tree|--namespace|--config-env|--super-prefix|--list-cmds|--attr-source)\n        shift\n        [ \"$#\" -gt 0 ] || break\n        ;;\n      --exec-path=*|--git-dir=*|--work-tree=*|--namespace=*|--config-env=*|--super-prefix=*|--list-cmds=*|--attr-source=*)\n        ;;\n      --)\n        break\n        ;;\n      -*)\n        ;;\n      *)\n        break\n        ;;\n    esac\n    shift\n  done\n\n  canonicalize_path \"$effective_pwd\"\n}\n\nin_openclaw_root() {\n  local candidate_path resolved_repo_root resolved_candidate_path\n  candidate_path=\"$1\"\n  if [ -z \"$OPENCLAW_REPO_ROOT\" ]; then\n    return 1\n  fi\n  resolved_repo_root=\"$(canonicalize_path \"$OPENCLAW_REPO_ROOT\")\"\n  resolved_candidate_path=\"$(canonicalize_path \"$candidate_path\")\"\n  case \"$resolved_candidate_path\" in\n    \"$resolved_repo_root\"|\"${resolved_repo_root}\"/*) return 0 ;;\n    *) return 1 ;;\n  esac\n}\n\nREAL_GIT=\"$(resolve_real_git || true)\"\nif [ -z \"$REAL_GIT\" ]; then\n  echo \"alphaclaw git shim: real git binary not found\" >&2\n  exit 127\nfi\n\nEFFECTIVE_PWD=\"$(resolve_effective_pwd \"$@\")\"\n\nif [ -z \"${GITHUB_TOKEN:-}\" ] && in_openclaw_root \"$EFFECTIVE_PWD\" && [ -f \"$OPENCLAW_REPO_ROOT/.env\" ]; then\n  set -a\n  . \"$OPENCLAW_REPO_ROOT/.env\" >/dev/null 2>&1 || true\n  set +a\nfi\n\nif [ \"${ALPHACLAW_GIT_NO_AUTH:-}\" = \"1\" ] || [ -z \"${GITHUB_TOKEN:-}\" ] || ! in_openclaw_root \"$EFFECTIVE_PWD\"; then\n  exec \"$REAL_GIT\" \"$@\"\nfi\n\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_ASKPASS=\"$ASKPASS_PATH\"\nexec \"$REAL_GIT\" \"$@\"\n"
  },
  {
    "path": "lib/scripts/git-askpass",
    "content": "#!/usr/bin/env sh\ncase \"$1\" in\n  *Username*) echo \"x-access-token\" ;;\n  *Password*) echo \"${GITHUB_TOKEN:-}\" ;;\n  *) echo \"\" ;;\nesac\n"
  },
  {
    "path": "lib/scripts/systemctl",
    "content": "#!/bin/bash\n# systemctl shim for Docker (no real systemd available)\n# Translates restart/stop/status into process signals so the Express wrapper handles lifecycle.\n\nACTION=\"\"\nUNIT=\"\"\nfor arg in \"$@\"; do\n  case \"$arg\" in\n    --user|--system|--no-pager|--quiet) ;;\n    restart|stop|start|status|enable|disable|is-active|is-enabled|daemon-reload|show) ACTION=\"$arg\" ;;\n    *) [ -n \"$ACTION\" ] && [ -z \"$UNIT\" ] && UNIT=\"$arg\" ;;\n  esac\ndone\n\ncase \"$ACTION\" in\n  status)\n    if [ -z \"$UNIT\" ]; then\n      # Bare \"systemctl --user status\" — systemd availability check\n      echo \"● user-session\"\n      echo \"   Active: active (running)\"\n      exit 0\n    fi\n    # Status check for a specific unit\n    if pgrep -f \"openclaw gateway\" >/dev/null 2>&1; then\n      echo \"● ${UNIT}\"\n      echo \"   Active: active (running)\"\n      exit 0\n    else\n      echo \"● ${UNIT}\"\n      echo \"   Active: inactive (dead)\"\n      exit 3\n    fi\n    ;;\n  is-active)\n    if pgrep -f \"openclaw gateway\" >/dev/null 2>&1; then\n      echo \"active\"\n      exit 0\n    else\n      echo \"inactive\"\n      exit 3\n    fi\n    ;;\n  restart|stop)\n    pkill -TERM -f \"openclaw gateway\" 2>/dev/null || true\n    sleep 1\n    pkill -9 -f \"openclaw gateway\" 2>/dev/null || true\n    rm -f /data/.openclaw/gateway.lock /data/.openclaw/gateway.pid 2>/dev/null\n    exit 0\n    ;;\n  start|enable|is-enabled|disable|daemon-reload|show)\n    exit 0\n    ;;\n  *)\n    exit 0\n    ;;\nesac\n"
  },
  {
    "path": "lib/server/agents/agents.js",
    "content": "const path = require(\"path\");\n\nconst {\n  kDefaultAgentId,\n  resolveAgentWorkspacePath,\n  loadConfig,\n  saveConfig,\n  cloneJson,\n  getSafeStat,\n  calculatePathSizeBytes,\n  withNormalizedAgentsConfig,\n  isValidAgentId,\n  resolveRequestedWorkspacePath,\n  ensureAgentScaffold,\n} = require(\"./shared\");\n\nconst toTitleWords = (value = \"\") =>\n  String(value || \"\")\n    .trim()\n    .split(/[-_\\s]+/)\n    .filter(Boolean)\n    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n    .join(\" \");\n\nconst getFallbackAgentName = (agentId = \"\") => {\n  const normalizedAgentId = String(agentId || \"\").trim();\n  if (!normalizedAgentId) return \"Agent\";\n  const title = toTitleWords(normalizedAgentId) || normalizedAgentId;\n  return `${title} Agent`;\n};\n\nconst getAgentDisplayName = (agent = {}) =>\n  String(agent?.identity?.name || \"\").trim() ||\n  String(agent?.name || \"\").trim() ||\n  getFallbackAgentName(agent?.id || \"\");\n\nconst toReadableAgent = (agent = {}) => ({\n  ...agent,\n  id: String(agent.id || \"\").trim(),\n  name: getAgentDisplayName(agent),\n  default: !!agent.default,\n});\n\nconst createAgentsDomain = ({ fsImpl, OPENCLAW_DIR }) => {\n  const listAgents = () => {\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    return (cfg.agents?.list || []).map((entry) => toReadableAgent(entry));\n  };\n\n  const getAgent = (agentId) => {\n    const normalized = String(agentId || \"\").trim();\n    return listAgents().find((entry) => entry.id === normalized) || null;\n  };\n\n  const getAgentWorkspaceSize = (agentId) => {\n    const normalized = String(agentId || \"\").trim();\n    const agent = getAgent(normalized);\n    if (!agent) throw new Error(`Agent \"${normalized}\" not found`);\n    const workspacePath = String(\n      agent.workspace ||\n        resolveAgentWorkspacePath({ OPENCLAW_DIR, agentId: normalized }),\n    ).trim();\n    if (!workspacePath) {\n      return { workspacePath: \"\", exists: false, sizeBytes: 0 };\n    }\n    const stat = getSafeStat({ fsImpl, targetPath: workspacePath });\n    if (!stat) {\n      return { workspacePath, exists: false, sizeBytes: 0 };\n    }\n    return {\n      workspacePath,\n      exists: true,\n      sizeBytes: calculatePathSizeBytes({ fsImpl, targetPath: workspacePath }),\n    };\n  };\n\n  const createAgent = (input = {}) => {\n    const agentId = String(input.id || \"\").trim();\n    if (!isValidAgentId(agentId)) {\n      throw new Error(\n        \"Agent id must be lowercase letters, numbers, and hyphens only\",\n      );\n    }\n\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const existing = cfg.agents.list.find((entry) => entry.id === agentId);\n    if (existing) {\n      throw new Error(`Agent \"${agentId}\" already exists`);\n    }\n\n    const workspacePath = resolveRequestedWorkspacePath({\n      OPENCLAW_DIR,\n      agentId,\n      workspaceFolder: input.workspaceFolder,\n    });\n    const { workspacePath: scaffoldWorkspacePath, agentDirPath } =\n      ensureAgentScaffold({\n        fsImpl,\n        workspacePath,\n        OPENCLAW_DIR,\n        agentId,\n      });\n    const requestedIdentity =\n      input.identity && typeof input.identity === \"object\"\n        ? { ...input.identity }\n        : {};\n    const requestedName = String(input.name || \"\").trim();\n    const identityName =\n      requestedName ||\n      String(requestedIdentity.name || \"\").trim() ||\n      getFallbackAgentName(agentId);\n    const nextAgent = {\n      id: agentId,\n      default: false,\n      workspace: scaffoldWorkspacePath,\n      agentDir: agentDirPath,\n      identity: {\n        ...requestedIdentity,\n        name: identityName,\n      },\n      ...(input.model ? { model: input.model } : {}),\n    };\n    cfg.agents.list = [...cfg.agents.list, nextAgent];\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n    return toReadableAgent(nextAgent);\n  };\n\n  const updateAgent = (agentId, patch = {}) => {\n    const normalized = String(agentId || \"\").trim();\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const index = cfg.agents.list.findIndex((entry) => entry.id === normalized);\n    if (index < 0) throw new Error(`Agent \"${normalized}\" not found`);\n    const current = cfg.agents.list[index];\n    const next = { ...current };\n    const identityPatched =\n      patch.identity !== undefined || patch.name !== undefined;\n    if (identityPatched) {\n      const baseIdentity =\n        patch.identity !== undefined\n          ? patch.identity && typeof patch.identity === \"object\"\n            ? { ...patch.identity }\n            : {}\n          : current.identity && typeof current.identity === \"object\"\n            ? { ...current.identity }\n            : {};\n      const requestedName =\n        patch.name !== undefined\n          ? String(patch.name || \"\").trim()\n          : String(baseIdentity.name || \"\").trim();\n      const fallbackLegacyName = String(current.name || \"\").trim();\n      baseIdentity.name =\n        requestedName || fallbackLegacyName || getFallbackAgentName(normalized);\n      next.identity = baseIdentity;\n      // Only remove legacy top-level name once identity.name is persisted.\n      delete next.name;\n    }\n    if (patch.model !== undefined) {\n      if (patch.model === null) {\n        delete next.model;\n      } else {\n        next.model = patch.model;\n      }\n    }\n    if (patch.tools !== undefined) {\n      if (patch.tools && typeof patch.tools === \"object\") {\n        const toolsCfg = {};\n        if (patch.tools.profile) toolsCfg.profile = String(patch.tools.profile);\n        if (\n          Array.isArray(patch.tools.alsoAllow) &&\n          patch.tools.alsoAllow.length\n        ) {\n          toolsCfg.alsoAllow = patch.tools.alsoAllow.map(String);\n        }\n        if (Array.isArray(patch.tools.deny) && patch.tools.deny.length) {\n          toolsCfg.deny = patch.tools.deny.map(String);\n        }\n        next.tools = toolsCfg;\n      } else {\n        delete next.tools;\n      }\n    }\n    cfg.agents.list[index] = next;\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n    return toReadableAgent(next);\n  };\n\n  const setDefaultAgent = (agentId) => {\n    const normalized = String(agentId || \"\").trim();\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const exists = cfg.agents.list.some((entry) => entry.id === normalized);\n    if (!exists) throw new Error(`Agent \"${normalized}\" not found`);\n    cfg.agents.list = cfg.agents.list.map((entry) => ({\n      ...entry,\n      default: entry.id === normalized,\n    }));\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n    return cfg.agents.list.find((entry) => entry.id === normalized) || null;\n  };\n\n  const deleteAgent = (agentId, { keepWorkspace = true } = {}) => {\n    const normalized = String(agentId || \"\").trim();\n    if (!normalized || normalized === kDefaultAgentId) {\n      throw new Error(\"The default main agent cannot be deleted\");\n    }\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const target = cfg.agents.list.find((entry) => entry.id === normalized);\n    if (!target) throw new Error(`Agent \"${normalized}\" not found`);\n    if (target.default) {\n      throw new Error(\"Default agent cannot be deleted\");\n    }\n    cfg.agents.list = cfg.agents.list.filter(\n      (entry) => entry.id !== normalized,\n    );\n    if (Array.isArray(cfg.bindings)) {\n      cfg.bindings = cfg.bindings.filter(\n        (binding) => String(binding?.agentId || \"\") !== normalized,\n      );\n    }\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n\n    if (!keepWorkspace) {\n      const workspacePath = String(\n        target.workspace ||\n          resolveAgentWorkspacePath({\n            OPENCLAW_DIR,\n            agentId: normalized,\n          }),\n      ).trim();\n      const agentDirPath = path.join(OPENCLAW_DIR, \"agents\", normalized);\n      if (workspacePath) {\n        fsImpl.rmSync(workspacePath, { recursive: true, force: true });\n      }\n      fsImpl.rmSync(agentDirPath, { recursive: true, force: true });\n    }\n    return { ok: true };\n  };\n\n  return {\n    listAgents,\n    getAgent,\n    getAgentWorkspaceSize,\n    createAgent,\n    updateAgent,\n    setDefaultAgent,\n    deleteAgent,\n  };\n};\n\nmodule.exports = { createAgentsDomain };\n"
  },
  {
    "path": "lib/server/agents/bindings.js",
    "content": "const {\n  loadConfig,\n  saveConfig,\n  cloneJson,\n  normalizeBindingMatch,\n  matchesBinding,\n  appendBindingToConfig,\n  withNormalizedAgentsConfig,\n} = require(\"./shared\");\n\nconst createBindingsDomain = ({ fsImpl, OPENCLAW_DIR }) => {\n  const getBindingsForAgent = (agentId) => {\n    const normalized = String(agentId || \"\").trim();\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];\n    return bindings\n      .filter((binding) => String(binding?.agentId || \"\").trim() === normalized)\n      .map((binding) => cloneJson(binding));\n  };\n\n  const addBinding = (agentId, input = {}) => {\n    const normalizedAgentId = String(agentId || \"\").trim();\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const agent = cfg.agents.list.find(\n      (entry) => entry.id === normalizedAgentId,\n    );\n    if (!agent) throw new Error(`Agent \"${normalizedAgentId}\" not found`);\n    const match = normalizeBindingMatch(input);\n    const nextBinding = appendBindingToConfig({\n      cfg,\n      agentId: normalizedAgentId,\n      match,\n    });\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n    return nextBinding;\n  };\n\n  const removeBinding = (agentId, input = {}) => {\n    const normalizedAgentId = String(agentId || \"\").trim();\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];\n    const nextMatch = normalizeBindingMatch(input);\n    const nextBindings = bindings.filter(\n      (binding) =>\n        !(\n          String(binding?.agentId || \"\").trim() === normalizedAgentId &&\n          matchesBinding(binding?.match || {}, nextMatch)\n        ),\n    );\n    if (nextBindings.length === bindings.length) {\n      throw new Error(\"Binding not found\");\n    }\n    cfg.bindings = nextBindings;\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n    return { ok: true };\n  };\n\n  return {\n    getBindingsForAgent,\n    addBinding,\n    removeBinding,\n  };\n};\n\nmodule.exports = { createBindingsDomain };\n"
  },
  {
    "path": "lib/server/agents/channels.js",
    "content": "const path = require(\"path\");\n\nconst {\n  kChannelTokenFields,\n  kChannelLabels,\n  kMaskedChannelToken,\n  shellEscapeArg,\n  resolveCredentialsDirPath,\n  loadConfig,\n  saveConfig,\n  ensurePluginAllowed,\n  cloneJson,\n  normalizeBindingMatch,\n  matchesBinding,\n  isValidChannelAccountId,\n  normalizeChannelProvider,\n  deriveChannelEnvKey,\n  deriveChannelExtraEnvKeys,\n  getConfiguredChannelEnvKeys,\n  assertActiveChannelTokenEnvVars,\n  hasSavedWhatsAppCredentials,\n  normalizeChannelConfig,\n  appendBindingToConfig,\n  buildBindingSpec,\n  hasLegacyDefaultChannelAccount,\n  listConfiguredChannelAccounts,\n  withNormalizedAgentsConfig,\n} = require(\"./shared\");\n\nconst createChannelsDomain = ({\n  fsImpl,\n  OPENCLAW_DIR,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  restartGateway,\n  clawCmd,\n}) => {\n  let createChannelAccountInProgress = false;\n\n  const formatClawResultOutput = (result) =>\n    [result?.stderr, result?.stdout].filter(Boolean).join(\"\\n\").trim();\n\n  const isConfigMutationConflictResult = (result) =>\n    /ConfigMutationConflictError|config changed since last load/i.test(\n      formatClawResultOutput(result),\n    );\n\n  const waitForRetry = (delayMs) =>\n    new Promise((resolve) => setTimeout(resolve, delayMs));\n\n  const clawCmdWithConfigConflictRetry = async (\n    command,\n    options,\n    { label = \"command\", delaysMs = [250, 750] } = {},\n  ) => {\n    for (let attempt = 0; attempt <= delaysMs.length; attempt += 1) {\n      const result = await clawCmd(command, options);\n      if (result?.ok || !isConfigMutationConflictResult(result)) {\n        return result;\n      }\n      if (attempt >= delaysMs.length) {\n        return result;\n      }\n      const delayMs = Number(delaysMs[attempt] || 0);\n      console.warn(\n        `[alphaclaw] Retrying openclaw ${label} after config mutation conflict`,\n      );\n      if (delayMs > 0) {\n        await waitForRetry(delayMs);\n      }\n    }\n    return { ok: false, stdout: \"\", stderr: \"Command retry exhausted\" };\n  };\n\n  const getChannelAccountToken = ({\n    provider: rawProvider,\n    accountId: rawAccountId,\n  } = {}) => {\n    const provider = normalizeChannelProvider(rawProvider);\n    const accountId = String(rawAccountId || \"\").trim() || \"default\";\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const providerConfig =\n      cfg.channels?.[provider] && typeof cfg.channels[provider] === \"object\"\n        ? cfg.channels[provider]\n        : null;\n    if (!providerConfig) {\n      throw new Error(`Channel \"${provider}\" not found`);\n    }\n    const hasAccounts =\n      providerConfig.accounts && typeof providerConfig.accounts === \"object\";\n    const hasLegacyDefault =\n      accountId === \"default\" &&\n      !hasAccounts &&\n      hasLegacyDefaultChannelAccount({ config: providerConfig });\n    if (!hasLegacyDefault && !providerConfig.accounts?.[accountId]) {\n      throw new Error(`Channel account \"${provider}/${accountId}\" not found`);\n    }\n    const envKey = deriveChannelEnvKey({ provider, accountId });\n    const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });\n    const envVars = readEnvFile();\n    const envEntry = (Array.isArray(envVars) ? envVars : []).find(\n      (entry) => String(entry?.key || \"\").trim() === envKey,\n    );\n    const appEnvKey = extraEnvKeys[0] || \"\";\n    const appEnvEntry = appEnvKey\n      ? (Array.isArray(envVars) ? envVars : []).find(\n          (entry) => String(entry?.key || \"\").trim() === appEnvKey,\n        )\n      : null;\n    return {\n      provider,\n      accountId,\n      envKey,\n      token: String(envEntry?.value || \"\"),\n      ...(provider === \"slack\"\n        ? {\n            appEnvKey,\n            appToken: String(appEnvEntry?.value || \"\"),\n          }\n        : {}),\n    };\n  };\n\n  const createChannelAccount = async (\n    input = {},\n    { onProgress = () => {} } = {},\n  ) => {\n    if (createChannelAccountInProgress) {\n      throw new Error(\"A channel account creation is already in progress\");\n    }\n    createChannelAccountInProgress = true;\n    try {\n      const provider = normalizeChannelProvider(input.provider);\n      const name =\n        String(input.name || \"\").trim() || kChannelLabels[provider] || provider;\n\n      const cfg = withNormalizedAgentsConfig({\n        OPENCLAW_DIR,\n        cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n      });\n\n      const agentId = String(input.agentId || \"\").trim();\n      const agent = cfg.agents.list.find((entry) => entry.id === agentId);\n      if (!agent) throw new Error(`Agent \"${agentId}\" not found`);\n\n      const existingChannelConfig =\n        cfg.channels?.[provider] && typeof cfg.channels[provider] === \"object\"\n          ? cfg.channels[provider]\n          : {};\n      const normalizedChannelConfig = normalizeChannelConfig({\n        provider,\n        channelConfig: existingChannelConfig,\n      });\n      const existingAccounts =\n        normalizedChannelConfig.accounts &&\n        typeof normalizedChannelConfig.accounts === \"object\"\n          ? normalizedChannelConfig.accounts\n          : {};\n      const requestedAccountId = String(input.accountId || \"\").trim();\n      const accountId =\n        requestedAccountId ||\n        (Object.keys(existingAccounts).length > 0 ? \"\" : \"default\");\n      if (!accountId) {\n        throw new Error(\"Channel account id is required\");\n      }\n      if (!isValidChannelAccountId(accountId)) {\n        throw new Error(\n          \"Channel account id must be lowercase letters, numbers, and hyphens only\",\n        );\n      }\n      if (existingAccounts[accountId]) {\n        throw new Error(\n          `Channel account \"${provider}/${accountId}\" already exists`,\n        );\n      }\n      if (\n        (provider === \"discord\" || provider === \"whatsapp\") &&\n        Object.keys(existingAccounts).length > 0\n      ) {\n        throw new Error(\n          `${kChannelLabels[provider] || \"This provider\"} supports a single channel account`,\n        );\n      }\n\n      if (provider === \"whatsapp\") {\n        return await createWhatsAppChannelAccount({\n          input,\n          cfg,\n          agentId,\n          accountId,\n          name,\n          normalizedChannelConfig,\n          existingAccounts,\n          onProgress,\n        });\n      }\n\n      const token = String(input.token || \"\").trim();\n      if (!token) throw new Error(\"Channel token is required\");\n\n      const envKey = deriveChannelEnvKey({ provider, accountId });\n      const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });\n      const appToken = String(input.appToken || \"\").trim();\n      if (provider === \"slack\" && !appToken) {\n        throw new Error(\"Slack App Token is required\");\n      }\n      const tokenField = kChannelTokenFields[provider];\n      const currentEnvVars = readEnvFile();\n      const previousEnvVars = Array.isArray(currentEnvVars)\n        ? currentEnvVars\n        : [];\n      const duplicateEnvEntry = previousEnvVars.find((entry) => {\n        const existingKey = String(entry?.key || \"\").trim();\n        const existingValue = String(entry?.value || \"\").trim();\n        if (!existingKey || !existingValue) return false;\n        if (existingKey === envKey) return false;\n        return existingValue === token;\n      });\n      let orphanedEnvKey = null;\n      if (duplicateEnvEntry) {\n        const dupKey = String(duplicateEnvEntry.key || \"\").trim();\n        const configuredKeys = getConfiguredChannelEnvKeys(cfg);\n        if (configuredKeys.has(dupKey)) {\n          throw new Error(`Channel token already exists in ${dupKey}`);\n        }\n        orphanedEnvKey = dupKey;\n        console.log(\n          `[alphaclaw] Overwriting orphaned channel env var ${dupKey} (no matching config entry)`,\n        );\n      }\n      let orphanedExtraEnvKey = null;\n      if (provider === \"slack\") {\n        const appEnvKey = extraEnvKeys[0];\n        const duplicateAppTokenEntry = previousEnvVars.find((entry) => {\n          const existingKey = String(entry?.key || \"\").trim();\n          const existingValue = String(entry?.value || \"\").trim();\n          if (!existingKey || !existingValue) return false;\n          if (existingKey === envKey || existingKey === appEnvKey) return false;\n          return existingValue === appToken;\n        });\n        if (duplicateAppTokenEntry) {\n          const dupKey = String(duplicateAppTokenEntry.key || \"\").trim();\n          const configuredKeys = getConfiguredChannelEnvKeys(cfg);\n          if (configuredKeys.has(dupKey)) {\n            throw new Error(`Channel token already exists in ${dupKey}`);\n          }\n          orphanedExtraEnvKey = dupKey;\n          console.log(\n            `[alphaclaw] Overwriting orphaned channel env var ${dupKey} (no matching config entry)`,\n          );\n        }\n      }\n      const nextEnvVars = previousEnvVars.filter((entry) => {\n        const key = String(entry?.key || \"\").trim();\n        return (\n          key !== envKey &&\n          key !== orphanedEnvKey &&\n          !extraEnvKeys.includes(key) &&\n          key !== orphanedExtraEnvKey\n        );\n      });\n      nextEnvVars.push({ key: envKey, value: token });\n      if (provider === \"slack\" && extraEnvKeys[0]) {\n        nextEnvVars.push({ key: extraEnvKeys[0], value: appToken });\n      }\n\n      const previousConfig = cloneJson(cfg);\n      try {\n        onProgress({ phase: \"restarting\", label: \"Rebooting...\" });\n        writeEnvFile(nextEnvVars);\n        reloadEnv();\n        assertActiveChannelTokenEnvVars({\n          cfg: withNormalizedAgentsConfig({\n            OPENCLAW_DIR,\n            cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n          }),\n          envVars: nextEnvVars,\n        });\n        await restartGateway();\n        const pluginEnabledCfg = withNormalizedAgentsConfig({\n          OPENCLAW_DIR,\n          cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n        });\n        ensurePluginAllowed({ cfg: pluginEnabledCfg, pluginKey: provider });\n        saveConfig({ fsImpl, OPENCLAW_DIR, config: pluginEnabledCfg });\n        const addArgs = [\n          \"channels add\",\n          `--channel ${shellEscapeArg(provider)}`,\n          accountId !== \"default\"\n            ? `--account ${shellEscapeArg(accountId)}`\n            : \"\",\n          name ? `--name ${shellEscapeArg(name)}` : \"\",\n          provider === \"slack\"\n            ? `--bot-token ${shellEscapeArg(token)}`\n            : `--token ${shellEscapeArg(token)}`,\n          provider === \"slack\" && appToken\n            ? `--app-token ${shellEscapeArg(appToken)}`\n            : \"\",\n        ].filter(Boolean);\n        const addResult = await clawCmdWithConfigConflictRetry(\n          addArgs.join(\" \"),\n          {\n            quiet: true,\n            timeoutMs: 30000,\n          },\n          { label: \"channels add\" },\n        );\n        if (!addResult?.ok) {\n          throw new Error(\n            addResult?.stderr ||\n              addResult?.stdout ||\n              \"Could not add channel account\",\n          );\n        }\n        const nextCfg = withNormalizedAgentsConfig({\n          OPENCLAW_DIR,\n          cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n        });\n        const nextProviderConfig = normalizeChannelConfig({\n          provider,\n          channelConfig:\n            nextCfg.channels?.[provider] &&\n            typeof nextCfg.channels[provider] === \"object\"\n              ? nextCfg.channels[provider]\n              : {},\n        });\n        const nextAccounts =\n          nextProviderConfig.accounts &&\n          typeof nextProviderConfig.accounts === \"object\"\n            ? { ...nextProviderConfig.accounts }\n            : {};\n        nextAccounts[accountId] = {\n          ...(nextAccounts[accountId] &&\n          typeof nextAccounts[accountId] === \"object\"\n            ? nextAccounts[accountId]\n            : {}),\n          ...(name ? { name } : {}),\n          [tokenField]: `\\${${envKey}}`,\n          ...(provider === \"slack\" && extraEnvKeys[0]\n            ? { appToken: `\\${${extraEnvKeys[0]}}` }\n            : {}),\n          dmPolicy: \"pairing\",\n        };\n        nextProviderConfig.accounts = nextAccounts;\n        nextProviderConfig.enabled = true;\n        if (\n          nextProviderConfig.accounts &&\n          typeof nextProviderConfig.accounts === \"object\" &&\n          !String(nextProviderConfig.defaultAccount || \"\").trim()\n        ) {\n          nextProviderConfig.defaultAccount = \"default\";\n        }\n        nextCfg.channels =\n          nextCfg.channels && typeof nextCfg.channels === \"object\"\n            ? { ...nextCfg.channels }\n            : {};\n        nextCfg.channels[provider] = nextProviderConfig;\n        saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg });\n        onProgress({ phase: \"binding\", label: \"Binding agent...\" });\n        const bindSpec = buildBindingSpec({ provider, accountId });\n        const bindResult = await clawCmdWithConfigConflictRetry(\n          `agents bind --agent ${shellEscapeArg(agentId)} --bind ${shellEscapeArg(bindSpec)}`,\n          { quiet: true, timeoutMs: 30000 },\n          { label: \"agents bind\" },\n        );\n        if (!bindResult?.ok) {\n          throw new Error(\n            bindResult?.stderr ||\n              bindResult?.stdout ||\n              \"Could not bind channel account\",\n          );\n        }\n      } catch (error) {\n        try {\n          await clawCmd(\n            [\n              \"channels remove\",\n              `--channel ${shellEscapeArg(provider)}`,\n              accountId !== \"default\"\n                ? `--account ${shellEscapeArg(accountId)}`\n                : \"\",\n              \"--delete\",\n            ]\n              .filter(Boolean)\n              .join(\" \"),\n            { quiet: true, timeoutMs: 30000 },\n          );\n        } catch {}\n        try {\n          writeEnvFile(previousEnvVars);\n          reloadEnv();\n        } catch {}\n        try {\n          saveConfig({ fsImpl, OPENCLAW_DIR, config: previousConfig });\n        } catch {}\n        throw error;\n      }\n\n      const binding = {\n        agentId,\n        match: normalizeBindingMatch({\n          channel: provider,\n          accountId,\n        }),\n      };\n      return {\n        channel: provider,\n        account: {\n          id: accountId,\n          name,\n          envKey,\n        },\n        binding,\n      };\n    } finally {\n      createChannelAccountInProgress = false;\n    }\n  };\n\n  const updateChannelAccount = (input = {}) => {\n    const provider = normalizeChannelProvider(input.provider);\n    const accountId = String(input.accountId || \"\").trim() || \"default\";\n    const nextName = String(input.name || \"\").trim();\n    const nextAgentId = String(input.agentId || \"\").trim();\n    const nextToken = String(input.token || \"\").trim();\n    const nextAppToken = String(input.appToken || \"\").trim();\n    if (!nextName) throw new Error(\"Channel name is required\");\n    if (!nextAgentId) throw new Error(\"Agent is required\");\n\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const agent = cfg.agents.list.find((entry) => entry.id === nextAgentId);\n    if (!agent) throw new Error(`Agent \"${nextAgentId}\" not found`);\n\n    const providerConfig =\n      cfg.channels?.[provider] && typeof cfg.channels[provider] === \"object\"\n        ? { ...cfg.channels[provider] }\n        : null;\n    if (!providerConfig) {\n      throw new Error(`Channel \"${provider}\" not found`);\n    }\n\n    const hasAccounts =\n      providerConfig.accounts && typeof providerConfig.accounts === \"object\";\n    const hasLegacyDefault =\n      accountId === \"default\" &&\n      !hasAccounts &&\n      hasLegacyDefaultChannelAccount({ config: providerConfig });\n    if (!hasLegacyDefault && !providerConfig.accounts?.[accountId]) {\n      throw new Error(`Channel account \"${provider}/${accountId}\" not found`);\n    }\n\n    let tokenUpdated = false;\n    if (provider === \"slack\" && nextAppToken) {\n      const appEnvKey = deriveChannelExtraEnvKeys({ provider, accountId })[0];\n      const currentEnvVars = readEnvFile();\n      const previousEnvVars = Array.isArray(currentEnvVars)\n        ? currentEnvVars\n        : [];\n      const existingAppToken = String(\n        previousEnvVars.find(\n          (entry) => String(entry?.key || \"\").trim() === appEnvKey,\n        )?.value || \"\",\n      );\n      const duplicateEnvEntry = previousEnvVars.find((entry) => {\n        const existingKey = String(entry?.key || \"\").trim();\n        const existingValue = String(entry?.value || \"\").trim();\n        if (!existingKey || !existingValue) return false;\n        if (existingKey === appEnvKey) return false;\n        return existingValue === nextAppToken;\n      });\n      if (duplicateEnvEntry) {\n        const dupKey = String(duplicateEnvEntry.key || \"\").trim();\n        const configuredKeys = getConfiguredChannelEnvKeys(cfg);\n        if (configuredKeys.has(dupKey)) {\n          throw new Error(`Channel token already exists in ${dupKey}`);\n        }\n      }\n      if (existingAppToken !== nextAppToken) {\n        const nextEnvVars = previousEnvVars.filter(\n          (entry) => String(entry?.key || \"\").trim() !== appEnvKey,\n        );\n        nextEnvVars.push({ key: appEnvKey, value: nextAppToken });\n        writeEnvFile(nextEnvVars);\n        reloadEnv();\n        tokenUpdated = true;\n      }\n    }\n    if (nextToken) {\n      const envKey = deriveChannelEnvKey({ provider, accountId });\n      const currentEnvVars = readEnvFile();\n      const previousEnvVars = Array.isArray(currentEnvVars)\n        ? currentEnvVars\n        : [];\n      const existingToken = String(\n        previousEnvVars.find(\n          (entry) => String(entry?.key || \"\").trim() === envKey,\n        )?.value || \"\",\n      );\n      const duplicateEnvEntry = previousEnvVars.find((entry) => {\n        const existingKey = String(entry?.key || \"\").trim();\n        const existingValue = String(entry?.value || \"\").trim();\n        if (!existingKey || !existingValue) return false;\n        if (existingKey === envKey) return false;\n        return existingValue === nextToken;\n      });\n      if (duplicateEnvEntry) {\n        const dupKey = String(duplicateEnvEntry.key || \"\").trim();\n        const configuredKeys = getConfiguredChannelEnvKeys(cfg);\n        if (configuredKeys.has(dupKey)) {\n          throw new Error(`Channel token already exists in ${dupKey}`);\n        }\n      }\n      if (existingToken !== nextToken) {\n        const nextEnvVars = previousEnvVars.filter(\n          (entry) => String(entry?.key || \"\").trim() !== envKey,\n        );\n        nextEnvVars.push({ key: envKey, value: nextToken });\n        writeEnvFile(nextEnvVars);\n        reloadEnv();\n        tokenUpdated = true;\n      }\n    }\n\n    if (hasLegacyDefault) {\n      providerConfig.name = nextName;\n    } else {\n      providerConfig.accounts = { ...providerConfig.accounts };\n      providerConfig.accounts[accountId] = {\n        ...(providerConfig.accounts[accountId] || {}),\n        name: nextName,\n      };\n    }\n    cfg.channels =\n      cfg.channels && typeof cfg.channels === \"object\"\n        ? { ...cfg.channels }\n        : {};\n    cfg.channels[provider] = providerConfig;\n\n    const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];\n    const targetMatch = normalizeBindingMatch({ channel: provider, accountId });\n    const nextBindings = bindings.filter((binding) => {\n      const match = binding?.match || {};\n      const hasScopedFields =\n        !!match.peer ||\n        !!match.parentPeer ||\n        !!String(match.guildId || \"\").trim() ||\n        !!String(match.teamId || \"\").trim() ||\n        (Array.isArray(match.roles) && match.roles.length > 0);\n      if (hasScopedFields) return true;\n      return !matchesBinding(match, targetMatch);\n    });\n    cfg.bindings = nextBindings;\n    appendBindingToConfig({\n      cfg,\n      agentId: nextAgentId,\n      match: targetMatch,\n    });\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: cfg });\n\n    return {\n      channel: provider,\n      account: {\n        id: accountId,\n        name: nextName,\n        boundAgentId: nextAgentId,\n      },\n      tokenUpdated,\n    };\n  };\n\n  const cleanupChannelAccountPairingFiles = ({ provider, accountId }) => {\n    const credDir = resolveCredentialsDirPath({ OPENCLAW_DIR });\n    const normalizedAccountId =\n      String(accountId || \"\")\n        .trim()\n        .toLowerCase() || \"default\";\n\n    const pairingFilePath = path.join(credDir, `${provider}-pairing.json`);\n    try {\n      const raw = fsImpl.readFileSync(pairingFilePath, \"utf8\");\n      const parsed = JSON.parse(raw);\n      const requests = Array.isArray(parsed?.requests) ? parsed.requests : [];\n      const nextRequests = requests.filter((entry) => {\n        const entryAccountId =\n          String(entry?.meta?.accountId || \"\")\n            .trim()\n            .toLowerCase() || \"default\";\n        return entryAccountId !== normalizedAccountId;\n      });\n      if (nextRequests.length !== requests.length) {\n        fsImpl.writeFileSync(\n          pairingFilePath,\n          JSON.stringify({ version: 1, requests: nextRequests }, null, 2),\n        );\n      }\n    } catch {}\n\n    const allowFromPatterns = [\n      `${provider}-${normalizedAccountId}-allowFrom.json`,\n      ...(normalizedAccountId === \"default\"\n        ? [`${provider}-allowFrom.json`]\n        : []),\n    ];\n    for (const fileName of allowFromPatterns) {\n      try {\n        fsImpl.rmSync(path.join(credDir, fileName), { force: true });\n      } catch {}\n    }\n  };\n\n  const cleanupWhatsAppAuthFiles = ({ accountId }) => {\n    const credDir = resolveCredentialsDirPath({ OPENCLAW_DIR });\n    const providerCredDir = path.join(credDir, \"whatsapp\");\n    const normalizedAccountId =\n      String(accountId || \"\")\n        .trim()\n        .toLowerCase() || \"default\";\n\n    try {\n      fsImpl.rmSync(path.join(credDir, \"whatsapp\", normalizedAccountId), {\n        recursive: true,\n        force: true,\n      });\n    } catch {}\n\n    try {\n      fsImpl.rmSync(providerCredDir, {\n        recursive: true,\n        force: true,\n      });\n    } catch {}\n\n    if (normalizedAccountId !== \"default\") {\n      return;\n    }\n\n    const legacyAuthPatterns = [\n      \"creds.json\",\n      \"creds.json.bak\",\n    ];\n    try {\n      const entries = fsImpl.readdirSync(credDir);\n      for (const entry of Array.isArray(entries) ? entries : []) {\n        const fileName = String(entry || \"\").trim();\n        if (!fileName) continue;\n        if (\n          legacyAuthPatterns.includes(fileName) ||\n          /^(app-state-sync|session|sender-key|pre-key)-.*\\.json$/.test(fileName)\n        ) {\n          try {\n            fsImpl.rmSync(path.join(credDir, fileName), { force: true });\n          } catch {}\n        }\n      }\n    } catch {}\n  };\n\n  const deleteChannelAccount = async (input = {}) => {\n    const provider = normalizeChannelProvider(input.provider);\n    const accountId = String(input.accountId || \"\").trim() || \"default\";\n\n    const cfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const providerConfig =\n      cfg.channels?.[provider] && typeof cfg.channels[provider] === \"object\"\n        ? cfg.channels[provider]\n        : null;\n    if (!providerConfig) {\n      throw new Error(`Channel \"${provider}\" not found`);\n    }\n    const hasAccounts =\n      providerConfig.accounts && typeof providerConfig.accounts === \"object\";\n    const hasLegacyDefault =\n      accountId === \"default\" &&\n      !hasAccounts &&\n      hasLegacyDefaultChannelAccount({ config: providerConfig });\n    if (!hasLegacyDefault && !providerConfig.accounts?.[accountId]) {\n      throw new Error(`Channel account \"${provider}/${accountId}\" not found`);\n    }\n\n    if (provider === \"discord\") {\n      const nextCfg = withNormalizedAgentsConfig({\n        OPENCLAW_DIR,\n        cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n      });\n      const nextChannels =\n        nextCfg.channels && typeof nextCfg.channels === \"object\"\n          ? { ...nextCfg.channels }\n          : {};\n      const nextProviderConfig = normalizeChannelConfig({\n        provider,\n        channelConfig:\n          nextChannels[provider] && typeof nextChannels[provider] === \"object\"\n            ? nextChannels[provider]\n            : {},\n      });\n      const nextAccounts =\n        nextProviderConfig.accounts &&\n        typeof nextProviderConfig.accounts === \"object\"\n          ? { ...nextProviderConfig.accounts }\n          : {};\n      delete nextAccounts[accountId];\n      if (Object.keys(nextAccounts).length > 0) {\n        nextProviderConfig.accounts = nextAccounts;\n        nextChannels[provider] = nextProviderConfig;\n      } else {\n        delete nextChannels[provider];\n      }\n      nextCfg.channels = nextChannels;\n\n      const targetMatch = normalizeBindingMatch({\n        channel: provider,\n        accountId,\n      });\n      const existingBindings = Array.isArray(nextCfg.bindings)\n        ? nextCfg.bindings\n        : [];\n      nextCfg.bindings = existingBindings.filter((binding) => {\n        const match = binding?.match || {};\n        const hasScopedFields =\n          !!match.peer ||\n          !!match.parentPeer ||\n          !!String(match.guildId || \"\").trim() ||\n          !!String(match.teamId || \"\").trim() ||\n          (Array.isArray(match.roles) && match.roles.length > 0);\n        if (hasScopedFields) return true;\n        return !matchesBinding(match, targetMatch);\n      });\n      if (!nextChannels[provider] && nextCfg.plugins?.entries?.[provider]) {\n        nextCfg.plugins.entries[provider] = {\n          ...(nextCfg.plugins.entries[provider] || {}),\n          enabled: false,\n        };\n      }\n      saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg });\n\n      const envKey = deriveChannelEnvKey({ provider, accountId });\n      const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });\n      const currentEnvVars = readEnvFile();\n      const previousEnvVars = Array.isArray(currentEnvVars)\n        ? currentEnvVars\n        : [];\n      const nextEnvVars = previousEnvVars.filter(\n        (entry) =>\n          String(entry?.key || \"\").trim() !== envKey &&\n          !extraEnvKeys.includes(String(entry?.key || \"\").trim()),\n      );\n      if (nextEnvVars.length !== previousEnvVars.length) {\n        writeEnvFile(nextEnvVars);\n        reloadEnv();\n      }\n\n      cleanupChannelAccountPairingFiles({ provider, accountId });\n      return { ok: true };\n    }\n\n    const removeArgs = [\n      \"channels remove\",\n      `--channel ${shellEscapeArg(provider)}`,\n      `--account ${shellEscapeArg(accountId)}`,\n      \"--delete\",\n    ].filter(Boolean);\n    const removeResult = await clawCmd(removeArgs.join(\" \"), {\n      quiet: true,\n      timeoutMs: 30000,\n    });\n    if (!removeResult?.ok) {\n      throw new Error(\n        removeResult?.stderr ||\n          removeResult?.stdout ||\n          \"Could not delete channel account\",\n      );\n    }\n\n    const envKey = deriveChannelEnvKey({ provider, accountId });\n    const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });\n    const currentEnvVars = readEnvFile();\n    const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : [];\n    const nextEnvVars = previousEnvVars.filter(\n      (entry) =>\n        String(entry?.key || \"\").trim() !== envKey &&\n        !extraEnvKeys.includes(String(entry?.key || \"\").trim()),\n    );\n    if (nextEnvVars.length !== previousEnvVars.length) {\n      writeEnvFile(nextEnvVars);\n      reloadEnv();\n    }\n\n    const nextCfg = withNormalizedAgentsConfig({\n      OPENCLAW_DIR,\n      cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n    });\n    const nextChannels =\n      nextCfg.channels && typeof nextCfg.channels === \"object\"\n        ? { ...nextCfg.channels }\n        : {};\n    const nextProviderConfig = normalizeChannelConfig({\n      provider,\n      channelConfig:\n        nextChannels[provider] && typeof nextChannels[provider] === \"object\"\n          ? nextChannels[provider]\n          : {},\n    });\n    const nextAccounts =\n      nextProviderConfig.accounts &&\n      typeof nextProviderConfig.accounts === \"object\"\n        ? { ...nextProviderConfig.accounts }\n        : {};\n    delete nextAccounts[accountId];\n    if (Object.keys(nextAccounts).length > 0) {\n      nextProviderConfig.accounts = nextAccounts;\n      nextChannels[provider] = nextProviderConfig;\n    } else {\n      delete nextChannels[provider];\n    }\n    nextCfg.channels = nextChannels;\n    const targetMatch = normalizeBindingMatch({ channel: provider, accountId });\n    const existingBindings = Array.isArray(nextCfg.bindings)\n      ? nextCfg.bindings\n      : [];\n    nextCfg.bindings = existingBindings.filter((binding) => {\n      const match = binding?.match || {};\n      const hasScopedFields =\n        !!match.peer ||\n        !!match.parentPeer ||\n        !!String(match.guildId || \"\").trim() ||\n        !!String(match.teamId || \"\").trim() ||\n        (Array.isArray(match.roles) && match.roles.length > 0);\n      if (hasScopedFields) return true;\n      return !matchesBinding(match, targetMatch);\n    });\n    if (!nextChannels[provider] && nextCfg.plugins?.entries?.[provider]) {\n      nextCfg.plugins.entries[provider] = {\n        ...(nextCfg.plugins.entries[provider] || {}),\n        enabled: false,\n      };\n    }\n    saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg });\n\n    cleanupChannelAccountPairingFiles({ provider, accountId });\n    if (provider === \"whatsapp\") {\n      cleanupWhatsAppAuthFiles({ accountId });\n    }\n    if (provider === \"whatsapp\") {\n      await restartGateway();\n    }\n    return { ok: true };\n  };\n\n  const listConfiguredChannelAccountsWithMaskedTokens = () => {\n    const channels = listConfiguredChannelAccounts({\n      fsImpl,\n      OPENCLAW_DIR,\n      cfg: withNormalizedAgentsConfig({\n        OPENCLAW_DIR,\n        cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n      }),\n    });\n    const envVars = readEnvFile();\n    const envKeySet = new Set(\n      (Array.isArray(envVars) ? envVars : [])\n        .filter((v) => v?.key && String(v?.value || \"\").trim())\n        .map((v) => String(v.key).trim()),\n    );\n    return channels.map((entry) => ({\n      ...entry,\n      accounts: entry.accounts.map((account) => ({\n        ...account,\n        token: envKeySet.has(String(account.envKey || \"\").trim())\n          ? kMaskedChannelToken\n          : \"\",\n      })),\n    }));\n  };\n\n  const createWhatsAppChannelAccount = async ({\n    input,\n    cfg,\n    agentId,\n    accountId,\n    name,\n    normalizedChannelConfig,\n    existingAccounts,\n    onProgress,\n  }) => {\n    const ownerNumber = String(input.token || \"\").trim();\n    if (!ownerNumber) throw new Error(\"WhatsApp owner number is required\");\n\n    const envKey = deriveChannelEnvKey({ provider: \"whatsapp\", accountId });\n    const currentEnvVars = readEnvFile();\n    const previousEnvVars = Array.isArray(currentEnvVars) ? currentEnvVars : [];\n    const previousConfig = cloneJson(cfg);\n\n    const nextEnvVars = previousEnvVars.filter(\n      (entry) => String(entry?.key || \"\").trim() !== envKey,\n    );\n    nextEnvVars.push({ key: envKey, value: ownerNumber });\n\n    try {\n      onProgress({ phase: \"configuring\", label: \"Configuring...\" });\n      writeEnvFile(nextEnvVars);\n      reloadEnv();\n\n      const nextCfg = withNormalizedAgentsConfig({\n        OPENCLAW_DIR,\n        cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n      });\n      ensurePluginAllowed({ cfg: nextCfg, pluginKey: \"whatsapp\" });\n      saveConfig({ fsImpl, OPENCLAW_DIR, config: nextCfg });\n\n      onProgress({ phase: \"configuring\", label: \"Adding channel...\" });\n      const addArgs = [\n        \"channels add\",\n        \"--channel whatsapp\",\n        accountId !== \"default\" ? `--account ${shellEscapeArg(accountId)}` : \"\",\n        name ? `--name ${shellEscapeArg(name)}` : \"\",\n        `--token ${shellEscapeArg(ownerNumber)}`,\n      ].filter(Boolean);\n      const addResult = await clawCmdWithConfigConflictRetry(\n        addArgs.join(\" \"),\n        {\n          quiet: true,\n          timeoutMs: 30000,\n        },\n        { label: \"channels add\" },\n      );\n      if (!addResult?.ok) {\n        throw new Error(\n          addResult?.stderr ||\n            addResult?.stdout ||\n            \"Could not add WhatsApp channel account\",\n        );\n      }\n\n      const refreshedCfg = withNormalizedAgentsConfig({\n        OPENCLAW_DIR,\n        cfg: loadConfig({ fsImpl, OPENCLAW_DIR }),\n      });\n\n      const nextAccounts = { ...existingAccounts };\n      nextAccounts[accountId] = {\n        ...(nextAccounts[accountId] &&\n        typeof nextAccounts[accountId] === \"object\"\n          ? nextAccounts[accountId]\n          : {}),\n        ...(name ? { name } : {}),\n        allowFrom: [`\\${${envKey}}`],\n        groupAllowFrom: [`\\${${envKey}}`],\n        dmPolicy: \"allowlist\",\n        groupPolicy: \"allowlist\",\n        selfChatMode: true,\n      };\n      normalizedChannelConfig.accounts = nextAccounts;\n      normalizedChannelConfig.enabled = true;\n      if (!String(normalizedChannelConfig.defaultAccount || \"\").trim()) {\n        normalizedChannelConfig.defaultAccount = \"default\";\n      }\n      refreshedCfg.channels =\n        refreshedCfg.channels && typeof refreshedCfg.channels === \"object\"\n          ? { ...refreshedCfg.channels }\n          : {};\n      refreshedCfg.channels.whatsapp = normalizedChannelConfig;\n\n      const bindSpec = buildBindingSpec({ provider: \"whatsapp\", accountId });\n      appendBindingToConfig({\n        cfg: refreshedCfg,\n        agentId,\n        match: normalizeBindingMatch({ channel: \"whatsapp\", accountId }),\n      });\n      saveConfig({ fsImpl, OPENCLAW_DIR, config: refreshedCfg });\n\n      onProgress({ phase: \"restarting\", label: \"Rebooting...\" });\n      await restartGateway();\n    } catch (error) {\n      try {\n        await clawCmd(\n          [\n            \"channels remove\",\n            \"--channel whatsapp\",\n            accountId !== \"default\" ? `--account ${shellEscapeArg(accountId)}` : \"\",\n            \"--delete\",\n          ]\n            .filter(Boolean)\n            .join(\" \"),\n          { quiet: true, timeoutMs: 30000 },\n        );\n      } catch {}\n      try {\n        writeEnvFile(previousEnvVars);\n        reloadEnv();\n      } catch {}\n      try {\n        saveConfig({ fsImpl, OPENCLAW_DIR, config: previousConfig });\n      } catch {}\n      throw error;\n    }\n\n    return {\n      channel: \"whatsapp\",\n      account: { id: accountId, name, envKey },\n      binding: {\n        agentId,\n        match: normalizeBindingMatch({ channel: \"whatsapp\", accountId }),\n      },\n      restartRequired: true,\n    };\n  };\n\n  const runChannelAccountLogin = async ({\n    provider: rawProvider,\n    accountId: rawAccountId,\n  } = {}) => {\n    const provider = normalizeChannelProvider(rawProvider);\n    if (provider !== \"whatsapp\") {\n      throw new Error(\"Channel login is currently only supported for WhatsApp\");\n    }\n    const accountId = String(rawAccountId || \"\").trim() || \"default\";\n    const loginArgs = [\n      \"channels login\",\n      `--channel ${shellEscapeArg(provider)}`,\n      accountId !== \"default\" ? `--account ${shellEscapeArg(accountId)}` : \"\",\n    ].filter(Boolean);\n    const loginStartedAt = Date.now();\n    const result = await clawCmd(loginArgs.join(\" \"), {\n      quiet: true,\n      timeoutMs: 12000,\n      killSignal: \"SIGKILL\",\n    });\n    const elapsedMs = Date.now() - loginStartedAt;\n    console.log(\n      `[channels] login ${provider}/${accountId} finished ok=${!!result?.ok} code=${String(\n        result?.code ?? \"\",\n      )} elapsedMs=${elapsedMs}`,\n    );\n    return {\n      ok: !!result?.ok,\n      stdout: String(result?.stdout || \"\"),\n      stderr: String(result?.stderr || \"\"),\n      completed: !!result?.ok,\n    };\n  };\n\n  const getChannelAccountLoginStatus = ({\n    provider: rawProvider,\n    accountId: rawAccountId,\n  } = {}) => {\n    const provider = normalizeChannelProvider(rawProvider);\n    if (provider !== \"whatsapp\") {\n      throw new Error(\"Channel login status is currently only supported for WhatsApp\");\n    }\n    const accountId = String(rawAccountId || \"\").trim() || \"default\";\n    return {\n      provider,\n      accountId,\n      linked: hasSavedWhatsAppCredentials({\n        fsImpl,\n        OPENCLAW_DIR,\n        accountId,\n      }),\n    };\n  };\n\n  return {\n    getChannelAccountToken,\n    createChannelAccount,\n    updateChannelAccount,\n    deleteChannelAccount,\n    runChannelAccountLogin,\n    getChannelAccountLoginStatus,\n    listConfiguredChannelAccountsWithMaskedTokens,\n  };\n};\n\nmodule.exports = { createChannelsDomain };\n"
  },
  {
    "path": "lib/server/agents/service.js",
    "content": "const fs = require(\"fs\");\nconst { createAgentsDomain } = require(\"./agents\");\nconst { createBindingsDomain } = require(\"./bindings\");\nconst { createChannelsDomain } = require(\"./channels\");\n\nconst createAgentsService = ({\n  fs: fsImpl = fs,\n  OPENCLAW_DIR,\n  readEnvFile = () => [],\n  writeEnvFile = () => {},\n  reloadEnv = () => false,\n  restartGateway = async () => {},\n  clawCmd = async () => ({\n    ok: false,\n    stdout: \"\",\n    stderr: \"openclaw command unavailable\",\n  }),\n}) => {\n  const agentsDomain = createAgentsDomain({\n    fsImpl,\n    OPENCLAW_DIR,\n  });\n  const bindingsDomain = createBindingsDomain({\n    fsImpl,\n    OPENCLAW_DIR,\n  });\n  const channelsDomain = createChannelsDomain({\n    fsImpl,\n    OPENCLAW_DIR,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    restartGateway,\n    clawCmd,\n  });\n\n  return {\n    ...agentsDomain,\n    ...bindingsDomain,\n    getChannelAccountToken: channelsDomain.getChannelAccountToken,\n    createChannelAccount: channelsDomain.createChannelAccount,\n    updateChannelAccount: channelsDomain.updateChannelAccount,\n    deleteChannelAccount: channelsDomain.deleteChannelAccount,\n    runChannelAccountLogin: channelsDomain.runChannelAccountLogin,\n    getChannelAccountLoginStatus: channelsDomain.getChannelAccountLoginStatus,\n    listConfiguredChannelAccounts:\n      channelsDomain.listConfiguredChannelAccountsWithMaskedTokens,\n  };\n};\n\nmodule.exports = { createAgentsService };\n"
  },
  {
    "path": "lib/server/agents/shared.js",
    "content": "const path = require(\"path\");\nconst {\n  readOpenclawConfig,\n  writeOpenclawConfig,\n} = require(\"../openclaw-config\");\n\nconst kDefaultAgentId = \"main\";\nconst kAgentIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nconst kChannelAccountIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nconst kDefaultWorkspaceBasename = \"workspace\";\nconst kWorkspaceFolderPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nconst kDefaultAgentFiles = [\"SOUL.md\", \"AGENTS.md\", \"USER.md\", \"IDENTITY.md\"];\nconst kChannelEnvKeys = {\n  telegram: \"TELEGRAM_BOT_TOKEN\",\n  discord: \"DISCORD_BOT_TOKEN\",\n  slack: \"SLACK_BOT_TOKEN\",\n  whatsapp: \"WHATSAPP_OWNER_NUMBER\",\n};\nconst kChannelExtraEnvKeys = {\n  slack: [\"SLACK_APP_TOKEN\"],\n};\nconst kChannelTokenFields = {\n  telegram: \"botToken\",\n  discord: \"token\",\n  slack: \"botToken\",\n  // WhatsApp uses owner number, not a bot token field\n};\nconst kChannelExtraTokenFields = {\n  slack: [\"appToken\"],\n};\nconst kChannelLabels = {\n  telegram: \"Telegram\",\n  discord: \"Discord\",\n  slack: \"Slack\",\n  whatsapp: \"WhatsApp\",\n};\nconst kChannelProviderAliases = {\n  wa: \"whatsapp\",\n  \"whats-app\": \"whatsapp\",\n  whats_app: \"whatsapp\",\n  \"whats app\": \"whatsapp\",\n};\nconst kMaskedChannelToken = \"********\";\n\nconst shellEscapeArg = (value) =>\n  `'${String(value || \"\").replace(/'/g, `'\\\\''`)}'`;\n\nconst resolveCredentialsDirPath = ({ OPENCLAW_DIR }) =>\n  path.join(OPENCLAW_DIR, \"credentials\");\n\nconst resolveWhatsAppCredentialCandidatePaths = ({\n  OPENCLAW_DIR,\n  accountId,\n}) => {\n  const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });\n  const normalizedAccountId = normalizeChannelAccountId(accountId);\n  return [\n    path.join(credentialsDir, \"whatsapp\", normalizedAccountId, \"creds.json\"),\n    ...(normalizedAccountId === \"default\"\n      ? [path.join(credentialsDir, \"creds.json\")]\n      : []),\n  ];\n};\n\nconst hasSavedWhatsAppCredentials = ({\n  fsImpl,\n  OPENCLAW_DIR,\n  accountId,\n}) => {\n  const candidatePaths = resolveWhatsAppCredentialCandidatePaths({\n    OPENCLAW_DIR,\n    accountId,\n  });\n  const matches = candidatePaths.map((targetPath) => {\n    try {\n      const exists = !!String(fsImpl.readFileSync(targetPath, \"utf8\") || \"\").trim();\n      return { path: targetPath, exists };\n    } catch (error) {\n      return {\n        path: targetPath,\n        exists: false,\n        error: String(error?.message || error || \"read failed\"),\n      };\n    }\n  });\n  return matches.some((entry) => entry.exists);\n};\n\nconst resolveAgentWorkspacePath = ({ OPENCLAW_DIR, agentId }) =>\n  path.join(\n    OPENCLAW_DIR,\n    agentId === kDefaultAgentId\n      ? kDefaultWorkspaceBasename\n      : `${kDefaultWorkspaceBasename}-${agentId}`,\n  );\n\nconst resolveAgentDirPath = ({ OPENCLAW_DIR, agentId }) =>\n  path.join(OPENCLAW_DIR, \"agents\", agentId, \"agent\");\n\nconst loadConfig = ({ fsImpl, OPENCLAW_DIR }) =>\n  readOpenclawConfig({\n    fsModule: fsImpl,\n    openclawDir: OPENCLAW_DIR,\n    fallback: {},\n  });\n\nconst saveConfig = ({ fsImpl, OPENCLAW_DIR, config }) => {\n  writeOpenclawConfig({\n    fsModule: fsImpl,\n    openclawDir: OPENCLAW_DIR,\n    config,\n    spacing: 2,\n  });\n};\n\nconst ensurePluginAllowed = ({ cfg, pluginKey }) => {\n  if (!cfg.plugins || typeof cfg.plugins !== \"object\") cfg.plugins = {};\n  if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = [];\n  if (!cfg.plugins.entries || typeof cfg.plugins.entries !== \"object\") {\n    cfg.plugins.entries = {};\n  }\n  if (!cfg.plugins.allow.includes(pluginKey)) {\n    cfg.plugins.allow.push(pluginKey);\n  }\n  cfg.plugins.entries[pluginKey] = {\n    ...(cfg.plugins.entries[pluginKey] &&\n    typeof cfg.plugins.entries[pluginKey] === \"object\"\n      ? cfg.plugins.entries[pluginKey]\n      : {}),\n    enabled: true,\n  };\n};\n\nconst normalizeAgentsList = ({ list }) =>\n  (Array.isArray(list) ? list : [])\n    .filter((entry) => entry && typeof entry === \"object\")\n    .map((entry) => ({ ...entry }));\n\nconst normalizeAgentDefaults = ({ cfg }) => ({\n  model: cfg?.agents?.defaults?.model || {},\n});\n\nconst cloneJson = (value) => JSON.parse(JSON.stringify(value));\nconst isEnvRef = (value) =>\n  /^\\$\\{[A-Z_][A-Z0-9_]*\\}$/.test(String(value || \"\").trim());\n\nconst normalizePeerMatch = (value) => {\n  if (!value || typeof value !== \"object\") return undefined;\n  const kind = String(value.kind || \"\").trim();\n  const id = String(value.id || \"\").trim();\n  if (!kind || !id) return undefined;\n  return { kind, id };\n};\n\nconst normalizeBindingMatch = (input = {}) => {\n  const channel = String(input.channel || \"\").trim();\n  if (!channel) {\n    throw new Error(\"Binding channel is required\");\n  }\n  const accountId = String(input.accountId || \"\").trim();\n  const guildId = String(input.guildId || \"\").trim();\n  const teamId = String(input.teamId || \"\").trim();\n  const peer = normalizePeerMatch(input.peer);\n  const parentPeer = normalizePeerMatch(input.parentPeer);\n  const roles = Array.isArray(input.roles)\n    ? input.roles.map((entry) => String(entry || \"\").trim()).filter(Boolean)\n    : [];\n  return {\n    channel,\n    ...(accountId ? { accountId } : {}),\n    ...(guildId ? { guildId } : {}),\n    ...(teamId ? { teamId } : {}),\n    ...(peer ? { peer } : {}),\n    ...(parentPeer ? { parentPeer } : {}),\n    ...(roles.length > 0 ? { roles } : {}),\n  };\n};\n\nconst toComparableBindingMatch = (input = {}) => {\n  const match = normalizeBindingMatch(input);\n  return {\n    ...match,\n    ...(match.accountId ? {} : { accountId: \"default\" }),\n  };\n};\n\nconst matchesBinding = (left, right) =>\n  JSON.stringify(toComparableBindingMatch(left)) ===\n  JSON.stringify(toComparableBindingMatch(right));\n\nconst isValidChannelAccountId = (value) =>\n  kChannelAccountIdPattern.test(String(value || \"\").trim());\n\nconst normalizeChannelProvider = (value) => {\n  const provider = String(value || \"\")\n    .trim()\n    .toLowerCase();\n  if (!provider || !kChannelEnvKeys[provider]) {\n    throw new Error(`Unsupported channel provider \"${provider}\"`);\n  }\n  return provider;\n};\n\nconst deriveChannelEnvKey = ({ provider, accountId }) => {\n  const envKey = kChannelEnvKeys[normalizeChannelProvider(provider)];\n  const normalizedAccountId = String(accountId || \"\").trim();\n  if (!normalizedAccountId || normalizedAccountId === \"default\") return envKey;\n  return `${envKey}_${normalizedAccountId.replace(/-/g, \"_\").toUpperCase()}`;\n};\n\nconst deriveChannelExtraEnvKeys = ({ provider, accountId }) => {\n  const normalizedProvider = normalizeChannelProvider(provider);\n  const baseEnvKeys = Array.isArray(kChannelExtraEnvKeys[normalizedProvider])\n    ? kChannelExtraEnvKeys[normalizedProvider]\n    : [];\n  const normalizedAccountId = String(accountId || \"\").trim();\n  if (!normalizedAccountId || normalizedAccountId === \"default\") {\n    return [...baseEnvKeys];\n  }\n  const suffix = normalizedAccountId.replace(/-/g, \"_\").toUpperCase();\n  return baseEnvKeys.map((baseKey) => `${baseKey}_${suffix}`);\n};\n\nconst getConfiguredChannelEnvKeys = (cfg) => {\n  const keys = new Set();\n  const channels =\n    cfg?.channels && typeof cfg.channels === \"object\" ? cfg.channels : {};\n  for (const [provider, providerConfig] of Object.entries(channels)) {\n    if (!kChannelEnvKeys[provider]) continue;\n    const accounts =\n      providerConfig?.accounts && typeof providerConfig.accounts === \"object\"\n        ? providerConfig.accounts\n        : {};\n    for (const accountId of Object.keys(accounts)) {\n      keys.add(deriveChannelEnvKey({ provider, accountId }));\n      const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });\n      for (const extraEnvKey of extraEnvKeys) {\n        keys.add(extraEnvKey);\n      }\n    }\n    if (Object.keys(accounts).length === 0 && providerConfig?.enabled) {\n      keys.add(kChannelEnvKeys[provider]);\n      for (const extraEnvKey of deriveChannelExtraEnvKeys({\n        provider,\n        accountId: \"default\",\n      })) {\n        keys.add(extraEnvKey);\n      }\n    }\n  }\n  return keys;\n};\n\nconst assertActiveChannelTokenEnvVars = ({ cfg, envVars }) => {\n  const envMap = new Map(\n    (Array.isArray(envVars) ? envVars : [])\n      .map((entry) => [\n        String(entry?.key || \"\").trim(),\n        String(entry?.value || \"\").trim(),\n      ])\n      .filter(([key]) => key),\n  );\n  const channels =\n    cfg?.channels && typeof cfg.channels === \"object\" ? cfg.channels : {};\n  for (const [provider, providerConfig] of Object.entries(channels)) {\n    if (!kChannelEnvKeys[provider]) continue;\n    if (providerConfig?.enabled === false) continue;\n    const normalizedProviderConfig = normalizeChannelConfig({\n      provider,\n      channelConfig: providerConfig,\n    });\n    const accounts =\n      normalizedProviderConfig.accounts &&\n      typeof normalizedProviderConfig.accounts === \"object\"\n        ? normalizedProviderConfig.accounts\n        : {};\n    const accountEntries =\n      Object.keys(accounts).length > 0\n        ? Object.entries(accounts)\n        : [[\"default\", {}]];\n    for (const [accountId, accountConfig] of accountEntries) {\n      if (accountConfig?.enabled === false) continue;\n      const envKey = deriveChannelEnvKey({ provider, accountId });\n      const envValue = String(envMap.get(envKey) || \"\").trim();\n      if (!envValue) {\n        throw new Error(\n          `Missing required channel token env var ${envKey} for active channel ${provider}/${accountId}`,\n        );\n      }\n      const extraEnvKeys = deriveChannelExtraEnvKeys({ provider, accountId });\n      for (const extraEnvKey of extraEnvKeys) {\n        const extraEnvValue = String(envMap.get(extraEnvKey) || \"\").trim();\n        if (!extraEnvValue) {\n          throw new Error(\n            `Missing required channel token env var ${extraEnvKey} for active channel ${provider}/${accountId}`,\n          );\n        }\n      }\n    }\n  }\n};\n\nconst normalizeChannelConfig = ({ provider, channelConfig }) => {\n  const normalizedProvider = normalizeChannelProvider(provider);\n  const nextConfig =\n    channelConfig && typeof channelConfig === \"object\"\n      ? cloneJson(channelConfig)\n      : {};\n  const existingAccounts =\n    nextConfig.accounts && typeof nextConfig.accounts === \"object\"\n      ? { ...nextConfig.accounts }\n      : {};\n  const tokenField = kChannelTokenFields[normalizedProvider];\n  const extraTokenFields = Array.isArray(\n    kChannelExtraTokenFields[normalizedProvider],\n  )\n    ? kChannelExtraTokenFields[normalizedProvider]\n    : [];\n  if (Object.keys(existingAccounts).length > 0) {\n    if (tokenField || extraTokenFields.length > 0) {\n      for (const [accountId, accountConfig] of Object.entries(\n        existingAccounts,\n      )) {\n        if (!accountConfig || typeof accountConfig !== \"object\") continue;\n        const nextAccountConfig = { ...accountConfig };\n        const rawTokenFieldValue = String(\n          nextAccountConfig[tokenField] || \"\",\n        ).trim();\n        if (rawTokenFieldValue && !isEnvRef(rawTokenFieldValue)) {\n          nextAccountConfig[tokenField] = `\\${${deriveChannelEnvKey({\n            provider: normalizedProvider,\n            accountId,\n          })}}`;\n        }\n        const extraEnvKeys = deriveChannelExtraEnvKeys({\n          provider: normalizedProvider,\n          accountId,\n        });\n        extraTokenFields.forEach((fieldName, index) => {\n          const rawValue = String(nextAccountConfig[fieldName] || \"\").trim();\n          if (!rawValue) return;\n          if (isEnvRef(rawValue)) return;\n          if (!extraEnvKeys[index]) return;\n          nextAccountConfig[fieldName] = `\\${${extraEnvKeys[index]}}`;\n        });\n        existingAccounts[accountId] = nextAccountConfig;\n      }\n    }\n    nextConfig.accounts = existingAccounts;\n    return nextConfig;\n  }\n\n  const defaultAccountConfig = {};\n  for (const [key, value] of Object.entries(nextConfig)) {\n    if (key === \"enabled\" || key === \"accounts\" || key === \"defaultAccount\")\n      continue;\n    defaultAccountConfig[key] = cloneJson(value);\n    delete nextConfig[key];\n  }\n\n  const defaultTokenEnvRef = `\\${${deriveChannelEnvKey({\n    provider: normalizedProvider,\n    accountId: \"default\",\n  })}}`;\n  if (tokenField && defaultAccountConfig[tokenField]) {\n    const rawTokenFieldValue = String(\n      defaultAccountConfig[tokenField] || \"\",\n    ).trim();\n    if (rawTokenFieldValue && !isEnvRef(rawTokenFieldValue)) {\n      defaultAccountConfig[tokenField] = defaultTokenEnvRef;\n    }\n  }\n  const defaultExtraEnvKeys = deriveChannelExtraEnvKeys({\n    provider: normalizedProvider,\n    accountId: \"default\",\n  });\n  extraTokenFields.forEach((fieldName, index) => {\n    const rawValue = String(defaultAccountConfig[fieldName] || \"\").trim();\n    if (!rawValue) return;\n    if (isEnvRef(rawValue)) return;\n    if (!defaultExtraEnvKeys[index]) return;\n    defaultAccountConfig[fieldName] = `\\${${defaultExtraEnvKeys[index]}}`;\n  });\n  if (\n    Object.keys(defaultAccountConfig).length > 0 ||\n    defaultAccountConfig[tokenField]\n  ) {\n    nextConfig.accounts = { default: defaultAccountConfig };\n    if (!String(nextConfig.defaultAccount || \"\").trim()) {\n      nextConfig.defaultAccount = \"default\";\n    }\n  } else {\n    nextConfig.accounts = {};\n  }\n  return nextConfig;\n};\n\nconst appendBindingToConfig = ({ cfg, agentId, match }) => {\n  const normalizedAgentId = String(agentId || \"\").trim();\n  const existingBindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];\n  const conflictingBinding = existingBindings.find((binding) =>\n    matchesBinding(binding?.match || {}, match),\n  );\n  if (conflictingBinding) {\n    const conflictingAgentId = String(conflictingBinding.agentId || \"\").trim();\n    if (conflictingAgentId === normalizedAgentId) {\n      return cloneJson(conflictingBinding);\n    }\n    throw new Error(\n      `Binding already assigned to agent \"${conflictingAgentId}\"`,\n    );\n  }\n  const nextBinding = {\n    agentId: normalizedAgentId,\n    match,\n  };\n  cfg.bindings = [...existingBindings, nextBinding];\n  return cloneJson(nextBinding);\n};\n\nconst buildBindingSpec = ({ provider, accountId }) => {\n  const channel = normalizeChannelProvider(provider);\n  const normalizedAccountId = String(accountId || \"\").trim();\n  return normalizedAccountId ? `${channel}:${normalizedAccountId}` : channel;\n};\n\nconst hasLegacyDefaultChannelAccount = ({ config }) =>\n  Object.keys(config || {}).some(\n    (entry) =>\n      entry !== \"accounts\" && entry !== \"defaultAccount\" && entry !== \"enabled\",\n  );\n\nconst normalizeChannelAccountId = (value) =>\n  String(value || \"\").trim() || \"default\";\n\nconst resolveCredentialPairingAccountId = ({ channelId, fileName }) => {\n  const prefix = `${String(channelId || \"\").trim()}-`;\n  const suffix = \"-allowFrom.json\";\n  const rawFileName = String(fileName || \"\").trim();\n  if (!rawFileName.startsWith(prefix) || !rawFileName.endsWith(suffix)) {\n    return \"\";\n  }\n  const rawAccountId = rawFileName.slice(prefix.length, -suffix.length);\n  return normalizeChannelAccountId(rawAccountId);\n};\n\nconst hasImplicitWhatsAppSelfPairing = ({\n  fsImpl,\n  OPENCLAW_DIR,\n  channelId,\n  accountId,\n  accountConfig,\n}) => {\n  if (String(channelId || \"\").trim() !== \"whatsapp\") return false;\n  if (!accountConfig || typeof accountConfig !== \"object\") return false;\n  if (accountConfig.selfChatMode === false) return false;\n  if (String(accountConfig.dmPolicy || \"\").trim().toLowerCase() === \"disabled\") {\n    return false;\n  }\n  return hasSavedWhatsAppCredentials({\n    fsImpl,\n    OPENCLAW_DIR,\n    accountId,\n  });\n};\n\nconst readPairedCountsByAccount = ({\n  fsImpl,\n  OPENCLAW_DIR,\n  channelId,\n  accountIds,\n  config,\n}) => {\n  const counts = new Map(\n    (Array.isArray(accountIds) ? accountIds : []).map((accountId) => [\n      normalizeChannelAccountId(accountId),\n      0,\n    ]),\n  );\n  const credentialsDir = resolveCredentialsDirPath({ OPENCLAW_DIR });\n  try {\n    if (String(channelId || \"\").trim() !== \"whatsapp\") {\n      const files = fsImpl\n        .readdirSync(credentialsDir)\n        .filter(\n          (fileName) =>\n            String(fileName || \"\").startsWith(\n              `${String(channelId || \"\").trim()}-`,\n            ) && String(fileName || \"\").endsWith(\"-allowFrom.json\"),\n        );\n      for (const fileName of files) {\n        const accountId = resolveCredentialPairingAccountId({\n          channelId,\n          fileName,\n        });\n        if (!accountId || !counts.has(accountId)) continue;\n        const filePath = path.join(credentialsDir, fileName);\n        const parsed = JSON.parse(fsImpl.readFileSync(filePath, \"utf8\"));\n        const pairedCount = Array.isArray(parsed?.allowFrom)\n          ? parsed.allowFrom.length\n          : 0;\n        counts.set(accountId, Number(counts.get(accountId) || 0) + pairedCount);\n      }\n    }\n  } catch {}\n\n  for (const accountId of counts.keys()) {\n    if (String(channelId || \"\").trim() === \"whatsapp\") continue;\n    const accountConfig =\n      accountId === \"default\" &&\n      !(config.accounts && typeof config.accounts === \"object\")\n        ? config\n        : config.accounts?.[accountId] || {};\n    const inlineAllowFrom = accountConfig?.allowFrom;\n    if (!Array.isArray(inlineAllowFrom)) continue;\n    counts.set(\n      accountId,\n      Number(counts.get(accountId) || 0) + inlineAllowFrom.length,\n    );\n  }\n\n  for (const accountId of counts.keys()) {\n    if (Number(counts.get(accountId) || 0) > 0) continue;\n    const accountConfig =\n      accountId === \"default\" &&\n      !(config.accounts && typeof config.accounts === \"object\")\n        ? config\n        : config.accounts?.[accountId] || {};\n    if (\n      hasImplicitWhatsAppSelfPairing({\n        fsImpl,\n        OPENCLAW_DIR,\n        channelId,\n        accountId,\n        accountConfig,\n      })\n    ) {\n      counts.set(accountId, 1);\n    }\n  }\n\n  return counts;\n};\n\nconst listConfiguredChannelAccounts = ({ fsImpl, OPENCLAW_DIR, cfg }) => {\n  const bindings = Array.isArray(cfg?.bindings) ? cfg.bindings : [];\n  const boundAccountMap = new Map();\n  for (const binding of bindings) {\n    const match = binding?.match || {};\n    const hasScopedFields =\n      !!match.peer ||\n      !!match.parentPeer ||\n      !!String(match.guildId || \"\").trim() ||\n      !!String(match.teamId || \"\").trim() ||\n      (Array.isArray(match.roles) && match.roles.length > 0);\n    if (hasScopedFields) continue;\n    const channel = String(match.channel || \"\").trim();\n    if (!channel) continue;\n    const accountId = String(match.accountId || \"\").trim() || \"default\";\n    const agentId = String(binding?.agentId || \"\").trim();\n    if (!agentId) continue;\n    const key = `${channel}:${accountId}`;\n    if (!boundAccountMap.has(key)) {\n      boundAccountMap.set(key, agentId);\n    }\n  }\n  const channels =\n    cfg?.channels && typeof cfg.channels === \"object\" ? cfg.channels : {};\n  return Object.entries(channels)\n    .map(([channelId, channelConfig]) => {\n      if (!kChannelEnvKeys[String(channelId || \"\").trim()]) return null;\n      const config =\n        channelConfig && typeof channelConfig === \"object\" ? channelConfig : {};\n      const accountsConfig =\n        config.accounts && typeof config.accounts === \"object\"\n          ? config.accounts\n          : {};\n      const accountIds = Object.keys(accountsConfig)\n        .map((entry) => String(entry || \"\").trim())\n        .filter(Boolean);\n      const topLevelKeys = Object.keys(config).filter(\n        (entry) =>\n          entry !== \"accounts\" &&\n          entry !== \"defaultAccount\" &&\n          entry !== \"enabled\",\n      );\n      if (accountIds.length === 0 && topLevelKeys.length === 0) return null;\n      const normalizedAccountIds = accountIds.includes(\"default\")\n        ? accountIds\n        : topLevelKeys.length > 0\n          ? [\"default\", ...accountIds]\n          : accountIds;\n      const pairedCounts = readPairedCountsByAccount({\n        fsImpl,\n        OPENCLAW_DIR,\n        channelId,\n        accountIds: normalizedAccountIds,\n        config,\n      });\n      return {\n        channel: String(channelId || \"\").trim(),\n        accounts: normalizedAccountIds.map((accountId) => {\n          const accountConfig =\n            accountId === \"default\" && accountIds.length === 0\n              ? config\n              : accountsConfig?.[accountId] || {};\n          return {\n            id: accountId,\n            name: String(accountConfig?.name || \"\").trim(),\n            envKey: deriveChannelEnvKey({ provider: channelId, accountId }),\n            boundAgentId:\n              boundAccountMap.get(\n                `${String(channelId || \"\").trim()}:${accountId}`,\n              ) || \"\",\n            paired: Number(pairedCounts.get(accountId) || 0),\n            status:\n              Number(pairedCounts.get(accountId) || 0) > 0\n                ? \"paired\"\n                : \"configured\",\n          };\n        }),\n      };\n    })\n    .filter(Boolean);\n};\n\nconst getSafeStat = ({ fsImpl, targetPath }) => {\n  try {\n    if (typeof fsImpl.lstatSync === \"function\") {\n      return fsImpl.lstatSync(targetPath);\n    }\n    if (typeof fsImpl.statSync === \"function\") {\n      return fsImpl.statSync(targetPath);\n    }\n  } catch {}\n  return null;\n};\n\nconst calculatePathSizeBytes = ({ fsImpl, targetPath }) => {\n  const stat = getSafeStat({ fsImpl, targetPath });\n  if (!stat) return 0;\n  if (typeof stat.isSymbolicLink === \"function\" && stat.isSymbolicLink())\n    return 0;\n  if (typeof stat.isFile === \"function\" && stat.isFile()) {\n    return Number(stat.size || 0);\n  }\n  if (!(typeof stat.isDirectory === \"function\" && stat.isDirectory())) {\n    return 0;\n  }\n  let entries = [];\n  try {\n    entries = fsImpl.readdirSync(targetPath) || [];\n  } catch {\n    return 0;\n  }\n  return entries.reduce(\n    (total, entry) =>\n      total +\n      calculatePathSizeBytes({\n        fsImpl,\n        targetPath: path.join(targetPath, String(entry || \"\")),\n      }),\n    0,\n  );\n};\n\nconst getImplicitMainAgent = ({ OPENCLAW_DIR, cfg }) => {\n  const defaults = normalizeAgentDefaults({ cfg });\n  const defaultPrimaryModel = String(defaults?.model?.primary || \"\").trim();\n  return {\n    id: kDefaultAgentId,\n    default: true,\n    name: \"Main Agent\",\n    workspace: resolveAgentWorkspacePath({\n      OPENCLAW_DIR,\n      agentId: kDefaultAgentId,\n    }),\n    agentDir: resolveAgentDirPath({ OPENCLAW_DIR, agentId: kDefaultAgentId }),\n    ...(defaultPrimaryModel ? { model: { primary: defaultPrimaryModel } } : {}),\n  };\n};\n\nconst withNormalizedAgentsConfig = ({ OPENCLAW_DIR, cfg }) => {\n  const nextCfg = cfg && typeof cfg === \"object\" ? { ...cfg } : {};\n  const existingAgents =\n    nextCfg.agents && typeof nextCfg.agents === \"object\" ? nextCfg.agents : {};\n  const existingList = normalizeAgentsList({ list: existingAgents.list });\n  const hasMain = existingList.some(\n    (entry) => String(entry.id || \"\").trim() === kDefaultAgentId,\n  );\n  const nextList = hasMain\n    ? existingList\n    : [getImplicitMainAgent({ OPENCLAW_DIR, cfg: nextCfg }), ...existingList];\n\n  let hasDefault = false;\n  const listWithSingleDefault = nextList.map((entry) => {\n    if (!entry.default) return entry;\n    if (hasDefault) return { ...entry, default: false };\n    hasDefault = true;\n    return { ...entry, default: true };\n  });\n  if (!hasDefault && listWithSingleDefault.length > 0) {\n    listWithSingleDefault[0] = { ...listWithSingleDefault[0], default: true };\n  }\n\n  nextCfg.agents = {\n    ...existingAgents,\n    list: listWithSingleDefault,\n  };\n  return nextCfg;\n};\n\nconst isValidAgentId = (value) =>\n  kAgentIdPattern.test(String(value || \"\").trim());\n\nconst isValidWorkspaceFolder = (value) =>\n  kWorkspaceFolderPattern.test(String(value || \"\").trim());\n\nconst resolveRequestedWorkspacePath = ({\n  OPENCLAW_DIR,\n  agentId,\n  workspaceFolder,\n}) => {\n  const normalizedFolder = String(workspaceFolder || \"\").trim();\n  if (!normalizedFolder)\n    return resolveAgentWorkspacePath({ OPENCLAW_DIR, agentId });\n  if (!isValidWorkspaceFolder(normalizedFolder)) {\n    throw new Error(\n      \"Workspace folder must be lowercase letters, numbers, and hyphens only\",\n    );\n  }\n  return path.join(OPENCLAW_DIR, normalizedFolder);\n};\n\nconst ensureAgentScaffold = ({\n  fsImpl,\n  agentId,\n  workspacePath,\n  OPENCLAW_DIR,\n}) => {\n  const agentDirPath = resolveAgentDirPath({ OPENCLAW_DIR, agentId });\n  fsImpl.mkdirSync(workspacePath, { recursive: true });\n  fsImpl.mkdirSync(agentDirPath, { recursive: true });\n  for (const fileName of kDefaultAgentFiles) {\n    const targetPath = path.join(workspacePath, fileName);\n    if (fsImpl.existsSync(targetPath)) continue;\n    fsImpl.writeFileSync(\n      targetPath,\n      `# ${fileName}\\n\\nCreated for agent \"${agentId}\".\\n`,\n    );\n  }\n  return {\n    workspacePath,\n    agentDirPath,\n  };\n};\n\nmodule.exports = {\n  kDefaultAgentId,\n  kChannelTokenFields,\n  kChannelLabels,\n  kMaskedChannelToken,\n  shellEscapeArg,\n  resolveCredentialsDirPath,\n  resolveWhatsAppCredentialCandidatePaths,\n  hasSavedWhatsAppCredentials,\n  resolveAgentWorkspacePath,\n  loadConfig,\n  saveConfig,\n  ensurePluginAllowed,\n  cloneJson,\n  normalizeBindingMatch,\n  matchesBinding,\n  isValidChannelAccountId,\n  normalizeChannelProvider,\n  deriveChannelEnvKey,\n  deriveChannelExtraEnvKeys,\n  getConfiguredChannelEnvKeys,\n  assertActiveChannelTokenEnvVars,\n  normalizeChannelConfig,\n  appendBindingToConfig,\n  buildBindingSpec,\n  hasLegacyDefaultChannelAccount,\n  listConfiguredChannelAccounts,\n  getSafeStat,\n  calculatePathSizeBytes,\n  withNormalizedAgentsConfig,\n  isValidAgentId,\n  resolveRequestedWorkspacePath,\n  ensureAgentScaffold,\n};\n"
  },
  {
    "path": "lib/server/alphaclaw-version.js",
    "content": "const childProcess = require(\"child_process\");\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst {\n  kLatestVersionCacheTtlMs,\n  kAlphaclawRegistryUrl,\n  kNpmPackageRoot,\n  kOpenclawUpdateCopyTimeoutMs,\n  kRootDir,\n} = require(\"./constants\");\nconst {\n  compareVersionParts,\n  normalizeOpenclawVersion,\n  resolveGithubRepoUrl,\n} = require(\"./helpers\");\n\nconst kGithubApiBaseUrl = \"https://api.github.com/repos\";\nconst kGithubRawBaseUrl = \"https://raw.githubusercontent.com\";\nconst kDefaultTemplateBranch = \"main\";\nconst kRailwayTemplateRepoUrl =\n  \"https://github.com/chrysb/openclaw-railway-template.git\";\nconst kRenderTemplateRepoUrl =\n  \"https://github.com/chrysb/openclaw-render-template.git\";\nconst kApexTemplateRepoUrl =\n  \"https://github.com/chrysb/openclaw-apex-template.git\";\n\nconst isNewerVersion = (latest, current) => {\n  if (!latest || !current) return false;\n  const parse = (value) => {\n    const normalized = String(value || \"\").replace(/^v/, \"\").trim();\n    const [core, prerelease = \"\"] = normalized.split(\"-\", 2);\n    const parts = core.split(\".\").map(Number);\n    return {\n      major: parts[0] || 0,\n      minor: parts[1] || 0,\n      patch: parts[2] || 0,\n      prerelease,\n    };\n  };\n  const latestParts = parse(latest);\n  const currentParts = parse(current);\n  if (latestParts.major !== currentParts.major) {\n    return latestParts.major > currentParts.major;\n  }\n  if (latestParts.minor !== currentParts.minor) {\n    return latestParts.minor > currentParts.minor;\n  }\n  if (latestParts.patch !== currentParts.patch) {\n    return latestParts.patch > currentParts.patch;\n  }\n  if (!latestParts.prerelease && currentParts.prerelease) {\n    return true;\n  }\n  return false;\n};\n\nconst normalizeVersion = (value) => {\n  const normalized = String(value || \"\").trim();\n  return normalized || null;\n};\n\nconst parseJsonResponse = async (response, fallbackMessage) => {\n  const text = await response.text();\n  let data = {};\n  try {\n    data = text ? JSON.parse(text) : {};\n  } catch {\n    throw new Error(text || fallbackMessage);\n  }\n  if (!response.ok) {\n    throw new Error(\n      data?.message ||\n        data?.error ||\n        text ||\n        `${fallbackMessage} (${response.status})`,\n    );\n  }\n  return data;\n};\n\nconst buildGithubHeaders = ({ env = process.env, accept = \"application/json\" } = {}) => {\n  const headers = {\n    Accept: accept,\n    \"User-Agent\": \"alphaclaw\",\n  };\n  const token = String(env?.GITHUB_TOKEN || env?.GH_TOKEN || \"\").trim();\n  if (token) {\n    headers.Authorization = `Bearer ${token}`;\n  }\n  return headers;\n};\n\nconst extractTemplateVersions = (pkg) => ({\n  latestVersion: normalizeVersion(pkg?.dependencies?.[\"@chrysb/alphaclaw\"]),\n  latestOpenclawVersion: normalizeOpenclawVersion(pkg?.dependencies?.openclaw),\n});\n\nconst fetchLatestVersionFromRegistry = async ({ fetchImpl, version = null }) => {\n  if (typeof fetchImpl !== \"function\") {\n    throw new Error(\"Fetch is not available for AlphaClaw version checks\");\n  }\n  const response = await fetchImpl(kAlphaclawRegistryUrl, {\n    headers: buildGithubHeaders({\n      accept: \"application/vnd.npm.install-v1+json\",\n    }),\n  });\n  const data = await parseJsonResponse(\n    response,\n    \"Failed to fetch latest AlphaClaw version\",\n  );\n  const latestVersion =\n    normalizeVersion(version) || normalizeVersion(data?.[\"dist-tags\"]?.latest);\n  const latestOpenclawVersion = latestVersion\n    ? normalizeOpenclawVersion(\n        data?.versions?.[latestVersion]?.dependencies?.openclaw,\n      )\n    : null;\n  return { latestVersion, latestOpenclawVersion };\n};\n\nconst fetchTemplatePackageVersions = async ({\n  fetchImpl,\n  repoUrl,\n  branch = kDefaultTemplateBranch,\n}) => {\n  if (typeof fetchImpl !== \"function\") {\n    throw new Error(\"Fetch is not available for template version checks\");\n  }\n  const repoPath = resolveGithubRepoUrl(repoUrl);\n  if (!repoPath) {\n    throw new Error(\"Template repository is not configured\");\n  }\n  const response = await fetchImpl(\n    `${kGithubRawBaseUrl}/${repoPath}/${encodeURIComponent(branch)}/package.json`,\n    {\n      headers: buildGithubHeaders(),\n    },\n  );\n  const data = await parseJsonResponse(\n    response,\n    \"Could not fetch the deployment template metadata\",\n  );\n  const versions = extractTemplateVersions(data);\n  if (!versions.latestOpenclawVersion && versions.latestVersion) {\n    try {\n      const registry = await fetchLatestVersionFromRegistry({\n        fetchImpl,\n        version: versions.latestVersion,\n      });\n      versions.latestOpenclawVersion = registry.latestOpenclawVersion || null;\n    } catch {}\n  }\n  return versions;\n};\n\nconst fetchTemplateHeadRef = async ({\n  fetchImpl,\n  repoUrl,\n  branch = kDefaultTemplateBranch,\n  env = process.env,\n}) => {\n  if (typeof fetchImpl !== \"function\") {\n    throw new Error(\"Fetch is not available for template update requests\");\n  }\n  const repoPath = resolveGithubRepoUrl(repoUrl);\n  if (!repoPath) {\n    throw new Error(\"Template repository is not configured\");\n  }\n  const response = await fetchImpl(\n    `${kGithubApiBaseUrl}/${repoPath}/commits/${encodeURIComponent(branch)}`,\n    {\n      headers: buildGithubHeaders({\n        env,\n        accept: \"application/vnd.github+json\",\n      }),\n    },\n  );\n  const data = await parseJsonResponse(\n    response,\n    \"Could not fetch the deployment template metadata\",\n  );\n  return normalizeVersion(data?.sha);\n};\n\nconst createUpdateStrategy = ({\n  action,\n  provider,\n  label,\n  templateRepoUrl = \"\",\n  templateBranch = kDefaultTemplateBranch,\n  description,\n  steps = [],\n  primaryActionLabel,\n  primaryActionUrl = \"\",\n  managedUpdateUrl = \"\",\n  managedUpdateToken = \"\",\n}) => ({\n  action,\n  provider,\n  label,\n  templateRepoUrl,\n  templateBranch,\n  description: String(description || \"\").trim(),\n  steps: Array.isArray(steps)\n    ? steps.map((entry) => String(entry || \"\").trim()).filter(Boolean)\n    : [],\n  primaryActionLabel: String(primaryActionLabel || \"\").trim() || \"Update now\",\n  primaryActionUrl: String(primaryActionUrl || \"\").trim(),\n  managedUpdateUrl: String(managedUpdateUrl || \"\").trim(),\n  managedUpdateToken: String(managedUpdateToken || \"\").trim(),\n});\n\nconst buildRailwayDeploymentUrl = (env = process.env) => {\n  const projectId = String(env.RAILWAY_PROJECT_ID || \"\").trim();\n  const serviceId = String(env.RAILWAY_SERVICE_ID || \"\").trim();\n  const environmentId = String(env.RAILWAY_ENVIRONMENT_ID || \"\").trim();\n  if (!projectId) return \"\";\n  const baseUrl = serviceId\n    ? `https://railway.com/project/${projectId}/service/${serviceId}`\n    : `https://railway.com/project/${projectId}`;\n  return environmentId\n    ? `${baseUrl}?environmentId=${encodeURIComponent(environmentId)}`\n    : baseUrl;\n};\n\nconst buildRenderDeploymentUrl = (env = process.env) => {\n  const serviceId = String(env.RENDER_SERVICE_ID || \"\").trim();\n  if (!serviceId) return \"\";\n  return `https://dashboard.render.com/web/${encodeURIComponent(serviceId)}`;\n};\n\nconst detectUpdateStrategy = ({\n  env = process.env,\n  fsImpl = fs,\n} = {}) => {\n  const deploymentProvider = String(env.ALPHACLAW_DEPLOYMENT_PROVIDER || \"\")\n    .trim()\n    .toLowerCase();\n  const managedUpdateUrl = String(env.ALPHACLAW_MANAGED_UPDATE_URL || \"\").trim();\n  const managedUpdateToken = String(\n    env.ALPHACLAW_MANAGED_UPDATE_TOKEN || \"\",\n  ).trim();\n  const managedTemplateRepoUrl =\n    String(env.ALPHACLAW_TEMPLATE_REPO_URL || \"\").trim() || kApexTemplateRepoUrl;\n  const managedTemplateBranch =\n    String(env.ALPHACLAW_TEMPLATE_BRANCH || \"\").trim() || kDefaultTemplateBranch;\n\n  if (deploymentProvider === \"apex\" && managedUpdateUrl && managedUpdateToken) {\n    return createUpdateStrategy({\n      action: \"managed-update\",\n      provider: \"apex\",\n      label: \"Apex\",\n      templateRepoUrl: managedTemplateRepoUrl,\n      templateBranch: managedTemplateBranch,\n      primaryActionLabel: \"Update now\",\n      managedUpdateUrl,\n      managedUpdateToken,\n    });\n  }\n\n  if (deploymentProvider === \"apex\") {\n    return createUpdateStrategy({\n      action: \"instructions\",\n      provider: \"apex\",\n      label: \"Apex\",\n      templateRepoUrl: managedTemplateRepoUrl,\n      templateBranch: managedTemplateBranch,\n      description:\n        \"This Apex deployment must be migrated to the managed updater before one-click updates are available.\",\n      primaryActionLabel: \"Done\",\n    });\n  }\n\n  if (managedUpdateUrl && managedUpdateToken) {\n    return createUpdateStrategy({\n      action: \"managed-update\",\n      provider: \"apex\",\n      label: \"Apex\",\n      templateRepoUrl: managedTemplateRepoUrl,\n      templateBranch: managedTemplateBranch,\n      primaryActionLabel: \"Update now\",\n      managedUpdateUrl,\n      managedUpdateToken,\n    });\n  }\n\n  if (\n    env.RAILWAY_ENVIRONMENT ||\n    env.RAILWAY_PUBLIC_DOMAIN ||\n    env.RAILWAY_STATIC_URL\n  ) {\n    const railwayDeploymentUrl = buildRailwayDeploymentUrl(env);\n    return createUpdateStrategy({\n      action: \"instructions\",\n      provider: \"railway\",\n      label: \"Railway\",\n      templateRepoUrl: kRailwayTemplateRepoUrl,\n      description:\n        \"Railway deployments update by syncing the latest template repo changes and redeploying the service.\",\n      steps: [\n        \"Open your Railway project and select the AlphaClaw service\",\n        \"Update the upstream template/source repo to the latest commit on main\",\n        \"Redeploy the service so AlphaClaw and OpenClaw update together\",\n      ],\n      primaryActionLabel: railwayDeploymentUrl ? \"Update on Railway\" : \"Done\",\n      primaryActionUrl: railwayDeploymentUrl,\n    });\n  }\n\n  if (env.RENDER || env.RENDER_EXTERNAL_URL) {\n    const renderDeploymentUrl = buildRenderDeploymentUrl(env);\n    return createUpdateStrategy({\n      action: \"instructions\",\n      provider: \"render\",\n      label: \"Render\",\n      templateRepoUrl: kRenderTemplateRepoUrl,\n      description:\n        \"Render deployments update by deploying the latest template commit.\",\n      steps: [\n        \"Open your Render service for this AlphaClaw deployment\",\n        \"Click the arrow next to Manual Deploy\",\n        'Choose \"Deploy latest commit\"',\n      ],\n      primaryActionLabel: renderDeploymentUrl ? \"Update on Render\" : \"Done\",\n      primaryActionUrl: renderDeploymentUrl,\n    });\n  }\n\n  if (fsImpl.existsSync(\"/.dockerenv\")) {\n    return createUpdateStrategy({\n      action: \"instructions\",\n      provider: \"container\",\n      label: \"Container\",\n      description:\n        \"This AlphaClaw instance is running in a container. Rebuild or redeploy the container from the latest deployment template or image to apply updates.\",\n      steps: [\n        \"Pull the latest deployment template or image for this container\",\n        \"Rebuild or redeploy the container with the updated bundle\",\n        \"Restart the service after the new build is ready\",\n      ],\n      primaryActionLabel: \"Done\",\n    });\n  }\n\n  return createUpdateStrategy({\n    action: \"self-update\",\n    provider: \"self-hosted\",\n    label: \"This install\",\n    description:\n      \"This will install the latest @chrysb/alphaclaw package in place and restart AlphaClaw.\",\n    steps: [\n      \"AlphaClaw will install the latest published package in place\",\n      \"The process will restart after the new files are copied into node_modules\",\n    ],\n    primaryActionLabel: \"Update now\",\n  });\n};\n\nconst createAlphaclawVersionService = ({\n  readOpenclawVersion = () => null,\n  env = process.env,\n  fsImpl = fs,\n  fetchImpl = global.fetch,\n} = {}) => {\n  let kRegistryStatusCache = {\n    latestVersion: null,\n    fetchedAt: 0,\n  };\n  const kTemplateStatusCache = new Map();\n  let kUpdateInProgress = false;\n\n  const readAlphaclawVersion = () => {\n    try {\n      const pkg = JSON.parse(\n        fsImpl.readFileSync(path.join(kNpmPackageRoot, \"package.json\"), \"utf8\"),\n      );\n      return normalizeVersion(pkg.version);\n    } catch {\n      return null;\n    }\n  };\n\n  const readTemplateStatus = async ({\n    repoUrl,\n    branch = kDefaultTemplateBranch,\n    refresh = false,\n  }) => {\n    const cacheKey = `${resolveGithubRepoUrl(repoUrl)}#${branch}`;\n    const now = Date.now();\n    if (!refresh && kTemplateStatusCache.has(cacheKey)) {\n      const cached = kTemplateStatusCache.get(cacheKey);\n      if (now - cached.fetchedAt < kLatestVersionCacheTtlMs) {\n        return cached;\n      }\n    }\n    const payload = await fetchTemplatePackageVersions({\n      fetchImpl,\n      repoUrl,\n      branch,\n    });\n    const next = { ...payload, fetchedAt: Date.now() };\n    kTemplateStatusCache.set(cacheKey, next);\n    return next;\n  };\n\n  const readRegistryStatus = async ({ refresh = false } = {}) => {\n    const now = Date.now();\n    if (\n      !refresh &&\n      kRegistryStatusCache.fetchedAt &&\n      now - kRegistryStatusCache.fetchedAt < kLatestVersionCacheTtlMs\n    ) {\n      return kRegistryStatusCache;\n    }\n    const registry = await fetchLatestVersionFromRegistry({ fetchImpl });\n    kRegistryStatusCache = {\n      latestVersion: registry.latestVersion,\n      latestOpenclawVersion: registry.latestOpenclawVersion,\n      fetchedAt: Date.now(),\n    };\n    return kRegistryStatusCache;\n  };\n\n  const buildVersionStatus = ({\n    strategy,\n    latestVersion = null,\n    latestOpenclawVersion = null,\n    ok = true,\n    error = \"\",\n  }) => {\n    const currentVersion = readAlphaclawVersion();\n    const currentOpenclawVersion = normalizeOpenclawVersion(readOpenclawVersion());\n    const alphaclawHasUpdate = isNewerVersion(latestVersion, currentVersion);\n    const openclawHasUpdate =\n      strategy.templateRepoUrl && latestOpenclawVersion\n        ? !currentOpenclawVersion ||\n          compareVersionParts(latestOpenclawVersion, currentOpenclawVersion) > 0\n        : false;\n    return {\n      ok,\n      currentVersion,\n      currentOpenclawVersion,\n      latestVersion: normalizeVersion(latestVersion),\n      latestOpenclawVersion: normalizeOpenclawVersion(latestOpenclawVersion),\n      hasUpdate: Boolean(alphaclawHasUpdate || openclawHasUpdate),\n      updateStrategy: strategy,\n      ...(error ? { error: String(error || \"\").trim() } : {}),\n    };\n  };\n\n  const installLatestAlphaclaw = () =>\n    new Promise((resolve, reject) => {\n      const installDir = findInstallDir(fsImpl);\n      const tmpDir = fsImpl.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-update-\"));\n\n      const cleanup = () => {\n        try {\n          fsImpl.rmSync(tmpDir, { recursive: true, force: true });\n        } catch {}\n      };\n\n      fsImpl.writeFileSync(\n        path.join(tmpDir, \"package.json\"),\n        JSON.stringify({\n          private: true,\n          dependencies: { \"@chrysb/alphaclaw\": \"latest\" },\n        }),\n      );\n\n      const npmEnv = {\n        ...process.env,\n        npm_config_update_notifier: \"false\",\n        npm_config_fund: \"false\",\n        npm_config_audit: \"false\",\n      };\n\n      console.log(\n        `[alphaclaw] Running: npm install @chrysb/alphaclaw@latest in temp dir (target: ${installDir})`,\n      );\n      childProcess.exec(\n        \"npm install --omit=dev --prefer-online --package-lock=false\",\n        {\n          cwd: tmpDir,\n          env: npmEnv,\n          timeout: 180000,\n        },\n        (err, stdout, stderr) => {\n          if (err) {\n            const message = String(stderr || err.message || \"\").trim();\n            console.log(\n              `[alphaclaw] alphaclaw install error: ${message.slice(0, 200)}`,\n            );\n            cleanup();\n            return reject(\n              new Error(\n                message || \"Failed to install @chrysb/alphaclaw@latest\",\n              ),\n            );\n          }\n          if (stdout?.trim()) {\n            console.log(\n              `[alphaclaw] alphaclaw install stdout: ${stdout.trim().slice(0, 300)}`,\n            );\n          }\n\n          const src = path.join(tmpDir, \"node_modules\");\n          const dest = path.join(installDir, \"node_modules\");\n          childProcess.exec(\n            `cp -af \"${src}/.\" \"${dest}/\"`,\n            { timeout: kOpenclawUpdateCopyTimeoutMs },\n            (copyErr) => {\n              cleanup();\n              if (copyErr) {\n                console.log(\n                  `[alphaclaw] alphaclaw copy error: ${(copyErr.message || \"\").slice(0, 200)}`,\n                );\n                return reject(\n                  new Error(\n                    `Failed to copy updated AlphaClaw files: ${copyErr.message}`,\n                  ),\n                );\n              }\n              console.log(\"[alphaclaw] alphaclaw install completed\");\n              resolve({ stdout: stdout?.trim(), stderr: stderr?.trim() });\n            },\n          );\n        },\n      );\n    });\n\n  const updateManagedDeployment = async (strategy) => {\n    try {\n      const latestStatus = await readTemplateStatus({\n        repoUrl: strategy.templateRepoUrl,\n        branch: strategy.templateBranch,\n        refresh: true,\n      });\n      const latestRef = await fetchTemplateHeadRef({\n        fetchImpl,\n        repoUrl: strategy.templateRepoUrl,\n        branch: strategy.templateBranch,\n        env,\n      });\n      const response = await fetchImpl(strategy.managedUpdateUrl, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${strategy.managedUpdateToken}`,\n          \"User-Agent\": \"alphaclaw\",\n        },\n        body: JSON.stringify({\n          repo: strategy.templateRepoUrl,\n          ref: latestRef,\n          alphaclawVersion: latestStatus.latestVersion || readAlphaclawVersion() || \"\",\n          openclawVersion:\n            latestStatus.latestOpenclawVersion ||\n            normalizeOpenclawVersion(readOpenclawVersion()) ||\n            \"\",\n        }),\n      });\n      const data = await parseJsonResponse(\n        response,\n        \"Failed to trigger the managed deployment update\",\n      );\n      return {\n        status: 200,\n        body: {\n          ok: true,\n          previousVersion: readAlphaclawVersion(),\n          currentVersion: latestStatus.latestVersion || readAlphaclawVersion(),\n          currentOpenclawVersion: normalizeOpenclawVersion(readOpenclawVersion()),\n          latestVersion: latestStatus.latestVersion || readAlphaclawVersion(),\n          latestOpenclawVersion:\n            latestStatus.latestOpenclawVersion ||\n            normalizeOpenclawVersion(readOpenclawVersion()),\n          managedUpdate: true,\n          restarting: true,\n          noop: !!data?.noop,\n          phase: String(data?.phase || \"\").trim(),\n        },\n      };\n    } catch (err) {\n      return {\n        status: 502,\n        body: {\n          ok: false,\n          error:\n            err.message || \"Failed to trigger the managed deployment update\",\n          updateStrategy: strategy,\n        },\n      };\n    }\n  };\n\n  const restartProcess = () => {\n    if (\n      env.RAILWAY_ENVIRONMENT ||\n      env.RENDER ||\n      env.FLY_APP_NAME ||\n      fsImpl.existsSync(\"/.dockerenv\")\n    ) {\n      console.log(\"[alphaclaw] Restarting via container crash (exit 1)...\");\n      process.exit(1);\n    }\n    console.log(\"[alphaclaw] Spawning new process and exiting...\");\n    const { spawn } = require(\"child_process\");\n    const child = spawn(process.argv[0], process.argv.slice(1), {\n      detached: true,\n      stdio: \"inherit\",\n    });\n    child.unref();\n    process.exit(0);\n  };\n\n  const getVersionStatus = async (refresh) => {\n    const strategy = detectUpdateStrategy({ env, fsImpl });\n    try {\n      if (strategy.templateRepoUrl) {\n        const status = await readTemplateStatus({\n          repoUrl: strategy.templateRepoUrl,\n          branch: strategy.templateBranch,\n          refresh,\n        });\n        return buildVersionStatus({\n          strategy,\n          latestVersion: status.latestVersion,\n          latestOpenclawVersion: status.latestOpenclawVersion,\n        });\n      }\n      const status = await readRegistryStatus({ refresh });\n      return buildVersionStatus({\n        strategy,\n        latestVersion: status.latestVersion,\n        latestOpenclawVersion: status.latestOpenclawVersion,\n      });\n    } catch (err) {\n      const cachedTemplateStatus = strategy.templateRepoUrl\n        ? kTemplateStatusCache.get(\n            `${resolveGithubRepoUrl(strategy.templateRepoUrl)}#${strategy.templateBranch}`,\n          ) || {}\n        : {};\n      return buildVersionStatus({\n        strategy,\n        latestVersion:\n          cachedTemplateStatus.latestVersion || kRegistryStatusCache.latestVersion,\n        latestOpenclawVersion:\n          cachedTemplateStatus.latestOpenclawVersion ||\n          kRegistryStatusCache.latestOpenclawVersion,\n        ok: false,\n        error: err.message || \"Failed to fetch latest AlphaClaw version\",\n      });\n    }\n  };\n\n  const updateAlphaclaw = async () => {\n    const strategy = detectUpdateStrategy({ env, fsImpl });\n    if (kUpdateInProgress) {\n      return {\n        status: 409,\n        body: { ok: false, error: \"AlphaClaw update already in progress\" },\n      };\n    }\n    if (strategy.action === \"managed-update\") {\n      return updateManagedDeployment(strategy);\n    }\n    if (strategy.action !== \"self-update\") {\n      return {\n        status: 409,\n        body: {\n          ok: false,\n          error:\n            strategy.description || \"This deployment is updated outside AlphaClaw.\",\n          updateStrategy: strategy,\n        },\n      };\n    }\n\n    kUpdateInProgress = true;\n    const previousVersion = readAlphaclawVersion();\n    try {\n      await installLatestAlphaclaw();\n      const markerPath = path.join(kRootDir, \".alphaclaw-update-pending\");\n      try {\n        fsImpl.writeFileSync(\n          markerPath,\n          JSON.stringify({ from: previousVersion, ts: Date.now() }),\n        );\n        console.log(`[alphaclaw] Update marker written to ${markerPath}`);\n      } catch (e) {\n        console.log(`[alphaclaw] Could not write update marker: ${e.message}`);\n      }\n      kRegistryStatusCache = {\n        latestVersion: null,\n        fetchedAt: 0,\n      };\n      return {\n        status: 200,\n        body: {\n          ok: true,\n          previousVersion,\n          restarting: true,\n        },\n      };\n    } catch (err) {\n      kUpdateInProgress = false;\n      return {\n        status: 500,\n        body: { ok: false, error: err.message || \"Failed to update AlphaClaw\" },\n      };\n    }\n  };\n\n  return {\n    readAlphaclawVersion,\n    getVersionStatus,\n    updateAlphaclaw,\n    restartProcess,\n  };\n};\n\nconst findInstallDir = (fsImpl) => {\n  let dir = kNpmPackageRoot;\n  while (dir !== path.dirname(dir)) {\n    const parent = path.dirname(dir);\n    if (\n      path.basename(parent) === \"node_modules\" ||\n      parent.includes(`${path.sep}node_modules${path.sep}`)\n    ) {\n      dir = parent;\n      continue;\n    }\n    const pkgPath = path.join(parent, \"package.json\");\n    if (fsImpl.existsSync(pkgPath)) {\n      try {\n        const pkg = JSON.parse(fsImpl.readFileSync(pkgPath, \"utf8\"));\n        if (\n          pkg.dependencies?.[\"@chrysb/alphaclaw\"] ||\n          pkg.devDependencies?.[\"@chrysb/alphaclaw\"] ||\n          pkg.optionalDependencies?.[\"@chrysb/alphaclaw\"]\n        ) {\n          return parent;\n        }\n      } catch {}\n    }\n    dir = parent;\n  }\n  return kNpmPackageRoot;\n};\n\nmodule.exports = {\n  createAlphaclawVersionService,\n  detectUpdateStrategy,\n};\n"
  },
  {
    "path": "lib/server/auth-profiles.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { AUTH_PROFILES_PATH, CODEX_PROFILE_ID, OPENCLAW_DIR } = require(\"./constants\");\n\nconst kDefaultAgentId = \"main\";\nconst kApiKeyEnvVarByProvider = {\n  anthropic: \"ANTHROPIC_API_KEY\",\n  openai: \"OPENAI_API_KEY\",\n  google: \"GEMINI_API_KEY\",\n  opencode: \"OPENCODE_API_KEY\",\n  openrouter: \"OPENROUTER_API_KEY\",\n  zai: \"ZAI_API_KEY\",\n  \"vercel-ai-gateway\": \"AI_GATEWAY_API_KEY\",\n  kilocode: \"KILOCODE_API_KEY\",\n  xai: \"XAI_API_KEY\",\n  mistral: \"MISTRAL_API_KEY\",\n  cerebras: \"CEREBRAS_API_KEY\",\n  moonshot: \"MOONSHOT_API_KEY\",\n  \"kimi-coding\": \"KIMI_API_KEY\",\n  volcengine: \"VOLCANO_ENGINE_API_KEY\",\n  byteplus: \"BYTEPLUS_API_KEY\",\n  synthetic: \"SYNTHETIC_API_KEY\",\n  minimax: \"MINIMAX_API_KEY\",\n  voyage: \"VOYAGE_API_KEY\",\n  groq: \"GROQ_API_KEY\",\n  deepgram: \"DEEPGRAM_API_KEY\",\n  vllm: \"VLLM_API_KEY\",\n};\n\nconst normalizeSecret = (raw) =>\n  String(raw ?? \"\")\n    .replace(/[\\r\\n\\u2028\\u2029]/g, \"\")\n    .trim();\n\nconst credentialMode = (credential) => {\n  if (credential.type === \"api_key\") return \"api_key\";\n  if (credential.type === \"token\") return \"token\";\n  return \"oauth\";\n};\n\nconst getEnvVarForApiKeyProvider = (provider) =>\n  kApiKeyEnvVarByProvider[String(provider || \"\").trim()] || \"\";\n\nconst listApiKeyProviders = () => Object.keys(kApiKeyEnvVarByProvider);\n\nconst getDefaultProfileIdForApiKeyProvider = (provider) => {\n  const normalized = String(provider || \"\").trim();\n  return normalized ? `${normalized}:default` : \"\";\n};\n\nconst resolveAgentDir = (agentId = kDefaultAgentId) =>\n  path.join(OPENCLAW_DIR, \"agents\", agentId, \"agent\");\n\nconst resolveAuthProfilesPath = (agentId = kDefaultAgentId) =>\n  path.join(resolveAgentDir(agentId), \"auth-profiles.json\");\n\nconst resolveOpenclawConfigPath = () =>\n  path.join(OPENCLAW_DIR, \"openclaw.json\");\n\nconst hasCompletedOnboardingConfig = (cfg) =>\n  String(cfg?.agents?.defaults?.model?.primary || \"\").trim().includes(\"/\");\n\nconst loadAuthStore = (agentId = kDefaultAgentId) => {\n  const storePath = resolveAuthProfilesPath(agentId);\n  let store = { version: 1, profiles: {} };\n  try {\n    if (fs.existsSync(storePath)) {\n      const parsed = JSON.parse(fs.readFileSync(storePath, \"utf8\"));\n      if (\n        parsed &&\n        typeof parsed === \"object\" &&\n        parsed.profiles &&\n        typeof parsed.profiles === \"object\"\n      ) {\n        store = {\n          version: Number(parsed.version || 1),\n          profiles: parsed.profiles,\n          order: parsed.order,\n          lastGood: parsed.lastGood,\n          usageStats: parsed.usageStats,\n        };\n      }\n    }\n  } catch {}\n  return store;\n};\n\nconst saveAuthStore = (agentId, store) => {\n  const storePath = resolveAuthProfilesPath(agentId);\n  fs.mkdirSync(path.dirname(storePath), { recursive: true });\n  fs.writeFileSync(\n    storePath,\n    JSON.stringify(\n      {\n        version: Number(store.version || 1),\n        profiles: store.profiles || {},\n        ...(store.order !== undefined ? { order: store.order } : {}),\n        ...(store.lastGood !== undefined ? { lastGood: store.lastGood } : {}),\n        ...(store.usageStats !== undefined\n          ? { usageStats: store.usageStats }\n          : {}),\n      },\n      null,\n      2,\n    ),\n  );\n};\n\nconst loadOpenclawConfig = () => {\n  const configPath = resolveOpenclawConfigPath();\n  try {\n    return JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n  } catch {\n    return {};\n  }\n};\n\nconst canSyncOpenclawAuthReferences = () => {\n  const configPath = resolveOpenclawConfigPath();\n  if (!fs.existsSync(configPath)) return false;\n  try {\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    return hasCompletedOnboardingConfig(cfg);\n  } catch {\n    return false;\n  }\n};\n\nconst saveOpenclawConfig = (cfg) => {\n  const configPath = resolveOpenclawConfigPath();\n  fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));\n};\n\nconst syncConfigAuthReference = (cfg, profileId, credential) => {\n  const next = { ...cfg };\n  if (!next.auth) next.auth = {};\n  if (!next.auth.profiles) next.auth.profiles = {};\n  next.auth = { ...next.auth, profiles: { ...next.auth.profiles } };\n  next.auth.profiles[profileId] = {\n    provider: credential.provider,\n    mode: credentialMode(credential),\n  };\n  return next;\n};\n\nconst removeConfigAuthReference = (cfg, profileId) => {\n  if (!cfg.auth?.profiles?.[profileId]) return cfg;\n  const next = { ...cfg };\n  next.auth = { ...next.auth, profiles: { ...next.auth.profiles } };\n  delete next.auth.profiles[profileId];\n  if (Object.keys(next.auth.profiles).length === 0) {\n    delete next.auth.profiles;\n  }\n  if (Object.keys(next.auth).length === 0) {\n    delete next.auth;\n  }\n  return next;\n};\n\nconst createAuthProfiles = () => {\n  // ── Generic profile operations ──\n\n  const listProfiles = (agentId = kDefaultAgentId) => {\n    const store = loadAuthStore(agentId);\n    return Object.entries(store.profiles || {}).map(([id, cred]) => ({\n      id,\n      ...cred,\n    }));\n  };\n\n  const listProfilesByProvider = (provider, agentId = kDefaultAgentId) =>\n    listProfiles(agentId).filter((p) => p.provider === provider);\n\n  const getProfile = (profileId, agentId = kDefaultAgentId) => {\n    const store = loadAuthStore(agentId);\n    const cred = store.profiles?.[profileId];\n    if (!cred) return null;\n    return { id: profileId, ...cred };\n  };\n\n  const upsertProfile = (profileId, credential, agentId = kDefaultAgentId) => {\n    const store = loadAuthStore(agentId);\n    const sanitized = { ...credential };\n    if (sanitized.key) sanitized.key = normalizeSecret(sanitized.key);\n    if (sanitized.token) sanitized.token = normalizeSecret(sanitized.token);\n    if (sanitized.access) sanitized.access = normalizeSecret(sanitized.access);\n    if (sanitized.refresh)\n      sanitized.refresh = normalizeSecret(sanitized.refresh);\n    store.profiles[profileId] = sanitized;\n    saveAuthStore(agentId, store);\n\n    if (!canSyncOpenclawAuthReferences()) return;\n    const cfg = loadOpenclawConfig();\n    const updated = syncConfigAuthReference(cfg, profileId, sanitized);\n    saveOpenclawConfig(updated);\n  };\n\n  const removeProfile = (profileId, agentId = kDefaultAgentId) => {\n    const store = loadAuthStore(agentId);\n    if (!store.profiles[profileId]) return false;\n    delete store.profiles[profileId];\n    saveAuthStore(agentId, store);\n\n    if (!canSyncOpenclawAuthReferences()) return true;\n    const cfg = loadOpenclawConfig();\n    const updated = removeConfigAuthReference(cfg, profileId);\n    saveOpenclawConfig(updated);\n    return true;\n  };\n\n  const setAuthOrder = (provider, orderedProfileIds, agentId = kDefaultAgentId) => {\n    const store = loadAuthStore(agentId);\n    if (!store.order) store.order = {};\n    store.order[provider] = orderedProfileIds;\n    saveAuthStore(agentId, store);\n  };\n\n  const syncConfigAuthReferencesForAgent = (agentId = kDefaultAgentId) => {\n    if (!canSyncOpenclawAuthReferences()) return;\n    const store = loadAuthStore(agentId);\n    let cfg = loadOpenclawConfig();\n    for (const [profileId, credential] of Object.entries(store.profiles || {})) {\n      if (!credential?.type || !credential?.provider) continue;\n      cfg = syncConfigAuthReference(cfg, profileId, credential);\n    }\n    saveOpenclawConfig(cfg);\n  };\n\n  const upsertApiKeyProfileForEnvVar = (\n    provider,\n    rawValue,\n    agentId = kDefaultAgentId,\n  ) => {\n    const key = normalizeSecret(rawValue);\n    if (!provider || !key) return false;\n    upsertProfile(\n      getDefaultProfileIdForApiKeyProvider(provider),\n      {\n        type: \"api_key\",\n        provider,\n        key,\n      },\n      agentId,\n    );\n    return true;\n  };\n\n  const removeApiKeyProfileForEnvVar = (provider, agentId = kDefaultAgentId) => {\n    const profileId = getDefaultProfileIdForApiKeyProvider(provider);\n    if (!profileId) return false;\n    const existing = getProfile(profileId, agentId);\n    if (!existing) return false;\n    if (existing.type !== \"api_key\" || existing.provider !== provider) return false;\n    return removeProfile(profileId, agentId);\n  };\n\n  // ── Model config operations ──\n\n  const getModelConfig = () => {\n    const cfg = loadOpenclawConfig();\n    const defaults = cfg.agents?.defaults || {};\n    return {\n      primary: defaults.model?.primary || null,\n      configuredModels: defaults.models || {},\n    };\n  };\n\n  const setModelConfig = ({ primary, configuredModels }) => {\n    const cfg = loadOpenclawConfig();\n    if (!cfg.agents) cfg.agents = {};\n    if (!cfg.agents.defaults) cfg.agents.defaults = {};\n    if (!cfg.agents.defaults.model) cfg.agents.defaults.model = {};\n    if (primary !== undefined) {\n      cfg.agents.defaults.model.primary = primary;\n    }\n    if (configuredModels !== undefined) {\n      cfg.agents.defaults.models = configuredModels;\n    }\n    saveOpenclawConfig(cfg);\n  };\n\n  // ── Legacy Codex-specific wrappers ──\n\n  const listCodexProfiles = () => listProfilesByProvider(\"openai-codex\");\n\n  const getCodexProfile = () => {\n    const profiles = listCodexProfiles();\n    if (profiles.length === 0) return null;\n    const preferred =\n      profiles.find((p) => p.id === CODEX_PROFILE_ID) || profiles[0];\n    return { profileId: preferred.id, ...preferred };\n  };\n\n  const hasCodexOauthProfile = () => {\n    const profile = getCodexProfile();\n    return !!(profile?.access && profile?.refresh);\n  };\n\n  const upsertCodexProfile = ({ access, refresh, expires, accountId }) => {\n    upsertProfile(CODEX_PROFILE_ID, {\n      type: \"oauth\",\n      provider: \"openai-codex\",\n      access,\n      refresh,\n      expires,\n      ...(accountId ? { accountId } : {}),\n    });\n  };\n\n  const removeCodexProfiles = () => {\n    const store = loadAuthStore();\n    let changed = false;\n    for (const [id, cred] of Object.entries(store.profiles || {})) {\n      if (cred?.provider === \"openai-codex\") {\n        delete store.profiles[id];\n        changed = true;\n      }\n    }\n    if (changed) {\n      saveAuthStore(kDefaultAgentId, store);\n      if (!canSyncOpenclawAuthReferences()) return changed;\n      let cfg = loadOpenclawConfig();\n      for (const [id, cred] of Object.entries(cfg.auth?.profiles || {})) {\n        if (cred?.provider === \"openai-codex\") {\n          cfg = removeConfigAuthReference(cfg, id);\n        }\n      }\n      saveOpenclawConfig(cfg);\n    }\n    return changed;\n  };\n\n  return {\n    listProfiles,\n    listProfilesByProvider,\n    getProfile,\n    upsertProfile,\n    removeProfile,\n    setAuthOrder,\n    syncConfigAuthReferencesForAgent,\n    upsertApiKeyProfileForEnvVar,\n    removeApiKeyProfileForEnvVar,\n    getEnvVarForApiKeyProvider,\n    listApiKeyProviders,\n    getDefaultProfileIdForApiKeyProvider,\n    getModelConfig,\n    setModelConfig,\n    getCodexProfile,\n    hasCodexOauthProfile,\n    upsertCodexProfile,\n    removeCodexProfiles,\n    loadAuthStore,\n  };\n};\n\nmodule.exports = { createAuthProfiles, getEnvVarForApiKeyProvider };\n"
  },
  {
    "path": "lib/server/chat-ws.js",
    "content": "const { readOpenclawConfig } = require(\"./openclaw-config\");\n\nconst kWsOpen = 1;\nconst kHistoryLimit = 200;\nconst kEnvRefPattern = /^\\$\\{([A-Z0-9_]+)\\}$/i;\nconst kConnectTimeoutMs = 8000;\nconst kHistoryTimeoutMs = 12000;\nconst kGatewayReqTimeoutMs = 15000;\nconst kGatewayProtocolVersion = 3;\n// Gateway method auth (see OpenClaw method-scopes): chat.history needs operator.read;\n// chat.send / chat.abort need operator.write. Align with CLI_DEFAULT_OPERATOR_SCOPES plus admin.\nconst kGatewayChatBridgeScopes = [\n  \"operator.admin\",\n  \"operator.read\",\n  \"operator.write\",\n  \"operator.approvals\",\n  \"operator.pairing\",\n];\n\nconst collectHistoryTextFragments = (value) => {\n  if (typeof value === \"string\") {\n    return value.length > 0 ? [value] : [];\n  }\n  if (Array.isArray(value)) {\n    return value.flatMap((entry) => collectHistoryTextFragments(entry));\n  }\n  if (!value || typeof value !== \"object\") return [];\n\n  if (typeof value.type === \"string\") {\n    const partType = String(value.type || \"\").toLowerCase();\n    if (partType === \"text\") {\n      return collectHistoryTextFragments(value.text);\n    }\n    if (\n      partType === \"thinking\" ||\n      partType === \"toolcall\" ||\n      partType === \"tool_call\" ||\n      partType === \"toolresult\" ||\n      partType === \"tool_result\"\n    ) {\n      return [];\n    }\n  }\n\n  const textFields = [\n    value.text,\n    value.message,\n    value.content,\n    value.parts,\n    value.value,\n    value.output,\n    value.input,\n  ];\n\n  const fragments = textFields.flatMap((entry) => collectHistoryTextFragments(entry));\n\n  if (fragments.length > 0) return fragments;\n\n  // Fallback: scan object values to catch unknown transcript block shapes.\n  return Object.values(value).flatMap((entry) => collectHistoryTextFragments(entry));\n};\n\nconst normalizeHistoryContent = (rawContent) => {\n  const parts = collectHistoryTextFragments(rawContent);\n  return parts\n    .join(\"\")\n    .replace(/\\r\\n/g, \"\\n\")\n    .replace(/\\n{3,}/g, \"\\n\\n\")\n    .trim();\n};\n\nconst normalizeHistoryRole = (rawRole = \"\") => {\n  const role = String(rawRole || \"\").toLowerCase();\n  if (\n    role === \"user\" ||\n    role === \"human\" ||\n    role === \"client\" ||\n    role === \"input\" ||\n    role.includes(\"user\")\n  ) {\n    return \"user\";\n  }\n  return \"assistant\";\n};\n\nconst normalizeHistoryTimestamp = (messageRow = {}) => {\n  const numericCandidate =\n    Number(messageRow?.timestamp) || Number(messageRow?.createdAt) || 0;\n  if (numericCandidate > 0) return numericCandidate;\n  const parsedDateMs = Date.parse(\n    String(messageRow?.timestamp || messageRow?.createdAt || \"\"),\n  );\n  return Number.isFinite(parsedDateMs) && parsedDateMs > 0\n    ? parsedDateMs\n    : Date.now();\n};\n\nconst extractToolCalls = (messageRow = {}) => {\n  const contentParts = Array.isArray(messageRow?.content) ? messageRow.content : [];\n  return contentParts\n    .filter((part) => String(part?.type || \"\").toLowerCase() === \"toolcall\")\n    .map((part) => ({\n      id: String(part?.id || \"\"),\n      name: String(part?.name || \"\"),\n      arguments: part?.arguments || null,\n      partialJson: String(part?.partialJson || \"\"),\n    }))\n    .filter((toolCall) => toolCall.name || toolCall.id);\n};\n\nconst extractHistoryMetadata = (messageRow = {}) => {\n  const metadata = {};\n  const assign = (key, value) => {\n    if (value === null || value === undefined) return;\n    if (typeof value === \"string\" && !value.trim()) return;\n    metadata[key] = value;\n  };\n  assign(\"api\", messageRow?.api);\n  assign(\"provider\", messageRow?.provider);\n  assign(\"model\", messageRow?.model);\n  assign(\"stopReason\", messageRow?.stopReason);\n  assign(\"thinkingLevel\", messageRow?.thinkingLevel);\n  assign(\"senderLabel\", messageRow?.senderLabel);\n  assign(\"runId\", messageRow?.runId);\n  assign(\"inputTokens\", Number(messageRow?.inputTokens) || undefined);\n  assign(\"outputTokens\", Number(messageRow?.outputTokens) || undefined);\n  assign(\"totalTokens\", Number(messageRow?.totalTokens) || undefined);\n  assign(\n    \"cacheCreationInputTokens\",\n    Number(messageRow?.cacheCreationInputTokens) || undefined,\n  );\n  assign(\n    \"cacheReadInputTokens\",\n    Number(messageRow?.cacheReadInputTokens) || undefined,\n  );\n  return Object.keys(metadata).length > 0 ? metadata : null;\n};\n\nconst normalizePartType = (value = \"\") =>\n  String(value || \"\")\n    .toLowerCase()\n    .replaceAll(\"_\", \"\")\n    .replaceAll(\"-\", \"\");\n\nconst collectTextFromUnknownShape = (value) =>\n  normalizeHistoryContent(value?.content ?? value?.result ?? value?.text ?? value?.message);\n\nconst extractToolCallFromUnknownShape = (value) => {\n  if (!value || typeof value !== \"object\") return null;\n  if (Array.isArray(value)) {\n    for (const entry of value) {\n      const match = extractToolCallFromUnknownShape(entry);\n      if (match) return match;\n    }\n    return null;\n  }\n  const partType = normalizePartType(value?.type);\n  if (partType === \"toolcall\") {\n    const normalized = {\n      id: String(value?.id || value?.toolCallId || value?.callId || \"\"),\n      name: String(value?.name || value?.toolName || \"\"),\n      arguments: value?.arguments || value?.args || null,\n      partialJson: String(value?.partialJson || \"\"),\n    };\n    return normalized.name || normalized.id ? normalized : null;\n  }\n  const nestedCandidates = [\n    value?.part,\n    value?.delta,\n    value?.item,\n    value?.message,\n    value?.payload,\n    value?.data,\n    value?.value,\n    value?.content,\n  ];\n  for (const candidate of nestedCandidates) {\n    const match = extractToolCallFromUnknownShape(candidate);\n    if (match) return match;\n  }\n  return null;\n};\n\nconst extractToolResultFromUnknownShape = (value) => {\n  if (!value || typeof value !== \"object\") return null;\n  if (Array.isArray(value)) {\n    for (const entry of value) {\n      const match = extractToolResultFromUnknownShape(entry);\n      if (match) return match;\n    }\n    return null;\n  }\n  const partType = normalizePartType(value?.type);\n  const rawRole = normalizePartType(value?.role);\n  const looksLikeToolResult =\n    partType === \"toolresult\" ||\n    rawRole === \"toolresult\" ||\n    (String(value?.toolCallId || value?.callId || \"\").trim().length > 0 &&\n      (value?.isError !== undefined ||\n        value?.status !== undefined ||\n        value?.content !== undefined ||\n        value?.result !== undefined ||\n        value?.text !== undefined));\n  if (looksLikeToolResult) {\n    const text = collectTextFromUnknownShape(value);\n    const content =\n      Array.isArray(value?.content) && value.content.length > 0\n        ? value.content\n        : text\n          ? [{ type: \"text\", text }]\n          : [];\n    return {\n      role: \"toolResult\",\n      toolCallId: String(value?.toolCallId || value?.callId || value?.id || \"\"),\n      toolName: String(value?.toolName || value?.name || \"\"),\n      content,\n      isError:\n        value?.isError === true ||\n        String(value?.status || \"\").toLowerCase() === \"error\",\n      timestamp: normalizeHistoryTimestamp(value),\n    };\n  }\n  const nestedCandidates = [\n    value?.part,\n    value?.delta,\n    value?.item,\n    value?.message,\n    value?.payload,\n    value?.data,\n    value?.value,\n    value?.content,\n    value?.result,\n  ];\n  for (const candidate of nestedCandidates) {\n    const match = extractToolResultFromUnknownShape(candidate);\n    if (match) return match;\n  }\n  return null;\n};\n\nconst resolveRunIdFromPayload = (payload = {}) =>\n  String(\n    payload?.runId ||\n      payload?.run?.id ||\n      payload?.data?.runId ||\n      payload?.data?.run?.id ||\n      payload?.meta?.runId ||\n      \"\",\n  ).trim();\n\nconst resolveSessionKeyFromPayload = (payload = {}) =>\n  String(\n    payload?.sessionKey ||\n      payload?.session?.key ||\n      payload?.data?.sessionKey ||\n      payload?.data?.session?.key ||\n      payload?.meta?.sessionKey ||\n      \"\",\n  ).trim();\n\nconst sanitizeError = (error) => {\n  const message = error instanceof Error ? error.message : String(error || \"\");\n  const lower = message.toLowerCase();\n  console.error(`[alphaclaw] chat websocket handler error: ${message}`);\n  if (lower.includes(\"not connected\")) {\n    return \"Agent runtime is not connected right now.\";\n  }\n  if (\n    lower.includes(\"gateway is not connected\") ||\n    lower.includes(\"econnrefused\") ||\n    lower.includes(\"connect failed\")\n  ) {\n    return \"Could not connect to the OpenClaw gateway. Check that the gateway is running and reachable.\";\n  }\n  if (lower.includes(\"timed out\") || lower.includes(\"timeout\")) {\n    return \"The gateway did not respond in time. Try again after the gateway finishes starting.\";\n  }\n  if (\n    lower.includes(\"auth\") ||\n    lower.includes(\"token\") ||\n    lower.includes(\"unauthorized\") ||\n    lower.includes(\"forbidden\")\n  ) {\n    return \"Gateway authentication failed. Verify OPENCLAW_GATEWAY_TOKEN matches the gateway.\";\n  }\n  if (lower.includes(\"method not found\") || lower.includes(\"unknown method\")) {\n    return \"This gateway build does not support chat APIs. Update OpenClaw.\";\n  }\n  if (lower.includes(\"gateway request failed\")) {\n    return \"The gateway could not start this chat run. Check gateway logs.\";\n  }\n  return \"Something went wrong. Please try again.\";\n};\n\nconst resolveTokenValue = (candidate = \"\") => {\n  const normalizedCandidate = String(candidate || \"\").trim();\n  if (!normalizedCandidate) return \"\";\n  const envMatch = normalizedCandidate.match(kEnvRefPattern);\n  if (!envMatch) return normalizedCandidate;\n  const envKey = String(envMatch[1] || \"\").trim();\n  if (!envKey) return \"\";\n  return String(process.env[envKey] || \"\").trim();\n};\n\nconst withTimeout = async (promise, timeoutMs, label) => {\n  let timeoutId = null;\n  try {\n    return await Promise.race([\n      promise,\n      new Promise((_, reject) => {\n        timeoutId = setTimeout(() => {\n          reject(new Error(`${label} timed out after ${timeoutMs}ms`));\n        }, timeoutMs);\n      }),\n    ]);\n  } finally {\n    if (timeoutId) clearTimeout(timeoutId);\n  }\n};\n\nconst createChatWsService = ({\n  fs,\n  openclawDir = \"\",\n  getGatewayPort = () => 18789,\n}) => {\n  let WebSocketServer = null;\n  let GatewayWebSocket = null;\n  try {\n    const wsModule = require(\"ws\");\n    ({ WebSocketServer } = wsModule);\n    GatewayWebSocket = wsModule.WebSocket || wsModule;\n  } catch (err) {\n    console.warn(\n      `[alphaclaw] chat websocket disabled: missing ws dependency (${err.message})`,\n    );\n    return {\n      handleUpgrade: (request, socket) => {\n        socket.write(\n          \"HTTP/1.1 503 Service Unavailable\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\nChat websocket unavailable\",\n        );\n        socket.destroy();\n      },\n      fetchHistory: async () => {\n        throw new Error(\"Chat websocket unavailable\");\n      },\n    };\n  }\n\n  const wss = new WebSocketServer({\n    noServer: true,\n    maxPayload: 1 * 1024 * 1024,\n  });\n  let gatewaySocket = null;\n  let gatewayConnectPromise = null;\n  const pendingGatewayRequests = new Map();\n  const runTargets = new Map();\n  /** While `chat.send` is in flight, agent events can arrive before we have `runId` + runTargets — match by session. */\n  const pendingSessionBySessionKey = new Map();\n  /** Agent events keyed by runId when the event references a run not yet in runTargets (race with chat.send response). */\n  const pendingAgentEventsByRunId = new Map();\n  const browserRuns = new WeakMap();\n\n  const sendJson = (ws, payload = {}) => {\n    if (!ws || ws.readyState !== kWsOpen) return;\n    ws.send(JSON.stringify(payload));\n  };\n\n  const getGatewayToken = () => {\n    const config = readOpenclawConfig({\n      fsModule: fs,\n      openclawDir,\n      fallback: {},\n    });\n    const envToken = String(process.env.OPENCLAW_GATEWAY_TOKEN || \"\").trim();\n    if (envToken) return envToken;\n    return resolveTokenValue(config?.gateway?.auth?.token);\n  };\n\n  const registerRunForBrowser = (ws, runId) => {\n    const existingRuns = browserRuns.get(ws);\n    if (existingRuns) {\n      existingRuns.add(runId);\n      return;\n    }\n    browserRuns.set(ws, new Set([runId]));\n  };\n\n  const clearRunTargetsForBrowser = (ws) => {\n    for (const [sk, pending] of pendingSessionBySessionKey.entries()) {\n      if (pending.ws === ws) pendingSessionBySessionKey.delete(sk);\n    }\n    const runs = browserRuns.get(ws);\n    if (!runs) return;\n    for (const runId of runs) runTargets.delete(runId);\n    runs.clear();\n    browserRuns.delete(ws);\n  };\n\n  const settleGatewayRequest = (id, payload) => {\n    const pending = pendingGatewayRequests.get(id);\n    if (!pending) return;\n    pendingGatewayRequests.delete(id);\n    if (payload?.ok) {\n      pending.resolve(payload.payload || null);\n      return;\n    }\n    pending.reject(\n      new Error(\n        payload?.error?.message ||\n          payload?.error?.code ||\n          \"Gateway request failed\",\n      ),\n    );\n  };\n\n  const rejectAllGatewayRequests = (reason = \"Gateway disconnected\") => {\n    for (const [id, pending] of pendingGatewayRequests.entries()) {\n      pendingGatewayRequests.delete(id);\n      pending.reject(new Error(reason));\n    }\n  };\n\n  const markGatewayDisconnected = (reason = \"Gateway disconnected\") => {\n    gatewaySocket = null;\n    gatewayConnectPromise = null;\n    pendingAgentEventsByRunId.clear();\n    rejectAllGatewayRequests(reason);\n  };\n\n  const handleGatewayEvent = (eventPayload = {}) => {\n    const eventName = String(eventPayload.event || \"\");\n    const payload = eventPayload.payload || {};\n    const resolveTargetForPayload = () => {\n      const runId = resolveRunIdFromPayload(payload);\n      if (runId) {\n        const runTarget = runTargets.get(runId);\n        if (runTarget) return { runId, target: runTarget };\n      }\n      const sessionKey = resolveSessionKeyFromPayload(payload);\n      if (sessionKey) {\n        let sessionTarget = null;\n        for (const [, targetRow] of runTargets.entries()) {\n          if (String(targetRow?.sessionKey || \"\") !== sessionKey) continue;\n          sessionTarget = targetRow;\n        }\n        if (sessionTarget) return { runId: \"\", target: sessionTarget };\n        const pending = pendingSessionBySessionKey.get(sessionKey);\n        if (pending) {\n          return {\n            runId: resolveRunIdFromPayload(payload),\n            target: {\n              ws: pending.ws,\n              messageId: pending.messageId,\n              sessionKey,\n            },\n          };\n        }\n      }\n      if (runTargets.size === 1) {\n        for (const [singleRunId, singleTarget] of runTargets.entries()) {\n          return { runId: String(singleRunId || \"\"), target: singleTarget };\n        }\n      }\n      return { runId: \"\", target: null };\n    };\n    if (eventName === \"agent\") {\n      const { runId, target } = resolveTargetForPayload();\n      if (!target) {\n        const runIdEarly = resolveRunIdFromPayload(payload);\n        if (runIdEarly && !runTargets.get(runIdEarly)) {\n          const list = pendingAgentEventsByRunId.get(runIdEarly) || [];\n          list.push(eventPayload);\n          pendingAgentEventsByRunId.set(runIdEarly, list);\n        }\n        return;\n      }\n      const stream = String(payload?.stream || \"\");\n      const data = payload?.data || {};\n      if (stream === \"tool\") {\n        const toolPhase = String(data?.phase || \"\");\n        const toolName = String(data?.name || \"unknown\");\n        const toolCallId = String(data?.toolCallId || \"\");\n        if (toolPhase === \"start\") {\n          sendJson(target.ws, {\n            type: \"tool\",\n            phase: \"call\",\n            messageId: target.messageId,\n            sessionKey: target.sessionKey,\n            timestamp: Number(payload?.ts) || Date.now(),\n            toolCall: {\n              id: toolCallId,\n              name: toolName,\n              arguments: data?.args || null,\n              partialJson: \"\",\n            },\n            toolResult: null,\n            rawEvent: eventPayload || null,\n          });\n        } else if (toolPhase === \"result\") {\n          const resultText = collectTextFromUnknownShape(data?.result);\n          sendJson(target.ws, {\n            type: \"tool\",\n            phase: \"result\",\n            messageId: target.messageId,\n            sessionKey: target.sessionKey,\n            timestamp: Number(payload?.ts) || Date.now(),\n            toolCall: null,\n            toolResult: {\n              role: \"toolResult\",\n              toolCallId,\n              toolName,\n              content: resultText\n                ? [{ type: \"text\", text: resultText }]\n                : [],\n              isError: data?.isError === true,\n            },\n            rawEvent: eventPayload || null,\n          });\n        }\n        return;\n      }\n      const toolCall =\n        extractToolCallFromUnknownShape(payload) ||\n        extractToolCallFromUnknownShape(data);\n      if (toolCall) {\n        sendJson(target.ws, {\n          type: \"tool\",\n          phase: \"call\",\n          messageId: target.messageId,\n          sessionKey: target.sessionKey,\n          timestamp: Date.now(),\n          toolCall,\n          toolResult: null,\n          rawEvent: eventPayload || null,\n        });\n      }\n      const toolResult =\n        extractToolResultFromUnknownShape(payload) ||\n        extractToolResultFromUnknownShape(data);\n      if (toolResult) {\n        sendJson(target.ws, {\n          type: \"tool\",\n          phase: \"result\",\n          messageId: target.messageId,\n          sessionKey: target.sessionKey,\n          timestamp: Number(toolResult?.timestamp) || Date.now(),\n          toolCall: null,\n          toolResult,\n          rawEvent: eventPayload || null,\n        });\n      }\n      if (stream === \"assistant\") {\n        const rawDelta =\n          data?.delta == null || data?.delta === \"\"\n            ? data?.text\n            : data?.delta;\n        const delta = String(rawDelta || \"\");\n        if (!delta) return;\n        sendJson(target.ws, {\n          type: \"chunk\",\n          messageId: target.messageId,\n          content: delta,\n          sessionKey: target.sessionKey,\n        });\n        return;\n      }\n      if (stream === \"lifecycle\" && String(data?.phase || \"\") === \"end\") {\n        sendJson(target.ws, {\n          type: \"done\",\n          messageId: target.messageId,\n          sessionKey: target.sessionKey,\n        });\n        if (runId) {\n          runTargets.delete(runId);\n        } else {\n          for (const [candidateRunId, candidateTarget] of runTargets.entries()) {\n            if (candidateTarget !== target) continue;\n            runTargets.delete(candidateRunId);\n            break;\n          }\n        }\n        const runs = browserRuns.get(target.ws);\n        if (runs && runId) runs.delete(runId);\n      }\n      return;\n    }\n    if (eventName === \"chat\") {\n      const { runId, target } = resolveTargetForPayload();\n      if (!target) return;\n      if (String(payload?.state || \"\") === \"error\") {\n        sendJson(target.ws, {\n          type: \"error\",\n          message: \"Something went wrong connecting to the agent.\",\n          messageId: target.messageId,\n          sessionKey: target.sessionKey,\n        });\n        if (runId) {\n          runTargets.delete(runId);\n        } else {\n          for (const [candidateRunId, candidateTarget] of runTargets.entries()) {\n            if (candidateTarget !== target) continue;\n            runTargets.delete(candidateRunId);\n            break;\n          }\n        }\n        const runs = browserRuns.get(target.ws);\n        if (runs && runId) runs.delete(runId);\n      }\n    }\n  };\n\n  const ensureGatewayConnected = async () => {\n    if (gatewaySocket && gatewaySocket.readyState === kWsOpen) return gatewaySocket;\n    if (!gatewayConnectPromise) {\n      gatewayConnectPromise = withTimeout(\n        new Promise((resolve, reject) => {\n          const socket = new GatewayWebSocket(`ws://127.0.0.1:${getGatewayPort()}`);\n          const connectRequestId = crypto.randomUUID();\n          const connectParams = {\n            minProtocol: kGatewayProtocolVersion,\n            maxProtocol: kGatewayProtocolVersion,\n            client: {\n              id: \"gateway-client\",\n              version: \"0.1.0\",\n              platform: process.platform,\n              mode: \"backend\",\n            },\n            role: \"operator\",\n            scopes: kGatewayChatBridgeScopes,\n            caps: [\"tool-events\"],\n            commands: [],\n            permissions: {},\n            auth: { token: getGatewayToken() },\n            locale: \"en-US\",\n            userAgent: \"alphaclaw-chat-bridge/0.1.0\",\n          };\n\n          socket.on(\"message\", (rawData) => {\n            let payload = null;\n            try {\n              payload = JSON.parse(String(rawData || \"\"));\n            } catch {\n              return;\n            }\n            if (!payload || typeof payload !== \"object\") return;\n            if (\n              payload.type === \"event\" &&\n              String(payload.event || \"\") === \"connect.challenge\"\n            ) {\n              socket.send(\n                JSON.stringify({\n                  type: \"req\",\n                  id: connectRequestId,\n                  method: \"connect\",\n                  params: connectParams,\n                }),\n              );\n              return;\n            }\n            if (payload.type === \"res\") {\n              if (String(payload.id || \"\") === connectRequestId) {\n                if (payload.ok && payload?.payload?.type === \"hello-ok\") {\n                  gatewaySocket = socket;\n                  resolve(socket);\n                  return;\n                }\n                reject(\n                  new Error(\n                    payload?.error?.message ||\n                      payload?.error?.code ||\n                      \"OpenClaw gateway connect failed\",\n                  ),\n                );\n                try {\n                  socket.close();\n                } catch {}\n                return;\n              }\n              settleGatewayRequest(String(payload.id || \"\"), payload);\n              return;\n            }\n            if (payload.type === \"event\") {\n              handleGatewayEvent(payload);\n            }\n          });\n\n          socket.on(\"error\", (err) => {\n            const message = err instanceof Error ? err.message : String(err || \"\");\n            reject(new Error(message || \"OpenClaw gateway websocket failed\"));\n            markGatewayDisconnected(\"OpenClaw gateway websocket failed\");\n          });\n\n          socket.on(\"close\", (code) => {\n            markGatewayDisconnected(`Gateway disconnected (code ${code})`);\n          });\n        }),\n        kConnectTimeoutMs,\n        \"OpenClaw client connect\",\n      )\n        .finally(() => {\n          gatewayConnectPromise = null;\n        });\n    }\n    return gatewayConnectPromise;\n  };\n\n  const requestGateway = async (\n    method = \"\",\n    params = {},\n    timeoutMs = kGatewayReqTimeoutMs,\n  ) => {\n    const socket = await ensureGatewayConnected();\n    if (!socket || socket.readyState !== kWsOpen) {\n      throw new Error(\"OpenClaw gateway is not connected\");\n    }\n    const requestId = crypto.randomUUID();\n    const responsePromise = new Promise((resolve, reject) => {\n      pendingGatewayRequests.set(requestId, { resolve, reject });\n    });\n    socket.send(\n      JSON.stringify({\n        type: \"req\",\n        id: requestId,\n        method,\n        params,\n      }),\n    );\n    return withTimeout(responsePromise, timeoutMs, `OpenClaw ${method} request`).finally(\n      () => {\n        pendingGatewayRequests.delete(requestId);\n      },\n    );\n  };\n\n  const handleHistory = async ({ ws, payload }) => {\n    const sessionKey = String(payload?.sessionKey || \"\").trim();\n    if (!sessionKey) {\n      sendJson(ws, { type: \"history\", messages: [] });\n      return;\n    }\n    const { messages, rawHistory } = await fetchHistory(sessionKey);\n    sendJson(ws, {\n      type: \"history\",\n      sessionKey,\n      messages,\n      rawHistory,\n    });\n  };\n\n  const handleMessage = async ({ ws, payload }) => {\n    const sessionKey = String(payload?.sessionKey || \"\").trim();\n    const content = String(payload?.content || \"\").trim();\n    const messageId = crypto.randomUUID();\n    if (!sessionKey || !content) {\n      sendJson(ws, {\n        type: \"error\",\n        message: \"sessionKey and content are required\",\n        messageId,\n      });\n      return;\n    }\n    pendingSessionBySessionKey.set(sessionKey, { ws, messageId });\n    let result;\n    try {\n      result = await requestGateway(\"chat.send\", {\n        sessionKey,\n        message: content,\n        idempotencyKey: crypto.randomUUID(),\n      });\n    } catch (err) {\n      pendingSessionBySessionKey.delete(sessionKey);\n      throw err;\n    }\n    const runId = String(result?.runId || \"\").trim();\n    if (!runId) {\n      pendingSessionBySessionKey.delete(sessionKey);\n      sendJson(ws, {\n        type: \"error\",\n        message: \"Something went wrong connecting to the agent.\",\n        messageId,\n        sessionKey,\n      });\n      return;\n    }\n    runTargets.set(runId, { ws, messageId, sessionKey });\n    registerRunForBrowser(ws, runId);\n    pendingSessionBySessionKey.delete(sessionKey);\n    sendJson(ws, {\n      type: \"started\",\n      sessionKey,\n      runId,\n      messageId,\n    });\n    const buffered = pendingAgentEventsByRunId.get(runId);\n    if (buffered && buffered.length) {\n      pendingAgentEventsByRunId.delete(runId);\n      for (const stored of buffered) {\n        handleGatewayEvent(stored);\n      }\n    }\n  };\n\n  const handleStop = async ({ ws, payload }) => {\n    const sessionKey = String(payload?.sessionKey || \"\").trim();\n    if (!sessionKey) {\n      sendJson(ws, {\n        type: \"error\",\n        message: \"sessionKey is required\",\n      });\n      return;\n    }\n    const runs = browserRuns.get(ws);\n    if (runs) {\n      for (const runId of Array.from(runs)) {\n        const target = runTargets.get(runId);\n        if (!target || String(target.sessionKey || \"\") !== sessionKey) continue;\n        runTargets.delete(runId);\n        runs.delete(runId);\n      }\n    }\n    await requestGateway(\"chat.abort\", { sessionKey });\n    sendJson(ws, {\n      type: \"done\",\n      sessionKey,\n      stopped: true,\n    });\n  };\n\n  wss.on(\"connection\", (ws) => {\n    ws.on(\"close\", () => {\n      clearRunTargetsForBrowser(ws);\n    });\n    ws.on(\"message\", (rawData) => {\n      let payload = null;\n      try {\n        payload = JSON.parse(String(rawData || \"\"));\n      } catch {\n        return;\n      }\n      if (!payload || typeof payload !== \"object\") return;\n      const type = String(payload.type || \"\");\n      const run = async () => {\n        if (type === \"history\") {\n          await handleHistory({ ws, payload });\n          return;\n        }\n        if (type === \"message\") {\n          await handleMessage({ ws, payload });\n          return;\n        }\n        if (type === \"stop\") {\n          await handleStop({ ws, payload });\n        }\n      };\n      run().catch((err) => {\n        const sessionKey = String(payload?.sessionKey || \"\").trim();\n        sendJson(ws, {\n          type: \"error\",\n          message: sanitizeError(err),\n          ...(sessionKey ? { sessionKey } : {}),\n          messageId: crypto.randomUUID(),\n        });\n      });\n    });\n  });\n\n  const fetchHistory = async (sessionKey = \"\") => {\n    const normalizedSessionKey = String(sessionKey || \"\").trim();\n    if (!normalizedSessionKey) {\n      return { messages: [], rawHistory: null };\n    }\n    const history = await requestGateway(\n      \"chat.history\",\n      {\n        sessionKey: normalizedSessionKey,\n        limit: kHistoryLimit,\n      },\n      kHistoryTimeoutMs,\n    );\n    const rawMessages = Array.isArray(history?.messages)\n      ? history.messages\n      : Array.isArray(history?.history)\n        ? history.history\n        : Array.isArray(history?.items)\n          ? history.items\n          : [];\n    const toolResultsByCallId = {};\n    for (const messageRow of rawMessages) {\n      if (String(messageRow?.role || \"\").toLowerCase() !== \"toolresult\") continue;\n      const toolCallId = String(messageRow?.toolCallId || \"\");\n      if (!toolCallId) continue;\n      toolResultsByCallId[toolCallId] = messageRow;\n    }\n\n    const messages = rawMessages\n      .flatMap((messageRow) => {\n        const rawRole = String(messageRow?.role || \"\").toLowerCase();\n        if (rawRole === \"toolresult\") return [];\n        let content = normalizeHistoryContent(\n          messageRow?.content ?? messageRow?.text ?? messageRow?.message,\n        );\n        const role = normalizeHistoryRole(messageRow?.role ?? messageRow?.author);\n        if (role === \"user\") {\n          content = content.replace(/^\\[.*?\\]\\s*/, \"\");\n        }\n        const toolCalls = extractToolCalls(messageRow);\n        const normalizedContent = String(content || \"\").trim();\n        const timestamp = normalizeHistoryTimestamp(messageRow);\n        const metadata = extractHistoryMetadata(messageRow);\n        const basePayload = {\n          timestamp,\n          metadata,\n          rawMessage: messageRow || null,\n        };\n        const rows = [];\n        if (normalizedContent) {\n          rows.push({\n            role,\n            content: normalizedContent,\n            ...basePayload,\n            toolCalls: [],\n            toolResult: null,\n          });\n        }\n        for (const toolCall of toolCalls) {\n          const toolCallId = String(toolCall?.id || \"\");\n          rows.push({\n            role: \"tool\",\n            content: `Tool call: ${String(toolCall?.name || \"unknown\")}`,\n            ...basePayload,\n            toolCalls: [toolCall],\n            toolResult: toolCallId ? toolResultsByCallId[toolCallId] || null : null,\n          });\n        }\n        return rows;\n      })\n      .filter(\n        (messageRow) =>\n          String(messageRow.content || \"\").trim() ||\n          (Array.isArray(messageRow.toolCalls) && messageRow.toolCalls.length > 0),\n      );\n    return {\n      messages,\n      rawHistory: history || null,\n    };\n  };\n\n  return {\n    handleUpgrade: (request, socket, head) => {\n      wss.handleUpgrade(request, socket, head, (ws) => {\n        wss.emit(\"connection\", ws, request);\n      });\n    },\n    fetchHistory,\n  };\n};\n\nmodule.exports = { createChatWsService };\n"
  },
  {
    "path": "lib/server/commands.js",
    "content": "const { exec } = require(\"child_process\");\nconst { OPENCLAW_DIR, GOG_KEYRING_PASSWORD } = require(\"./constants\");\n\nconst createCommands = ({ gatewayEnv }) => {\n  const shellCmd = (cmd, opts = {}) =>\n    new Promise((resolve, reject) => {\n      const {\n        logStdout,\n        timeoutMs = 60000,\n        ...execOpts\n      } = opts;\n      const shouldLogStdout =\n        typeof logStdout === \"boolean\" ? logStdout : !cmd.includes(\"--json\");\n      console.log(\n        `[onboard] Running: ${cmd\n          .replace(/ghp_[^\\s\"]+/g, \"***\")\n          .replace(/github_pat_[^\\s\"]+/g, \"***\")\n          .replace(/sk-[^\\s\"]+/g, \"***\")\n          .slice(0, 200)}`,\n      );\n      exec(cmd, { timeout: timeoutMs, ...execOpts }, (err, stdout, stderr) => {\n        if (err) {\n          err.stdout = String(stdout || \"\").trim();\n          err.stderr = String(stderr || \"\").trim();\n          err.cmd = cmd;\n          console.error(\n            `[onboard] Error: ${String(stderr || err.message || \"\").slice(0, 300)}`,\n          );\n          return reject(err);\n        }\n        if (shouldLogStdout && stdout.trim()) {\n          console.log(`[onboard] ${stdout.trim().slice(0, 300)}`);\n        }\n        resolve(stdout.trim());\n      });\n    });\n\n  const clawCmd = (\n    cmd,\n    { quiet = false, timeoutMs = 15000, killSignal = \"SIGTERM\" } = {},\n  ) =>\n    new Promise((resolve) => {\n      if (!quiet) console.log(`[alphaclaw] Running: openclaw ${cmd}`);\n      exec(\n        `openclaw ${cmd}`,\n        {\n          env: gatewayEnv(),\n          timeout: timeoutMs,\n          killSignal,\n        },\n        (err, stdout, stderr) => {\n          const result = {\n            ok: !err,\n            stdout: stdout.trim(),\n            stderr: stderr.trim(),\n            code: err?.code,\n          };\n          if (err) {\n            result.killed = Boolean(err.killed);\n            result.signal = err.signal || null;\n            result.timedOut = Boolean(err.killed && err.signal === killSignal);\n          }\n          if (!quiet && !result.ok) {\n            console.log(`[alphaclaw] Error: ${result.stderr.slice(0, 200)}`);\n          }\n          resolve(result);\n        },\n      );\n    });\n\n  const gogCmd = (cmd, { quiet = false } = {}) =>\n    new Promise((resolve) => {\n      if (!quiet) console.log(`[alphaclaw] Running: gog ${cmd}`);\n      exec(\n        `gog ${cmd}`,\n        {\n          timeout: 15000,\n          env: {\n            ...process.env,\n            XDG_CONFIG_HOME: OPENCLAW_DIR,\n            GOG_KEYRING_PASSWORD,\n          },\n        },\n        (err, stdout, stderr) => {\n          const result = {\n            ok: !err,\n            stdout: stdout.trim(),\n            stderr: stderr.trim(),\n          };\n          if (!quiet && !result.ok) {\n            console.log(`[alphaclaw] gog error: ${result.stderr.slice(0, 200)}`);\n          }\n          resolve(result);\n        },\n      );\n    });\n\n  return { shellCmd, clawCmd, gogCmd };\n};\n\nmodule.exports = { createCommands };\n"
  },
  {
    "path": "lib/server/constants.js",
    "content": "const os = require(\"os\");\nconst path = require(\"path\");\nconst kBrowseFilePolicies = require(\"../public/shared/browse-file-policies.json\");\nconst kBootstrapModelCatalog = require(\"./model-catalog-bootstrap.json\");\nconst { parsePositiveInt } = require(\"./utils/number\");\n\n// Portable root directory: --root-dir flag sets ALPHACLAW_ROOT_DIR before require\nconst kRootDir =\n  process.env.ALPHACLAW_ROOT_DIR || path.join(os.homedir(), \".alphaclaw\");\nconst ALPHACLAW_DIR = kRootDir;\nconst kPackageRoot = path.resolve(__dirname, \"..\");\nconst kNpmPackageRoot = path.resolve(kPackageRoot, \"..\");\nconst kSetupDir = path.join(kPackageRoot, \"setup\");\n\nconst PORT = parseInt(process.env.PORT || \"3000\", 10);\nconst kDefaultGatewayPort = 18789;\nconst GATEWAY_HOST = \"127.0.0.1\";\nconst kDefaultGatewayUrl = `http://${GATEWAY_HOST}:${kDefaultGatewayPort}`;\nconst OPENCLAW_DIR = path.join(kRootDir, \".openclaw\");\nconst GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || \"\";\nconst ENV_FILE_PATH = path.join(kRootDir, \".env\");\nconst WORKSPACE_DIR = path.join(OPENCLAW_DIR, \"workspace\");\nconst kOnboardingMarkerPath = path.join(ALPHACLAW_DIR, \"onboarded.json\");\nconst AUTH_PROFILES_PATH = path.join(\n  OPENCLAW_DIR,\n  \"agents\",\n  \"main\",\n  \"agent\",\n  \"auth-profiles.json\",\n);\nconst CODEX_PROFILE_ID = \"openai-codex:codex-cli\";\nconst CODEX_OAUTH_CLIENT_ID = \"app_EMoamEEZ73f0CkXaXp7hrann\";\nconst CODEX_OAUTH_AUTHORIZE_URL = \"https://auth.openai.com/oauth/authorize\";\nconst CODEX_OAUTH_TOKEN_URL = \"https://auth.openai.com/oauth/token\";\nconst CODEX_OAUTH_REDIRECT_URI = \"http://localhost:1455/auth/callback\";\nconst CODEX_OAUTH_SCOPE = \"openid profile email offline_access\";\nconst CODEX_JWT_CLAIM_PATH = \"https://api.openai.com/auth\";\nconst kCodexOauthStateTtlMs = 10 * 60 * 1000;\n\nconst kTrustProxyHops = parsePositiveInt(process.env.TRUST_PROXY_HOPS, 1);\nconst kLoginWindowMs = parsePositiveInt(\n  process.env.LOGIN_RATE_WINDOW_MS,\n  10 * 60 * 1000,\n);\nconst kLoginMaxAttempts = parsePositiveInt(\n  process.env.LOGIN_RATE_MAX_ATTEMPTS,\n  5,\n);\nconst kLoginBaseLockMs = parsePositiveInt(\n  process.env.LOGIN_RATE_BASE_LOCK_MS,\n  60 * 1000,\n);\nconst kLoginMaxLockMs = parsePositiveInt(\n  process.env.LOGIN_RATE_MAX_LOCK_MS,\n  15 * 60 * 1000,\n);\nconst kLoginCleanupIntervalMs = parsePositiveInt(\n  process.env.LOGIN_RATE_CLEANUP_INTERVAL_MS,\n  60 * 1000,\n);\nconst kLoginStateTtlMs = Math.max(\n  parsePositiveInt(\n    process.env.LOGIN_RATE_STATE_TTL_MS,\n    Math.max(kLoginWindowMs, kLoginMaxLockMs) * 3,\n  ),\n  kLoginMaxLockMs,\n);\n\nconst kOnboardingModelProviders = new Set([\n  \"anthropic\",\n  \"openai\",\n  \"openai-codex\",\n  \"google\",\n  \"opencode\",\n  \"openrouter\",\n  \"zai\",\n  \"vercel-ai-gateway\",\n  \"kilocode\",\n  \"xai\",\n  \"mistral\",\n  \"cerebras\",\n  \"moonshot\",\n  \"kimi-coding\",\n  \"volcengine\",\n  \"volcengine-plan\",\n  \"byteplus\",\n  \"byteplus-plan\",\n  \"synthetic\",\n  \"minimax\",\n  \"voyage\",\n  \"groq\",\n  \"vllm\",\n]);\nconst kMinimalFallbackOnboardingModels = [\n  {\n    key: \"anthropic/claude-opus-4-7\",\n    provider: \"anthropic\",\n    label: \"Claude Opus 4.7\",\n  },\n  {\n    key: \"anthropic/claude-opus-4-6\",\n    provider: \"anthropic\",\n    label: \"Claude Opus 4.6\",\n  },\n  {\n    key: \"anthropic/claude-sonnet-4-6\",\n    provider: \"anthropic\",\n    label: \"Claude Sonnet 4.6\",\n  },\n  {\n    key: \"anthropic/claude-haiku-4-6\",\n    provider: \"anthropic\",\n    label: \"Claude Haiku 4.6\",\n  },\n  {\n    key: \"openai-codex/gpt-5.5\",\n    provider: \"openai-codex\",\n    label: \"GPT-5.5\",\n  },\n  {\n    key: \"openai-codex/gpt-5.3-codex\",\n    provider: \"openai-codex\",\n    label: \"Codex GPT-5.3\",\n  },\n  {\n    key: \"openai/gpt-5.1-codex\",\n    provider: \"openai\",\n    label: \"OpenAI GPT-5.1 Codex\",\n  },\n  {\n    key: \"google/gemini-3.1-pro-preview\",\n    provider: \"google\",\n    label: \"Gemini 3.1 Pro\",\n  },\n  {\n    key: \"google/gemini-3-flash-preview\",\n    provider: \"google\",\n    label: \"Gemini 3 Flash Preview\",\n  },\n];\nconst kFallbackOnboardingModels =\n  Array.isArray(kBootstrapModelCatalog.models) &&\n  kBootstrapModelCatalog.models.length > 0\n    ? kBootstrapModelCatalog.models\n    : kMinimalFallbackOnboardingModels;\n\nconst kVersionCacheTtlMs = 60 * 1000;\nconst kLatestVersionCacheTtlMs = 10 * 60 * 1000;\n/** `cp` of a full openclaw npm tree into /app/node_modules can exceed 60s on slow volumes. */\nconst kOpenclawUpdateCopyTimeoutMs = 5 * 60 * 1000;\nconst kOpenclawRegistryUrl = \"https://registry.npmjs.org/openclaw\";\nconst kAlphaclawRegistryUrl = \"https://registry.npmjs.org/@chrysb%2falphaclaw\";\nconst kAlphaclawGithubReleasesBaseUrl =\n  \"https://api.github.com/repos/chrysb/alphaclaw/releases\";\nconst kAppDir = kNpmPackageRoot;\nconst kMaxPayloadBytes = parsePositiveInt(process.env.WEBHOOK_LOG_MAX_BYTES, 50 * 1024);\nconst kWebhookPruneDays = parsePositiveInt(process.env.WEBHOOK_LOG_RETENTION_DAYS, 30);\nconst kWatchdogCheckIntervalMs =\n  parsePositiveInt(process.env.WATCHDOG_CHECK_INTERVAL, 120) * 1000;\nconst kWatchdogDegradedCheckIntervalMs =\n  parsePositiveInt(process.env.WATCHDOG_DEGRADED_CHECK_INTERVAL, 5) * 1000;\nconst kWatchdogStartupFailureThreshold = parsePositiveInt(\n  process.env.WATCHDOG_STARTUP_FAILURE_THRESHOLD,\n  3,\n);\nconst kWatchdogMaxRepairAttempts = parsePositiveInt(\n  process.env.WATCHDOG_MAX_REPAIR_ATTEMPTS,\n  2,\n);\nconst kWatchdogCrashLoopWindowMs =\n  parsePositiveInt(process.env.WATCHDOG_CRASH_LOOP_WINDOW, 300) * 1000;\nconst kWatchdogCrashLoopThreshold = parsePositiveInt(\n  process.env.WATCHDOG_CRASH_LOOP_THRESHOLD,\n  3,\n);\nconst kWatchdogLogRetentionDays = parsePositiveInt(\n  process.env.WATCHDOG_LOG_RETENTION_DAYS,\n  30,\n);\nconst kLogMaxBytes = parsePositiveInt(\n  process.env.LOG_MAX_BYTES,\n  2 * 1024 * 1024,\n);\n\nconst kSystemVars = new Set([\n  \"WEBHOOK_TOKEN\",\n  \"OPENCLAW_GATEWAY_TOKEN\",\n  \"SETUP_PASSWORD\",\n  \"PORT\",\n  \"ALPHACLAW_DEPLOYMENT_PROVIDER\",\n  \"ALPHACLAW_MANAGED_UPDATE_URL\",\n  \"ALPHACLAW_MANAGED_UPDATE_TOKEN\",\n  \"ALPHACLAW_TEMPLATE_REPO_URL\",\n  \"ALPHACLAW_TEMPLATE_BRANCH\",\n  \"WATCHDOG_AUTO_REPAIR\",\n  \"WATCHDOG_NOTIFICATIONS_DISABLED\",\n]);\nconst kKnownVars = [\n  {\n    key: \"ANTHROPIC_API_KEY\",\n    label: \"Anthropic API Key\",\n    group: \"ai\",\n    hint: \"From console.anthropic.com\",\n    features: [\"Models\"],\n  },\n  {\n    key: \"ANTHROPIC_TOKEN\",\n    label: \"Anthropic Setup Token\",\n    group: \"ai\",\n    hint: \"From claude setup-token\",\n    features: [\"Models\"],\n    visibleInEnvars: false,\n  },\n  {\n    key: \"OPENAI_API_KEY\",\n    label: \"OpenAI API Key\",\n    group: \"ai\",\n    hint: \"From platform.openai.com\",\n    features: [\"Models\", \"Embeddings\", \"TTS\", \"STT\"],\n  },\n  {\n    key: \"GEMINI_API_KEY\",\n    label: \"Gemini API Key\",\n    group: \"ai\",\n    hint: \"From aistudio.google.com\",\n    features: [\"Models\", \"Embeddings\", \"Image\", \"STT\"],\n  },\n  {\n    key: \"ELEVENLABS_API_KEY\",\n    label: \"ElevenLabs API Key\",\n    group: \"ai\",\n    hint: \"From elevenlabs.io (XI_API_KEY also works)\",\n    features: [\"TTS\"],\n  },\n  {\n    key: \"GITHUB_TOKEN\",\n    label: \"GitHub Access Token\",\n    group: \"github\",\n  },\n  {\n    key: \"GITHUB_WORKSPACE_REPO\",\n    label: \"Workspace Repo\",\n    group: \"github\",\n    hint: \"username/repo or https://github.com/username/repo\",\n  },\n  {\n    key: \"TELEGRAM_BOT_TOKEN\",\n    label: \"Telegram Bot Token\",\n    group: \"channels\",\n    hint: \"From @BotFather\",\n  },\n  {\n    key: \"DISCORD_BOT_TOKEN\",\n    label: \"Discord Bot Token\",\n    group: \"channels\",\n    hint: \"From Discord Developer Portal\",\n  },\n  {\n    key: \"SLACK_BOT_TOKEN\",\n    label: \"Slack Bot Token\",\n    group: \"channels\",\n    hint: \"From your Slack app's OAuth & Permissions page (xoxb-...)\",\n  },\n  {\n    key: \"SLACK_APP_TOKEN\",\n    label: \"Slack App Token\",\n    group: \"channels\",\n    hint: \"From Basic Information → App-Level Tokens (xapp-...)\",\n  },\n  {\n    key: \"WHATSAPP_OWNER_NUMBER\",\n    label: \"WhatsApp Owner Number\",\n    group: \"channels\",\n    hint: \"E.164 number, e.g. +15551234567\",\n  },\n  {\n    key: \"MISTRAL_API_KEY\",\n    label: \"Mistral API Key\",\n    group: \"ai\",\n    hint: \"From console.mistral.ai\",\n    features: [\"Models\", \"Embeddings\", \"STT\"],\n  },\n  {\n    key: \"VOYAGE_API_KEY\",\n    label: \"Voyage API Key\",\n    group: \"ai\",\n    hint: \"From dash.voyageai.com\",\n    features: [\"Embeddings\"],\n  },\n  {\n    key: \"GROQ_API_KEY\",\n    label: \"Groq API Key\",\n    group: \"ai\",\n    hint: \"From console.groq.com\",\n    features: [\"Models\", \"STT\"],\n  },\n  {\n    key: \"DEEPGRAM_API_KEY\",\n    label: \"Deepgram API Key\",\n    group: \"ai\",\n    hint: \"From console.deepgram.com\",\n    features: [\"STT\"],\n  },\n  {\n    key: \"BRAVE_API_KEY\",\n    label: \"Brave Search API Key\",\n    group: \"tools\",\n    hint: \"From brave.com/search/api\",\n  },\n];\nconst kKnownKeys = new Set(kKnownVars.map((v) => v.key));\n\nconst SCOPE_MAP = {\n  \"gmail:read\": \"https://www.googleapis.com/auth/gmail.readonly\",\n  \"gmail:write\": \"https://www.googleapis.com/auth/gmail.modify\",\n  \"calendar:read\": \"https://www.googleapis.com/auth/calendar.readonly\",\n  \"calendar:write\": \"https://www.googleapis.com/auth/calendar\",\n  \"tasks:read\": \"https://www.googleapis.com/auth/tasks.readonly\",\n  \"tasks:write\": \"https://www.googleapis.com/auth/tasks\",\n  \"docs:read\": \"https://www.googleapis.com/auth/documents.readonly\",\n  \"docs:write\": \"https://www.googleapis.com/auth/documents\",\n  \"meet:read\": \"https://www.googleapis.com/auth/meetings.space.readonly\",\n  \"meet:write\": \"https://www.googleapis.com/auth/meetings.space.created\",\n  \"drive:read\": \"https://www.googleapis.com/auth/drive.readonly\",\n  \"drive:write\": \"https://www.googleapis.com/auth/drive\",\n  \"contacts:read\": \"https://www.googleapis.com/auth/contacts.readonly\",\n  \"contacts:write\": \"https://www.googleapis.com/auth/contacts\",\n  \"sheets:read\": \"https://www.googleapis.com/auth/spreadsheets.readonly\",\n  \"sheets:write\": \"https://www.googleapis.com/auth/spreadsheets\",\n};\nconst REVERSE_SCOPE_MAP = Object.fromEntries(\n  Object.entries(SCOPE_MAP).map(([k, v]) => [v, k]),\n);\nconst BASE_SCOPES = [\n  \"openid\",\n  \"https://www.googleapis.com/auth/userinfo.email\",\n];\n\nconst GOG_CONFIG_DIR = path.join(OPENCLAW_DIR, \"gogcli\");\nconst GOG_CREDENTIALS_PATH = path.join(GOG_CONFIG_DIR, \"credentials.json\");\nconst GOG_STATE_PATH = path.join(GOG_CONFIG_DIR, \"state.json\");\nconst GOG_KEYRING_PASSWORD = process.env.GOG_KEYRING_PASSWORD || \"alphaclaw\";\nconst kMaxGoogleAccounts = 5;\nconst kGmailServeBasePort = parsePositiveInt(\n  process.env.GMAIL_SERVE_BASE_PORT,\n  18801,\n);\nconst kGmailWatchRenewalIntervalMs =\n  parsePositiveInt(process.env.GMAIL_WATCH_RENEWAL_INTERVAL_SECONDS, 6 * 60 * 60) *\n  1000;\nconst kGmailWatchRenewalThresholdMs =\n  parsePositiveInt(process.env.GMAIL_WATCH_RENEWAL_THRESHOLD_SECONDS, 24 * 60 * 60) *\n  1000;\nconst kGmailMaxBodyBytes = parsePositiveInt(\n  process.env.GMAIL_WATCH_MAX_BODY_BYTES,\n  20000,\n);\nconst gogClientCredentialsPath = (clientName = \"default\") =>\n  clientName === \"default\"\n    ? GOG_CREDENTIALS_PATH\n    : path.join(GOG_CONFIG_DIR, `credentials-${clientName}.json`);\n\nconst API_TEST_COMMANDS = {\n  gmail: \"gmail labels list\",\n  calendar: \"calendar calendars\",\n  tasks: \"tasks lists\",\n  docs: \"docs info __api_check__\",\n  meet: \"meet spaces list\",\n  drive: \"drive ls\",\n  contacts: \"contacts list\",\n  sheets: \"sheets metadata __api_check__\",\n};\n\nconst kChannelDefs = {\n  telegram: { envKey: \"TELEGRAM_BOT_TOKEN\" },\n  discord: { envKey: \"DISCORD_BOT_TOKEN\" },\n  slack: { envKey: \"SLACK_BOT_TOKEN\", extraEnvKeys: [\"SLACK_APP_TOKEN\"] },\n  whatsapp: { envKey: \"WHATSAPP_OWNER_NUMBER\", sync: false },\n};\nconst kProtectedBrowsePaths = new Set(\n  Array.isArray(kBrowseFilePolicies?.protectedPaths)\n    ? kBrowseFilePolicies.protectedPaths\n    : [],\n);\nconst kLockedBrowsePaths = new Set(\n  Array.isArray(kBrowseFilePolicies?.lockedPaths)\n    ? kBrowseFilePolicies.lockedPaths\n    : [],\n);\n\nconst SETUP_API_PREFIXES = [\n  \"/api/status\",\n  \"/api/pairings\",\n  \"/api/google\",\n  \"/api/codex\",\n  \"/api/models\",\n  \"/api/browse\",\n  \"/api/chat\",\n  \"/api/gateway\",\n  \"/api/restart-status\",\n  \"/api/onboard\",\n  \"/api/env\",\n  \"/api/auth\",\n  \"/api/openclaw\",\n  \"/api/devices\",\n  \"/api/sync-cron\",\n  \"/api/telegram\",\n  \"/api/webhooks\",\n  \"/api/gmail\",\n  \"/api/watchdog\",\n  \"/api/usage\",\n  \"/api/cron\",\n  \"/api/agents\",\n  \"/api/channels\",\n  \"/api/operations\",\n  \"/api/nodes\",\n];\n\nmodule.exports = {\n  ALPHACLAW_DIR,\n  kRootDir,\n  kPackageRoot,\n  kNpmPackageRoot,\n  kSetupDir,\n  PORT,\n  kDefaultGatewayPort,\n  GATEWAY_HOST,\n  kDefaultGatewayUrl,\n  OPENCLAW_DIR,\n  GATEWAY_TOKEN,\n  ENV_FILE_PATH,\n  WORKSPACE_DIR,\n  kOnboardingMarkerPath,\n  AUTH_PROFILES_PATH,\n  CODEX_PROFILE_ID,\n  CODEX_OAUTH_CLIENT_ID,\n  CODEX_OAUTH_AUTHORIZE_URL,\n  CODEX_OAUTH_TOKEN_URL,\n  CODEX_OAUTH_REDIRECT_URI,\n  CODEX_OAUTH_SCOPE,\n  CODEX_JWT_CLAIM_PATH,\n  kCodexOauthStateTtlMs,\n  kTrustProxyHops,\n  kLoginWindowMs,\n  kLoginMaxAttempts,\n  kLoginBaseLockMs,\n  kLoginMaxLockMs,\n  kLoginCleanupIntervalMs,\n  kLoginStateTtlMs,\n  kOnboardingModelProviders,\n  kFallbackOnboardingModels,\n  kVersionCacheTtlMs,\n  kLatestVersionCacheTtlMs,\n  kOpenclawUpdateCopyTimeoutMs,\n  kOpenclawRegistryUrl,\n  kAlphaclawRegistryUrl,\n  kAlphaclawGithubReleasesBaseUrl,\n  kAppDir,\n  kMaxPayloadBytes,\n  kWebhookPruneDays,\n  kWatchdogCheckIntervalMs,\n  kWatchdogDegradedCheckIntervalMs,\n  kWatchdogStartupFailureThreshold,\n  kWatchdogMaxRepairAttempts,\n  kWatchdogCrashLoopWindowMs,\n  kWatchdogCrashLoopThreshold,\n  kWatchdogLogRetentionDays,\n  kLogMaxBytes,\n  kSystemVars,\n  kKnownVars,\n  kKnownKeys,\n  kProtectedBrowsePaths,\n  kLockedBrowsePaths,\n  SCOPE_MAP,\n  REVERSE_SCOPE_MAP,\n  BASE_SCOPES,\n  GOG_CONFIG_DIR,\n  GOG_CREDENTIALS_PATH,\n  GOG_STATE_PATH,\n  GOG_KEYRING_PASSWORD,\n  kMaxGoogleAccounts,\n  kGmailServeBasePort,\n  kGmailWatchRenewalIntervalMs,\n  kGmailWatchRenewalThresholdMs,\n  kGmailMaxBodyBytes,\n  gogClientCredentialsPath,\n  API_TEST_COMMANDS,\n  kChannelDefs,\n  SETUP_API_PREFIXES,\n};\n"
  },
  {
    "path": "lib/server/cost-utils.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst kTokensPerMillion = 1_000_000;\nconst kLongContextThresholdTokens = 200_000;\nconst kNodeModulesPricingCacheTtlMs = 60_000;\n\nconst kClaudeOpus47Pricing = {\n  input: 5.0,\n  output: 25.0,\n  cacheRead: 0.5,\n  cacheWrite: 6.25,\n};\n\nconst kGlobalModelPricing = {\n  \"claude-opus-4-7\": kClaudeOpus47Pricing,\n  \"claude-opus-4.7\": kClaudeOpus47Pricing,\n  \"claude-opus-4-6\": {\n    input: (tokens) => (tokens > kLongContextThresholdTokens ? 10.0 : 5.0),\n    output: (tokens) => (tokens > kLongContextThresholdTokens ? 37.5 : 25.0),\n  },\n  \"claude-sonnet-4-5\": {\n    input: 3.0,\n    output: 15.0,\n    cacheRead: 0.3,\n    cacheWrite: 3.75,\n  },\n  \"claude-sonnet-4.5\": {\n    input: 3.0,\n    output: 15.0,\n    cacheRead: 0.3,\n    cacheWrite: 3.75,\n  },\n  \"claude-sonnet-4-6\": {\n    input: 3.0,\n    output: 15.0,\n    cacheRead: 0.3,\n    cacheWrite: 3.75,\n  },\n  \"claude-sonnet-4.6\": {\n    input: 3.0,\n    output: 15.0,\n    cacheRead: 0.3,\n    cacheWrite: 3.75,\n  },\n  \"claude-haiku-4-6\": { input: 0.8, output: 4.0 },\n  \"gpt-5\": { input: 1.25, output: 10.0 },\n  \"gpt-5.4\": { input: 2.5, output: 10.0 },\n  \"gpt-5.1-codex\": { input: 2.5, output: 10.0 },\n  \"gpt-5.3-codex\": { input: 2.5, output: 10.0 },\n  \"gpt-4.1\": { input: 2.0, output: 8.0 },\n  \"gpt-4o\": { input: 2.5, output: 10.0 },\n  \"gpt-4o-mini\": { input: 0.15, output: 0.6 },\n  \"gemini-3.1-pro-preview\": { input: 2.0, output: 12.0 },\n  \"gemini-3-flash-preview\": { input: 0.5, output: 3.0 },\n  \"gemini-2.0-flash\": { input: 0.1, output: 0.4 },\n};\n\nconst toInt = (value, fallbackValue = 0) => {\n  const parsed = Number.parseInt(String(value ?? \"\"), 10);\n  return Number.isFinite(parsed) ? parsed : fallbackValue;\n};\n\nconst toCleanString = (value = \"\") =>\n  String(value || \"\")\n    .trim()\n    .toLowerCase();\n\nconst toFiniteRate = (value, fallbackValue = 0) => {\n  const parsed = Number(value);\n  return Number.isFinite(parsed) ? parsed : fallbackValue;\n};\n\nconst parseCostObjectText = (costObjectText = \"\") => {\n  const inputMatch = costObjectText.match(/input:\\s*([0-9.]+)/);\n  const outputMatch = costObjectText.match(/output:\\s*([0-9.]+)/);\n  const cacheReadMatch = costObjectText.match(/cacheRead:\\s*([0-9.]+)/);\n  const cacheWriteMatch = costObjectText.match(/cacheWrite:\\s*([0-9.]+)/);\n  if (!inputMatch || !outputMatch) return null;\n  return {\n    input: toFiniteRate(inputMatch[1]),\n    output: toFiniteRate(outputMatch[1]),\n    cacheRead: toFiniteRate(cacheReadMatch?.[1], 0),\n    cacheWrite: toFiniteRate(cacheWriteMatch?.[1], 0),\n  };\n};\n\nconst setPricingCandidates = (\n  pricingByModelKey,\n  modelKey = \"\",\n  pricing = null,\n) => {\n  const normalizedModelKey = toCleanString(modelKey);\n  if (!normalizedModelKey || !pricing) return;\n  pricingByModelKey.set(normalizedModelKey, pricing);\n  const claudeDashVariant = normalizedModelKey.replace(\n    /(claude-(?:opus|sonnet)-\\d+)\\.(\\d+)/g,\n    \"$1-$2\",\n  );\n  const claudeDotVariant = normalizedModelKey.replace(\n    /(claude-(?:opus|sonnet)-\\d+)-(\\d+)/g,\n    \"$1.$2\",\n  );\n  if (claudeDashVariant !== normalizedModelKey) {\n    pricingByModelKey.set(claudeDashVariant, pricing);\n  }\n  if (claudeDotVariant !== normalizedModelKey) {\n    pricingByModelKey.set(claudeDotVariant, pricing);\n  }\n  const modelId = normalizedModelKey.split(\"/\").filter(Boolean).pop();\n  if (modelId && !pricingByModelKey.has(modelId)) {\n    pricingByModelKey.set(modelId, pricing);\n  }\n  const modelIdClaudeDashVariant = String(modelId || \"\").replace(\n    /(claude-(?:opus|sonnet)-\\d+)\\.(\\d+)/g,\n    \"$1-$2\",\n  );\n  const modelIdClaudeDotVariant = String(modelId || \"\").replace(\n    /(claude-(?:opus|sonnet)-\\d+)-(\\d+)/g,\n    \"$1.$2\",\n  );\n  if (modelIdClaudeDashVariant && !pricingByModelKey.has(modelIdClaudeDashVariant)) {\n    pricingByModelKey.set(modelIdClaudeDashVariant, pricing);\n  }\n  if (modelIdClaudeDotVariant && !pricingByModelKey.has(modelIdClaudeDotVariant)) {\n    pricingByModelKey.set(modelIdClaudeDotVariant, pricing);\n  }\n};\n\nconst extractPricingFromDistFile = (\n  filePath = \"\",\n  pricingByModelKey = new Map(),\n) => {\n  let sourceText = \"\";\n  try {\n    sourceText = fs.readFileSync(filePath, \"utf8\");\n  } catch {\n    return;\n  }\n\n  const directEntryPattern =\n    /id:\\s*\"([^\"]+)\"[\\s\\S]{0,260}?cost:\\s*(\\{[\\s\\S]{0,180}?\\})/g;\n  let directEntryMatch = directEntryPattern.exec(sourceText);\n  while (directEntryMatch) {\n    const pricing = parseCostObjectText(directEntryMatch[2] || \"\");\n    if (pricing) {\n      setPricingCandidates(pricingByModelKey, directEntryMatch[1], pricing);\n    }\n    directEntryMatch = directEntryPattern.exec(sourceText);\n  }\n\n  const defaultModelPattern =\n    /const\\s+([A-Z0-9_]+)_DEFAULT_MODEL_(?:ID|REF)\\s*=\\s*(?:`([^`]+)`|\"([^\"]+)\"|'([^']+)')/g;\n  let defaultModelMatch = defaultModelPattern.exec(sourceText);\n  while (defaultModelMatch) {\n    const constantPrefix = defaultModelMatch[1];\n    const modelKey =\n      defaultModelMatch[2] || defaultModelMatch[3] || defaultModelMatch[4] || \"\";\n    const defaultCostPattern = new RegExp(\n      `const\\\\s+${constantPrefix}_DEFAULT_COST\\\\s*=\\\\s*(\\\\{[\\\\s\\\\S]{0,180}?\\\\})`,\n      \"m\",\n    );\n    const defaultCostMatch = sourceText.match(defaultCostPattern);\n    const pricing = parseCostObjectText(defaultCostMatch?.[1] || \"\");\n    if (pricing) {\n      setPricingCandidates(pricingByModelKey, modelKey, pricing);\n    }\n    defaultModelMatch = defaultModelPattern.exec(sourceText);\n  }\n};\n\nlet cachedNodeModulesPricingMap = null;\nlet cachedNodeModulesPricingLoadedAt = 0;\nconst kOpenclawPricingDistFilePatterns = [\n  /^model-selection(?:-.+)?\\.js$/,\n  /^config(?:-.+)?\\.js$/,\n  /^onboard-custom(?:-.+)?\\.js$/,\n  /^configure(?:-.+)?\\.js$/,\n];\n\nconst loadOpenclawNodeModulesPricingMap = () => {\n  const nowMs = Date.now();\n  if (\n    cachedNodeModulesPricingMap &&\n    nowMs - cachedNodeModulesPricingLoadedAt < kNodeModulesPricingCacheTtlMs\n  ) {\n    return cachedNodeModulesPricingMap;\n  }\n\n  let distDirPath = \"\";\n  try {\n    const openclawEntryPath = require.resolve(\"openclaw\");\n    distDirPath = path.dirname(openclawEntryPath);\n  } catch {\n    cachedNodeModulesPricingMap = new Map();\n    cachedNodeModulesPricingLoadedAt = nowMs;\n    return cachedNodeModulesPricingMap;\n  }\n\n  const pricingByModelKey = new Map();\n  let distFileNames = [];\n  try {\n    distFileNames = fs\n      .readdirSync(distDirPath)\n      .filter((fileName) => fileName.endsWith(\".js\"));\n  } catch {\n    cachedNodeModulesPricingMap = new Map();\n    cachedNodeModulesPricingLoadedAt = nowMs;\n    return cachedNodeModulesPricingMap;\n  }\n\n  distFileNames.forEach((fileName) => {\n    const shouldScanFile = kOpenclawPricingDistFilePatterns.some((pattern) =>\n      pattern.test(fileName),\n    );\n    if (!shouldScanFile) return;\n    extractPricingFromDistFile(\n      path.join(distDirPath, fileName),\n      pricingByModelKey,\n    );\n  });\n\n  cachedNodeModulesPricingMap = pricingByModelKey;\n  cachedNodeModulesPricingLoadedAt = nowMs;\n  return pricingByModelKey;\n};\n\nconst resolvePricingFromOpenclawNodeModules = ({\n  provider = \"\",\n  model = \"\",\n} = {}) => {\n  const normalizedProvider = toCleanString(provider);\n  const normalizedModel = toCleanString(model);\n  if (!normalizedModel) return null;\n  const pricingByModelKey = loadOpenclawNodeModulesPricingMap();\n  const modelId =\n    normalizedModel.split(\"/\").filter(Boolean).pop() || normalizedModel;\n  const lookupCandidates = [];\n  if (normalizedProvider && modelId) {\n    lookupCandidates.push(`${normalizedProvider}/${modelId}`);\n  }\n  lookupCandidates.push(normalizedModel);\n  if (modelId) lookupCandidates.push(modelId);\n\n  for (const candidate of lookupCandidates) {\n    const pricing = pricingByModelKey.get(candidate);\n    if (pricing) return pricing;\n  }\n  return null;\n};\n\nconst resolvePricingFromFallbackMap = (model = \"\") => {\n  const normalized = String(model || \"\").toLowerCase();\n  if (!normalized) return null;\n  const exact = kGlobalModelPricing[normalized];\n  if (exact) return exact;\n  const matchKey = Object.keys(kGlobalModelPricing).find((key) =>\n    normalized.includes(key),\n  );\n  return matchKey ? kGlobalModelPricing[matchKey] : null;\n};\n\nconst resolvePerMillionRate = (rate, tokens) => {\n  if (typeof rate === \"function\") {\n    return Number(rate(toInt(tokens)));\n  }\n  return Number(rate || 0);\n};\n\nconst deriveCostBreakdown = ({\n  inputTokens = 0,\n  outputTokens = 0,\n  cacheReadTokens = 0,\n  cacheWriteTokens = 0,\n  provider = \"\",\n  model = \"\",\n} = {}) => {\n  const pricing =\n    resolvePricingFromOpenclawNodeModules({ provider, model }) ||\n    resolvePricingFromFallbackMap(model);\n  if (!pricing) {\n    return {\n      inputCost: 0,\n      outputCost: 0,\n      cacheReadCost: 0,\n      cacheWriteCost: 0,\n      totalCost: 0,\n      pricingFound: false,\n    };\n  }\n  const inputRate = resolvePerMillionRate(pricing.input, inputTokens);\n  const outputRate = resolvePerMillionRate(pricing.output, outputTokens);\n  const inputCost = (inputTokens / kTokensPerMillion) * inputRate;\n  const outputCost = (outputTokens / kTokensPerMillion) * outputRate;\n  const cacheReadRate = resolvePerMillionRate(\n    pricing.cacheRead,\n    cacheReadTokens,\n  );\n  const cacheReadCost = (cacheReadTokens / kTokensPerMillion) * cacheReadRate;\n  const cacheWriteRate = resolvePerMillionRate(\n    pricing.cacheWrite == null ? pricing.input : pricing.cacheWrite,\n    cacheWriteTokens,\n  );\n  const cacheWriteCost =\n    (cacheWriteTokens / kTokensPerMillion) * cacheWriteRate;\n  return {\n    inputCost,\n    outputCost,\n    cacheReadCost,\n    cacheWriteCost,\n    totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost,\n    pricingFound: true,\n  };\n};\n\nmodule.exports = {\n  kGlobalModelPricing,\n  deriveCostBreakdown,\n  resolvePricingFromOpenclawNodeModules,\n  loadOpenclawNodeModulesPricingMap,\n};\n"
  },
  {
    "path": "lib/server/cron-service.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { parseJsonValueFromNoisyOutput } = require(\"./utils/json\");\nconst { deriveCostBreakdown } = require(\"./cost-utils\");\n\nconst kCronStoreFile = \"jobs.json\";\nconst kCronRunsDir = \"runs\";\nconst kMaxRunsLimit = 200;\nconst kDefaultRunsLimit = 20;\nconst kDayMs = 24 * 60 * 60 * 1000;\nconst kTrendRange24h = \"24h\";\nconst kTrendRange7d = \"7d\";\nconst kTrendRange30d = \"30d\";\n\nconst toFiniteNumber = (value, fallback = 0) => {\n  const parsed = Number(value);\n  return Number.isFinite(parsed) ? parsed : fallback;\n};\n\nconst sanitizeCronJobId = (jobId = \"\") => {\n  const trimmed = String(jobId || \"\").trim();\n  if (!trimmed) throw new Error(\"Job id is required\");\n  if (trimmed.includes(\"/\") || trimmed.includes(\"\\\\\") || trimmed.includes(\"\\0\")) {\n    throw new Error(\"Invalid job id\");\n  }\n  return trimmed;\n};\n\nconst normalizeRunStatus = (value = \"all\") => {\n  const normalized = String(value || \"all\").trim().toLowerCase();\n  if ([\"ok\", \"error\", \"skipped\", \"all\"].includes(normalized)) return normalized;\n  return \"all\";\n};\n\nconst normalizeDeliveryStatus = (value = \"all\") => {\n  const normalized = String(value || \"all\").trim().toLowerCase();\n  if (\n    [\"delivered\", \"not-delivered\", \"unknown\", \"not-requested\", \"all\"].includes(\n      normalized,\n    )\n  ) {\n    return normalized;\n  }\n  return \"all\";\n};\n\nconst readJsonFile = (filePath) => {\n  try {\n    return JSON.parse(fs.readFileSync(filePath, \"utf8\"));\n  } catch {\n    return null;\n  }\n};\n\nconst normalizeJobs = (storeValue) => {\n  if (!storeValue || typeof storeValue !== \"object\") return [];\n  if (!Array.isArray(storeValue.jobs)) return [];\n  return storeValue.jobs\n    .filter((job) => job && typeof job === \"object\")\n    .map((job) => ({\n      ...job,\n      id: String(job.id || \"\").trim(),\n      name: String(job.name || \"\").trim(),\n      enabled: job.enabled !== false,\n      state: job.state && typeof job.state === \"object\" ? job.state : {},\n      payload: job.payload && typeof job.payload === \"object\" ? job.payload : {},\n      delivery: job.delivery && typeof job.delivery === \"object\" ? job.delivery : {},\n      schedule: job.schedule && typeof job.schedule === \"object\" ? job.schedule : {},\n    }))\n    .filter((job) => job.id);\n};\n\nconst readCronStore = ({ cronDir }) => {\n  const storePath = path.join(cronDir, kCronStoreFile);\n  const parsed = readJsonFile(storePath);\n  return {\n    storePath,\n    version: 1,\n    jobs: normalizeJobs(parsed),\n  };\n};\n\nconst sortJobs = (jobs = [], { sortBy = \"nextRunAtMs\", sortDir = \"asc\" } = {}) => {\n  const direction = String(sortDir || \"asc\").toLowerCase() === \"desc\" ? -1 : 1;\n  const readSortable = (job) => {\n    if (sortBy === \"name\") return String(job?.name || \"\").toLowerCase();\n    if (sortBy === \"updatedAtMs\") return toFiniteNumber(job?.updatedAtMs, 0);\n    return toFiniteNumber(job?.state?.nextRunAtMs, Number.MAX_SAFE_INTEGER);\n  };\n  return [...jobs].sort((a, b) => {\n    const aValue = readSortable(a);\n    const bValue = readSortable(b);\n    if (aValue === bValue) return 0;\n    return aValue > bValue ? direction : -direction;\n  });\n};\n\nconst paginate = (items = [], { limit = 200, offset = 0 } = {}) => {\n  const safeLimit = Math.max(1, Math.min(200, Number.parseInt(String(limit), 10) || 200));\n  const safeOffset = Math.max(0, Number.parseInt(String(offset), 10) || 0);\n  const total = items.length;\n  const entries = items.slice(safeOffset, safeOffset + safeLimit);\n  const nextOffset = safeOffset + entries.length;\n  return {\n    entries,\n    total,\n    offset: safeOffset,\n    limit: safeLimit,\n    hasMore: nextOffset < total,\n    nextOffset: nextOffset < total ? nextOffset : null,\n  };\n};\n\nconst parseRunLogLine = (line, jobId) => {\n  if (!line) return null;\n  try {\n    const value = JSON.parse(line);\n    if (!value || typeof value !== \"object\") return null;\n    if (String(value.action || \"\") !== \"finished\") return null;\n    if (String(value.jobId || \"\") !== jobId) return null;\n    const ts = toFiniteNumber(value.ts, 0);\n    if (!ts) return null;\n    return {\n      ts,\n      jobId,\n      action: \"finished\",\n      status: value.status,\n      error: value.error,\n      summary: value.summary,\n      delivered:\n        typeof value.delivered === \"boolean\" ? value.delivered : undefined,\n      deliveryStatus: value.deliveryStatus,\n      deliveryError: value.deliveryError,\n      sessionId: value.sessionId,\n      sessionKey: value.sessionKey,\n      runAtMs: value.runAtMs,\n      durationMs: value.durationMs,\n      nextRunAtMs: value.nextRunAtMs,\n      model: value.model,\n      provider: value.provider,\n      usage:\n        value.usage && typeof value.usage === \"object\" ? value.usage : undefined,\n    };\n  } catch {\n    return null;\n  }\n};\n\nconst readTokenValue = (source = {}, keys = []) => {\n  for (const key of keys) {\n    const numericValue = Number(source?.[key]);\n    if (Number.isFinite(numericValue) && numericValue >= 0) {\n      return numericValue;\n    }\n  }\n  return 0;\n};\n\nconst enrichRunEntryEstimatedCost = (entry = {}) => {\n  const usage = entry?.usage;\n  if (!usage || typeof usage !== \"object\") return entry;\n  const existingEstimatedCost = Number(\n    usage?.estimatedCost ?? usage?.estimated_cost ?? entry?.estimatedCost ?? entry?.estimated_cost,\n  );\n  if (Number.isFinite(existingEstimatedCost) && existingEstimatedCost >= 0) {\n    return {\n      ...entry,\n      estimatedCost: existingEstimatedCost,\n      usage: {\n        ...usage,\n        estimatedCost: existingEstimatedCost,\n      },\n    };\n  }\n  const inputTokens = readTokenValue(usage, [\"input_tokens\", \"inputTokens\"]);\n  const outputTokens = readTokenValue(usage, [\"output_tokens\", \"outputTokens\"]);\n  const cacheReadTokens = readTokenValue(usage, [\"cache_read_tokens\", \"cacheReadTokens\"]);\n  const cacheWriteTokens = readTokenValue(usage, [\"cache_write_tokens\", \"cacheWriteTokens\"]);\n  if (inputTokens <= 0 && outputTokens <= 0 && cacheReadTokens <= 0 && cacheWriteTokens <= 0) {\n    return entry;\n  }\n  const model = String(entry?.model || usage?.model || \"\").trim();\n  if (!model) return entry;\n  const breakdown = deriveCostBreakdown({\n    inputTokens,\n    outputTokens,\n    cacheReadTokens,\n    cacheWriteTokens,\n    provider: String(entry?.provider || \"\").trim(),\n    model,\n  });\n  if (!breakdown.pricingFound) {\n    return {\n      ...entry,\n      usage: {\n        ...usage,\n        pricingFound: false,\n      },\n    };\n  }\n  return {\n    ...entry,\n    estimatedCost: breakdown.totalCost,\n    usage: {\n      ...usage,\n      estimatedCost: breakdown.totalCost,\n      pricingFound: true,\n    },\n  };\n};\n\nconst readJobRuns = ({\n  runsDir,\n  jobId,\n  limit = kDefaultRunsLimit,\n  offset = 0,\n  status = \"all\",\n  deliveryStatus = \"all\",\n  sortDir = \"desc\",\n  query = \"\",\n}) => {\n  const safeJobId = sanitizeCronJobId(jobId);\n  const runLogPath = path.join(runsDir, `${safeJobId}.jsonl`);\n  const raw = fs.existsSync(runLogPath) ? fs.readFileSync(runLogPath, \"utf8\") : \"\";\n  const lines = String(raw || \"\")\n    .split(\"\\n\")\n    .map((line) => line.trim())\n    .filter(Boolean);\n  const entries = lines\n    .map((line) => parseRunLogLine(line, safeJobId))\n    .filter(Boolean);\n\n  const normalizedStatus = normalizeRunStatus(status);\n  const normalizedDeliveryStatus = normalizeDeliveryStatus(deliveryStatus);\n  const queryText = String(query || \"\").trim().toLowerCase();\n\n  const filtered = entries.filter((entry) => {\n    if (normalizedStatus !== \"all\" && String(entry.status || \"\") !== normalizedStatus) {\n      return false;\n    }\n    const entryDelivery = String(entry.deliveryStatus || \"not-requested\");\n    if (\n      normalizedDeliveryStatus !== \"all\" &&\n      entryDelivery !== normalizedDeliveryStatus\n    ) {\n      return false;\n    }\n    if (!queryText) return true;\n    const searchable = [\n      String(entry.summary || \"\"),\n      String(entry.error || \"\"),\n      String(entry.model || \"\"),\n      String(entry.provider || \"\"),\n    ]\n      .join(\" \")\n      .toLowerCase();\n    return searchable.includes(queryText);\n  });\n\n  filtered.sort((a, b) => {\n    if (sortDir === \"asc\") return a.ts - b.ts;\n    return b.ts - a.ts;\n  });\n\n  const page = paginate(filtered, {\n    limit: Math.max(1, Math.min(kMaxRunsLimit, Number.parseInt(String(limit), 10) || kDefaultRunsLimit)),\n    offset,\n  });\n  return {\n    runLogPath,\n    entries: page.entries,\n    total: page.total,\n    offset: page.offset,\n    limit: page.limit,\n    hasMore: page.hasMore,\n    nextOffset: page.nextOffset,\n  };\n};\n\nconst readJobDurationStats = ({ runsDir, jobId, sinceMs = 0 }) => {\n  const safeJobId = sanitizeCronJobId(jobId);\n  const runLogPath = path.join(runsDir, `${safeJobId}.jsonl`);\n  const raw = fs.existsSync(runLogPath) ? fs.readFileSync(runLogPath, \"utf8\") : \"\";\n  const lines = String(raw || \"\")\n    .split(\"\\n\")\n    .map((line) => line.trim())\n    .filter(Boolean);\n  const safeSinceMs = toFiniteNumber(sinceMs, 0);\n  let totalDurationMs = 0;\n  let sampleCount = 0;\n  for (const line of lines) {\n    const entry = parseRunLogLine(line, safeJobId);\n    if (!entry) continue;\n    if (safeSinceMs > 0 && toFiniteNumber(entry.ts, 0) < safeSinceMs) continue;\n    const durationMs = toFiniteNumber(entry.durationMs, -1);\n    if (!Number.isFinite(durationMs) || durationMs < 0) continue;\n    totalDurationMs += durationMs;\n    sampleCount += 1;\n  }\n  return {\n    totalDurationMs,\n    sampleCount,\n    avgDurationMs: sampleCount > 0 ? Math.round(totalDurationMs / sampleCount) : 0,\n  };\n};\nconst startOfLocalDayMs = (valueMs) => {\n  const dateValue = new Date(toFiniteNumber(valueMs, 0));\n  dateValue.setHours(0, 0, 0, 0);\n  return dateValue.getTime();\n};\nconst addLocalDaysMs = (valueMs, dayCount = 0) => {\n  const dateValue = new Date(toFiniteNumber(valueMs, 0));\n  dateValue.setDate(dateValue.getDate() + Number(dayCount || 0));\n  return dateValue.getTime();\n};\nconst readRunEntryTotalTokens = (entry = {}) => {\n  const usage = entry?.usage && typeof entry.usage === \"object\" ? entry.usage : {};\n  const componentCandidates = [\n    usage?.input_tokens,\n    usage?.inputTokens,\n    usage?.output_tokens,\n    usage?.outputTokens,\n    usage?.cache_read_tokens,\n    usage?.cacheReadTokens,\n    usage?.cache_write_tokens,\n    usage?.cacheWriteTokens,\n  ];\n  const componentTotal = componentCandidates.reduce((sum, candidate) => {\n    const numericValue = Number(candidate);\n    if (!Number.isFinite(numericValue) || numericValue < 0) return sum;\n    return sum + numericValue;\n  }, 0);\n  if (componentTotal > 0) return componentTotal;\n  const fallbackCandidates = [\n    usage?.total_tokens,\n    usage?.totalTokens,\n    entry?.total_tokens,\n    entry?.totalTokens,\n  ];\n  for (const candidate of fallbackCandidates) {\n    const numericValue = Number(candidate);\n    if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;\n  }\n  return 0;\n};\nconst readRunEntryEstimatedCost = (entry = {}) => {\n  const usage = entry?.usage && typeof entry.usage === \"object\" ? entry.usage : {};\n  const candidates = [\n    entry?.estimatedCost,\n    entry?.estimated_cost,\n    usage?.estimatedCost,\n    usage?.estimated_cost,\n    usage?.totalCost,\n    usage?.total_cost,\n    usage?.costUsd,\n    usage?.cost,\n  ];\n  for (const candidate of candidates) {\n    const numericValue = Number(candidate);\n    if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;\n  }\n  return null;\n};\nconst getJobTrends = ({\n  runsDir,\n  jobId,\n  sinceMs = 0,\n  nowMs = Date.now(),\n  range = kTrendRange7d,\n}) => {\n  const safeJobId = sanitizeCronJobId(jobId);\n  const runLogPath = path.join(runsDir, `${safeJobId}.jsonl`);\n  const raw = fs.existsSync(runLogPath) ? fs.readFileSync(runLogPath, \"utf8\") : \"\";\n  const lines = String(raw || \"\")\n    .split(\"\\n\")\n    .map((line) => line.trim())\n    .filter(Boolean);\n  const safeNowMs = toFiniteNumber(nowMs, Date.now());\n  const safeSinceMs = toFiniteNumber(sinceMs, 0);\n  const normalizedRange = (() => {\n    const rawValue = String(range || kTrendRange7d).trim().toLowerCase();\n    if (rawValue === kTrendRange24h) return kTrendRange24h;\n    if (rawValue === kTrendRange30d) return kTrendRange30d;\n    return kTrendRange7d;\n  })();\n  const rangeConfig = normalizedRange === kTrendRange24h\n    ? {\n      bucketCount: 24,\n      bucketMs: 60 * 60 * 1000,\n      alignToLocalDay: false,\n    }\n    : normalizedRange === kTrendRange30d\n      ? {\n        bucketCount: 30,\n        bucketMs: kDayMs,\n        alignToLocalDay: true,\n      }\n      : {\n        bucketCount: 7,\n        bucketMs: kDayMs,\n        alignToLocalDay: true,\n      };\n  const windowStartMs = safeSinceMs > 0\n    ? (rangeConfig.alignToLocalDay ? startOfLocalDayMs(safeSinceMs) : safeSinceMs)\n    : rangeConfig.alignToLocalDay\n      ? addLocalDaysMs(startOfLocalDayMs(safeNowMs), -(rangeConfig.bucketCount - 1))\n      : safeNowMs - rangeConfig.bucketCount * rangeConfig.bucketMs;\n  const windowEndMs = safeNowMs;\n  const pointsByDayStartMs = new Map();\n  for (let index = 0; index < rangeConfig.bucketCount; index += 1) {\n    const bucketStartMs = rangeConfig.alignToLocalDay\n      ? addLocalDaysMs(windowStartMs, index)\n      : windowStartMs + index * rangeConfig.bucketMs;\n    const bucketEndMs = index === rangeConfig.bucketCount - 1\n      ? windowEndMs\n      : rangeConfig.alignToLocalDay\n        ? addLocalDaysMs(windowStartMs, index + 1)\n        : windowStartMs + (index + 1) * rangeConfig.bucketMs;\n    pointsByDayStartMs.set(bucketStartMs, {\n      startMs: bucketStartMs,\n      endMs: bucketEndMs,\n      ok: 0,\n      error: 0,\n      skipped: 0,\n      totalRuns: 0,\n      totalTokens: 0,\n      totalCost: 0,\n      costSamples: 0,\n      totalDurationMs: 0,\n      durationSamples: 0,\n    });\n  }\n  for (const line of lines) {\n    const parsedEntry = parseRunLogLine(line, safeJobId);\n    if (!parsedEntry) continue;\n    const entry = enrichRunEntryEstimatedCost(parsedEntry);\n    const timestampMs = toFiniteNumber(entry.ts, 0);\n    if (timestampMs <= 0 || timestampMs < windowStartMs || timestampMs > windowEndMs) {\n      continue;\n    }\n    const bucketKey = rangeConfig.alignToLocalDay\n      ? startOfLocalDayMs(timestampMs)\n      : windowStartMs +\n        Math.floor((timestampMs - windowStartMs) / rangeConfig.bucketMs) * rangeConfig.bucketMs;\n    const point = pointsByDayStartMs.get(bucketKey);\n    if (!point) continue;\n    point.totalRuns += 1;\n    const status = String(entry?.status || \"\").trim().toLowerCase();\n    if (status === \"ok\" || status === \"error\" || status === \"skipped\") {\n      point[status] += 1;\n    }\n    point.totalTokens += readRunEntryTotalTokens(entry);\n    const estimatedCost = readRunEntryEstimatedCost(entry);\n    if (estimatedCost != null) {\n      point.totalCost += estimatedCost;\n      point.costSamples += 1;\n    }\n    const durationMs = toFiniteNumber(entry?.durationMs, -1);\n    if (Number.isFinite(durationMs) && durationMs >= 0) {\n      point.totalDurationMs += durationMs;\n      point.durationSamples += 1;\n    }\n  }\n  const points = Array.from(pointsByDayStartMs.values()).map((point) => ({\n    ...point,\n    avgDurationMs: point.durationSamples > 0\n      ? Math.round(point.totalDurationMs / point.durationSamples)\n      : 0,\n  }));\n  return {\n    sinceMs: windowStartMs,\n    nowMs: windowEndMs,\n    bucket: rangeConfig.alignToLocalDay ? \"day\" : \"hour\",\n    range: normalizedRange,\n    points,\n  };\n};\n\nconst shellEscapeArg = (value) => `'${String(value || \"\").replace(/'/g, `'\\\\''`)}'`;\nconst normalizeRoutingField = (value) => String(value || \"\").trim().toLowerCase();\n\nconst parseCommandJson = (rawOutput) => {\n  const parsed = parseJsonValueFromNoisyOutput(rawOutput);\n  if (parsed && typeof parsed === \"object\") return parsed;\n  return null;\n};\n\nconst resolvePromptEditFlag = ({ cronDir, jobId }) => {\n  const store = readCronStore({ cronDir });\n  const job = store.jobs.find((entry) => String(entry?.id || \"\") === jobId);\n  if (!job) throw new Error(`unknown cron job id: ${jobId}`);\n  const payloadKind = String(job?.payload?.kind || \"\").trim();\n  if (payloadKind === \"systemEvent\") return \"--system-event\";\n  if (payloadKind === \"agentTurn\") return \"--message\";\n  throw new Error(`unsupported cron payload kind: ${payloadKind || \"unknown\"}`);\n};\n\nconst createCronService = ({\n  clawCmd,\n  OPENCLAW_DIR,\n  getSessionUsageByKeyPattern,\n}) => {\n  const cronDir = path.join(OPENCLAW_DIR, \"cron\");\n  const runsDir = path.join(cronDir, kCronRunsDir);\n\n  const listJobs = ({ sortBy = \"nextRunAtMs\", sortDir = \"asc\" } = {}) => {\n    const store = readCronStore({ cronDir });\n    const jobs = sortJobs(store.jobs, { sortBy, sortDir });\n    return {\n      storePath: store.storePath,\n      jobs,\n    };\n  };\n\n  const getStatus = () => {\n    const { storePath, jobs } = listJobs({ sortBy: \"nextRunAtMs\", sortDir: \"asc\" });\n    const enabledJobs = jobs.filter((job) => job.enabled !== false);\n    const nextWakeAtMs = enabledJobs.reduce((lowestValue, job) => {\n      const candidate = toFiniteNumber(job?.state?.nextRunAtMs, 0);\n      if (!candidate) return lowestValue;\n      if (!lowestValue) return candidate;\n      return Math.min(lowestValue, candidate);\n    }, 0);\n    return {\n      enabled: true,\n      storePath,\n      jobs: jobs.length,\n      enabledJobs: enabledJobs.length,\n      nextWakeAtMs: nextWakeAtMs || null,\n    };\n  };\n\n  const runCommand = async (command, { timeoutMs = 30000 } = {}) => {\n    const baseOptions = { quiet: true, timeoutMs };\n    const result = await clawCmd(command, baseOptions);\n    if (!result?.ok) {\n      const message = String(result?.stderr || result?.stdout || \"Command failed\").trim();\n      throw new Error(message || \"Command failed\");\n    }\n    return {\n      raw: result.stdout || \"\",\n      parsed: parseCommandJson(result.stdout || \"\"),\n    };\n  };\n\n  const runJobNow = async (jobId) => {\n    const safeJobId = sanitizeCronJobId(jobId);\n    const command = `cron run ${shellEscapeArg(safeJobId)}`;\n    return runCommand(command, { timeoutMs: 600000 });\n  };\n\n  const setJobEnabled = async ({ jobId, enabled }) => {\n    const safeJobId = sanitizeCronJobId(jobId);\n    const action = enabled ? \"enable\" : \"disable\";\n    const command = `cron ${action} ${shellEscapeArg(safeJobId)}`;\n    return runCommand(command, { timeoutMs: 60000 });\n  };\n\n  const updateJobPrompt = async ({ jobId, message }) => {\n    const safeJobId = sanitizeCronJobId(jobId);\n    const promptFlag = resolvePromptEditFlag({ cronDir, jobId: safeJobId });\n    const command = `cron edit ${shellEscapeArg(safeJobId)} ${promptFlag} ${shellEscapeArg(message || \"\")}`;\n    return runCommand(command, { timeoutMs: 60000 });\n  };\n\n  const updateJobRouting = async ({\n    jobId,\n    sessionTarget,\n    wakeMode,\n    deliveryMode,\n    deliveryChannel,\n    deliveryTo,\n  }) => {\n    const safeJobId = sanitizeCronJobId(jobId);\n    const normalizedSessionTarget = normalizeRoutingField(sessionTarget);\n    const normalizedWakeMode = normalizeRoutingField(wakeMode);\n    const normalizedDeliveryMode = normalizeRoutingField(deliveryMode);\n    const commandParts = [\"cron\", \"edit\", shellEscapeArg(safeJobId)];\n\n    if (normalizedSessionTarget) {\n      if (normalizedSessionTarget !== \"main\" && normalizedSessionTarget !== \"isolated\") {\n        throw new Error(\"sessionTarget must be main or isolated\");\n      }\n      commandParts.push(\"--session\", shellEscapeArg(normalizedSessionTarget));\n    }\n\n    if (normalizedWakeMode) {\n      if (normalizedWakeMode !== \"now\" && normalizedWakeMode !== \"next-heartbeat\") {\n        throw new Error(\"wakeMode must be now or next-heartbeat\");\n      }\n      commandParts.push(\"--wake\", shellEscapeArg(normalizedWakeMode));\n    }\n\n    if (normalizedDeliveryMode) {\n      if (normalizedDeliveryMode === \"announce\") commandParts.push(\"--announce\");\n      else if (normalizedDeliveryMode === \"none\") commandParts.push(\"--no-deliver\");\n      else throw new Error(\"deliveryMode must be announce or none\");\n    }\n\n    const normalizedDeliveryChannel = String(deliveryChannel || \"\").trim();\n    const normalizedDeliveryTo = String(deliveryTo || \"\").trim();\n    if (normalizedDeliveryChannel) {\n      commandParts.push(\"--channel\", shellEscapeArg(normalizedDeliveryChannel));\n    }\n    if (normalizedDeliveryTo) {\n      commandParts.push(\"--to\", shellEscapeArg(normalizedDeliveryTo));\n    }\n\n    if (commandParts.length <= 3) {\n      throw new Error(\"At least one routing field is required\");\n    }\n\n    return runCommand(commandParts.join(\" \"), { timeoutMs: 60000 });\n  };\n\n  const getJobRuns = ({\n    jobId,\n    limit = kDefaultRunsLimit,\n    offset = 0,\n    status = \"all\",\n    deliveryStatus = \"all\",\n    sortDir = \"desc\",\n    query = \"\",\n  }) => {\n    const runs = readJobRuns({\n      runsDir,\n      jobId,\n      limit,\n      offset,\n      status,\n      deliveryStatus,\n      sortDir,\n      query,\n    });\n    return {\n      ...runs,\n      entries: runs.entries.map((entry) => enrichRunEntryEstimatedCost(entry)),\n    };\n  };\n\n  const getJobUsage = ({ jobId, sinceMs = 0 }) => {\n    const safeJobId = sanitizeCronJobId(jobId);\n    const keyPattern = `%:cron:${safeJobId}%`;\n    const usage = getSessionUsageByKeyPattern({\n      keyPattern,\n      sinceMs: toFiniteNumber(sinceMs, 0),\n    });\n    const durationStats = readJobDurationStats({\n      runsDir,\n      jobId: safeJobId,\n      sinceMs: toFiniteNumber(sinceMs, 0),\n    });\n    const totals =\n      usage?.totals && typeof usage.totals === \"object\"\n        ? usage.totals\n        : {};\n    return {\n      ...usage,\n      totals: {\n        ...totals,\n        totalDurationMs: durationStats.totalDurationMs,\n        durationSamples: durationStats.sampleCount,\n        avgDurationMs: durationStats.avgDurationMs,\n      },\n    };\n  };\n\n  const getBulkJobUsage = ({ sinceMs = 0 } = {}) => {\n    const { jobs } = listJobs({ sortBy: \"name\", sortDir: \"asc\" });\n    const safeSinceMs = toFiniteNumber(sinceMs, 0);\n    const byJobId = {};\n    jobs.forEach((job) => {\n      const usage = getJobUsage({ jobId: job.id, sinceMs: safeSinceMs }) || {};\n      const totals = usage?.totals || {};\n      const runCount = toFiniteNumber(totals.runCount, 0);\n      const totalTokens = toFiniteNumber(totals.totalTokens, 0);\n      const totalCost = toFiniteNumber(totals.totalCost, 0);\n      byJobId[job.id] = {\n        totalTokens,\n        totalCost,\n        runCount,\n        avgTokensPerRun: runCount > 0 ? Math.round(totalTokens / runCount) : 0,\n      };\n    });\n    return {\n      sinceMs: safeSinceMs,\n      byJobId,\n    };\n  };\n\n  const getBulkJobRuns = ({\n    sinceMs = 0,\n    limitPerJob = kDefaultRunsLimit,\n    status = \"all\",\n    deliveryStatus = \"all\",\n    sortDir = \"desc\",\n    query = \"\",\n  } = {}) => {\n    const { jobs } = listJobs({ sortBy: \"name\", sortDir: \"asc\" });\n    const safeSinceMs = toFiniteNumber(sinceMs, 0);\n    const safeLimitPerJob = Math.max(\n      1,\n      Math.min(kMaxRunsLimit, Number.parseInt(String(limitPerJob), 10) || kDefaultRunsLimit),\n    );\n    const byJobId = {};\n    jobs.forEach((job) => {\n      const runs = getJobRuns({\n        jobId: job.id,\n        limit: safeLimitPerJob,\n        offset: 0,\n        status,\n        deliveryStatus,\n        sortDir,\n        query,\n      });\n      const filteredEntries = safeSinceMs > 0\n        ? runs.entries.filter((entry) => toFiniteNumber(entry?.ts, 0) >= safeSinceMs)\n        : runs.entries;\n      byJobId[job.id] = {\n        entries: filteredEntries,\n        total: filteredEntries.length,\n      };\n    });\n    return {\n      sinceMs: safeSinceMs,\n      byJobId,\n    };\n  };\n  const getJobRunTrends = ({ jobId, sinceMs = 0, range = kTrendRange7d }) =>\n    getJobTrends({\n      runsDir,\n      jobId,\n      sinceMs: toFiniteNumber(sinceMs, 0),\n      range,\n    });\n\n  return {\n    listJobs,\n    getStatus,\n    runJobNow,\n    setJobEnabled,\n    updateJobPrompt,\n    updateJobRouting,\n    getJobRuns,\n    getJobUsage,\n    getJobRunTrends,\n    getBulkJobUsage,\n    getBulkJobRuns,\n  };\n};\n\nmodule.exports = {\n  createCronService,\n};\n"
  },
  {
    "path": "lib/server/db/doctor/index.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { DatabaseSync } = require(\"node:sqlite\");\nconst { createSchema } = require(\"./schema\");\nconst {\n  kDoctorCardStatus,\n  kDoctorDefaultRunsLimit,\n  kDoctorMaxRunsLimit,\n  kDoctorPriority,\n  kDoctorRunStatus,\n} = require(\"../../doctor/constants\");\n\nlet db = null;\nconst kDoctorInitialBaselineMetaKey = \"initial_workspace_baseline\";\n\nconst ensureDb = () => {\n  if (!db) throw new Error(\"Doctor DB not initialized\");\n  return db;\n};\n\nconst closeDoctorDb = () => {\n  if (!db) return;\n  const database = db;\n  db = null;\n  database.close();\n};\n\nconst parseJsonText = (value, fallbackValue) => {\n  if (typeof value !== \"string\" || !value) return fallbackValue;\n  try {\n    return JSON.parse(value);\n  } catch {\n    return fallbackValue;\n  }\n};\n\nconst buildPriorityCounts = (cards = []) => ({\n  P0: cards.filter((card) => card.priority === kDoctorPriority.P0).length,\n  P1: cards.filter((card) => card.priority === kDoctorPriority.P1).length,\n  P2: cards.filter((card) => card.priority === kDoctorPriority.P2).length,\n});\n\nconst buildStatusCounts = (cards = []) => ({\n  open: cards.filter((card) => card.status === kDoctorCardStatus.open).length,\n  dismissed: cards.filter((card) => card.status === kDoctorCardStatus.dismissed).length,\n  fixed: cards.filter((card) => card.status === kDoctorCardStatus.fixed).length,\n});\n\nconst toCardModel = (row) => {\n  if (!row) return null;\n  return {\n    id: Number(row.id || 0),\n    runId: Number(row.run_id || 0),\n    createdAt: row.created_at || null,\n    updatedAt: row.updated_at || null,\n    priority: row.priority || kDoctorPriority.P2,\n    category: row.category || \"workspace\",\n    title: row.title || \"\",\n    summary: row.summary || \"\",\n    recommendation: row.recommendation || \"\",\n    evidence: parseJsonText(row.evidence_json, []),\n    targetPaths: parseJsonText(row.target_paths_json, []),\n    fixPrompt: row.fix_prompt || \"\",\n    status: row.status || kDoctorCardStatus.open,\n  };\n};\n\nconst attachRunCounts = (run, cards = []) =>\n  run\n    ? {\n        ...run,\n        cardCount: cards.length,\n        priorityCounts: buildPriorityCounts(cards),\n        statusCounts: buildStatusCounts(cards),\n      }\n    : null;\n\nconst getCardsByRunId = (runId) => {\n  const database = ensureDb();\n  const rows = database\n    .prepare(`\n      SELECT\n        id,\n        run_id,\n        created_at,\n        updated_at,\n        priority,\n        category,\n        title,\n        summary,\n        recommendation,\n        evidence_json,\n        target_paths_json,\n        fix_prompt,\n        status\n      FROM doctor_cards\n      WHERE run_id = $run_id\n      ORDER BY\n        CASE priority\n          WHEN 'P0' THEN 0\n          WHEN 'P1' THEN 1\n          ELSE 2\n        END ASC,\n        created_at DESC\n    `)\n    .all({ $run_id: Number(runId || 0) });\n  return rows.map(toCardModel);\n};\n\nconst listDoctorCards = ({ runId } = {}) => {\n  const database = ensureDb();\n  const hasRunFilter =\n    runId !== undefined &&\n    runId !== null &&\n    String(runId || \"\").trim() !== \"\" &&\n    String(runId || \"\").trim().toLowerCase() !== \"all\";\n  const rows = database\n    .prepare(`\n      SELECT\n        c.id,\n        c.run_id,\n        c.created_at,\n        c.updated_at,\n        c.priority,\n        c.category,\n        c.title,\n        c.summary,\n        c.recommendation,\n        c.evidence_json,\n        c.target_paths_json,\n        c.fix_prompt,\n        c.status,\n        r.started_at AS run_started_at,\n        r.completed_at AS run_completed_at,\n        r.status AS run_status\n      FROM doctor_cards c\n      INNER JOIN doctor_runs r ON r.id = c.run_id\n      ${hasRunFilter ? \"WHERE c.run_id = $run_id\" : \"\"}\n      ORDER BY\n        CASE c.status\n          WHEN 'open' THEN 0\n          WHEN 'dismissed' THEN 1\n          ELSE 2\n        END ASC,\n        CASE c.priority\n          WHEN 'P0' THEN 0\n          WHEN 'P1' THEN 1\n          ELSE 2\n        END ASC,\n        c.created_at DESC\n    `)\n    .all(hasRunFilter ? { $run_id: Number(runId || 0) } : {});\n  return rows.map((row) => ({\n    ...toCardModel(row),\n    runStartedAt: row.run_started_at || null,\n    runCompletedAt: row.run_completed_at || null,\n    runStatus: row.run_status || kDoctorRunStatus.failed,\n  }));\n};\n\nconst toRunModel = (row) => {\n  if (!row) return null;\n  return {\n    id: Number(row.id || 0),\n    startedAt: row.started_at || null,\n    completedAt: row.completed_at || null,\n    status: row.status || kDoctorRunStatus.failed,\n    engine: row.engine || \"\",\n    workspaceRoot: row.workspace_root || \"\",\n    workspaceFingerprint: row.workspace_fingerprint || \"\",\n    workspaceManifest: parseJsonText(row.workspace_manifest_json, null),\n    promptVersion: row.prompt_version || \"\",\n    summary: row.summary || \"\",\n    rawResult: parseJsonText(row.raw_result_json, null),\n    error: row.error || \"\",\n    reusedFromRunId: Number(row.reused_from_run_id || 0),\n  };\n};\n\nconst initDoctorDb = ({ rootDir }) => {\n  closeDoctorDb();\n  const dbDir = path.join(rootDir, \"db\");\n  fs.mkdirSync(dbDir, { recursive: true });\n  const dbPath = path.join(dbDir, \"doctor.db\");\n  db = new DatabaseSync(dbPath);\n  createSchema(db);\n  markIncompleteRunsFailed();\n  return { path: dbPath };\n};\n\nconst getDoctorMeta = (key) => {\n  const database = ensureDb();\n  const row = database\n    .prepare(`\n      SELECT\n        key,\n        value_json,\n        updated_at\n      FROM doctor_meta\n      WHERE key = $key\n      LIMIT 1\n    `)\n    .get({ $key: String(key || \"\") });\n  if (!row) return null;\n  return {\n    key: row.key || \"\",\n    value: parseJsonText(row.value_json, null),\n    updatedAt: row.updated_at || null,\n  };\n};\n\nconst setDoctorMeta = ({ key, value = null }) => {\n  const database = ensureDb();\n  database\n    .prepare(`\n      INSERT INTO doctor_meta (\n        key,\n        value_json,\n        updated_at\n      ) VALUES (\n        $key,\n        $value_json,\n        strftime('%Y-%m-%dT%H:%M:%fZ','now')\n      )\n      ON CONFLICT(key) DO UPDATE SET\n        value_json = excluded.value_json,\n        updated_at = excluded.updated_at\n    `)\n    .run({\n      $key: String(key || \"\"),\n      $value_json: value == null ? null : JSON.stringify(value),\n    });\n  return getDoctorMeta(key);\n};\n\nconst getInitialWorkspaceBaseline = () => getDoctorMeta(kDoctorInitialBaselineMetaKey)?.value || null;\n\nconst setInitialWorkspaceBaseline = (baseline) =>\n  setDoctorMeta({\n    key: kDoctorInitialBaselineMetaKey,\n    value: baseline,\n  })?.value || null;\n\nconst markIncompleteRunsFailed = (errorMessage = \"Doctor run interrupted before completion\") => {\n  const database = ensureDb();\n  const result = database\n    .prepare(`\n      UPDATE doctor_runs\n      SET\n        status = $status,\n        completed_at = COALESCE(completed_at, strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n        error = COALESCE(NULLIF(error, ''), $error)\n      WHERE status = $running_status\n    `)\n    .run({\n      $status: kDoctorRunStatus.failed,\n      $running_status: kDoctorRunStatus.running,\n      $error: String(errorMessage || \"\"),\n    });\n  return Number(result.changes || 0);\n};\n\nconst createDoctorRun = ({\n  status = kDoctorRunStatus.running,\n  engine,\n  workspaceRoot,\n  workspaceFingerprint = \"\",\n  workspaceManifest = null,\n  promptVersion,\n  reusedFromRunId = 0,\n}) => {\n  const database = ensureDb();\n  const result = database\n    .prepare(`\n      INSERT INTO doctor_runs (\n        status,\n        engine,\n        workspace_root,\n        workspace_fingerprint,\n        workspace_manifest_json,\n        prompt_version,\n        reused_from_run_id\n      ) VALUES (\n        $status,\n        $engine,\n        $workspace_root,\n        $workspace_fingerprint,\n        $workspace_manifest_json,\n        $prompt_version,\n        $reused_from_run_id\n      )\n    `)\n    .run({\n      $status: String(status || kDoctorRunStatus.running),\n      $engine: String(engine || \"\"),\n      $workspace_root: String(workspaceRoot || \"\"),\n      $workspace_fingerprint: String(workspaceFingerprint || \"\"),\n      $workspace_manifest_json: workspaceManifest == null ? null : JSON.stringify(workspaceManifest),\n      $prompt_version: String(promptVersion || \"\"),\n      $reused_from_run_id: Number(reusedFromRunId || 0),\n    });\n  return Number(result.lastInsertRowid || 0);\n};\n\nconst completeDoctorRun = ({\n  id,\n  status,\n  summary = \"\",\n  rawResult = null,\n  error = \"\",\n}) => {\n  const database = ensureDb();\n  const result = database\n    .prepare(`\n      UPDATE doctor_runs\n      SET\n        completed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),\n        status = $status,\n        summary = $summary,\n        raw_result_json = $raw_result_json,\n        error = $error\n      WHERE id = $id\n    `)\n    .run({\n      $id: Number(id || 0),\n      $status: String(status || kDoctorRunStatus.failed),\n      $summary: String(summary || \"\"),\n      $raw_result_json: rawResult == null ? null : JSON.stringify(rawResult),\n      $error: String(error || \"\"),\n    });\n  return Number(result.changes || 0);\n};\n\nconst insertDoctorCards = ({ runId, cards = [] }) => {\n  const database = ensureDb();\n  database.exec(\"BEGIN\");\n  try {\n    const stmt = database.prepare(`\n      INSERT INTO doctor_cards (\n        run_id,\n        priority,\n        category,\n        title,\n        summary,\n        recommendation,\n        evidence_json,\n        target_paths_json,\n        fix_prompt,\n        status\n      ) VALUES (\n        $run_id,\n        $priority,\n        $category,\n        $title,\n        $summary,\n        $recommendation,\n        $evidence_json,\n        $target_paths_json,\n        $fix_prompt,\n        $status\n      )\n    `);\n    for (const card of cards) {\n      stmt.run({\n        $run_id: Number(runId || 0),\n        $priority: String(card?.priority || kDoctorPriority.P2),\n        $category: String(card?.category || \"workspace\"),\n        $title: String(card?.title || \"\"),\n        $summary: String(card?.summary || \"\"),\n        $recommendation: String(card?.recommendation || \"\"),\n        $evidence_json: JSON.stringify(card?.evidence || []),\n        $target_paths_json: JSON.stringify(card?.targetPaths || []),\n        $fix_prompt: String(card?.fixPrompt || \"\"),\n        $status: String(card?.status || kDoctorCardStatus.open),\n      });\n    }\n    database.exec(\"COMMIT\");\n  } catch (error) {\n    database.exec(\"ROLLBACK\");\n    throw error;\n  }\n};\n\nconst getDoctorRun = (id) => {\n  const database = ensureDb();\n  const row = database\n    .prepare(`\n      SELECT\n        id,\n        started_at,\n        completed_at,\n        status,\n        engine,\n        workspace_root,\n        workspace_fingerprint,\n        workspace_manifest_json,\n        prompt_version,\n        summary,\n        raw_result_json,\n        error,\n        reused_from_run_id\n      FROM doctor_runs\n      WHERE id = $id\n      LIMIT 1\n    `)\n    .get({ $id: Number(id || 0) });\n  const run = toRunModel(row);\n  if (!run) return null;\n  return attachRunCounts(run, getCardsByRunId(run.id));\n};\n\nconst getLatestDoctorRun = () => {\n  const database = ensureDb();\n  const row = database\n    .prepare(`\n      SELECT\n        id,\n        started_at,\n        completed_at,\n        status,\n        engine,\n        workspace_root,\n        workspace_fingerprint,\n        workspace_manifest_json,\n        prompt_version,\n        summary,\n        raw_result_json,\n        error,\n        reused_from_run_id\n      FROM doctor_runs\n      ORDER BY started_at DESC\n      LIMIT 1\n    `)\n    .get();\n  const run = toRunModel(row);\n  if (!run) return null;\n  return attachRunCounts(run, getCardsByRunId(run.id));\n};\n\nconst listDoctorRuns = ({ limit = kDoctorDefaultRunsLimit } = {}) => {\n  const database = ensureDb();\n  const safeLimit = Math.max(\n    1,\n    Math.min(Number.parseInt(String(limit || kDoctorDefaultRunsLimit), 10) || kDoctorDefaultRunsLimit, kDoctorMaxRunsLimit),\n  );\n  const rows = database\n    .prepare(`\n      SELECT\n        id,\n        started_at,\n        completed_at,\n        status,\n        engine,\n        workspace_root,\n        workspace_fingerprint,\n        workspace_manifest_json,\n        prompt_version,\n        summary,\n        raw_result_json,\n        error,\n        reused_from_run_id\n      FROM doctor_runs\n      ORDER BY started_at DESC\n      LIMIT $limit\n    `)\n    .all({ $limit: safeLimit });\n  return rows.map((row) => {\n    const run = toRunModel(row);\n    return attachRunCounts(run, getCardsByRunId(run.id));\n  });\n};\n\nconst getDoctorCard = (id) => {\n  const database = ensureDb();\n  const row = database\n    .prepare(`\n      SELECT\n        id,\n        run_id,\n        created_at,\n        updated_at,\n        priority,\n        category,\n        title,\n        summary,\n        recommendation,\n        evidence_json,\n        target_paths_json,\n        fix_prompt,\n        status\n      FROM doctor_cards\n      WHERE id = $id\n      LIMIT 1\n    `)\n    .get({ $id: Number(id || 0) });\n  return toCardModel(row);\n};\n\nconst updateDoctorCardStatus = ({ id, status }) => {\n  const database = ensureDb();\n  const nextStatus =\n    status === kDoctorCardStatus.fixed || status === kDoctorCardStatus.dismissed\n      ? status\n      : kDoctorCardStatus.open;\n  const result = database\n    .prepare(`\n      UPDATE doctor_cards\n      SET\n        status = $status,\n        updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')\n      WHERE id = $id\n    `)\n    .run({\n      $id: Number(id || 0),\n      $status: nextStatus,\n    });\n  return Number(result.changes || 0) > 0 ? getDoctorCard(id) : null;\n};\n\nmodule.exports = {\n  initDoctorDb,\n  closeDoctorDb,\n  markIncompleteRunsFailed,\n  getDoctorMeta,\n  setDoctorMeta,\n  getInitialWorkspaceBaseline,\n  setInitialWorkspaceBaseline,\n  createDoctorRun,\n  completeDoctorRun,\n  insertDoctorCards,\n  getDoctorRun,\n  getLatestDoctorRun,\n  listDoctorRuns,\n  listDoctorCards,\n  getDoctorCardsByRunId: getCardsByRunId,\n  getDoctorCard,\n  updateDoctorCardStatus,\n};\n"
  },
  {
    "path": "lib/server/db/doctor/schema.js",
    "content": "const hasColumn = (database, tableName, columnName) => {\n  const rows = database.prepare(`PRAGMA table_info(${tableName})`).all();\n  return rows.some((row) => row.name === columnName);\n};\n\nconst ensureColumn = (database, tableName, columnName, definition) => {\n  if (hasColumn(database, tableName, columnName)) return;\n  database.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition};`);\n};\n\nconst createSchema = (database) => {\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS doctor_runs (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n      completed_at TEXT,\n      status TEXT NOT NULL,\n      engine TEXT NOT NULL,\n      workspace_root TEXT NOT NULL,\n      workspace_fingerprint TEXT,\n      workspace_manifest_json TEXT,\n      prompt_version TEXT NOT NULL,\n      summary TEXT,\n      raw_result_json TEXT,\n      error TEXT,\n      reused_from_run_id INTEGER\n    );\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS doctor_meta (\n      key TEXT PRIMARY KEY,\n      value_json TEXT,\n      updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n    );\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS doctor_cards (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      run_id INTEGER NOT NULL,\n      created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n      updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n      priority TEXT NOT NULL,\n      category TEXT NOT NULL,\n      title TEXT NOT NULL,\n      summary TEXT,\n      recommendation TEXT NOT NULL,\n      evidence_json TEXT,\n      target_paths_json TEXT,\n      fix_prompt TEXT NOT NULL,\n      status TEXT NOT NULL,\n      FOREIGN KEY (run_id) REFERENCES doctor_runs(id) ON DELETE CASCADE\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_doctor_runs_started_at\n    ON doctor_runs(started_at DESC);\n  `);\n  ensureColumn(database, \"doctor_runs\", \"workspace_fingerprint\", \"TEXT\");\n  ensureColumn(database, \"doctor_runs\", \"workspace_manifest_json\", \"TEXT\");\n  ensureColumn(database, \"doctor_runs\", \"reused_from_run_id\", \"INTEGER\");\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_doctor_cards_run_id\n    ON doctor_cards(run_id, created_at DESC);\n  `);\n};\n\nmodule.exports = {\n  createSchema,\n};\n"
  },
  {
    "path": "lib/server/db/usage/index.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { DatabaseSync } = require(\"node:sqlite\");\nconst { kGlobalModelPricing, deriveCostBreakdown } = require(\"../../cost-utils\");\nconst { ensureSchema } = require(\"./schema\");\nconst { getDailySummary } = require(\"./summary\");\nconst { getSessionsList, getSessionDetail } = require(\"./sessions\");\nconst { getSessionTimeSeries } = require(\"./timeseries\");\n\nlet db = null;\nlet usageDbPath = \"\";\n\nconst ensureDb = () => {\n  if (!db) throw new Error(\"Usage DB not initialized\");\n  return db;\n};\n\nconst closeUsageDb = () => {\n  if (!db) {\n    usageDbPath = \"\";\n    return;\n  }\n  const database = db;\n  db = null;\n  usageDbPath = \"\";\n  database.close();\n};\n\nconst initUsageDb = ({ rootDir }) => {\n  closeUsageDb();\n  const dbDir = path.join(rootDir, \"db\");\n  fs.mkdirSync(dbDir, { recursive: true });\n  usageDbPath = path.join(dbDir, \"usage.db\");\n  db = new DatabaseSync(usageDbPath);\n  ensureSchema(db);\n  return { path: usageDbPath };\n};\n\nconst getSessionUsageByKeyPattern = ({ keyPattern = \"\", sinceMs = 0 } = {}) => {\n  const database = ensureDb();\n  const normalizedPattern = String(keyPattern || \"\").trim();\n  if (!normalizedPattern) {\n    return {\n      totals: {\n        inputTokens: 0,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n        totalTokens: 0,\n        totalCost: 0,\n        eventCount: 0,\n        runCount: 0,\n      },\n      modelBreakdown: [],\n    };\n  }\n\n  const rows = database\n    .prepare(\n      `\n        SELECT\n          COALESCE(model, '') AS model,\n          COALESCE(provider, '') AS provider,\n          COUNT(*) AS event_count,\n          COUNT(\n            DISTINCT COALESCE(\n              NULLIF(run_id, ''),\n              NULLIF(session_key, ''),\n              NULLIF(session_id, ''),\n              printf('event:%d', id)\n            )\n          ) AS run_count,\n          SUM(COALESCE(input_tokens, 0)) AS input_tokens,\n          SUM(COALESCE(output_tokens, 0)) AS output_tokens,\n          SUM(COALESCE(cache_read_tokens, 0)) AS cache_read_tokens,\n          SUM(COALESCE(cache_write_tokens, 0)) AS cache_write_tokens,\n          SUM(COALESCE(total_tokens, 0)) AS total_tokens\n        FROM usage_events\n        WHERE session_key LIKE $keyPattern\n          AND ($sinceMs <= 0 OR timestamp >= $sinceMs)\n        GROUP BY model, provider\n        ORDER BY total_tokens DESC\n      `,\n    )\n    .all({\n      $keyPattern: normalizedPattern,\n      $sinceMs: Number.isFinite(Number(sinceMs)) ? Number(sinceMs) : 0,\n    });\n  const totalsRow = database\n    .prepare(\n      `\n        SELECT\n          COUNT(*) AS event_count,\n          COUNT(\n            DISTINCT COALESCE(\n              NULLIF(run_id, ''),\n              NULLIF(session_key, ''),\n              NULLIF(session_id, ''),\n              printf('event:%d', id)\n            )\n          ) AS run_count\n        FROM usage_events\n        WHERE session_key LIKE $keyPattern\n          AND ($sinceMs <= 0 OR timestamp >= $sinceMs)\n      `,\n    )\n    .get({\n      $keyPattern: normalizedPattern,\n      $sinceMs: Number.isFinite(Number(sinceMs)) ? Number(sinceMs) : 0,\n    }) || {};\n  const modelBreakdown = rows.map((row) => {\n    const inputTokens = Number(row.input_tokens || 0);\n    const outputTokens = Number(row.output_tokens || 0);\n    const cacheReadTokens = Number(row.cache_read_tokens || 0);\n    const cacheWriteTokens = Number(row.cache_write_tokens || 0);\n    const totalTokens = Number(row.total_tokens || 0);\n    const costBreakdown = deriveCostBreakdown({\n      inputTokens,\n      outputTokens,\n      cacheReadTokens,\n      cacheWriteTokens,\n      provider: String(row.provider || \"\"),\n      model: String(row.model || \"\"),\n    });\n    return {\n      model: String(row.model || \"\"),\n      provider: String(row.provider || \"\"),\n      eventCount: Number(row.event_count || 0),\n      runCount: Number(row.run_count || 0),\n      inputTokens,\n      outputTokens,\n      cacheReadTokens,\n      cacheWriteTokens,\n      totalTokens,\n      totalCost: costBreakdown.totalCost,\n      pricingFound: costBreakdown.pricingFound,\n    };\n  });\n\n  const totals = modelBreakdown.reduce(\n    (accumulator, row) => ({\n      inputTokens: accumulator.inputTokens + row.inputTokens,\n      outputTokens: accumulator.outputTokens + row.outputTokens,\n      cacheReadTokens: accumulator.cacheReadTokens + row.cacheReadTokens,\n      cacheWriteTokens: accumulator.cacheWriteTokens + row.cacheWriteTokens,\n      totalTokens: accumulator.totalTokens + row.totalTokens,\n      totalCost: accumulator.totalCost + row.totalCost,\n      eventCount: accumulator.eventCount + row.eventCount,\n      runCount: accumulator.runCount + row.runCount,\n    }),\n    {\n      inputTokens: 0,\n      outputTokens: 0,\n      cacheReadTokens: 0,\n      cacheWriteTokens: 0,\n      totalTokens: 0,\n      totalCost: 0,\n      eventCount: 0,\n      runCount: 0,\n    },\n  );\n  totals.eventCount = Number(totalsRow.event_count || totals.eventCount || 0);\n  totals.runCount = Number(totalsRow.run_count || 0);\n\n  return { totals, modelBreakdown };\n};\n\nmodule.exports = {\n  initUsageDb,\n  closeUsageDb,\n  getDailySummary: (options = {}) => getDailySummary({ database: ensureDb(), ...options }),\n  getSessionsList: (options = {}) => getSessionsList({ database: ensureDb(), ...options }),\n  getSessionDetail: (options = {}) => getSessionDetail({ database: ensureDb(), ...options }),\n  getSessionTimeSeries: (options = {}) =>\n    getSessionTimeSeries({ database: ensureDb(), ...options }),\n  getSessionUsageByKeyPattern,\n  kGlobalModelPricing,\n};\n"
  },
  {
    "path": "lib/server/db/usage/pricing.js",
    "content": "module.exports = require(\"../../cost-utils\");\n"
  },
  {
    "path": "lib/server/db/usage/schema.js",
    "content": "const safeAlterTable = (database, sql) => {\n  try {\n    database.exec(sql);\n  } catch (err) {\n    const message = String(err?.message || \"\").toLowerCase();\n    if (!message.includes(\"duplicate column name\")) throw err;\n  }\n};\n\nconst ensureSchema = (database) => {\n  database.exec(\"PRAGMA journal_mode=WAL;\");\n  database.exec(\"PRAGMA synchronous=NORMAL;\");\n  database.exec(\"PRAGMA busy_timeout=5000;\");\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS usage_events (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      timestamp INTEGER NOT NULL,\n      session_id TEXT,\n      session_key TEXT,\n      run_id TEXT,\n      provider TEXT NOT NULL,\n      model TEXT NOT NULL,\n      input_tokens INTEGER NOT NULL DEFAULT 0,\n      output_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n      total_tokens INTEGER NOT NULL DEFAULT 0\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_usage_events_ts\n    ON usage_events(timestamp DESC);\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_usage_events_session\n    ON usage_events(session_id);\n  `);\n  safeAlterTable(\n    database,\n    \"ALTER TABLE usage_events ADD COLUMN session_key TEXT;\",\n  );\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_usage_events_session_key\n    ON usage_events(session_key);\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS usage_daily (\n      date TEXT NOT NULL,\n      model TEXT NOT NULL,\n      provider TEXT,\n      input_tokens INTEGER NOT NULL DEFAULT 0,\n      output_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n      cache_write_tokens INTEGER NOT NULL DEFAULT 0,\n      total_tokens INTEGER NOT NULL DEFAULT 0,\n      turn_count INTEGER NOT NULL DEFAULT 0,\n      PRIMARY KEY (date, model)\n    );\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS tool_events (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      timestamp INTEGER NOT NULL,\n      session_id TEXT,\n      session_key TEXT,\n      tool_name TEXT NOT NULL,\n      success INTEGER NOT NULL DEFAULT 1,\n      duration_ms INTEGER\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_tool_events_session\n    ON tool_events(session_id);\n  `);\n  safeAlterTable(\n    database,\n    \"ALTER TABLE tool_events ADD COLUMN session_key TEXT;\",\n  );\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_tool_events_session_key\n    ON tool_events(session_key);\n  `);\n};\n\nmodule.exports = {\n  ensureSchema,\n};\n"
  },
  {
    "path": "lib/server/db/usage/sessions.js",
    "content": "const {\n  kDefaultSessionLimit,\n  kMaxSessionLimit,\n  coerceInt,\n  clampInt,\n  getUsageMetricsFromEventRow,\n} = require(\"./shared\");\n\nconst getSessionsList = ({\n  database,\n  limit = kDefaultSessionLimit,\n} = {}) => {\n  const safeLimit = clampInt(limit, 1, kMaxSessionLimit, kDefaultSessionLimit);\n  const rows = database\n    .prepare(`\n      SELECT\n        COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) AS session_ref,\n        MAX(session_key) AS session_key,\n        MAX(session_id) AS session_id,\n        MIN(timestamp) AS first_activity_ms,\n        MAX(timestamp) AS last_activity_ms,\n        COUNT(*) AS turn_count,\n        SUM(input_tokens) AS input_tokens,\n        SUM(output_tokens) AS output_tokens,\n        SUM(cache_read_tokens) AS cache_read_tokens,\n        SUM(cache_write_tokens) AS cache_write_tokens,\n        SUM(total_tokens) AS total_tokens\n      FROM usage_events\n      WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) IS NOT NULL\n      GROUP BY session_ref\n      ORDER BY last_activity_ms DESC\n      LIMIT $limit\n    `)\n    .all({ $limit: safeLimit });\n  return rows.map((row) => {\n    const eventRows = database\n      .prepare(`\n        SELECT\n          model,\n          input_tokens,\n          output_tokens,\n          cache_read_tokens,\n          cache_write_tokens,\n          total_tokens\n        FROM usage_events\n        WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef\n      `)\n      .all({ $sessionRef: row.session_ref });\n    let totalCost = 0;\n    const modelTokenTotals = new Map();\n    for (const eventRow of eventRows) {\n      const metrics = getUsageMetricsFromEventRow(eventRow);\n      totalCost += metrics.totalCost;\n      const model = String(eventRow.model || \"\");\n      modelTokenTotals.set(model, (modelTokenTotals.get(model) || 0) + metrics.totalTokens);\n    }\n    const dominantModel = Array.from(modelTokenTotals.entries())\n      .sort((a, b) => b[1] - a[1])[0]?.[0] || \"\";\n    return {\n      sessionId: row.session_ref,\n      sessionKey: String(row.session_key || \"\"),\n      rawSessionId: String(row.session_id || \"\"),\n      firstActivityMs: coerceInt(row.first_activity_ms),\n      lastActivityMs: coerceInt(row.last_activity_ms),\n      durationMs: Math.max(\n        0,\n        coerceInt(row.last_activity_ms) - coerceInt(row.first_activity_ms),\n      ),\n      turnCount: coerceInt(row.turn_count),\n      inputTokens: coerceInt(row.input_tokens),\n      outputTokens: coerceInt(row.output_tokens),\n      cacheReadTokens: coerceInt(row.cache_read_tokens),\n      cacheWriteTokens: coerceInt(row.cache_write_tokens),\n      totalTokens: coerceInt(row.total_tokens),\n      totalCost,\n      dominantModel,\n    };\n  });\n};\n\nconst getSessionDetail = ({ database, sessionId }) => {\n  const safeSessionRef = String(sessionId || \"\").trim();\n  if (!safeSessionRef) return null;\n  const summaryRow = database\n    .prepare(`\n      SELECT\n        MAX(session_key) AS session_key,\n        MAX(session_id) AS session_id,\n        MIN(timestamp) AS first_activity_ms,\n        MAX(timestamp) AS last_activity_ms,\n        COUNT(*) AS turn_count,\n        SUM(input_tokens) AS input_tokens,\n        SUM(output_tokens) AS output_tokens,\n        SUM(cache_read_tokens) AS cache_read_tokens,\n        SUM(cache_write_tokens) AS cache_write_tokens,\n        SUM(total_tokens) AS total_tokens\n      FROM usage_events\n      WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef\n    `)\n    .get({ $sessionRef: safeSessionRef });\n  if (!summaryRow || !coerceInt(summaryRow.turn_count)) return null;\n\n  const modelEvents = database\n    .prepare(`\n      SELECT\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      FROM usage_events\n      WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef\n    `)\n    .all({ $sessionRef: safeSessionRef });\n  const byProviderModel = new Map();\n  for (const eventRow of modelEvents) {\n    const provider = String(eventRow.provider || \"unknown\");\n    const model = String(eventRow.model || \"unknown\");\n    const mapKey = `${provider}\\u0000${model}`;\n    if (!byProviderModel.has(mapKey)) {\n      byProviderModel.set(mapKey, {\n        provider,\n        model,\n        turnCount: 0,\n        inputTokens: 0,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n        totalTokens: 0,\n        totalCost: 0,\n        inputCost: 0,\n        outputCost: 0,\n        cacheReadCost: 0,\n        cacheWriteCost: 0,\n        pricingFound: false,\n      });\n    }\n    const aggregate = byProviderModel.get(mapKey);\n    const metrics = getUsageMetricsFromEventRow(eventRow);\n    aggregate.turnCount += 1;\n    aggregate.inputTokens += metrics.inputTokens;\n    aggregate.outputTokens += metrics.outputTokens;\n    aggregate.cacheReadTokens += metrics.cacheReadTokens;\n    aggregate.cacheWriteTokens += metrics.cacheWriteTokens;\n    aggregate.totalTokens += metrics.totalTokens;\n    aggregate.totalCost += metrics.totalCost;\n    aggregate.inputCost += metrics.inputCost;\n    aggregate.outputCost += metrics.outputCost;\n    aggregate.cacheReadCost += metrics.cacheReadCost;\n    aggregate.cacheWriteCost += metrics.cacheWriteCost;\n    aggregate.pricingFound = aggregate.pricingFound || metrics.pricingFound;\n  }\n  const modelRows = Array.from(byProviderModel.values()).sort(\n    (a, b) => b.totalTokens - a.totalTokens,\n  );\n\n  const toolRows = database\n    .prepare(`\n      SELECT\n        tool_name,\n        COUNT(*) AS call_count,\n        SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count,\n        SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS error_count,\n        AVG(duration_ms) AS avg_duration_ms,\n        MIN(duration_ms) AS min_duration_ms,\n        MAX(duration_ms) AS max_duration_ms\n      FROM tool_events\n      WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef\n      GROUP BY tool_name\n      ORDER BY call_count DESC\n    `)\n    .all({ $sessionRef: safeSessionRef })\n    .map((row) => {\n      const callCount = coerceInt(row.call_count);\n      const successCount = coerceInt(row.success_count);\n      const errorCount = coerceInt(row.error_count);\n      return {\n        toolName: row.tool_name,\n        callCount,\n        successCount,\n        errorCount,\n        errorRate: callCount > 0 ? errorCount / callCount : 0,\n        avgDurationMs: Number(row.avg_duration_ms || 0),\n        minDurationMs: coerceInt(row.min_duration_ms),\n        maxDurationMs: coerceInt(row.max_duration_ms),\n      };\n    });\n\n  const firstActivityMs = coerceInt(summaryRow.first_activity_ms);\n  const lastActivityMs = coerceInt(summaryRow.last_activity_ms);\n  const totalCost = modelRows.reduce(\n    (sum, modelRow) => sum + Number(modelRow.totalCost || 0),\n    0,\n  );\n\n  return {\n    sessionId: safeSessionRef,\n    sessionKey: String(summaryRow.session_key || \"\"),\n    rawSessionId: String(summaryRow.session_id || \"\"),\n    firstActivityMs,\n    lastActivityMs,\n    durationMs: Math.max(0, lastActivityMs - firstActivityMs),\n    turnCount: coerceInt(summaryRow.turn_count),\n    inputTokens: coerceInt(summaryRow.input_tokens),\n    outputTokens: coerceInt(summaryRow.output_tokens),\n    cacheReadTokens: coerceInt(summaryRow.cache_read_tokens),\n    cacheWriteTokens: coerceInt(summaryRow.cache_write_tokens),\n    totalTokens: coerceInt(summaryRow.total_tokens),\n    totalCost,\n    modelBreakdown: modelRows,\n    toolUsage: toolRows,\n  };\n};\n\nmodule.exports = {\n  getSessionsList,\n  getSessionDetail,\n};\n"
  },
  {
    "path": "lib/server/db/usage/shared.js",
    "content": "const { deriveCostBreakdown } = require(\"../../cost-utils\");\n\nconst kDefaultSessionLimit = 50;\nconst kMaxSessionLimit = 200;\nconst kDefaultDays = 30;\nconst kDefaultMaxPoints = 100;\nconst kMaxMaxPoints = 1000;\nconst kDayMs = 24 * 60 * 60 * 1000;\nconst kUtcTimeZone = \"UTC\";\nconst kDayKeyFormatterCache = new Map();\n\nconst coerceInt = (value, fallbackValue = 0) => {\n  const parsed = Number.parseInt(String(value ?? \"\"), 10);\n  return Number.isFinite(parsed) ? parsed : fallbackValue;\n};\n\nconst clampInt = (value, minValue, maxValue, fallbackValue) =>\n  Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue)));\n\nconst normalizeTimeZone = (value) => {\n  const raw = String(value || \"\").trim();\n  if (!raw) return kUtcTimeZone;\n  try {\n    new Intl.DateTimeFormat(\"en-US\", { timeZone: raw });\n    return raw;\n  } catch {\n    return kUtcTimeZone;\n  }\n};\n\nconst getDayKeyFormatter = (timeZone) => {\n  if (kDayKeyFormatterCache.has(timeZone)) {\n    return kDayKeyFormatterCache.get(timeZone);\n  }\n  const formatter = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone,\n    year: \"numeric\",\n    month: \"2-digit\",\n    day: \"2-digit\",\n  });\n  kDayKeyFormatterCache.set(timeZone, formatter);\n  return formatter;\n};\n\nconst toTimeZoneDayKey = (timestampMs, timeZone) => {\n  const parts = getDayKeyFormatter(timeZone).formatToParts(new Date(timestampMs));\n  const year = parts.find((part) => part.type === \"year\")?.value || \"0000\";\n  const month = parts.find((part) => part.type === \"month\")?.value || \"01\";\n  const day = parts.find((part) => part.type === \"day\")?.value || \"01\";\n  return `${year}-${month}-${day}`;\n};\n\nconst toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10);\n\nconst getPeriodRange = (days, timeZone = kUtcTimeZone) => {\n  const now = Date.now();\n  const safeDays = clampInt(days, 1, 3650, kDefaultDays);\n  const startMs = now - safeDays * kDayMs;\n  const normalizedTimeZone = normalizeTimeZone(timeZone);\n  const startDay = normalizedTimeZone === kUtcTimeZone\n    ? toDayKey(startMs)\n    : toTimeZoneDayKey(startMs, normalizedTimeZone);\n  return { now, safeDays, startDay, timeZone: normalizedTimeZone };\n};\n\nconst getUsageMetricsFromEventRow = (row) => {\n  const inputTokens = coerceInt(row.input_tokens);\n  const outputTokens = coerceInt(row.output_tokens);\n  const cacheReadTokens = coerceInt(row.cache_read_tokens);\n  const cacheWriteTokens = coerceInt(row.cache_write_tokens);\n  const totalTokens =\n    coerceInt(row.total_tokens) ||\n    inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;\n  const cost = deriveCostBreakdown({\n    inputTokens,\n    outputTokens,\n    cacheReadTokens,\n    cacheWriteTokens,\n    provider: row.provider,\n    model: row.model,\n  });\n  return {\n    inputTokens,\n    outputTokens,\n    cacheReadTokens,\n    cacheWriteTokens,\n    totalTokens,\n    ...cost,\n  };\n};\n\nconst parseAgentAndSourceFromSessionRef = (sessionRef) => {\n  const raw = String(sessionRef || \"\").trim();\n  if (!raw) {\n    return { agent: \"unknown\", source: \"chat\" };\n  }\n  const parts = raw.split(\":\");\n  const agent =\n    parts[0] === \"agent\" && String(parts[1] || \"\").trim()\n      ? String(parts[1] || \"\").trim()\n      : \"unknown\";\n  const source = parts.includes(\"hook\")\n    ? \"hooks\"\n    : parts.includes(\"cron\")\n      ? \"cron\"\n      : \"chat\";\n  return { agent, source };\n};\n\nconst downsamplePoints = (points, maxPoints) => {\n  if (points.length <= maxPoints) return points;\n  const stride = Math.ceil(points.length / maxPoints);\n  const sampled = [];\n  for (let index = 0; index < points.length; index += stride) {\n    sampled.push(points[index]);\n  }\n  const lastPoint = points[points.length - 1];\n  if (sampled[sampled.length - 1]?.timestamp !== lastPoint.timestamp) {\n    sampled.push(lastPoint);\n  }\n  return sampled;\n};\n\nmodule.exports = {\n  kDefaultSessionLimit,\n  kMaxSessionLimit,\n  kDefaultDays,\n  kDefaultMaxPoints,\n  kMaxMaxPoints,\n  kDayMs,\n  kUtcTimeZone,\n  coerceInt,\n  clampInt,\n  toTimeZoneDayKey,\n  toDayKey,\n  getPeriodRange,\n  getUsageMetricsFromEventRow,\n  parseAgentAndSourceFromSessionRef,\n  downsamplePoints,\n};\n"
  },
  {
    "path": "lib/server/db/usage/summary.js",
    "content": "const {\n  kDefaultDays,\n  kDayMs,\n  kUtcTimeZone,\n  coerceInt,\n  toDayKey,\n  toTimeZoneDayKey,\n  getPeriodRange,\n  getUsageMetricsFromEventRow,\n  parseAgentAndSourceFromSessionRef,\n} = require(\"./shared\");\n\nconst getAgentCostDistribution = ({\n  eventsRows = [],\n  startDay = \"\",\n  timeZone = kUtcTimeZone,\n}) => {\n  const byAgent = new Map();\n  const ensureAgentBucket = (agent) => {\n    if (byAgent.has(agent)) return byAgent.get(agent);\n    const bucket = {\n      agent,\n      inputTokens: 0,\n      outputTokens: 0,\n      cacheReadTokens: 0,\n      cacheWriteTokens: 0,\n      totalTokens: 0,\n      totalCost: 0,\n      turnCount: 0,\n      sourceBreakdown: {\n        chat: {\n          source: \"chat\",\n          inputTokens: 0,\n          outputTokens: 0,\n          cacheReadTokens: 0,\n          cacheWriteTokens: 0,\n          totalTokens: 0,\n          totalCost: 0,\n          turnCount: 0,\n        },\n        hooks: {\n          source: \"hooks\",\n          inputTokens: 0,\n          outputTokens: 0,\n          cacheReadTokens: 0,\n          cacheWriteTokens: 0,\n          totalTokens: 0,\n          totalCost: 0,\n          turnCount: 0,\n        },\n        cron: {\n          source: \"cron\",\n          inputTokens: 0,\n          outputTokens: 0,\n          cacheReadTokens: 0,\n          cacheWriteTokens: 0,\n          totalTokens: 0,\n          totalCost: 0,\n          turnCount: 0,\n        },\n      },\n    };\n    byAgent.set(agent, bucket);\n    return bucket;\n  };\n\n  for (const eventRow of eventsRows) {\n    const timestamp = coerceInt(eventRow.timestamp);\n    const dayKey = timeZone === kUtcTimeZone\n      ? toDayKey(timestamp)\n      : toTimeZoneDayKey(timestamp, timeZone);\n    if (dayKey < startDay) continue;\n\n    const metrics = getUsageMetricsFromEventRow(eventRow);\n    const sessionRef = String(eventRow.session_key || eventRow.session_id || \"\");\n    const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef);\n    const agentBucket = ensureAgentBucket(agent);\n    const sourceBucket = agentBucket.sourceBreakdown[source];\n\n    agentBucket.inputTokens += metrics.inputTokens;\n    agentBucket.outputTokens += metrics.outputTokens;\n    agentBucket.cacheReadTokens += metrics.cacheReadTokens;\n    agentBucket.cacheWriteTokens += metrics.cacheWriteTokens;\n    agentBucket.totalTokens += metrics.totalTokens;\n    agentBucket.totalCost += metrics.totalCost;\n    agentBucket.turnCount += 1;\n\n    sourceBucket.inputTokens += metrics.inputTokens;\n    sourceBucket.outputTokens += metrics.outputTokens;\n    sourceBucket.cacheReadTokens += metrics.cacheReadTokens;\n    sourceBucket.cacheWriteTokens += metrics.cacheWriteTokens;\n    sourceBucket.totalTokens += metrics.totalTokens;\n    sourceBucket.totalCost += metrics.totalCost;\n    sourceBucket.turnCount += 1;\n  }\n\n  const agents = Array.from(byAgent.values())\n    .map((bucket) => ({\n      agent: bucket.agent,\n      inputTokens: bucket.inputTokens,\n      outputTokens: bucket.outputTokens,\n      cacheReadTokens: bucket.cacheReadTokens,\n      cacheWriteTokens: bucket.cacheWriteTokens,\n      totalTokens: bucket.totalTokens,\n      totalCost: bucket.totalCost,\n      turnCount: bucket.turnCount,\n      sourceBreakdown: [\"chat\", \"hooks\", \"cron\"].map(\n        (source) => bucket.sourceBreakdown[source],\n      ),\n    }))\n    .sort((a, b) => b.totalCost - a.totalCost);\n\n  return {\n    agents,\n    totals: agents.reduce(\n      (acc, agentBucket) => {\n        acc.totalCost += Number(agentBucket.totalCost || 0);\n        acc.totalTokens += Number(agentBucket.totalTokens || 0);\n        acc.turnCount += Number(agentBucket.turnCount || 0);\n        return acc;\n      },\n      { totalCost: 0, totalTokens: 0, turnCount: 0 },\n    ),\n  };\n};\n\nconst getDailySummary = ({\n  database,\n  days = kDefaultDays,\n  timeZone = kUtcTimeZone,\n} = {}) => {\n  const { now, safeDays, startDay, timeZone: normalizedTimeZone } = getPeriodRange(\n    days,\n    timeZone,\n  );\n  const lookbackMs = now - (safeDays + 2) * kDayMs;\n  const eventsRows = database\n    .prepare(`\n      SELECT\n        timestamp,\n        session_id,\n        session_key,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      FROM usage_events\n      WHERE timestamp >= $lookbackMs\n      ORDER BY timestamp ASC\n    `)\n    .all({ $lookbackMs: lookbackMs });\n  const byDateModel = new Map();\n  const byDateSource = new Map();\n  const byDateAgent = new Map();\n  for (const eventRow of eventsRows) {\n    const timestamp = coerceInt(eventRow.timestamp);\n    const dayKey = normalizedTimeZone === kUtcTimeZone\n      ? toDayKey(timestamp)\n      : toTimeZoneDayKey(timestamp, normalizedTimeZone);\n    if (dayKey < startDay) continue;\n    const sessionRef = String(eventRow.session_key || eventRow.session_id || \"\");\n    const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef);\n    const model = String(eventRow.model || \"unknown\");\n    const mapKey = `${dayKey}\\u0000${model}`;\n    if (!byDateModel.has(mapKey)) {\n      byDateModel.set(mapKey, {\n        date: dayKey,\n        model,\n        provider: String(eventRow.provider || \"unknown\"),\n        inputTokens: 0,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n        totalTokens: 0,\n        turnCount: 0,\n        totalCost: 0,\n        inputCost: 0,\n        outputCost: 0,\n        cacheReadCost: 0,\n        cacheWriteCost: 0,\n        pricingFound: false,\n      });\n    }\n    const aggregate = byDateModel.get(mapKey);\n    const metrics = getUsageMetricsFromEventRow(eventRow);\n    aggregate.inputTokens += metrics.inputTokens;\n    aggregate.outputTokens += metrics.outputTokens;\n    aggregate.cacheReadTokens += metrics.cacheReadTokens;\n    aggregate.cacheWriteTokens += metrics.cacheWriteTokens;\n    aggregate.totalTokens += metrics.totalTokens;\n    aggregate.turnCount += 1;\n    aggregate.totalCost += metrics.totalCost;\n    aggregate.inputCost += metrics.inputCost;\n    aggregate.outputCost += metrics.outputCost;\n    aggregate.cacheReadCost += metrics.cacheReadCost;\n    aggregate.cacheWriteCost += metrics.cacheWriteCost;\n    aggregate.pricingFound = aggregate.pricingFound || metrics.pricingFound;\n    if (!aggregate.provider && eventRow.provider) {\n      aggregate.provider = String(eventRow.provider || \"unknown\");\n    }\n\n    const sourceMapKey = `${dayKey}\\u0000${source}`;\n    if (!byDateSource.has(sourceMapKey)) {\n      byDateSource.set(sourceMapKey, {\n        source,\n        date: dayKey,\n        inputTokens: 0,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n        totalTokens: 0,\n        turnCount: 0,\n        totalCost: 0,\n      });\n    }\n    const sourceAggregate = byDateSource.get(sourceMapKey);\n    sourceAggregate.inputTokens += metrics.inputTokens;\n    sourceAggregate.outputTokens += metrics.outputTokens;\n    sourceAggregate.cacheReadTokens += metrics.cacheReadTokens;\n    sourceAggregate.cacheWriteTokens += metrics.cacheWriteTokens;\n    sourceAggregate.totalTokens += metrics.totalTokens;\n    sourceAggregate.turnCount += 1;\n    sourceAggregate.totalCost += metrics.totalCost;\n\n    const agentMapKey = `${dayKey}\\u0000${agent}`;\n    if (!byDateAgent.has(agentMapKey)) {\n      byDateAgent.set(agentMapKey, {\n        agent,\n        date: dayKey,\n        inputTokens: 0,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n        totalTokens: 0,\n        turnCount: 0,\n        totalCost: 0,\n      });\n    }\n    const agentAggregate = byDateAgent.get(agentMapKey);\n    agentAggregate.inputTokens += metrics.inputTokens;\n    agentAggregate.outputTokens += metrics.outputTokens;\n    agentAggregate.cacheReadTokens += metrics.cacheReadTokens;\n    agentAggregate.cacheWriteTokens += metrics.cacheWriteTokens;\n    agentAggregate.totalTokens += metrics.totalTokens;\n    agentAggregate.turnCount += 1;\n    agentAggregate.totalCost += metrics.totalCost;\n  }\n  const enriched = Array.from(byDateModel.values()).sort((a, b) => {\n    if (a.date === b.date) return b.totalTokens - a.totalTokens;\n    return a.date.localeCompare(b.date);\n  });\n  const costByAgent = getAgentCostDistribution({\n    eventsRows,\n    startDay,\n    timeZone: normalizedTimeZone,\n  });\n  const byDate = new Map();\n  for (const row of enriched) {\n    if (!byDate.has(row.date)) byDate.set(row.date, []);\n    byDate.get(row.date).push({\n      model: row.model,\n      provider: row.provider,\n      inputTokens: row.inputTokens,\n      outputTokens: row.outputTokens,\n      cacheReadTokens: row.cacheReadTokens,\n      cacheWriteTokens: row.cacheWriteTokens,\n      totalTokens: row.totalTokens,\n      turnCount: row.turnCount,\n      totalCost: row.totalCost,\n      inputCost: row.inputCost,\n      outputCost: row.outputCost,\n      cacheReadCost: row.cacheReadCost,\n      cacheWriteCost: row.cacheWriteCost,\n      pricingFound: row.pricingFound,\n    });\n  }\n  const byDateSourceRows = new Map();\n  for (const row of byDateSource.values()) {\n    if (!byDateSourceRows.has(row.date)) byDateSourceRows.set(row.date, []);\n    byDateSourceRows.get(row.date).push({\n      source: row.source,\n      inputTokens: row.inputTokens,\n      outputTokens: row.outputTokens,\n      cacheReadTokens: row.cacheReadTokens,\n      cacheWriteTokens: row.cacheWriteTokens,\n      totalTokens: row.totalTokens,\n      turnCount: row.turnCount,\n      totalCost: row.totalCost,\n    });\n  }\n  for (const rows of byDateSourceRows.values()) {\n    rows.sort((left, right) => {\n      if (right.totalTokens !== left.totalTokens) {\n        return right.totalTokens - left.totalTokens;\n      }\n      return String(left.source || \"\").localeCompare(String(right.source || \"\"));\n    });\n  }\n  const byDateAgentRows = new Map();\n  for (const row of byDateAgent.values()) {\n    if (!byDateAgentRows.has(row.date)) byDateAgentRows.set(row.date, []);\n    byDateAgentRows.get(row.date).push({\n      agent: row.agent,\n      inputTokens: row.inputTokens,\n      outputTokens: row.outputTokens,\n      cacheReadTokens: row.cacheReadTokens,\n      cacheWriteTokens: row.cacheWriteTokens,\n      totalTokens: row.totalTokens,\n      turnCount: row.turnCount,\n      totalCost: row.totalCost,\n    });\n  }\n  for (const rows of byDateAgentRows.values()) {\n    rows.sort((left, right) => {\n      if (right.totalTokens !== left.totalTokens) {\n        return right.totalTokens - left.totalTokens;\n      }\n      return String(left.agent || \"\").localeCompare(String(right.agent || \"\"));\n    });\n  }\n  const daily = [];\n  const totals = {\n    inputTokens: 0,\n    outputTokens: 0,\n    cacheReadTokens: 0,\n    cacheWriteTokens: 0,\n    totalTokens: 0,\n    totalCost: 0,\n    turnCount: 0,\n    modelCount: 0,\n  };\n  for (const [date, modelRows] of byDate.entries()) {\n    const aggregate = modelRows.reduce(\n      (acc, row) => ({\n        inputTokens: acc.inputTokens + row.inputTokens,\n        outputTokens: acc.outputTokens + row.outputTokens,\n        cacheReadTokens: acc.cacheReadTokens + row.cacheReadTokens,\n        cacheWriteTokens: acc.cacheWriteTokens + row.cacheWriteTokens,\n        totalTokens: acc.totalTokens + row.totalTokens,\n        totalCost: acc.totalCost + row.totalCost,\n        turnCount: acc.turnCount + row.turnCount,\n      }),\n      {\n        inputTokens: 0,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n        totalTokens: 0,\n        totalCost: 0,\n        turnCount: 0,\n      },\n    );\n    daily.push({\n      date,\n      ...aggregate,\n      models: modelRows,\n      sources: byDateSourceRows.get(date) || [],\n      agents: byDateAgentRows.get(date) || [],\n    });\n    totals.inputTokens += aggregate.inputTokens;\n    totals.outputTokens += aggregate.outputTokens;\n    totals.cacheReadTokens += aggregate.cacheReadTokens;\n    totals.cacheWriteTokens += aggregate.cacheWriteTokens;\n    totals.totalTokens += aggregate.totalTokens;\n    totals.totalCost += aggregate.totalCost;\n    totals.turnCount += aggregate.turnCount;\n    totals.modelCount += modelRows.length;\n  }\n  return {\n    updatedAt: Date.now(),\n    days: safeDays,\n    timeZone: normalizedTimeZone,\n    daily,\n    totals,\n    costByAgent,\n  };\n};\n\nmodule.exports = {\n  getDailySummary,\n};\n"
  },
  {
    "path": "lib/server/db/usage/timeseries.js",
    "content": "const {\n  kDefaultMaxPoints,\n  kMaxMaxPoints,\n  coerceInt,\n  clampInt,\n  getUsageMetricsFromEventRow,\n  downsamplePoints,\n} = require(\"./shared\");\n\nconst getSessionTimeSeries = ({\n  database,\n  sessionId,\n  maxPoints = kDefaultMaxPoints,\n}) => {\n  const safeSessionRef = String(sessionId || \"\").trim();\n  if (!safeSessionRef) return { sessionId: safeSessionRef, points: [] };\n  const rows = database\n    .prepare(`\n      SELECT\n        timestamp,\n        session_key,\n        session_id,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      FROM usage_events\n      WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef\n      ORDER BY timestamp ASC\n    `)\n    .all({ $sessionRef: safeSessionRef });\n  let cumulativeTokens = 0;\n  let cumulativeCost = 0;\n  const points = rows.map((row) => {\n    const metrics = getUsageMetricsFromEventRow(row);\n    cumulativeTokens += metrics.totalTokens;\n    cumulativeCost += metrics.totalCost;\n    return {\n      timestamp: coerceInt(row.timestamp),\n      sessionKey: String(row.session_key || \"\"),\n      rawSessionId: String(row.session_id || \"\"),\n      model: String(row.model || \"\"),\n      inputTokens: metrics.inputTokens,\n      outputTokens: metrics.outputTokens,\n      cacheReadTokens: metrics.cacheReadTokens,\n      cacheWriteTokens: metrics.cacheWriteTokens,\n      totalTokens: metrics.totalTokens,\n      cost: metrics.totalCost,\n      cumulativeTokens,\n      cumulativeCost,\n    };\n  });\n  const safeMaxPoints = clampInt(maxPoints, 10, kMaxMaxPoints, kDefaultMaxPoints);\n  return {\n    sessionId: safeSessionRef,\n    points: downsamplePoints(points, safeMaxPoints),\n  };\n};\n\nmodule.exports = {\n  getSessionTimeSeries,\n};\n"
  },
  {
    "path": "lib/server/db/watchdog/index.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { DatabaseSync } = require(\"node:sqlite\");\nconst { createSchema } = require(\"./schema\");\n\nlet db = null;\nlet pruneTimer = null;\n\nconst kDefaultLimit = 20;\nconst kMaxLimit = 200;\nconst kPruneIntervalMs = 12 * 60 * 60 * 1000;\n\nconst ensureDb = () => {\n  if (!db) throw new Error(\"Watchdog DB not initialized\");\n  return db;\n};\n\nconst closeWatchdogDb = () => {\n  if (pruneTimer) {\n    clearInterval(pruneTimer);\n    pruneTimer = null;\n  }\n  if (!db) return;\n  const database = db;\n  db = null;\n  database.close();\n};\n\nconst initWatchdogDb = ({ rootDir, pruneDays = 30 }) => {\n  closeWatchdogDb();\n  const dbDir = path.join(rootDir, \"db\");\n  fs.mkdirSync(dbDir, { recursive: true });\n  const dbPath = path.join(dbDir, \"watchdog.db\");\n  db = new DatabaseSync(dbPath);\n  createSchema(db);\n  pruneWatchdogEvents(pruneDays);\n  pruneTimer = setInterval(() => {\n    try {\n      pruneWatchdogEvents(pruneDays);\n    } catch (err) {\n      console.error(`[watchdog-db] prune error: ${err.message}`);\n    }\n  }, kPruneIntervalMs);\n  if (typeof pruneTimer.unref === \"function\") pruneTimer.unref();\n  return { path: dbPath };\n};\n\nconst insertWatchdogEvent = ({\n  eventType,\n  source,\n  status,\n  details = null,\n  correlationId = \"\",\n}) => {\n  const database = ensureDb();\n  const stmt = database.prepare(`\n    INSERT INTO watchdog_events (\n      event_type,\n      source,\n      status,\n      details,\n      correlation_id\n    ) VALUES (\n      $event_type,\n      $source,\n      $status,\n      $details,\n      $correlation_id\n    )\n  `);\n  const result = stmt.run({\n    $event_type: String(eventType || \"\"),\n    $source: String(source || \"\"),\n    $status: String(status || \"failed\"),\n    $details:\n      details == null\n        ? null\n        : typeof details === \"string\"\n          ? details\n          : JSON.stringify(details),\n    $correlation_id: String(correlationId || \"\"),\n  });\n  return Number(result.lastInsertRowid || 0);\n};\n\nconst getRecentEvents = ({ limit = kDefaultLimit, includeRoutine = false } = {}) => {\n  const database = ensureDb();\n  const safeLimit = Math.max(\n    1,\n    Math.min(Number.parseInt(String(limit || kDefaultLimit), 10) || kDefaultLimit, kMaxLimit),\n  );\n  const whereClause = includeRoutine\n    ? \"\"\n    : \"WHERE NOT (event_type = 'health_check' AND status = 'ok')\";\n  const rows = database\n    .prepare(`\n      SELECT id, event_type, source, status, details, correlation_id, created_at\n      FROM watchdog_events\n      ${whereClause}\n      ORDER BY created_at DESC\n      LIMIT $limit\n    `)\n    .all({ $limit: safeLimit });\n  const mapped = rows.map((row) => {\n    let parsedDetails = row.details;\n    if (typeof row.details === \"string\" && row.details) {\n      try {\n        parsedDetails = JSON.parse(row.details);\n      } catch {}\n    }\n    return {\n      id: row.id,\n      eventType: row.event_type,\n      source: row.source,\n      status: row.status,\n      details: parsedDetails,\n      correlationId: row.correlation_id || \"\",\n      createdAt: row.created_at,\n    };\n  });\n  return mapped;\n};\n\nconst pruneWatchdogEvents = (days = 30) => {\n  const database = ensureDb();\n  const safeDays = Math.max(1, Number.parseInt(String(days || 30), 10) || 30);\n  const modifier = `-${safeDays} days`;\n  const result = database\n    .prepare(`\n      DELETE FROM watchdog_events\n      WHERE created_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', $modifier)\n    `)\n    .run({ $modifier: modifier });\n  return Number(result.changes || 0);\n};\n\nmodule.exports = {\n  initWatchdogDb,\n  closeWatchdogDb,\n  insertWatchdogEvent,\n  getRecentEvents,\n  pruneWatchdogEvents,\n};\n"
  },
  {
    "path": "lib/server/db/watchdog/schema.js",
    "content": "const createSchema = (database) => {\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS watchdog_events (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      event_type TEXT NOT NULL,\n      source TEXT NOT NULL,\n      status TEXT NOT NULL,\n      details TEXT,\n      correlation_id TEXT,\n      created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_watchdog_events_ts\n    ON watchdog_events(created_at DESC);\n  `);\n};\n\nmodule.exports = {\n  createSchema,\n};\n"
  },
  {
    "path": "lib/server/db/webhooks/index.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\nconst { DatabaseSync } = require(\"node:sqlite\");\nconst { createSchema } = require(\"./schema\");\n\nlet db = null;\nlet pruneTimer = null;\n\nconst kDefaultRequestLimit = 50;\nconst kMaxRequestLimit = 200;\nconst kPruneIntervalMs = 12 * 60 * 60 * 1000;\nconst kHealthSummaryWindow = 25;\n\nconst ensureDb = () => {\n  if (!db) throw new Error(\"Webhooks DB not initialized\");\n  return db;\n};\n\nconst closeWebhooksDb = () => {\n  if (pruneTimer) {\n    clearInterval(pruneTimer);\n    pruneTimer = null;\n  }\n  if (!db) return;\n  const database = db;\n  db = null;\n  database.close();\n};\n\nconst initWebhooksDb = ({ rootDir, pruneDays = 30 }) => {\n  closeWebhooksDb();\n  const dbDir = path.join(rootDir, \"db\");\n  fs.mkdirSync(dbDir, { recursive: true });\n  const dbPath = path.join(dbDir, \"webhooks.db\");\n  db = new DatabaseSync(dbPath);\n  createSchema(db);\n  pruneOldEntries(pruneDays);\n  pruneTimer = setInterval(() => {\n    try {\n      pruneOldEntries(pruneDays);\n    } catch (err) {\n      console.error(\"[webhooks-db] prune error:\", err.message);\n    }\n  }, kPruneIntervalMs);\n  if (typeof pruneTimer.unref === \"function\") pruneTimer.unref();\n  return { path: dbPath };\n};\n\nconst parseJsonText = (value) => {\n  if (typeof value !== \"string\" || !value) return null;\n  try {\n    return JSON.parse(value);\n  } catch {\n    return null;\n  }\n};\n\nconst generateOauthCallbackId = () => crypto.randomBytes(16).toString(\"hex\");\n\nconst toOauthCallbackModel = (row) => {\n  if (!row) return null;\n  return {\n    callbackId: String(row.callback_id || \"\"),\n    hookName: String(row.hook_name || \"\"),\n    createdAt: row.created_at || null,\n    rotatedAt: row.rotated_at || null,\n    lastUsedAt: row.last_used_at || null,\n  };\n};\n\nconst toRequestModel = (row) => {\n  if (!row) return null;\n  return {\n    id: row.id,\n    hookName: row.hook_name,\n    method: row.method || \"\",\n    headers: parseJsonText(row.headers) || {},\n    payload: row.payload || \"\",\n    payloadTruncated: !!row.payload_truncated,\n    payloadSize: Number(row.payload_size || 0),\n    sourceIp: row.source_ip || \"\",\n    gatewayStatus: row.gateway_status == null ? null : Number(row.gateway_status),\n    gatewayBody: row.gateway_body || \"\",\n    createdAt: row.created_at,\n    status:\n      row.gateway_status >= 200 && row.gateway_status < 300 ? \"success\" : \"error\",\n  };\n};\n\nconst insertRequest = ({\n  hookName,\n  method,\n  headers,\n  payload,\n  payloadTruncated,\n  payloadSize,\n  sourceIp,\n  gatewayStatus,\n  gatewayBody,\n}) => {\n  const database = ensureDb();\n  const stmt = database.prepare(`\n    INSERT INTO webhook_requests (\n      hook_name,\n      method,\n      headers,\n      payload,\n      payload_truncated,\n      payload_size,\n      source_ip,\n      gateway_status,\n      gateway_body\n    ) VALUES (\n      $hook_name,\n      $method,\n      $headers,\n      $payload,\n      $payload_truncated,\n      $payload_size,\n      $source_ip,\n      $gateway_status,\n      $gateway_body\n    )\n  `);\n  const info = stmt.run({\n    $hook_name: hookName,\n    $method: method || \"\",\n    $headers: JSON.stringify(headers || {}),\n    $payload: payload || \"\",\n    $payload_truncated: payloadTruncated ? 1 : 0,\n    $payload_size: Number(payloadSize || 0),\n    $source_ip: sourceIp || \"\",\n    $gateway_status:\n      Number.isFinite(Number(gatewayStatus)) ? Number(gatewayStatus) : null,\n    $gateway_body: gatewayBody || \"\",\n  });\n  return Number(info.lastInsertRowid || 0);\n};\n\nconst resolveStatusWhereClause = (status) => {\n  if (status === \"success\") return \"AND gateway_status >= 200 AND gateway_status < 300\";\n  if (status === \"error\")\n    return \"AND (gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300)\";\n  return \"\";\n};\n\nconst getRequests = (hookName, { limit, offset, status = \"all\" } = {}) => {\n  const database = ensureDb();\n  const safeLimit = Math.max(\n    1,\n    Math.min(Number.parseInt(String(limit || kDefaultRequestLimit), 10) || kDefaultRequestLimit, kMaxRequestLimit),\n  );\n  const safeOffset = Math.max(0, Number.parseInt(String(offset || 0), 10) || 0);\n  const statusClause = resolveStatusWhereClause(status);\n  const rows = database\n    .prepare(`\n      SELECT\n        id,\n        hook_name,\n        method,\n        headers,\n        payload,\n        payload_truncated,\n        payload_size,\n        source_ip,\n        gateway_status,\n        gateway_body,\n        created_at\n      FROM webhook_requests\n      WHERE hook_name = $hook_name\n      ${statusClause}\n      ORDER BY created_at DESC\n      LIMIT $limit\n      OFFSET $offset\n    `)\n    .all({\n      $hook_name: hookName,\n      $limit: safeLimit,\n      $offset: safeOffset,\n    });\n  return rows.map(toRequestModel);\n};\n\nconst getRequestById = (hookName, id) => {\n  const database = ensureDb();\n  const row = database\n    .prepare(`\n      SELECT\n        id,\n        hook_name,\n        method,\n        headers,\n        payload,\n        payload_truncated,\n        payload_size,\n        source_ip,\n        gateway_status,\n        gateway_body,\n        created_at\n      FROM webhook_requests\n      WHERE hook_name = $hook_name\n        AND id = $id\n      LIMIT 1\n    `)\n    .get({\n      $hook_name: hookName,\n      $id: Number.parseInt(String(id || 0), 10) || 0,\n    });\n  return toRequestModel(row);\n};\n\nconst getHookSummaries = () => {\n  const database = ensureDb();\n  const rows = database\n    .prepare(`\n      WITH ranked_requests AS (\n        SELECT\n          hook_name,\n          created_at,\n          gateway_status,\n          ROW_NUMBER() OVER (\n            PARTITION BY hook_name\n            ORDER BY created_at DESC, id DESC\n          ) AS row_num\n        FROM webhook_requests\n      ),\n      overall_counts AS (\n        SELECT\n          hook_name,\n          MAX(created_at) AS last_received,\n          COUNT(*) AS total_count,\n          SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS success_count,\n          SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS error_count\n        FROM webhook_requests\n        GROUP BY hook_name\n      ),\n      recent_counts AS (\n        SELECT\n          hook_name,\n          COUNT(*) AS recent_total_count,\n          SUM(CASE WHEN gateway_status >= 200 AND gateway_status < 300 THEN 1 ELSE 0 END) AS recent_success_count,\n          SUM(CASE WHEN gateway_status IS NULL OR gateway_status < 200 OR gateway_status >= 300 THEN 1 ELSE 0 END) AS recent_error_count\n        FROM ranked_requests\n        WHERE row_num <= $health_window\n        GROUP BY hook_name\n      )\n      SELECT\n        overall_counts.hook_name,\n        overall_counts.last_received,\n        overall_counts.total_count,\n        overall_counts.success_count,\n        overall_counts.error_count,\n        COALESCE(recent_counts.recent_total_count, 0) AS recent_total_count,\n        COALESCE(recent_counts.recent_success_count, 0) AS recent_success_count,\n        COALESCE(recent_counts.recent_error_count, 0) AS recent_error_count\n      FROM overall_counts\n      LEFT JOIN recent_counts\n        ON recent_counts.hook_name = overall_counts.hook_name\n    `)\n    .all({ $health_window: kHealthSummaryWindow });\n  return rows.map((row) => ({\n    hookName: row.hook_name,\n    lastReceived: row.last_received || null,\n    totalCount: Number(row.total_count || 0),\n    successCount: Number(row.success_count || 0),\n    errorCount: Number(row.error_count || 0),\n    recentTotalCount: Number(row.recent_total_count || 0),\n    recentSuccessCount: Number(row.recent_success_count || 0),\n    recentErrorCount: Number(row.recent_error_count || 0),\n    healthWindowSize: kHealthSummaryWindow,\n  }));\n};\n\nconst deleteRequestsByHook = (hookName) => {\n  const database = ensureDb();\n  const result = database\n    .prepare(`\n      DELETE FROM webhook_requests\n      WHERE hook_name = $hook_name\n    `)\n    .run({\n      $hook_name: String(hookName || \"\"),\n    });\n  return Number(result.changes || 0);\n};\n\nconst createOauthCallback = ({ hookName }) => {\n  const database = ensureDb();\n  const normalizedHookName = String(hookName || \"\").trim();\n  if (!normalizedHookName) throw new Error(\"hookName is required\");\n  const callbackId = generateOauthCallbackId();\n  database\n    .prepare(`\n      INSERT INTO oauth_callbacks (\n        callback_id,\n        hook_name\n      ) VALUES (\n        $callback_id,\n        $hook_name\n      )\n    `)\n    .run({\n      $callback_id: callbackId,\n      $hook_name: normalizedHookName,\n    });\n  const inserted = database\n    .prepare(`\n      SELECT\n        callback_id,\n        hook_name,\n        created_at,\n        rotated_at,\n        last_used_at\n      FROM oauth_callbacks\n      WHERE callback_id = $callback_id\n      LIMIT 1\n    `)\n    .get({ $callback_id: callbackId });\n  return toOauthCallbackModel(inserted);\n};\n\nconst getOauthCallbackByHook = (hookName) => {\n  const database = ensureDb();\n  const normalizedHookName = String(hookName || \"\").trim();\n  if (!normalizedHookName) return null;\n  const row = database\n    .prepare(`\n      SELECT\n        callback_id,\n        hook_name,\n        created_at,\n        rotated_at,\n        last_used_at\n      FROM oauth_callbacks\n      WHERE hook_name = $hook_name\n      LIMIT 1\n    `)\n    .get({ $hook_name: normalizedHookName });\n  return toOauthCallbackModel(row);\n};\n\nconst getOauthCallbackById = (callbackId) => {\n  const database = ensureDb();\n  const normalizedCallbackId = String(callbackId || \"\").trim();\n  if (!normalizedCallbackId) return null;\n  const row = database\n    .prepare(`\n      SELECT\n        callback_id,\n        hook_name,\n        created_at,\n        rotated_at,\n        last_used_at\n      FROM oauth_callbacks\n      WHERE callback_id = $callback_id\n      LIMIT 1\n    `)\n    .get({ $callback_id: normalizedCallbackId });\n  return toOauthCallbackModel(row);\n};\n\nconst rotateOauthCallback = (hookName) => {\n  const database = ensureDb();\n  const normalizedHookName = String(hookName || \"\").trim();\n  if (!normalizedHookName) throw new Error(\"hookName is required\");\n  const existing = getOauthCallbackByHook(normalizedHookName);\n  if (!existing) throw new Error(\"OAuth callback not found\");\n  const nextCallbackId = generateOauthCallbackId();\n  database\n    .prepare(`\n      UPDATE oauth_callbacks\n      SET\n        callback_id = $callback_id,\n        rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')\n      WHERE hook_name = $hook_name\n    `)\n    .run({\n      $callback_id: nextCallbackId,\n      $hook_name: normalizedHookName,\n    });\n  return getOauthCallbackById(nextCallbackId);\n};\n\nconst deleteOauthCallback = (hookName) => {\n  const database = ensureDb();\n  const normalizedHookName = String(hookName || \"\").trim();\n  if (!normalizedHookName) return 0;\n  const result = database\n    .prepare(`\n      DELETE FROM oauth_callbacks\n      WHERE hook_name = $hook_name\n    `)\n    .run({ $hook_name: normalizedHookName });\n  return Number(result.changes || 0);\n};\n\nconst markOauthCallbackUsed = (callbackId) => {\n  const database = ensureDb();\n  const normalizedCallbackId = String(callbackId || \"\").trim();\n  if (!normalizedCallbackId) return 0;\n  const result = database\n    .prepare(`\n      UPDATE oauth_callbacks\n      SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')\n      WHERE callback_id = $callback_id\n    `)\n    .run({ $callback_id: normalizedCallbackId });\n  return Number(result.changes || 0);\n};\n\nconst pruneOldEntries = (days = 30) => {\n  const database = ensureDb();\n  const safeDays = Math.max(1, Number.parseInt(String(days || 30), 10) || 30);\n  const modifier = `-${safeDays} days`;\n  const result = database\n    .prepare(`\n      DELETE FROM webhook_requests\n      WHERE created_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', $modifier)\n    `)\n    .run({ $modifier: modifier });\n  return Number(result.changes || 0);\n};\n\nmodule.exports = {\n  initWebhooksDb,\n  closeWebhooksDb,\n  insertRequest,\n  getRequests,\n  getRequestById,\n  getHookSummaries,\n  deleteRequestsByHook,\n  createOauthCallback,\n  getOauthCallbackByHook,\n  getOauthCallbackById,\n  rotateOauthCallback,\n  deleteOauthCallback,\n  markOauthCallbackUsed,\n  pruneOldEntries,\n};\n"
  },
  {
    "path": "lib/server/db/webhooks/schema.js",
    "content": "const createSchema = (database) => {\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS webhook_requests (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      hook_name TEXT NOT NULL,\n      method TEXT,\n      headers TEXT,\n      payload TEXT,\n      payload_truncated INTEGER DEFAULT 0,\n      payload_size INTEGER,\n      source_ip TEXT,\n      gateway_status INTEGER,\n      gateway_body TEXT,\n      created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_webhook_requests_hook_ts\n    ON webhook_requests(hook_name, created_at DESC);\n  `);\n  database.exec(`\n    CREATE TABLE IF NOT EXISTS oauth_callbacks (\n      callback_id TEXT PRIMARY KEY,\n      hook_name TEXT NOT NULL UNIQUE,\n      created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n      rotated_at TEXT,\n      last_used_at TEXT\n    );\n  `);\n  database.exec(`\n    CREATE INDEX IF NOT EXISTS idx_oauth_callbacks_hook_name\n    ON oauth_callbacks(hook_name);\n  `);\n};\n\nmodule.exports = {\n  createSchema,\n};\n"
  },
  {
    "path": "lib/server/discord-api.js",
    "content": "const kDiscordApiBase = \"https://discord.com/api/v10\";\n\nconst createDiscordApi = (getToken) => {\n  const call = async (path, { method = \"GET\", body } = {}) => {\n    const token = typeof getToken === \"function\" ? getToken() : getToken;\n    if (!token) throw new Error(\"DISCORD_BOT_TOKEN is not set\");\n    const res = await fetch(`${kDiscordApiBase}${path}`, {\n      method,\n      headers: {\n        Authorization: `Bot ${token}`,\n        \"Content-Type\": \"application/json\",\n      },\n      ...(body != null ? { body: JSON.stringify(body) } : {}),\n    });\n    const data = await res.json().catch(() => ({}));\n    if (!res.ok) {\n      const err = new Error(data?.message || `Discord API error: ${method} ${path}`);\n      err.discordStatusCode = res.status;\n      throw err;\n    }\n    return data;\n  };\n\n  const createDmChannel = (userId) =>\n    call(\"/users/@me/channels\", {\n      method: \"POST\",\n      body: { recipient_id: String(userId || \"\") },\n    });\n\n  const sendMessage = (channelId, content) =>\n    call(`/channels/${channelId}/messages`, {\n      method: \"POST\",\n      body: { content: String(content || \"\") },\n    });\n\n  const sendDirectMessage = async (userId, content) => {\n    const channel = await createDmChannel(userId);\n    return sendMessage(channel?.id, content);\n  };\n\n  return {\n    createDmChannel,\n    sendMessage,\n    sendDirectMessage,\n  };\n};\n\nmodule.exports = { createDiscordApi };\n"
  },
  {
    "path": "lib/server/doctor/bootstrap-context.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst kDoctorBootstrapMaxChars = 20000;\nconst kDoctorBootstrapTotalMaxChars = 150000;\nconst kDoctorBootstrapNearLimitRatio = 0.9;\nconst kDoctorContextTruncationGuidance =\n  \"OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.\";\n\nconst kDoctorRootContextFiles = [\n  { path: \"AGENTS.md\", injectMode: \"always\" },\n  { path: \"SOUL.md\", injectMode: \"always\" },\n  { path: \"TOOLS.md\", injectMode: \"always\" },\n  { path: \"IDENTITY.md\", injectMode: \"always\" },\n  { path: \"USER.md\", injectMode: \"always\" },\n  { path: \"HEARTBEAT.md\", injectMode: \"always\" },\n  { path: \"BOOTSTRAP.md\", injectMode: \"first_run_only\" },\n];\n\nconst kDoctorBootstrapExtraFiles = [\n  { path: \"hooks/bootstrap/AGENTS.md\", injectMode: \"always\" },\n  { path: \"hooks/bootstrap/TOOLS.md\", injectMode: \"always\" },\n];\n\nconst kDoctorBootstrapContextFiles = [...kDoctorRootContextFiles, ...kDoctorBootstrapExtraFiles];\n\nconst readWorkspaceFileChars = (workspaceRoot, relativePath) => {\n  const fullPath = path.join(workspaceRoot, relativePath);\n  try {\n    const content = fs.readFileSync(fullPath, \"utf8\");\n    return {\n      exists: true,\n      chars: content.length,\n    };\n  } catch {\n    return {\n      exists: false,\n      chars: 0,\n    };\n  }\n};\n\nconst analyzeBootstrapContext = ({\n  workspaceRoot = \"\",\n  bootstrapMaxChars = kDoctorBootstrapMaxChars,\n  bootstrapTotalMaxChars = kDoctorBootstrapTotalMaxChars,\n} = {}) => {\n  const files = kDoctorBootstrapContextFiles.map((spec) => {\n    const fileState = readWorkspaceFileChars(workspaceRoot, spec.path);\n    const rawChars = fileState.chars;\n    const fileLimitChars = Math.min(rawChars, bootstrapMaxChars);\n    const nearFileLimit = rawChars > 0 && rawChars >= Math.floor(bootstrapMaxChars * kDoctorBootstrapNearLimitRatio);\n    return {\n      ...spec,\n      exists: fileState.exists,\n      rawChars,\n      fileLimitChars,\n      injectedChars: 0,\n      truncatedByFileLimit: rawChars > bootstrapMaxChars,\n      truncatedByTotalLimit: false,\n      truncated: rawChars > bootstrapMaxChars,\n      nearFileLimit: nearFileLimit && rawChars <= bootstrapMaxChars,\n      active: spec.injectMode === \"always\",\n      reason: rawChars > bootstrapMaxChars ? \"file_limit\" : \"\",\n    };\n  });\n\n  let injectedTotalChars = 0;\n  for (const file of files) {\n    if (!file.active || !file.exists) continue;\n    const remainingChars = Math.max(0, bootstrapTotalMaxChars - injectedTotalChars);\n    file.injectedChars = Math.min(file.fileLimitChars, remainingChars);\n    file.truncatedByTotalLimit = file.fileLimitChars > file.injectedChars;\n    file.truncated = file.truncatedByFileLimit || file.truncatedByTotalLimit;\n    if (file.truncatedByFileLimit && file.truncatedByTotalLimit) {\n      file.reason = \"file_and_total_limit\";\n    } else if (file.truncatedByFileLimit) {\n      file.reason = \"file_limit\";\n    } else if (file.truncatedByTotalLimit) {\n      file.reason = \"total_limit\";\n    }\n    injectedTotalChars += file.injectedChars;\n  }\n\n  const activeFiles = files.filter((file) => file.active && file.exists);\n  const activeTruncatedFiles = activeFiles.filter((file) => file.truncated);\n  const activeNearLimitFiles = activeFiles.filter((file) => file.nearFileLimit && !file.truncated);\n  const inactiveTruncatedFiles = files.filter((file) => !file.active && file.exists && file.truncated);\n  const hasTotalLimitTruncation = activeTruncatedFiles.some(\n    (file) => file.reason === \"total_limit\" || file.reason === \"file_and_total_limit\",\n  );\n\n  return {\n    bootstrapMaxChars,\n    bootstrapTotalMaxChars,\n    truncationGuidance: kDoctorContextTruncationGuidance,\n    files,\n    activeFiles,\n    activeRawChars: activeFiles.reduce((sum, file) => sum + file.rawChars, 0),\n    activeInjectedChars: activeFiles.reduce((sum, file) => sum + file.injectedChars, 0),\n    hasActiveTruncation: activeTruncatedFiles.length > 0,\n    hasActiveNearLimitFiles: activeNearLimitFiles.length > 0,\n    hasActiveWarnings: activeTruncatedFiles.length > 0 || activeNearLimitFiles.length > 0,\n    hasAnyTruncation: activeTruncatedFiles.length > 0 || inactiveTruncatedFiles.length > 0,\n    activeTruncatedFiles,\n    activeNearLimitFiles,\n    inactiveTruncatedFiles,\n    hasTotalLimitTruncation,\n    totalLimitReached: injectedTotalChars >= bootstrapTotalMaxChars,\n  };\n};\n\nconst formatChars = (value = 0) => `${Number(value || 0).toLocaleString()} chars`;\n\nconst buildBootstrapTruncationCards = (bootstrapContext = null) => {\n  if (!bootstrapContext?.hasActiveTruncation) return [];\n\n  const cards = bootstrapContext.activeTruncatedFiles\n    .filter((file) => file.reason === \"file_limit\")\n    .map((file) => ({\n      priority: \"P0\",\n      category: \"project context\",\n      title: `${file.path} is being truncated in Project Context`,\n      summary:\n        `${file.path} is ${formatChars(file.rawChars)}, above the per-file Project Context limit ` +\n        `of ${formatChars(bootstrapContext.bootstrapMaxChars)}. The agent is not seeing the full file.`,\n      recommendation:\n        `Move the most important rules to the top of ${file.path}, shorten or split low-priority content, ` +\n        `and increase OpenClaw's bootstrap limits if this file legitimately needs more room. ` +\n        kDoctorContextTruncationGuidance,\n      evidence: [\n        { type: \"path\", path: file.path },\n        {\n          type: \"text\",\n          text:\n            `Raw size: ${formatChars(file.rawChars)}. ` +\n            `Per-file limit: ${formatChars(bootstrapContext.bootstrapMaxChars)}.`,\n        },\n      ],\n      targetPaths: [{ path: file.path }],\n      fixPrompt:\n        `Reorganize ${file.path} so the most important instructions appear at the top and reduce unnecessary length. ` +\n        `Do not change unrelated behavior.`,\n      status: \"open\",\n    }));\n\n  const totalLimitedFiles = bootstrapContext.activeTruncatedFiles.filter(\n    (file) => file.reason === \"total_limit\" || file.reason === \"file_and_total_limit\",\n  );\n  if (totalLimitedFiles.length > 0) {\n    cards.unshift({\n      priority: \"P0\",\n      category: \"project context\",\n      title: \"Project Context total bootstrap limit is truncating injected files\",\n      summary:\n        `Injected workspace guidance needs ${formatChars(bootstrapContext.activeRawChars)} raw across active ` +\n        `Project Context files, exceeding the total bootstrap budget of ` +\n        `${formatChars(bootstrapContext.bootstrapTotalMaxChars)}.`,\n      recommendation:\n        `Reduce total Project Context size across injected guidance files, keep critical instructions near the top, ` +\n        `and raise OpenClaw's total bootstrap budget if the workspace legitimately needs more injected guidance. ` +\n        kDoctorContextTruncationGuidance,\n      evidence: totalLimitedFiles.map((file) => ({\n        type: \"text\",\n        text:\n          `${file.path}: raw ${formatChars(file.rawChars)}, injected ${formatChars(file.injectedChars)} ` +\n          `before the total limit stopped more content from being included.`,\n      })),\n      targetPaths: totalLimitedFiles.map((file) => ({ path: file.path })),\n      fixPrompt:\n        `Reduce the combined size of the affected Project Context files and keep the most important instructions near the top. ` +\n        `Only edit the files listed in the finding.`,\n      status: \"open\",\n    });\n  }\n\n  return cards;\n};\n\nmodule.exports = {\n  analyzeBootstrapContext,\n  buildBootstrapTruncationCards,\n  formatChars,\n  kDoctorBootstrapContextFiles,\n  kDoctorBootstrapExtraFiles,\n  kDoctorBootstrapMaxChars,\n  kDoctorBootstrapNearLimitRatio,\n  kDoctorBootstrapTotalMaxChars,\n  kDoctorContextTruncationGuidance,\n  kDoctorRootContextFiles,\n};\n"
  },
  {
    "path": "lib/server/doctor/constants.js",
    "content": "const kDoctorPromptVersion = \"doctor-v1\";\nconst kDoctorRunStatus = {\n  running: \"running\",\n  completed: \"completed\",\n  failed: \"failed\",\n};\nconst kDoctorCardStatus = {\n  open: \"open\",\n  dismissed: \"dismissed\",\n  fixed: \"fixed\",\n};\nconst kDoctorPriority = {\n  P0: \"P0\",\n  P1: \"P1\",\n  P2: \"P2\",\n};\nconst kDoctorEngine = {\n  gatewayAgent: \"gateway_agent\",\n  acpRuntime: \"acp_runtime\",\n  agentMessageFallback: \"agent_message_fallback\",\n  manualImport: \"manual_import\",\n  deterministicReuse: \"deterministic_reuse\",\n};\nconst kDoctorStaleThresholdMs = 7 * 24 * 60 * 60 * 1000;\nconst kDoctorMeaningfulChangeScoreThreshold = 4;\nconst kDoctorRunTimeoutMs = 10 * 60 * 1000;\nconst kDoctorDefaultRunsLimit = 10;\nconst kDoctorMaxRunsLimit = 50;\nconst kDoctorMaxCardsPerRun = 12;\n\nmodule.exports = {\n  kDoctorPromptVersion,\n  kDoctorRunStatus,\n  kDoctorCardStatus,\n  kDoctorPriority,\n  kDoctorEngine,\n  kDoctorStaleThresholdMs,\n  kDoctorMeaningfulChangeScoreThreshold,\n  kDoctorRunTimeoutMs,\n  kDoctorDefaultRunsLimit,\n  kDoctorMaxRunsLimit,\n  kDoctorMaxCardsPerRun,\n};\n"
  },
  {
    "path": "lib/server/doctor/normalize.js",
    "content": "const {\n  parseJsonSafe,\n  parseJsonValueFromNoisyOutput,\n} = require(\"../utils/json\");\nconst {\n  kDoctorCardStatus,\n  kDoctorPriority,\n  kDoctorMaxCardsPerRun,\n} = require(\"./constants\");\n\nconst kCandidateArrayKeys = [\"cards\", \"findings\", \"issues\", \"recommendations\"];\nconst kCandidateObjectKeys = [\n  \"result\",\n  \"data\",\n  \"output\",\n  \"response\",\n  \"message\",\n  \"content\",\n  \"text\",\n  \"payload\",\n  \"payloads\",\n  \"body\",\n];\n\nconst toTrimmedString = (value) => String(value ?? \"\").trim();\n\nconst parseJsonCandidate = (value) => {\n  if (value == null) return null;\n  if (typeof value === \"object\") return value;\n  if (typeof value !== \"string\") return null;\n  const direct = parseJsonSafe(value, null, { trim: true });\n  if (direct) return direct;\n  const noisy = parseJsonValueFromNoisyOutput(value);\n  if (noisy) return noisy;\n  const fencedMatch = value.match(/```(?:json)?\\s*([\\s\\S]*?)```/i);\n  if (!fencedMatch) return null;\n  return parseJsonSafe(fencedMatch[1], null, { trim: true });\n};\n\nconst collectCandidatePayloads = (rootValue) => {\n  const queue = [rootValue];\n  const seen = new Set();\n  const candidates = [];\n  while (queue.length) {\n    const currentValue = queue.shift();\n    if (currentValue == null) continue;\n    if (typeof currentValue === \"string\") {\n      const parsedValue = parseJsonCandidate(currentValue);\n      if (parsedValue && typeof parsedValue === \"object\") {\n        queue.push(parsedValue);\n      }\n      continue;\n    }\n    if (typeof currentValue !== \"object\") continue;\n    if (seen.has(currentValue)) continue;\n    seen.add(currentValue);\n    if (Array.isArray(currentValue)) {\n      for (const item of currentValue) {\n        if (item != null) queue.push(item);\n      }\n      continue;\n    }\n    candidates.push(currentValue);\n    for (const key of kCandidateObjectKeys) {\n      if (currentValue[key] != null) queue.push(currentValue[key]);\n    }\n  }\n  return candidates;\n};\n\nconst normalizePriority = (value) => {\n  const normalized = toTrimmedString(value).toUpperCase();\n  if (normalized === \"P0\" || normalized === \"CRITICAL\" || normalized === \"HIGH\") {\n    return kDoctorPriority.P0;\n  }\n  if (normalized === \"P1\" || normalized === \"MEDIUM\" || normalized === \"MODERATE\") {\n    return kDoctorPriority.P1;\n  }\n  if (normalized === \"P2\" || normalized === \"LOW\" || normalized === \"NICE_TO_HAVE\") {\n    return kDoctorPriority.P2;\n  }\n  return kDoctorPriority.P2;\n};\n\nconst normalizeCardStatus = (value) => {\n  const normalized = toTrimmedString(value).toLowerCase();\n  if (normalized === kDoctorCardStatus.fixed) return kDoctorCardStatus.fixed;\n  if (normalized === kDoctorCardStatus.dismissed) return kDoctorCardStatus.dismissed;\n  return kDoctorCardStatus.open;\n};\n\nconst normalizeEvidenceItem = (item) => {\n  if (item == null) return null;\n  if (typeof item === \"string\") {\n    const text = toTrimmedString(item);\n    return text ? { type: \"text\", text } : null;\n  }\n  if (typeof item === \"object\") {\n    const entry = { ...item };\n    if (entry.type === \"path\" && entry.path) {\n      entry.path = toTrimmedString(entry.path);\n      if (Number.isFinite(entry.startLine) && entry.startLine > 0) {\n        entry.startLine = entry.startLine;\n      } else {\n        delete entry.startLine;\n      }\n      if (Number.isFinite(entry.endLine) && entry.endLine > 0) {\n        entry.endLine = entry.endLine;\n      } else {\n        delete entry.endLine;\n      }\n    }\n    return entry;\n  }\n  return { type: \"text\", text: String(item) };\n};\n\nconst normalizeEvidence = (value) => {\n  if (Array.isArray(value)) return value.map(normalizeEvidenceItem).filter(Boolean);\n  if (typeof value === \"string\") {\n    const text = toTrimmedString(value);\n    return text ? [{ type: \"text\", text }] : [];\n  }\n  if (value && typeof value === \"object\") return [normalizeEvidenceItem(value)].filter(Boolean);\n  return [];\n};\n\nconst normalizeTargetPathItem = (item) => {\n  if (item == null) return null;\n  if (typeof item === \"string\") {\n    const path = toTrimmedString(item);\n    return path ? { path } : null;\n  }\n  if (typeof item === \"object\" && item.path) {\n    const path = toTrimmedString(item.path);\n    if (!path) return null;\n    const entry = { path };\n    if (Number.isFinite(item.startLine) && item.startLine > 0) entry.startLine = item.startLine;\n    if (Number.isFinite(item.endLine) && item.endLine > 0) entry.endLine = item.endLine;\n    return entry;\n  }\n  return null;\n};\n\nconst normalizeTargetPaths = (value) => {\n  const values = Array.isArray(value) ? value : value == null ? [] : [value];\n  const seen = new Set();\n  return values\n    .map(normalizeTargetPathItem)\n    .filter((item) => {\n      if (!item) return false;\n      if (seen.has(item.path)) return false;\n      seen.add(item.path);\n      return true;\n    });\n};\n\nconst buildFallbackFixPrompt = ({ title, recommendation, targetPaths }) => {\n  const pathStrings = targetPaths.map((item) => item?.path || String(item)).filter(Boolean);\n  const targetLine = pathStrings.length\n    ? `Focus on these paths if relevant: ${pathStrings.join(\", \")}.`\n    : \"Inspect the relevant workspace files before making changes.\";\n  return (\n    `Please address this Doctor finding safely.\\n\\n` +\n    `Finding: ${title}\\n` +\n    `Recommendation: ${recommendation}\\n` +\n    `${targetLine}\\n` +\n    `Preserve existing behavior unless the change clearly improves workspace guidance organization.`\n  );\n};\n\nconst normalizeDoctorCard = (cardValue, index) => {\n  const title =\n    toTrimmedString(cardValue?.title) ||\n    toTrimmedString(cardValue?.headline) ||\n    toTrimmedString(cardValue?.name) ||\n    `Doctor recommendation ${index + 1}`;\n  const summary =\n    toTrimmedString(cardValue?.summary) ||\n    toTrimmedString(cardValue?.description) ||\n    toTrimmedString(cardValue?.detail) ||\n    \"\";\n  const recommendation =\n    toTrimmedString(cardValue?.recommendation) ||\n    toTrimmedString(cardValue?.recommendedAction) ||\n    toTrimmedString(cardValue?.action) ||\n    summary ||\n    title;\n  const targetPaths = normalizeTargetPaths(\n    cardValue?.targetPaths ?? cardValue?.paths ?? cardValue?.files,\n  );\n  return {\n    priority: normalizePriority(cardValue?.priority ?? cardValue?.severity),\n    category: toTrimmedString(cardValue?.category) || \"workspace\",\n    title,\n    summary,\n    recommendation,\n    evidence: normalizeEvidence(cardValue?.evidence),\n    targetPaths,\n    fixPrompt:\n      toTrimmedString(cardValue?.fixPrompt) ||\n      toTrimmedString(cardValue?.fix_prompt) ||\n      buildFallbackFixPrompt({ title, recommendation, targetPaths }),\n    status: normalizeCardStatus(cardValue?.status),\n  };\n};\n\nconst extractCardPayload = (payload) => {\n  if (!payload || typeof payload !== \"object\") return null;\n  for (const key of kCandidateArrayKeys) {\n    if (!Array.isArray(payload[key])) continue;\n    return {\n      summary:\n        toTrimmedString(payload.summary) ||\n        toTrimmedString(payload.overview) ||\n        toTrimmedString(payload.assessment) ||\n        \"\",\n      cards: payload[key],\n      rawPayload: payload,\n    };\n  }\n  return null;\n};\n\nconst normalizeDoctorResult = (rawOutput) => {\n  const initialPayload = parseJsonCandidate(rawOutput);\n  const payloadCandidates = collectCandidatePayloads(initialPayload || rawOutput);\n  for (const candidate of payloadCandidates) {\n    const extracted = extractCardPayload(candidate);\n    if (!extracted) continue;\n    const cards = extracted.cards\n      .slice(0, kDoctorMaxCardsPerRun)\n      .map((cardValue, index) => normalizeDoctorCard(cardValue, index));\n    return {\n      summary: extracted.summary,\n      cards,\n      rawPayload: extracted.rawPayload,\n    };\n  }\n  throw new Error(\"Doctor response did not include a recognizable cards payload\");\n};\n\nmodule.exports = {\n  normalizePriority,\n  normalizeCardStatus,\n  normalizeDoctorResult,\n  normalizeDoctorCard,\n};\n"
  },
  {
    "path": "lib/server/doctor/prompt.js",
    "content": "const {\n  kDoctorBootstrapExtraFiles,\n  kDoctorBootstrapMaxChars,\n  kDoctorBootstrapTotalMaxChars,\n  kDoctorContextTruncationGuidance,\n  kDoctorRootContextFiles,\n} = require(\"./bootstrap-context\");\n\nconst renderList = (items = []) =>\n  items.length ? items.map((item) => `- ${item}`).join(\"\\n\") : \"- (none)\";\n\nconst renderContextFileList = (files = []) =>\n  files.map((file) => `\\`${file.path}\\``).join(\", \");\n\nconst renderHistoricalCards = (cards = []) => {\n  if (!cards.length) return \"\";\n  const dismissedLines = cards\n    .filter((card) => card?.status === \"dismissed\")\n    .map(\n      (card) =>\n        `- [${card.status}] ${card.title}` +\n        (card.category ? ` (${card.category})` : \"\"),\n    );\n  const fixedLines = cards\n    .filter((card) => card?.status === \"fixed\")\n    .map(\n      (card) =>\n        `- [${card.status}] ${card.title}` +\n        (card.category ? ` (${card.category})` : \"\"),\n    );\n  const sections = [];\n  if (dismissedLines.length) {\n    sections.push(\n      `Previously dismissed findings (do not re-suggest these):\\n${dismissedLines.join(\"\\n\")}`,\n    );\n  }\n  if (fixedLines.length) {\n    sections.push(\n      `Previou findings marked as fixed (context only; re-suggest them if they are still present):\\n${fixedLines.join(\"\\n\")}`,\n    );\n  }\n  if (!sections.length) return \"\";\n  return `\n\n${sections.join(\"\\n\\n\")}\n`;\n};\n\nconst buildDoctorPrompt = ({\n  workspaceRoot = \"\",\n  managedRoot = \"\",\n  protectedPaths = [],\n  lockedPaths = [],\n  resolvedCards = [],\n  promptVersion = \"doctor-v1\",\n}) =>\n  `\nYou are AlphaClaw Doctor. Analyze this OpenClaw workspace for guidance drift, redundancy, misplacement, and cleanup opportunities.\n\nImportant:\n- Read the workspace and managed files as needed before deciding.\n- This is advisory only. Do not make changes.\n- Focus on organization and correctness of workspace guidance and setup-owned files.\n- Prefer fewer, higher-signal findings.\n- Avoid reporting issues that are already intentionally managed or locked by AlphaClaw.\n- Evaluate files against intended OpenClaw defaults, not against an idealized minimal workspace.\n- A fresh install can be healthy even if it includes broad default guidance.\n- Return ONLY valid JSON. No markdown fences. No extra prose.\n\nOpenClaw context injection:\n- OpenClaw automatically injects these workspace files into the agent's Project Context: ${renderContextFileList(\n    kDoctorRootContextFiles,\n  )}.\n- \\`BOOTSTRAP.md\\` is first-run only; the others above are injected on normal turns when present.\n- Additionally, AlphaClaw injects these extra bootstrap files on normal turns when present: ${renderContextFileList(\n    kDoctorBootstrapExtraFiles,\n  )}.\n- 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.\n- ${kDoctorContextTruncationGuidance}\n\nOpenClaw default context:\n- \\`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.\n- Do not treat default-template content as drift just because it is broad or multi-purpose.\n- 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.\n\nAlphaClaw ownership rules:\n- AlphaClaw-managed files and bootstrap files are product-owned constraints.\n- Do not recommend splitting, renaming, relocating, or otherwise restructuring AlphaClaw-managed files solely for cleanliness or purity.\n- Do not propose breaking changes to AlphaClaw's managed file layout, even if another structure might look cleaner.\n- Only flag AlphaClaw-managed content when there is a concrete correctness issue, internal contradiction, broken ownership boundary, or behavior that is actively misleading.\n\nWorkspace roots:\n- Primary workspace root: ${workspaceRoot || \"(unknown)\"}\n- Managed OpenClaw root: ${managedRoot || \"(unknown)\"}\n\nAlphaClaw protected paths:\n${renderList(protectedPaths)}\n\nAlphaClaw locked/managed paths:\n${renderList(lockedPaths)}\n\nReview priorities:\n- Drift between workspace reality and AGENTS.md, TOOLS.md, SKILL.md, README, and setup-owned docs\n- Redundant or scattered instructions that should be centralized\n- Tool-specific guidance placed in the wrong file\n- Workspace cleanup and consolidation opportunities\n- Real contradictions or misleading guidance inside AlphaClaw-managed files\n\nPriority rubric:\n- P0: dangerous drift, broken setup ownership, or issues likely to cause incorrect agent behavior\n- P1: meaningful duplication, misplaced guidance, or organizational drift with clear cleanup value\n- P2: nice-to-have consolidation and lower-risk cleanup opportunities\n\nReturn exactly this JSON shape:\n{\n  \"summary\": \"short overall assessment\",\n  \"cards\": [\n    {\n      \"priority\": \"P0 | P1 | P2\",\n      \"category\": \"short category\",\n      \"title\": \"short title\",\n      \"summary\": \"what is wrong and why it matters\",\n      \"recommendation\": \"clear recommended action\",\n      \"evidence\": [\n        { \"type\": \"path\", \"path\": \"relative/path\", \"startLine\": 10, \"endLine\": 25 },\n        { \"type\": \"note\", \"text\": \"short supporting note\" }\n      ],\n      \"targetPaths\": [\n        { \"path\": \"relative/path/one\", \"startLine\": 10 },\n        { \"path\": \"relative/path/two\" }\n      ],\n      \"fixPrompt\": \"a concise message another agent can use to fix just this finding safely\",\n      \"status\": \"open\"\n    }\n  ]\n}\n\n${renderHistoricalCards(resolvedCards)}Constraints:\n- Maximum 12 cards\n- Use relative paths in evidence and targetPaths\n- Include startLine (and optionally endLine) in evidence and targetPaths when the finding relates to a specific section of a file\n- targetPaths items can be strings or objects with { path, startLine? }\n- Do not include duplicate cards\n- Do not re-suggest findings that appear in the \"Previously dismissed\" list above\n- Previously fixed findings may be re-suggested if the underlying issue is still present\n- If a previously fixed finding is still present, you may call that out in the summary or card wording\n- Do not create cards for healthy default-template behavior\n- Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure\n- fixPrompt must only reference files the agent can edit. Never suggest editing files listed in \"AlphaClaw locked/managed paths\" above — they are managed by AlphaClaw, so manual edits would be lost.\n- If there are no meaningful findings, return an empty cards array\n- promptVersion: ${promptVersion}\n`.trim();\n\nmodule.exports = {\n  buildDoctorPrompt,\n};\n"
  },
  {
    "path": "lib/server/doctor/service.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst {\n  analyzeBootstrapContext,\n  buildBootstrapTruncationCards,\n} = require(\"./bootstrap-context\");\nconst { buildDoctorPrompt } = require(\"./prompt\");\nconst { normalizeDoctorResult } = require(\"./normalize\");\nconst { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require(\"./workspace-fingerprint\");\nconst {\n  kDoctorEngine,\n  kDoctorMeaningfulChangeScoreThreshold,\n  kDoctorPromptVersion,\n  kDoctorRunStatus,\n  kDoctorRunTimeoutMs,\n  kDoctorStaleThresholdMs,\n} = require(\"./constants\");\n\nconst kMaxSnippetLines = 20;\n\nconst shellEscapeArg = (value) => {\n  const safeValue = String(value || \"\");\n  return `'${safeValue.replace(/'/g, `'\\\\''`)}'`;\n};\n\nconst hasValidIsoTime = (value) => {\n  const timestamp = Date.parse(String(value || \"\"));\n  return Number.isFinite(timestamp);\n};\n\nconst formatElapsedSince = (isoTime) => {\n  if (!hasValidIsoTime(isoTime)) return \"the last scan\";\n  const elapsedMs = Math.max(0, Date.now() - Date.parse(isoTime));\n  const elapsedMinutes = Math.max(1, Math.round(elapsedMs / 60000));\n  if (elapsedMinutes < 60) {\n    return `${elapsedMinutes} minute${elapsedMinutes === 1 ? \"\" : \"s\"} ago`;\n  }\n  const elapsedHours = Math.round(elapsedMinutes / 60);\n  if (elapsedHours < 24) {\n    return `${elapsedHours} hour${elapsedHours === 1 ? \"\" : \"s\"} ago`;\n  }\n  const elapsedDays = Math.round(elapsedHours / 24);\n  return `${elapsedDays} day${elapsedDays === 1 ? \"\" : \"s\"} ago`;\n};\n\nconst readFileSnippet = (rootDir, relativePath, startLine, endLine) => {\n  try {\n    const fullPath = path.join(rootDir, String(relativePath || \"\"));\n    const content = fs.readFileSync(fullPath, \"utf-8\");\n    const lines = content.split(\"\\n\");\n    const start = Math.max(0, (startLine || 1) - 1);\n    const end = endLine && endLine >= startLine ? Math.min(lines.length, endLine) : start + 1;\n    const cappedEnd = Math.min(end, start + kMaxSnippetLines);\n    return {\n      text: lines.slice(start, cappedEnd).join(\"\\n\"),\n      startLine: start + 1,\n      endLine: start + (cappedEnd - start),\n      truncated: cappedEnd < end,\n      totalFileLines: lines.length,\n    };\n  } catch {\n    return null;\n  }\n};\n\nconst captureEvidenceSnippets = (cards, rootDir) => {\n  for (const card of cards) {\n    if (!Array.isArray(card.evidence)) continue;\n    for (const item of card.evidence) {\n      if (!item || item.type !== \"path\" || !item.path || !item.startLine) continue;\n      const snippet = readFileSnippet(rootDir, item.path, item.startLine, item.endLine);\n      if (snippet) item.snippet = snippet;\n    }\n  }\n};\n\nconst buildDoctorSessionKey = (runId) => `agent:main:doctor:${Number(runId || 0)}`;\nconst buildDoctorSessionId = (runId) => buildDoctorSessionKey(runId);\nconst buildDoctorIdempotencyKey = (runId) => `doctor-run-${Number(runId || 0)}`;\n\nconst createDoctorService = ({\n  clawCmd,\n  listDoctorRuns,\n  listDoctorCards,\n  getInitialWorkspaceBaseline,\n  setInitialWorkspaceBaseline,\n  createDoctorRun,\n  completeDoctorRun,\n  insertDoctorCards,\n  getDoctorRun,\n  getDoctorCardsByRunId,\n  getDoctorCard,\n  updateDoctorCardStatus,\n  workspaceRoot,\n  managedRoot,\n  protectedPaths = [],\n  lockedPaths = [],\n}) => {\n  const state = {\n    activeRunId: 0,\n    activeRunPromise: null,\n    snapshotCache: null,\n  };\n\n  const getLatestCompletedRun = () =>\n    listDoctorRuns({ limit: 25 }).find((run) => run.status === kDoctorRunStatus.completed) || null;\n\n  const getCurrentWorkspaceSnapshot = () => {\n    const now = Date.now();\n    if (state.snapshotCache && now - state.snapshotCache.computedAt < 5000) {\n      return state.snapshotCache.snapshot;\n    }\n    const snapshot = computeWorkspaceSnapshot(workspaceRoot);\n    state.snapshotCache = {\n      computedAt: now,\n      snapshot,\n    };\n    return snapshot;\n  };\n\n  const getOrCreateInitialBaseline = () => {\n    const existingBaseline = getInitialWorkspaceBaseline?.();\n    if (existingBaseline?.fingerprint && existingBaseline?.manifest) {\n      return existingBaseline;\n    }\n    const snapshot = getCurrentWorkspaceSnapshot();\n    const nextBaseline = {\n      fingerprint: snapshot.fingerprint,\n      manifest: snapshot.manifest,\n      capturedAt: new Date().toISOString(),\n    };\n    return setInitialWorkspaceBaseline?.(nextBaseline) || nextBaseline;\n  };\n\n  const cloneRunCards = ({ sourceRunId, targetRunId }) => {\n    const sourceCards = getDoctorCardsByRunId(sourceRunId);\n    insertDoctorCards({\n      runId: targetRunId,\n      cards: sourceCards,\n    });\n  };\n\n  const buildStatus = () => {\n    const bootstrapContext = analyzeBootstrapContext({ workspaceRoot });\n    const recentRuns = listDoctorRuns({ limit: 10 });\n    const latestRun = recentRuns[0] || null;\n    const latestCompletedRun =\n      recentRuns.find((run) => run.status === kDoctorRunStatus.completed) || null;\n    const lastRunAt =\n      latestCompletedRun?.completedAt || latestCompletedRun?.startedAt || null;\n    const lastRunAgeMs = hasValidIsoTime(lastRunAt) ? Date.now() - Date.parse(lastRunAt) : null;\n    const stale = lastRunAgeMs == null || lastRunAgeMs >= kDoctorStaleThresholdMs;\n    const baselineRun = latestCompletedRun;\n    const initialBaseline = !baselineRun ? getOrCreateInitialBaseline() : null;\n    const currentSnapshot = baselineRun || initialBaseline ? getCurrentWorkspaceSnapshot() : null;\n    const baselineManifest =\n      baselineRun?.workspaceManifest && typeof baselineRun.workspaceManifest === \"object\"\n        ? baselineRun.workspaceManifest\n        : initialBaseline?.manifest && typeof initialBaseline.manifest === \"object\"\n          ? initialBaseline.manifest\n          : null;\n    const hasManifestBaseline = !!baselineManifest;\n    const delta =\n      hasManifestBaseline && currentSnapshot\n        ? calculateWorkspaceDelta({\n            previousManifest: baselineManifest,\n            currentManifest: currentSnapshot.manifest,\n          })\n        : {\n            addedFilesCount: 0,\n            removedFilesCount: 0,\n            modifiedFilesCount: 0,\n            changedFilesCount: 0,\n            deltaScore: 0,\n            changedPaths: [],\n          };\n    const hasMeaningfulChanges =\n      !!latestCompletedRun &&\n      delta.deltaScore >= kDoctorMeaningfulChangeScoreThreshold;\n    return {\n      activeRunId: state.activeRunId || 0,\n      runInProgress: !!state.activeRunPromise,\n      lastRunAt,\n      lastRunAgeMs,\n      needsInitialRun: !latestCompletedRun,\n      stale,\n      bootstrapContext,\n      changeSummary: {\n        ...delta,\n        hasBaseline: hasManifestBaseline,\n        baselineSource: baselineRun ? \"last_run\" : initialBaseline ? \"initial_install\" : \"none\",\n        hasMeaningfulChanges,\n      },\n      latestRun,\n    };\n  };\n\n  const executeDoctorRun = async (runId) => {\n    try {\n      const allCards = listDoctorCards();\n      const resolvedCards = allCards\n        .filter((card) => card.status === \"dismissed\" || card.status === \"fixed\")\n        .map((card) => ({\n          status: card.status,\n          title: card.title || \"\",\n          category: card.category || \"\",\n        }));\n      const prompt = buildDoctorPrompt({\n        workspaceRoot,\n        managedRoot,\n        protectedPaths,\n        lockedPaths,\n        resolvedCards,\n        promptVersion: kDoctorPromptVersion,\n      });\n      const gatewayTimeoutMs = kDoctorRunTimeoutMs + 30000;\n      const gatewayParams = {\n        agentId: \"main\",\n        idempotencyKey: buildDoctorIdempotencyKey(runId),\n        message: prompt,\n        sessionKey: buildDoctorSessionKey(runId),\n        thinking: \"medium\",\n        timeout: Math.round(kDoctorRunTimeoutMs / 1000),\n      };\n      const result = await clawCmd(\n        `gateway call agent --expect-final --json --timeout ${gatewayTimeoutMs} --params ${shellEscapeArg(\n          JSON.stringify(gatewayParams),\n        )}`,\n        {\n          quiet: true,\n          timeoutMs: gatewayTimeoutMs,\n        },\n      );\n      if (!result?.ok) {\n        throw new Error(result?.stderr || \"Doctor analysis command failed\");\n      }\n      const stdoutText = String(result.stdout || \"\");\n      const stderrText = String(result.stderr || \"\");\n      let normalizedResult = null;\n      try {\n        normalizedResult = normalizeDoctorResult(stdoutText);\n      } catch (error) {\n        console.error(\n          `[doctor] run ${runId} normalize failed: ${error.message || \"Unknown error\"}`,\n        );\n        console.error(`[doctor] run ${runId} stdout begin`);\n        console.error(stdoutText || \"(empty)\");\n        console.error(`[doctor] run ${runId} stdout end`);\n        console.error(`[doctor] run ${runId} stderr begin`);\n        console.error(stderrText || \"(empty)\");\n        console.error(`[doctor] run ${runId} stderr end`);\n        throw error;\n      }\n      const bootstrapTruncationCards = buildBootstrapTruncationCards(\n        analyzeBootstrapContext({ workspaceRoot }),\n      );\n      const cards = [...bootstrapTruncationCards, ...normalizedResult.cards];\n      captureEvidenceSnippets(cards, workspaceRoot);\n      insertDoctorCards({\n        runId,\n        cards,\n      });\n      completeDoctorRun({\n        id: runId,\n        status: kDoctorRunStatus.completed,\n        summary: normalizedResult.summary,\n        rawResult: normalizedResult.rawPayload,\n      });\n    } catch (error) {\n      completeDoctorRun({\n        id: runId,\n        status: kDoctorRunStatus.failed,\n        error: error.message || \"Doctor run failed\",\n      });\n    } finally {\n      state.activeRunId = 0;\n      state.activeRunPromise = null;\n    }\n  };\n\n  const runDoctor = () => {\n    if (state.activeRunPromise) {\n      return {\n        ok: false,\n        alreadyRunning: true,\n        runId: state.activeRunId || 0,\n        status: buildStatus(),\n        error: \"Doctor run already in progress\",\n      };\n    }\n    const workspaceSnapshot = getCurrentWorkspaceSnapshot();\n    const workspaceFingerprint = workspaceSnapshot.fingerprint;\n    const latestCompletedRun = getLatestCompletedRun();\n    if (\n      latestCompletedRun &&\n      latestCompletedRun.workspaceFingerprint &&\n      latestCompletedRun.workspaceFingerprint === workspaceFingerprint\n    ) {\n      const runId = createDoctorRun({\n        status: kDoctorRunStatus.completed,\n        engine: kDoctorEngine.deterministicReuse,\n        workspaceRoot,\n        workspaceFingerprint,\n        workspaceManifest: workspaceSnapshot.manifest,\n        promptVersion: kDoctorPromptVersion,\n        reusedFromRunId: latestCompletedRun.id,\n      });\n      cloneRunCards({\n        sourceRunId: latestCompletedRun.id,\n        targetRunId: runId,\n      });\n      const summary = `No workspace changes since last scan (${formatElapsedSince(\n        latestCompletedRun.completedAt || latestCompletedRun.startedAt,\n      )}). Same findings apply.`;\n      completeDoctorRun({\n        id: runId,\n        status: kDoctorRunStatus.completed,\n        summary,\n        rawResult: latestCompletedRun.rawResult,\n      });\n      return {\n        ok: true,\n        runId,\n        reusedPreviousRun: true,\n        sourceRunId: latestCompletedRun.id,\n        status: buildStatus(),\n      };\n    }\n    const runId = createDoctorRun({\n      status: kDoctorRunStatus.running,\n      engine: kDoctorEngine.gatewayAgent,\n      workspaceRoot,\n      workspaceFingerprint,\n      workspaceManifest: workspaceSnapshot.manifest,\n      promptVersion: kDoctorPromptVersion,\n    });\n    state.activeRunId = runId;\n    state.activeRunPromise = executeDoctorRun(runId);\n    return {\n      ok: true,\n      runId,\n      status: buildStatus(),\n    };\n  };\n\n  const importDoctorResult = ({\n    rawOutput,\n    engine = kDoctorEngine.manualImport,\n  } = {}) => {\n    const normalizedRawOutput = String(rawOutput || \"\");\n    if (!normalizedRawOutput.trim()) {\n      throw new Error(\"Doctor import requires raw output\");\n    }\n    const normalizedResult = normalizeDoctorResult(normalizedRawOutput);\n    const bootstrapTruncationCards = buildBootstrapTruncationCards(\n      analyzeBootstrapContext({ workspaceRoot }),\n    );\n    const cards = [...bootstrapTruncationCards, ...normalizedResult.cards];\n    captureEvidenceSnippets(cards, workspaceRoot);\n    const workspaceSnapshot = getCurrentWorkspaceSnapshot();\n    const runId = createDoctorRun({\n      status: kDoctorRunStatus.completed,\n      engine,\n      workspaceRoot,\n      workspaceFingerprint: workspaceSnapshot.fingerprint,\n      workspaceManifest: workspaceSnapshot.manifest,\n      promptVersion: kDoctorPromptVersion,\n    });\n    insertDoctorCards({\n      runId,\n      cards,\n    });\n    completeDoctorRun({\n      id: runId,\n      status: kDoctorRunStatus.completed,\n      summary: normalizedResult.summary,\n      rawResult: normalizedResult.rawPayload,\n    });\n    return {\n      ok: true,\n      runId,\n      run: getDoctorRun(runId),\n    };\n  };\n\n  const requestCardFix = async ({\n    cardId,\n    sessionId = \"\",\n    replyChannel = \"\",\n    replyTo = \"\",\n    prompt = \"\",\n  } = {}) => {\n    const card = getDoctorCard(cardId);\n    if (!card) throw new Error(\"Doctor card not found\");\n    const resolvedPrompt = String(prompt || card.fixPrompt || \"\").trim();\n    if (!resolvedPrompt) throw new Error(\"Doctor card does not include a fix prompt\");\n    let command = `agent --agent main --message ${shellEscapeArg(resolvedPrompt)}`;\n    const trimmedSessionId = String(sessionId || \"\").trim();\n    const trimmedReplyChannel = String(replyChannel || \"\").trim();\n    const trimmedReplyTo = String(replyTo || \"\").trim();\n    if (trimmedReplyChannel && trimmedReplyTo) {\n      command +=\n        ` --deliver --reply-channel ${shellEscapeArg(trimmedReplyChannel)}` +\n        ` --reply-to ${shellEscapeArg(trimmedReplyTo)}`;\n    } else if (trimmedSessionId) {\n      command += ` --session-id ${shellEscapeArg(trimmedSessionId)}`;\n    }\n    const result = await clawCmd(command, {\n      quiet: true,\n      timeoutMs: kDoctorRunTimeoutMs,\n    });\n    if (!result?.ok) {\n      throw new Error(result?.stderr || \"Could not send Doctor fix request\");\n    }\n    return {\n      ok: true,\n      stdout: result.stdout || \"\",\n      card,\n    };\n  };\n\n  const setCardStatus = ({ cardId, status }) => {\n    const updatedCard = updateDoctorCardStatus({\n      id: cardId,\n      status,\n    });\n    if (!updatedCard) throw new Error(\"Doctor card not found\");\n    return updatedCard;\n  };\n\n  return {\n    buildStatus,\n    runDoctor,\n    importDoctorResult,\n    listDoctorRuns,\n    listDoctorCards,\n    getDoctorRun,\n    getDoctorCardsByRunId,\n    requestCardFix,\n    setCardStatus,\n    getDoctorCard,\n  };\n};\n\nmodule.exports = {\n  buildDoctorIdempotencyKey,\n  buildDoctorSessionKey,\n  buildDoctorSessionId,\n  createDoctorService,\n};\n"
  },
  {
    "path": "lib/server/doctor/workspace-fingerprint.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst kIgnoredDirectoryNames = new Set([\".git\", \"node_modules\"]);\n\nconst kContentFileExtensions = new Set([\n  \".md\", \".json\", \".js\", \".ts\", \".jsx\", \".tsx\", \".yaml\", \".yml\",\n  \".txt\", \".sh\", \".css\", \".html\", \".xml\", \".toml\", \".ini\", \".cfg\",\n  \".py\", \".rb\", \".go\", \".rs\", \".java\", \".c\", \".cpp\", \".h\",\n]);\n\nconst isContentFile = (relativePath = \"\") => {\n  const ext = path.extname(String(relativePath || \"\")).toLowerCase();\n  return kContentFileExtensions.has(ext);\n};\n\nconst hashFile = (filePath) => {\n  const buffer = fs.readFileSync(filePath);\n  return crypto.createHash(\"sha256\").update(buffer).digest(\"hex\");\n};\n\nconst normalizeRelativePath = (rootDir, filePath) =>\n  path.relative(rootDir, filePath).split(path.sep).join(\"/\");\n\nconst walkFiles = (rootDir, currentDir = rootDir) => {\n  const entries = fs.readdirSync(currentDir, { withFileTypes: true });\n  const sortedEntries = [...entries].sort((left, right) => left.name.localeCompare(right.name));\n  const files = [];\n\n  for (const entry of sortedEntries) {\n    if (entry.isDirectory()) {\n      if (kIgnoredDirectoryNames.has(entry.name)) continue;\n      files.push(...walkFiles(rootDir, path.join(currentDir, entry.name)));\n      continue;\n    }\n    if (!entry.isFile()) continue;\n    files.push(path.join(currentDir, entry.name));\n  }\n\n  return files;\n};\n\nconst buildWorkspaceManifest = (rootDir) => {\n  const normalizedRootDir = path.resolve(String(rootDir || \"\"));\n  const files = walkFiles(normalizedRootDir);\n  return files.reduce((manifest, filePath) => {\n    const stat = fs.statSync(filePath);\n    manifest[normalizeRelativePath(normalizedRootDir, filePath)] = {\n      hash: hashFile(filePath),\n      size: stat.size,\n    };\n    return manifest;\n  }, {});\n};\n\nconst getManifestEntryHash = (entry) =>\n  typeof entry === \"object\" && entry !== null ? String(entry.hash || \"\") : String(entry || \"\");\n\nconst getManifestEntrySize = (entry) =>\n  typeof entry === \"object\" && entry !== null ? Number(entry.size || 0) : 0;\n\nconst computeWorkspaceFingerprintFromManifest = (manifest = {}) => {\n  const hash = crypto.createHash(\"sha256\");\n  const entries = Object.entries(manifest).sort(([leftPath], [rightPath]) =>\n    leftPath.localeCompare(rightPath),\n  );\n\n  hash.update(\"workspace-fingerprint-v1\");\n  for (const [relativePath, entry] of entries) {\n    hash.update(relativePath);\n    hash.update(\"\\0\");\n    hash.update(getManifestEntryHash(entry));\n    hash.update(\"\\0\");\n  }\n\n  return hash.digest(\"hex\");\n};\n\nconst computeWorkspaceSnapshot = (rootDir) => {\n  const manifest = buildWorkspaceManifest(rootDir);\n  return {\n    fingerprint: computeWorkspaceFingerprintFromManifest(manifest),\n    manifest,\n  };\n};\n\nconst getPathChangeWeight = (relativePath = \"\") => {\n  const normalizedPath = String(relativePath || \"\").trim().toLowerCase();\n  if (!normalizedPath) return 1;\n  if (\n    normalizedPath === \"agents.md\" ||\n    normalizedPath === \"tools.md\" ||\n    normalizedPath === \"readme.md\" ||\n    normalizedPath === \"bootstrap.md\" ||\n    normalizedPath === \"memory.md\" ||\n    normalizedPath === \"user.md\" ||\n    normalizedPath === \"identity.md\"\n  ) {\n    return 4;\n  }\n  if (normalizedPath.startsWith(\"hooks/bootstrap/\")) return 4;\n  if (normalizedPath.startsWith(\"skills/\")) return 3;\n  if (normalizedPath.endsWith(\".md\")) return 2;\n  return 1;\n};\n\nconst kByteDeltaSmallThreshold = 100;\nconst kByteDeltaSignificantThreshold = 500;\n\nconst getModifiedFileScore = (relativePath, previousEntry, currentEntry) => {\n  if (!isContentFile(relativePath)) return 1;\n  const previousSize = getManifestEntrySize(previousEntry);\n  const currentSize = getManifestEntrySize(currentEntry);\n  if (!previousSize && !currentSize) return getPathChangeWeight(relativePath);\n  const byteDelta = Math.abs(currentSize - previousSize);\n  if (byteDelta < kByteDeltaSmallThreshold) return 1;\n  if (byteDelta < kByteDeltaSignificantThreshold) return 2;\n  return getPathChangeWeight(relativePath);\n};\n\nconst calculateWorkspaceDelta = ({ previousManifest = {}, currentManifest = {} } = {}) => {\n  const previousPaths = Object.keys(previousManifest);\n  const currentPaths = Object.keys(currentManifest);\n  const allPaths = Array.from(new Set([...previousPaths, ...currentPaths])).sort((left, right) =>\n    left.localeCompare(right),\n  );\n  const changeSummary = {\n    addedFilesCount: 0,\n    removedFilesCount: 0,\n    modifiedFilesCount: 0,\n    changedFilesCount: 0,\n    deltaScore: 0,\n    changedPaths: [],\n  };\n\n  for (const relativePath of allPaths) {\n    const previousEntry = previousManifest[relativePath];\n    const currentEntry = currentManifest[relativePath];\n    const previousHash = getManifestEntryHash(previousEntry);\n    const currentHash = getManifestEntryHash(currentEntry);\n    if (!previousHash && currentHash) {\n      changeSummary.addedFilesCount += 1;\n      changeSummary.deltaScore += getPathChangeWeight(relativePath);\n    } else if (previousHash && !currentHash) {\n      changeSummary.removedFilesCount += 1;\n      changeSummary.deltaScore += getPathChangeWeight(relativePath);\n    } else if (previousHash !== currentHash) {\n      changeSummary.modifiedFilesCount += 1;\n      changeSummary.deltaScore += getModifiedFileScore(relativePath, previousEntry, currentEntry);\n    } else {\n      continue;\n    }\n    changeSummary.changedFilesCount += 1;\n    changeSummary.changedPaths.push(relativePath);\n  }\n\n  return changeSummary;\n};\n\nmodule.exports = {\n  calculateWorkspaceDelta,\n  computeWorkspaceFingerprintFromManifest,\n  computeWorkspaceSnapshot,\n  isContentFile,\n};\n"
  },
  {
    "path": "lib/server/env.js",
    "content": "const fs = require(\"fs\");\nconst { ENV_FILE_PATH, kKnownVars } = require(\"./constants\");\n\nconst readEnvFile = () => {\n  try {\n    const content = fs.readFileSync(ENV_FILE_PATH, \"utf8\");\n    const vars = [];\n    for (const line of content.split(\"\\n\")) {\n      const trimmed = line.trim();\n      if (!trimmed || trimmed.startsWith(\"#\")) continue;\n      const eqIdx = trimmed.indexOf(\"=\");\n      if (eqIdx === -1) continue;\n      vars.push({\n        key: trimmed.slice(0, eqIdx),\n        value: trimmed.slice(eqIdx + 1),\n      });\n    }\n    return vars;\n  } catch {\n    return [];\n  }\n};\n\nconst writeEnvFile = (vars) => {\n  const lines = [];\n  for (const { key, value } of vars || []) {\n    if (!key) continue;\n    lines.push(`${key}=${String(value || \"\")}`);\n  }\n  fs.writeFileSync(ENV_FILE_PATH, lines.join(\"\\n\"));\n};\n\nconst reloadEnv = () => {\n  const vars = readEnvFile();\n  const fileKeys = new Set(vars.map((v) => v.key));\n  let changed = false;\n\n  for (const { key, value } of vars) {\n    if (value && value !== process.env[key]) {\n      console.log(\n        `[alphaclaw] Env updated: ${key}=${key.toLowerCase().includes(\"token\") || key.toLowerCase().includes(\"key\") || key.toLowerCase().includes(\"password\") ? \"***\" : value}`,\n      );\n      process.env[key] = value;\n      changed = true;\n    } else if (!value && process.env[key]) {\n      console.log(`[alphaclaw] Env cleared: ${key}`);\n      delete process.env[key];\n      changed = true;\n    }\n  }\n\n  const allKnownKeys = kKnownVars.map((v) => v.key);\n  for (const key of allKnownKeys) {\n    if (!fileKeys.has(key) && process.env[key]) {\n      console.log(`[alphaclaw] Env removed: ${key}`);\n      delete process.env[key];\n      changed = true;\n    }\n  }\n\n  return changed;\n};\n\nconst startEnvWatcher = () => {\n  try {\n    fs.watchFile(ENV_FILE_PATH, { interval: 2000 }, () => {\n      console.log(\n        `[alphaclaw] ${ENV_FILE_PATH} changed externally, reloading...`,\n      );\n      reloadEnv();\n    });\n  } catch {}\n};\n\nmodule.exports = {\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  startEnvWatcher,\n};\n"
  },
  {
    "path": "lib/server/exec-defaults-config.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst {\n  readOpenclawConfig,\n  resolveOpenclawConfigPath,\n  writeOpenclawConfig,\n} = require(\"./openclaw-config\");\n\nconst kManagedExecApprovalsDefaults = Object.freeze({\n  security: \"full\",\n  ask: \"off\",\n  askFallback: \"full\",\n});\n\nconst kManagedOpenclawExecDefaults = Object.freeze({\n  security: \"full\",\n  strictInlineEval: false,\n});\n\nconst resolveExecApprovalsConfigPath = ({ openclawDir }) =>\n  path.join(openclawDir, \"exec-approvals.json\");\n\nconst readExecApprovalsConfig = ({\n  fsModule = fs,\n  openclawDir,\n  fallback = { version: 1 },\n} = {}) => {\n  const filePath = resolveExecApprovalsConfigPath({ openclawDir });\n  try {\n    const parsed = JSON.parse(fsModule.readFileSync(filePath, \"utf8\"));\n    return parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n      ? parsed\n      : fallback;\n  } catch {\n    return fallback;\n  }\n};\n\nconst writeExecApprovalsConfig = ({\n  fsModule = fs,\n  openclawDir,\n  file = {},\n  spacing = 2,\n} = {}) => {\n  const filePath = resolveExecApprovalsConfigPath({ openclawDir });\n  fsModule.mkdirSync(path.dirname(filePath), { recursive: true });\n  fsModule.writeFileSync(filePath, JSON.stringify(file, null, spacing) + \"\\n\", \"utf8\");\n  return filePath;\n};\n\nconst hasOwn = (obj, key) =>\n  !!obj && typeof obj === \"object\" && Object.prototype.hasOwnProperty.call(obj, key);\n\nconst ensureManagedExecApprovalsDefaults = (rawFile = {}) => {\n  const file =\n    rawFile && typeof rawFile === \"object\" && !Array.isArray(rawFile) ? rawFile : {};\n  const before = JSON.stringify(file);\n  const defaults =\n    file.defaults && typeof file.defaults === \"object\" && !Array.isArray(file.defaults)\n      ? file.defaults\n      : null;\n  const hasNonEmptyDefaults = !!defaults && Object.keys(defaults).length > 0;\n  if (!hasNonEmptyDefaults) {\n    if (!Number.isInteger(file.version)) file.version = 1;\n    file.defaults = {\n      security: kManagedExecApprovalsDefaults.security,\n      ask: kManagedExecApprovalsDefaults.ask,\n      askFallback: kManagedExecApprovalsDefaults.askFallback,\n    };\n    if (!file.agents || typeof file.agents !== \"object\" || Array.isArray(file.agents)) {\n      file.agents = {};\n    }\n  }\n  return {\n    file,\n    changed: JSON.stringify(file) !== before,\n  };\n};\n\nconst ensureManagedOpenclawExecDefaults = (rawConfig = {}) => {\n  const config =\n    rawConfig && typeof rawConfig === \"object\" && !Array.isArray(rawConfig) ? rawConfig : {};\n  const before = JSON.stringify(config);\n  if (!config.tools || typeof config.tools !== \"object\" || Array.isArray(config.tools)) {\n    config.tools = {};\n  }\n  if (!hasOwn(config.tools, \"exec\")) {\n    config.tools.exec = {\n      security: kManagedOpenclawExecDefaults.security,\n      strictInlineEval: kManagedOpenclawExecDefaults.strictInlineEval,\n    };\n  }\n  return {\n    config,\n    changed: JSON.stringify(config) !== before,\n  };\n};\n\nconst ensureManagedExecDefaults = ({ fsModule = fs, openclawDir } = {}) => {\n  let openclawChanged = false;\n  let approvalsChanged = false;\n\n  const openclawConfigPath = resolveOpenclawConfigPath({ openclawDir });\n  const openclawExists =\n    typeof fsModule.existsSync === \"function\" ? fsModule.existsSync(openclawConfigPath) : null;\n  if (openclawExists !== false) {\n    const cfg = readOpenclawConfig({\n      fsModule,\n      openclawDir,\n      fallback: openclawExists === true ? null : {},\n    });\n    if (cfg && typeof cfg === \"object\" && !Array.isArray(cfg)) {\n      const ensuredConfig = ensureManagedOpenclawExecDefaults(cfg);\n      if (ensuredConfig.changed) {\n        writeOpenclawConfig({\n          fsModule,\n          openclawDir,\n          config: ensuredConfig.config,\n          spacing: 2,\n        });\n        openclawChanged = true;\n      }\n    }\n  }\n\n  const approvalsPath = resolveExecApprovalsConfigPath({ openclawDir });\n  const approvalsExists =\n    typeof fsModule.existsSync === \"function\" ? fsModule.existsSync(approvalsPath) : null;\n  const approvals = readExecApprovalsConfig({\n    fsModule,\n    openclawDir,\n    fallback: approvalsExists === true ? null : { version: 1 },\n  });\n  if (approvals && typeof approvals === \"object\" && !Array.isArray(approvals)) {\n    const ensuredApprovals = ensureManagedExecApprovalsDefaults(approvals);\n    if (ensuredApprovals.changed || approvalsExists === false) {\n      writeExecApprovalsConfig({\n        fsModule,\n        openclawDir,\n        file: ensuredApprovals.file,\n        spacing: 2,\n      });\n      approvalsChanged = true;\n    }\n  }\n\n  return {\n    changed: openclawChanged || approvalsChanged,\n    openclawChanged,\n    approvalsChanged,\n  };\n};\n\nmodule.exports = {\n  kManagedExecApprovalsDefaults,\n  kManagedOpenclawExecDefaults,\n  resolveExecApprovalsConfigPath,\n  readExecApprovalsConfig,\n  writeExecApprovalsConfig,\n  ensureManagedExecApprovalsDefaults,\n  ensureManagedOpenclawExecDefaults,\n  ensureManagedExecDefaults,\n};\n"
  },
  {
    "path": "lib/server/gateway.js",
    "content": "const path = require(\"path\");\nconst { spawn, execSync } = require(\"child_process\");\nconst fs = require(\"fs\");\nconst net = require(\"net\");\nconst {\n  ALPHACLAW_DIR,\n  OPENCLAW_DIR,\n  GATEWAY_HOST,\n  kDefaultGatewayPort,\n  kChannelDefs,\n  kOnboardingMarkerPath,\n  kRootDir,\n} = require(\"./constants\");\nconst { withOpenclawStartupEnv } = require(\"./openclaw-runtime-env\");\n\nlet gatewayChild = null;\nlet gatewayExitHandler = null;\nlet gatewayLaunchHandler = null;\nconst kGatewayStderrTailLines = 50;\nconst kPluginRuntimeDepsPreflightTimeoutMs = 120 * 1000;\nlet gatewayStderrTail = [];\nconst expectedExitPids = new Set();\n\nconst appendStderrTail = (chunk) => {\n  const text = Buffer.isBuffer(chunk)\n    ? chunk.toString(\"utf8\")\n    : String(chunk ?? \"\");\n  for (const line of text.split(\"\\n\")) {\n    const trimmed = line.trimEnd();\n    if (!trimmed) continue;\n    gatewayStderrTail.push(trimmed);\n  }\n  if (gatewayStderrTail.length > kGatewayStderrTailLines) {\n    gatewayStderrTail = gatewayStderrTail.slice(-kGatewayStderrTailLines);\n  }\n};\n\nconst setGatewayExitHandler = (handler) => {\n  gatewayExitHandler = typeof handler === \"function\" ? handler : null;\n};\n\nconst setGatewayLaunchHandler = (handler) => {\n  gatewayLaunchHandler = typeof handler === \"function\" ? handler : null;\n};\n\nconst gatewayEnv = () =>\n  withOpenclawStartupEnv({\n    ...process.env,\n    HOME: kRootDir,\n    OPENCLAW_HOME: kRootDir,\n    OPENCLAW_CONFIG_PATH: `${OPENCLAW_DIR}/openclaw.json`,\n    OPENCLAW_STATE_DIR: OPENCLAW_DIR,\n    XDG_CONFIG_HOME: OPENCLAW_DIR,\n  });\n\nconst resolveOpenclawExtensionsDir = () => {\n  try {\n    const entryPath = require.resolve(\"openclaw\");\n    const entryDir = path.dirname(entryPath);\n    const distDir =\n      path.basename(entryDir) === \"dist\" ? entryDir : path.join(entryDir, \"dist\");\n    return path.join(distDir, \"extensions\");\n  } catch {\n    return \"\";\n  }\n};\n\nconst isOpenclawInstallStageDir = (name) =>\n  name === \".openclaw-install-stage\" ||\n  String(name || \"\").startsWith(\".openclaw-install-stage-\");\n\nconst cleanupOpenclawPluginInstallStages = ({\n  extensionsDir = resolveOpenclawExtensionsDir(),\n} = {}) => {\n  if (!extensionsDir) return 0;\n  let removed = 0;\n  try {\n    for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {\n      if (!entry?.isDirectory?.()) continue;\n      const pluginDir = path.join(extensionsDir, entry.name);\n      for (const child of fs.readdirSync(pluginDir, { withFileTypes: true })) {\n        if (!child?.isDirectory?.() || !isOpenclawInstallStageDir(child.name)) {\n          continue;\n        }\n        const stageDir = path.join(pluginDir, child.name);\n        fs.rmSync(stageDir, {\n          recursive: true,\n          force: true,\n          maxRetries: 3,\n          retryDelay: 100,\n        });\n        removed += 1;\n        console.log(`[alphaclaw] Removed stale OpenClaw plugin install stage: ${stageDir}`);\n      }\n    }\n  } catch (err) {\n    console.warn(\n      `[alphaclaw] Could not clean OpenClaw plugin install stages: ${err.message}`,\n    );\n  }\n  return removed;\n};\n\nconst hasEnabledChannelConfig = () => {\n  try {\n    const configPath = `${OPENCLAW_DIR}/openclaw.json`;\n    if (!fs.existsSync(configPath)) return false;\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const channels = cfg?.channels && typeof cfg.channels === \"object\" ? cfg.channels : {};\n    return Object.keys(kChannelDefs).some((channel) => channels?.[channel]?.enabled === true);\n  } catch {\n    return false;\n  }\n};\n\nconst isInstallStageFailure = (err) =>\n  /ENOTEMPTY|openclaw-install-stage/i.test(\n    [\n      err?.message,\n      err?.stdout?.toString?.(),\n      err?.stderr?.toString?.(),\n    ]\n      .filter(Boolean)\n      .join(\"\\n\"),\n  );\n\nconst runPluginRuntimeDepsPreflight = () =>\n  execSync(\"openclaw plugins list --json\", {\n    env: gatewayEnv(),\n    timeout: kPluginRuntimeDepsPreflightTimeoutMs,\n    encoding: \"utf8\",\n  });\n\nconst prepareOpenclawChannelPlugins = () => {\n  if (!hasEnabledChannelConfig()) return;\n  cleanupOpenclawPluginInstallStages();\n  try {\n    runPluginRuntimeDepsPreflight();\n  } catch (err) {\n    if (!isInstallStageFailure(err)) {\n      console.warn(\n        `[alphaclaw] OpenClaw plugin preflight failed: ${(err.stderr || err.message || \"\").toString().trim().slice(0, 300)}`,\n      );\n      return;\n    }\n    cleanupOpenclawPluginInstallStages();\n    try {\n      runPluginRuntimeDepsPreflight();\n      console.log(\"[alphaclaw] OpenClaw plugin preflight recovered after cleaning install stage\");\n    } catch (retryErr) {\n      console.warn(\n        `[alphaclaw] OpenClaw plugin preflight retry failed: ${(retryErr.stderr || retryErr.message || \"\").toString().trim().slice(0, 300)}`,\n      );\n    }\n  }\n};\n\nconst writeOnboardingMarker = (reason) => {\n  fs.mkdirSync(ALPHACLAW_DIR, { recursive: true });\n  fs.writeFileSync(\n    kOnboardingMarkerPath,\n    JSON.stringify(\n      {\n        onboarded: true,\n        reason,\n        markedAt: new Date().toISOString(),\n      },\n      null,\n      2,\n    ),\n  );\n};\n\n// Legacy backfill: older deployments may only have the control-ui skill as\n// proof of onboarding (before the dedicated marker file existed).\nconst kLegacyControlUiSkillPath = path.join(OPENCLAW_DIR, \"skills\", \"control-ui\", \"SKILL.md\");\n\nconst isOnboarded = () => {\n  if (fs.existsSync(kOnboardingMarkerPath)) return true;\n  if (fs.existsSync(kLegacyControlUiSkillPath)) {\n    writeOnboardingMarker(\"legacy_artifact_backfill\");\n    return true;\n  }\n  return false;\n};\n\nconst getGatewayPort = () => {\n  try {\n    const configPath = `${OPENCLAW_DIR}/openclaw.json`;\n    if (!fs.existsSync(configPath)) return kDefaultGatewayPort;\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const parsedPort = Number.parseInt(String(cfg?.gateway?.port || \"\"), 10);\n    return parsedPort > 0 ? parsedPort : kDefaultGatewayPort;\n  } catch {\n    return kDefaultGatewayPort;\n  }\n};\n\nconst getGatewayUrl = () => `http://${GATEWAY_HOST}:${getGatewayPort()}`;\n\nconst normalizeChannelAccountId = (value) => String(value || \"\").trim() || \"default\";\n\nconst resolveCredentialPairingAccountId = ({ channel, fileName }) => {\n  const prefix = `${String(channel || \"\").trim()}-`;\n  const suffix = \"-allowFrom.json\";\n  if (!String(fileName || \"\").startsWith(prefix) || !String(fileName || \"\").endsWith(suffix)) {\n    return \"\";\n  }\n  const rawAccountId = String(fileName || \"\").slice(prefix.length, -suffix.length);\n  return normalizeChannelAccountId(rawAccountId);\n};\n\nconst isGatewayRunning = () =>\n  new Promise((resolve) => {\n    const sock = net.createConnection(getGatewayPort(), GATEWAY_HOST);\n    sock.setTimeout(1000);\n    sock.on(\"connect\", () => {\n      sock.destroy();\n      resolve(true);\n    });\n    sock.on(\"error\", () => resolve(false));\n    sock.on(\"timeout\", () => {\n      sock.destroy();\n      resolve(false);\n    });\n  });\n\nconst runGatewayCmd = (cmd) => {\n  console.log(`[alphaclaw] Running: openclaw gateway ${cmd}`);\n  try {\n    if (cmd === \"--force\" || cmd === \"restart\") {\n      prepareOpenclawChannelPlugins();\n    }\n    const out = execSync(`openclaw gateway ${cmd}`, {\n      env: gatewayEnv(),\n      timeout: 15000,\n      encoding: \"utf8\",\n    });\n    if (out.trim()) console.log(`[alphaclaw] ${out.trim()}`);\n  } catch (e) {\n    if (e.stdout?.trim())\n      console.log(`[alphaclaw] gateway ${cmd} stdout: ${e.stdout.trim()}`);\n    if (e.stderr?.trim())\n      console.log(`[alphaclaw] gateway ${cmd} stderr: ${e.stderr.trim()}`);\n    if (!e.stdout?.trim() && !e.stderr?.trim())\n      console.log(`[alphaclaw] gateway ${cmd} error: ${e.message}`);\n    console.log(`[alphaclaw] gateway ${cmd} exit code: ${e.status}`);\n  }\n};\n\nconst launchGatewayProcess = () => {\n  if (gatewayChild && gatewayChild.exitCode === null && !gatewayChild.killed) {\n    console.log(\n      \"[alphaclaw] Managed gateway process already running — skipping launch\",\n    );\n    return gatewayChild;\n  }\n  prepareOpenclawChannelPlugins();\n  gatewayStderrTail = [];\n  const child = spawn(\"openclaw\", [\"gateway\", \"run\"], {\n    env: gatewayEnv(),\n    stdio: [\"pipe\", \"pipe\", \"pipe\"],\n  });\n  gatewayChild = child;\n  let didSignalGatewayReady = false;\n  child.stdout.on(\"data\", (d) => {\n    const text = Buffer.isBuffer(d) ? d.toString(\"utf8\") : String(d ?? \"\");\n    if (\n      !didSignalGatewayReady &&\n      gatewayLaunchHandler &&\n      text.toLowerCase().includes(\"listening on\")\n    ) {\n      didSignalGatewayReady = true;\n      try {\n        gatewayLaunchHandler({\n          pid: child.pid,\n          startedAt: Date.now(),\n        });\n      } catch (err) {\n        console.error(`[alphaclaw] Gateway launch handler error: ${err.message}`);\n      }\n    }\n    process.stdout.write(`[gateway] ${d}`);\n  });\n  child.stderr.on(\"data\", (d) => {\n    appendStderrTail(d);\n    process.stderr.write(`[gateway] ${d}`);\n  });\n  child.on(\"exit\", (code, signal) => {\n    const expectedExit = expectedExitPids.has(child.pid);\n    expectedExitPids.delete(child.pid);\n    console.log(\n      `[alphaclaw] Gateway launcher exited with code ${code}${signal ? ` signal ${signal}` : \"\"}`,\n    );\n    if (gatewayExitHandler) {\n      try {\n        gatewayExitHandler({\n          code,\n          signal,\n          expectedExit,\n          stderrTail: gatewayStderrTail.slice(-kGatewayStderrTailLines),\n        });\n      } catch (err) {\n        console.error(`[alphaclaw] Gateway exit handler error: ${err.message}`);\n      }\n    }\n    if (gatewayChild === child) gatewayChild = null;\n  });\n  return child;\n};\n\nconst markManagedGatewayExitExpected = () => {\n  if (\n    !gatewayChild ||\n    gatewayChild.exitCode !== null ||\n    gatewayChild.killed ||\n    !gatewayChild.pid\n  ) {\n    return false;\n  }\n  expectedExitPids.add(gatewayChild.pid);\n  return true;\n};\n\nconst startGateway = async () => {\n  if (!isOnboarded()) {\n    console.log(\"[alphaclaw] Not onboarded yet — skipping gateway start\");\n    return;\n  }\n  if (await isGatewayRunning()) {\n    console.log(\"[alphaclaw] Gateway already running — skipping start\");\n    return;\n  }\n  console.log(\"[alphaclaw] Starting openclaw gateway...\");\n  launchGatewayProcess();\n};\n\nconst restartGateway = (reloadEnv) => {\n  reloadEnv();\n  markManagedGatewayExitExpected();\n  runGatewayCmd(\"--force\");\n};\n\nconst restartGatewayLight = (reloadEnv) => {\n  reloadEnv();\n  markManagedGatewayExitExpected();\n  runGatewayCmd(\"restart\");\n};\n\nconst attachGatewaySignalHandlers = () => {\n  process.on(\"SIGTERM\", () => {\n    runGatewayCmd(\"stop\");\n    process.exit(0);\n  });\n  process.on(\"SIGINT\", () => {\n    runGatewayCmd(\"stop\");\n    process.exit(0);\n  });\n};\n\nconst ensureGatewayProxyConfig = (origin) => {\n  if (!isOnboarded()) return false;\n  try {\n    const configPath = `${OPENCLAW_DIR}/openclaw.json`;\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    if (!cfg.gateway) cfg.gateway = {};\n    let changed = false;\n\n    if (!Array.isArray(cfg.gateway.trustedProxies)) {\n      cfg.gateway.trustedProxies = [];\n    }\n    if (!cfg.gateway.trustedProxies.includes(\"127.0.0.1\")) {\n      cfg.gateway.trustedProxies.push(\"127.0.0.1\");\n      console.log(\"[alphaclaw] Added 127.0.0.1 to gateway.trustedProxies\");\n      changed = true;\n    }\n\n    if (origin) {\n      if (!cfg.gateway.controlUi) cfg.gateway.controlUi = {};\n      if (!Array.isArray(cfg.gateway.controlUi.allowedOrigins)) {\n        cfg.gateway.controlUi.allowedOrigins = [];\n      }\n      if (!cfg.gateway.controlUi.allowedOrigins.includes(origin)) {\n        cfg.gateway.controlUi.allowedOrigins.push(origin);\n        console.log(`[alphaclaw] Added dashboard origin: ${origin}`);\n        changed = true;\n      }\n    }\n\n    if (changed) {\n      fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));\n    }\n    return changed;\n  } catch (e) {\n    console.error(`[alphaclaw] ensureGatewayProxyConfig error: ${e.message}`);\n    return false;\n  }\n};\n\nconst syncChannelConfig = (savedVars, mode = \"all\") => {\n  try {\n    const configPath = `${OPENCLAW_DIR}/openclaw.json`;\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const savedMap = Object.fromEntries(\n      savedVars.filter((v) => v.value).map((v) => [v.key, v.value]),\n    );\n    const env = gatewayEnv();\n\n    for (const [ch, def] of Object.entries(kChannelDefs)) {\n      const token = savedMap[def.envKey];\n      const isConfigured = cfg.channels?.[ch]?.enabled;\n\n      if (token && !isConfigured && (mode === \"add\" || mode === \"all\")) {\n        console.log(`[alphaclaw] Adding channel: ${ch}`);\n        try {\n          if (ch === \"slack\") {\n            const appToken = savedMap[def.extraEnvKeys?.[0]];\n            if (!appToken) continue;\n            execSync(\n              `openclaw channels add --channel slack --bot-token \"${token}\" --app-token \"${appToken}\"`,\n              { env, timeout: 15000, encoding: \"utf8\" },\n            );\n            let raw = fs.readFileSync(configPath, \"utf8\");\n            if (raw.includes(token)) {\n              raw = raw.split(token).join(\"${\" + def.envKey + \"}\");\n            }\n            if (raw.includes(appToken)) {\n              raw = raw.split(appToken).join(\"${\" + def.extraEnvKeys[0] + \"}\");\n            }\n            fs.writeFileSync(configPath, raw);\n          } else {\n            execSync(`openclaw channels add --channel ${ch} --token \"${token}\"`, {\n              env,\n              timeout: 15000,\n              encoding: \"utf8\",\n            });\n            const raw = fs.readFileSync(configPath, \"utf8\");\n            if (raw.includes(token)) {\n              fs.writeFileSync(\n                configPath,\n                raw.split(token).join(\"${\" + def.envKey + \"}\"),\n              );\n            }\n          }\n          console.log(`[alphaclaw] Channel ${ch} added`);\n        } catch (e) {\n          console.error(\n            `[alphaclaw] channels add ${ch}: ${(e.stderr || e.message || \"\").toString().trim().slice(0, 200)}`,\n          );\n        }\n      } else if (\n        !token &&\n        isConfigured &&\n        (mode === \"remove\" || mode === \"all\")\n      ) {\n        console.log(`[alphaclaw] Removing channel: ${ch}`);\n        try {\n          execSync(`openclaw channels remove --channel ${ch} --delete`, {\n            env,\n            timeout: 15000,\n            encoding: \"utf8\",\n          });\n          console.log(`[alphaclaw] Channel ${ch} removed`);\n        } catch (e) {\n          console.error(\n            `[alphaclaw] channels remove ${ch}: ${(e.stderr || e.message || \"\").toString().trim().slice(0, 200)}`,\n          );\n        }\n      }\n    }\n  } catch (e) {\n    console.error(\"[alphaclaw] syncChannelConfig error:\", e.message);\n  }\n};\n\nconst getChannelStatus = () => {\n  try {\n    const config = JSON.parse(\n      fs.readFileSync(`${OPENCLAW_DIR}/openclaw.json`, \"utf8\"),\n    );\n    const credDir = `${OPENCLAW_DIR}/credentials`;\n    const channels = {};\n    const hasImplicitWhatsAppSelfPairing = ({ accountId, accountConfig }) => {\n      if (!accountConfig || typeof accountConfig !== \"object\") return false;\n      if (accountConfig.selfChatMode === false) return false;\n      if (String(accountConfig.dmPolicy || \"\").trim().toLowerCase() === \"disabled\") {\n        return false;\n      }\n      const candidatePaths = [\n        `${credDir}/whatsapp/${accountId}/creds.json`,\n        ...(accountId === \"default\" ? [`${credDir}/creds.json`] : []),\n      ];\n      const matches = candidatePaths.map((targetPath) => {\n        try {\n          return {\n            path: targetPath,\n            exists: !!String(fs.readFileSync(targetPath, \"utf8\") || \"\").trim(),\n          };\n        } catch (error) {\n          return {\n            path: targetPath,\n            exists: false,\n            error: String(error?.message || error || \"read failed\"),\n          };\n        }\n      });\n      return matches.some((entry) => entry.exists);\n    };\n\n    for (const ch of Object.keys(kChannelDefs)) {\n      const channelConfig =\n        config.channels?.[ch] && typeof config.channels[ch] === \"object\"\n          ? config.channels[ch]\n          : null;\n      if (!channelConfig?.enabled) continue;\n\n      const rawAccounts =\n        channelConfig.accounts && typeof channelConfig.accounts === \"object\"\n          ? channelConfig.accounts\n          : {};\n      const accountEntries = Object.keys(rawAccounts).length > 0\n        ? Object.entries(rawAccounts)\n        : [[\"default\", channelConfig]];\n      const configuredAccountIds = new Set(\n        accountEntries.map(([accountId]) => normalizeChannelAccountId(accountId)),\n      );\n      const hasConfiguredToken = accountEntries.some(([accountId, accountConfig]) => {\n        const normalizedAccountId = normalizeChannelAccountId(accountId);\n        const envKey = normalizedAccountId === \"default\"\n          ? kChannelDefs[ch].envKey\n          : `${kChannelDefs[ch].envKey}_${normalizedAccountId.replace(/-/g, \"_\").toUpperCase()}`;\n        return !!process.env[envKey]\n          || !!accountConfig?.botToken\n          || !!accountConfig?.token;\n      });\n      if (!hasConfiguredToken) continue;\n\n      const pairedByAccount = new Map(\n        Array.from(configuredAccountIds).map((accountId) => [accountId, 0]),\n      );\n      try {\n        if (ch !== \"whatsapp\") {\n          const files = fs\n            .readdirSync(credDir)\n            .filter(\n              (f) => f.startsWith(`${ch}-`) && f.endsWith(\"-allowFrom.json\"),\n            );\n          for (const file of files) {\n            const accountId = resolveCredentialPairingAccountId({\n              channel: ch,\n              fileName: file,\n            });\n            if (!accountId || !configuredAccountIds.has(accountId)) continue;\n            const data = JSON.parse(\n              fs.readFileSync(`${credDir}/${file}`, \"utf8\"),\n            );\n            const nextCount =\n              Number(pairedByAccount.get(accountId) || 0)\n              + (Array.isArray(data.allowFrom) ? data.allowFrom.length : 0);\n            pairedByAccount.set(accountId, nextCount);\n          }\n        }\n      } catch {}\n      for (const [accountId, accountConfig] of accountEntries) {\n        if (ch === \"whatsapp\") continue;\n        const inlineAllowFrom = accountConfig?.allowFrom;\n        if (!Array.isArray(inlineAllowFrom)) continue;\n        const normalizedAccountId = normalizeChannelAccountId(accountId);\n        const nextCount =\n          Number(pairedByAccount.get(normalizedAccountId) || 0) + inlineAllowFrom.length;\n        pairedByAccount.set(normalizedAccountId, nextCount);\n      }\n      if (ch === \"whatsapp\") {\n        for (const [accountId, accountConfig] of accountEntries) {\n          const normalizedAccountId = normalizeChannelAccountId(accountId);\n          if (Number(pairedByAccount.get(normalizedAccountId) || 0) > 0) continue;\n          if (\n            hasImplicitWhatsAppSelfPairing({\n              accountId: normalizedAccountId,\n              accountConfig,\n            })\n          ) {\n            pairedByAccount.set(normalizedAccountId, 1);\n          }\n        }\n      }\n      const accounts = Object.fromEntries(\n        Array.from(pairedByAccount.entries()).map(([accountId, paired]) => [\n          accountId,\n          { status: paired > 0 ? \"paired\" : \"configured\", paired },\n        ]),\n      );\n      const paired = Array.from(pairedByAccount.values()).reduce(\n        (total, count) => total + Number(count || 0),\n        0,\n      );\n      channels[ch] = {\n        status: paired > 0 ? \"paired\" : \"configured\",\n        paired,\n        accounts,\n      };\n    }\n\n    return channels;\n  } catch {\n    return {};\n  }\n};\n\nmodule.exports = {\n  gatewayEnv,\n  getGatewayPort,\n  getGatewayUrl,\n  isOnboarded,\n  isGatewayRunning,\n  launchGatewayProcess,\n  cleanupOpenclawPluginInstallStages,\n  prepareOpenclawChannelPlugins,\n  setGatewayExitHandler,\n  setGatewayLaunchHandler,\n  runGatewayCmd,\n  startGateway,\n  restartGateway,\n  restartGatewayLight,\n  attachGatewaySignalHandlers,\n  ensureGatewayProxyConfig,\n  syncChannelConfig,\n  getChannelStatus,\n};\n"
  },
  {
    "path": "lib/server/gmail-push.js",
    "content": "const http = require(\"http\");\nconst { parsePositiveInt } = require(\"./utils/number\");\n\nconst kGmailPushDedupeWindowMs = parsePositiveInt(\n  process.env.GMAIL_PUSH_DEDUPE_WINDOW_MS,\n  24 * 60 * 60 * 1000,\n);\nconst kGmailPushDedupeMaxEntries = parsePositiveInt(\n  process.env.GMAIL_PUSH_DEDUPE_MAX_ENTRIES,\n  50000,\n);\n\nconst extractBodyBuffer = (body) => {\n  if (Buffer.isBuffer(body)) return body;\n  if (typeof body === \"string\") return Buffer.from(body, \"utf8\");\n  if (body && typeof body === \"object\") {\n    return Buffer.from(JSON.stringify(body), \"utf8\");\n  }\n  return Buffer.alloc(0);\n};\n\nconst parsePushEnvelope = (bodyBuffer) => {\n  const parsed = JSON.parse(String(bodyBuffer || Buffer.alloc(0)).toString(\"utf8\"));\n  const encodedData = String(parsed?.message?.data || \"\");\n  const decodedData = encodedData\n    ? JSON.parse(Buffer.from(encodedData, \"base64\").toString(\"utf8\"))\n    : {};\n  return {\n    envelope: parsed || {},\n    payload: decodedData || {},\n  };\n};\n\nconst createPushEventDedupeKey = ({ envelope, payload }) => {\n  const messageId = String(\n    envelope?.message?.messageId || envelope?.message?.message_id || \"\",\n  ).trim();\n  if (messageId) return `msg:${messageId}`;\n  const email = String(payload?.emailAddress || \"\")\n    .trim()\n    .toLowerCase();\n  const historyId = String(payload?.historyId || \"\").trim();\n  if (email && historyId) return `hist:${email}:${historyId}`;\n  if (historyId) return `hist:${historyId}`;\n  return \"\";\n};\n\nconst createGmailPushEventDeduper = ({\n  ttlMs = kGmailPushDedupeWindowMs,\n  maxEntries = kGmailPushDedupeMaxEntries,\n} = {}) => {\n  const seenEvents = new Map();\n\n  const pruneExpiredEntries = (receivedAt) => {\n    const cutoff = receivedAt - ttlMs;\n    for (const [eventKey, seenAt] of seenEvents.entries()) {\n      if (seenAt > cutoff) break;\n      seenEvents.delete(eventKey);\n    }\n    while (seenEvents.size > maxEntries) {\n      const oldestKey = seenEvents.keys().next().value;\n      if (!oldestKey) break;\n      seenEvents.delete(oldestKey);\n    }\n  };\n\n  const shouldProcessPushEvent = ({ envelope, payload, receivedAt = Date.now() }) => {\n    const timestamp = Number.isFinite(receivedAt) ? receivedAt : Date.now();\n    pruneExpiredEntries(timestamp);\n    const eventKey = createPushEventDedupeKey({ envelope, payload });\n    if (!eventKey) return true;\n    return !seenEvents.has(eventKey);\n  };\n\n  shouldProcessPushEvent.markProcessed = ({\n    envelope,\n    payload,\n    receivedAt = Date.now(),\n  }) => {\n    const timestamp = Number.isFinite(receivedAt) ? receivedAt : Date.now();\n    pruneExpiredEntries(timestamp);\n    const eventKey = createPushEventDedupeKey({ envelope, payload });\n    if (!eventKey) return true;\n    seenEvents.set(eventKey, timestamp);\n    return true;\n  };\n\n  return shouldProcessPushEvent;\n};\n\nconst isSuccessfulProxyStatus = (statusCode) => {\n  const numericStatus = Number.parseInt(String(statusCode || 0), 10);\n  return numericStatus >= 200 && numericStatus < 300;\n};\n\nconst proxyPushToServe = async ({\n  port,\n  bodyBuffer,\n  headers,\n}) =>\n  await new Promise((resolve, reject) => {\n    const request = http.request(\n      {\n        hostname: \"127.0.0.1\",\n        port,\n        method: \"POST\",\n        path: \"/\",\n        headers: {\n          \"content-type\": headers[\"content-type\"] || \"application/json\",\n          \"content-length\": String(bodyBuffer.length),\n        },\n      },\n      (response) => {\n        const chunks = [];\n        response.on(\"data\", (chunk) => chunks.push(Buffer.from(chunk)));\n        response.on(\"end\", () => {\n          resolve({\n            statusCode: response.statusCode || 200,\n            body: Buffer.concat(chunks).toString(\"utf8\"),\n          });\n        });\n      },\n    );\n    request.on(\"error\", reject);\n    if (bodyBuffer.length) request.write(bodyBuffer);\n    request.end();\n  });\n\nconst createGmailPushHandler = ({\n  resolvePushToken,\n  resolveTargetByEmail,\n  markPushReceived,\n  shouldProcessPushEvent = createGmailPushEventDeduper(),\n  proxyPushToServeImpl = proxyPushToServe,\n}) =>\n  async (req, res) => {\n    try {\n      const expectedToken = String(resolvePushToken?.() || \"\").trim();\n      const receivedToken = String(req.query?.token || \"\").trim();\n      if (!expectedToken || !receivedToken || expectedToken !== receivedToken) {\n        return res.status(401).json({ ok: false, error: \"Invalid push token\" });\n      }\n\n      const bodyBuffer = extractBodyBuffer(req.body);\n      const { envelope, payload } = parsePushEnvelope(bodyBuffer);\n      const email = String(payload?.emailAddress || \"\").trim().toLowerCase();\n      if (!email) {\n        return res.status(200).json({ ok: true, ignored: true, reason: \"missing_email\" });\n      }\n      if (\n        !shouldProcessPushEvent({\n          envelope,\n          payload,\n          receivedAt: Date.now(),\n        })\n      ) {\n        return res.status(200).json({\n          ok: true,\n          ignored: true,\n          reason: \"duplicate_event\",\n        });\n      }\n\n      const target = resolveTargetByEmail?.(email);\n      if (!target?.port) {\n        return res.status(200).json({ ok: true, ignored: true, reason: \"watch_not_enabled\" });\n      }\n\n      try {\n        const proxied = await proxyPushToServeImpl({\n          port: target.port,\n          bodyBuffer,\n          headers: req.headers || {},\n        });\n        if (isSuccessfulProxyStatus(proxied.statusCode)) {\n          shouldProcessPushEvent.markProcessed?.({\n            envelope,\n            payload,\n            receivedAt: Date.now(),\n          });\n          await markPushReceived?.({\n            accountId: target.accountId,\n            at: Date.now(),\n          });\n        }\n        return res\n          .status(proxied.statusCode)\n          .send(proxied.body || \"\");\n      } catch (err) {\n        console.error(\n          `[alphaclaw] Gmail push proxy error for ${email}: ${err.message || \"unknown\"}`,\n        );\n        return res.status(200).json({ ok: true, ignored: true, reason: \"proxy_error\" });\n      }\n    } catch (err) {\n      console.error(\"[alphaclaw] Gmail push handler error:\", err);\n      return res.status(200).json({ ok: true, ignored: true, reason: \"handler_error\" });\n    }\n  };\n\nmodule.exports = {\n  createGmailPushHandler,\n  createGmailPushEventDeduper,\n  createPushEventDedupeKey,\n};\n"
  },
  {
    "path": "lib/server/gmail-serve.js",
    "content": "const { spawn } = require(\"child_process\");\n\nconst kDefaultStopTimeoutMs = 5000;\nconst kDefaultBindHost = \"127.0.0.1\";\nconst kDefaultServerHost = \"127.0.0.1\";\nconst kPersonalClientName = \"personal\";\n\nconst resolveClientName = (account = {}) => {\n  const rawClient = String(account?.client || \"\").trim();\n  if (rawClient) return rawClient;\n  if (account?.personal) return kPersonalClientName;\n  return \"default\";\n};\n\nconst isPidRunning = (pid) => {\n  const normalizedPid = Number.parseInt(String(pid || \"\"), 10);\n  if (!Number.isFinite(normalizedPid) || normalizedPid <= 0) return false;\n  try {\n    process.kill(normalizedPid, 0);\n    return true;\n  } catch {\n    return false;\n  }\n};\n\nconst createStatus = (entry = {}) => ({\n  running: Boolean(entry.child && !entry.child.killed),\n  pid: entry.child?.pid || null,\n  port: entry.port || null,\n  accountId: entry.accountId || \"\",\n  email: entry.email || \"\",\n  client: entry.client || \"default\",\n  startedAt: entry.startedAt || null,\n});\n\nconst createGmailServeManager = ({\n  constants,\n  onServeExit = () => {},\n}) => {\n  const entriesByAccountId = new Map();\n\n  const getEntry = (accountId = \"\") =>\n    entriesByAccountId.get(String(accountId || \"\").trim()) || null;\n\n  const removeEntry = (accountId = \"\") => {\n    entriesByAccountId.delete(String(accountId || \"\").trim());\n  };\n\n  const getServeStatus = (accountId = \"\") => {\n    const entry = getEntry(accountId);\n    if (!entry) return createStatus({ accountId });\n    return createStatus(entry);\n  };\n\n  const listServeStatuses = () =>\n    Array.from(entriesByAccountId.values()).map((entry) => createStatus(entry));\n\n  const buildArgs = ({\n    account,\n    port,\n    webhookToken,\n  }) => {\n    const client = resolveClientName(account);\n    const args = [];\n    if (client !== \"default\") {\n      args.push(\"--client\", client);\n    }\n    args.push(\n      \"gmail\",\n      \"watch\",\n      \"serve\",\n      \"--account\",\n      String(account?.email || \"\"),\n      \"--bind\",\n      kDefaultBindHost,\n      \"--port\",\n      String(port),\n      \"--path\",\n      \"/\",\n      \"--hook-url\",\n      `http://${kDefaultServerHost}:${constants.PORT}/hooks/gmail`,\n      \"--hook-token\",\n      String(webhookToken || \"\"),\n      \"--include-body\",\n      \"--max-bytes\",\n      String(constants.kGmailMaxBodyBytes || 20000),\n    );\n    return args;\n  };\n\n  const startServe = async ({\n    account,\n    port,\n    webhookToken,\n  }) => {\n    const accountId = String(account?.id || \"\").trim();\n    if (!accountId) throw new Error(\"Account id is required\");\n    const email = String(account?.email || \"\").trim();\n    if (!email) throw new Error(\"Account email is required\");\n    if (!isPidRunning(getEntry(accountId)?.child?.pid)) {\n      removeEntry(accountId);\n    }\n    const existingEntry = getEntry(accountId);\n    if (existingEntry?.child?.pid && isPidRunning(existingEntry.child.pid)) {\n      return createStatus(existingEntry);\n    }\n    const normalizedPort = Number.parseInt(String(port || \"\"), 10);\n    if (!Number.isFinite(normalizedPort) || normalizedPort <= 0) {\n      throw new Error(\"A valid serve port is required\");\n    }\n    const token = String(webhookToken || \"\").trim();\n    if (!token) {\n      throw new Error(\"WEBHOOK_TOKEN is required to start Gmail watch serve\");\n    }\n\n    const args = buildArgs({ account, port: normalizedPort, webhookToken: token });\n    const env = {\n      ...process.env,\n      XDG_CONFIG_HOME: constants.OPENCLAW_DIR,\n      GOG_KEYRING_PASSWORD: constants.GOG_KEYRING_PASSWORD,\n    };\n    const child = spawn(\"gog\", args, {\n      env,\n      stdio: [\"ignore\", \"pipe\", \"pipe\"],\n    });\n\n    child.stdout.on(\"data\", () => {});\n    child.stderr.on(\"data\", () => {});\n\n    const nextEntry = {\n      accountId,\n      email,\n      client: resolveClientName(account),\n      port: normalizedPort,\n      startedAt: new Date().toISOString(),\n      child,\n    };\n    entriesByAccountId.set(accountId, nextEntry);\n\n    child.on(\"exit\", (code, signal) => {\n      const currentEntry = getEntry(accountId);\n      if (currentEntry?.child === child) {\n        removeEntry(accountId);\n      }\n      onServeExit({\n        accountId,\n        email,\n        client: nextEntry.client,\n        port: normalizedPort,\n        code,\n        signal,\n      });\n    });\n\n    return createStatus(nextEntry);\n  };\n\n  const stopServe = async ({\n    accountId,\n    timeoutMs = kDefaultStopTimeoutMs,\n  }) => {\n    const normalizedAccountId = String(accountId || \"\").trim();\n    const entry = getEntry(normalizedAccountId);\n    if (!entry?.child) {\n      return { stopped: true, accountId: normalizedAccountId };\n    }\n    const child = entry.child;\n    if (!isPidRunning(child.pid)) {\n      removeEntry(normalizedAccountId);\n      return { stopped: true, accountId: normalizedAccountId };\n    }\n    return await new Promise((resolve) => {\n      let settled = false;\n      const finalize = (result) => {\n        if (settled) return;\n        settled = true;\n        removeEntry(normalizedAccountId);\n        resolve(result);\n      };\n      const timeoutHandle = setTimeout(() => {\n        try {\n          child.kill(\"SIGKILL\");\n        } catch {}\n        finalize({\n          stopped: false,\n          forced: true,\n          accountId: normalizedAccountId,\n        });\n      }, Math.max(100, Number(timeoutMs) || kDefaultStopTimeoutMs));\n      child.once(\"exit\", () => {\n        clearTimeout(timeoutHandle);\n        finalize({\n          stopped: true,\n          forced: false,\n          accountId: normalizedAccountId,\n        });\n      });\n      try {\n        child.kill(\"SIGTERM\");\n      } catch {\n        clearTimeout(timeoutHandle);\n        finalize({\n          stopped: true,\n          forced: false,\n          accountId: normalizedAccountId,\n        });\n      }\n    });\n  };\n\n  const restartServe = async ({\n    account,\n    port,\n    webhookToken,\n  }) => {\n    await stopServe({ accountId: account?.id || \"\" });\n    return await startServe({ account, port, webhookToken });\n  };\n\n  const stopAll = async () => {\n    const accountIds = Array.from(entriesByAccountId.keys());\n    const results = [];\n    for (const accountId of accountIds) {\n      // eslint-disable-next-line no-await-in-loop\n      const result = await stopServe({ accountId });\n      results.push(result);\n    }\n    return results;\n  };\n\n  return {\n    getServeStatus,\n    listServeStatuses,\n    startServe,\n    stopServe,\n    restartServe,\n    stopAll,\n    isPidRunning,\n  };\n};\n\nmodule.exports = {\n  createGmailServeManager,\n};\n"
  },
  {
    "path": "lib/server/gmail-watch.js",
    "content": "const path = require(\"path\");\nconst {\n  readGoogleState,\n  writeGoogleState,\n  listGoogleAccounts,\n  getGoogleAccountById,\n  getGoogleAccountByEmail,\n  getGmailPushConfig,\n  setGmailPushConfig,\n  getAccountGmailWatch,\n  setAccountGmailWatch,\n  listWatchEnabledAccounts,\n  generatePushToken,\n  allocateServePort,\n} = require(\"./google-state\");\nconst { createGmailServeManager } = require(\"./gmail-serve\");\nconst { parseJsonObjectFromNoisyOutput, parseJsonSafe } = require(\"./utils/json\");\nconst { createWebhook } = require(\"./webhooks\");\nconst { readOpenclawConfig } = require(\"./openclaw-config\");\nconst { quoteShellArg } = require(\"./utils/shell\");\n\nconst parseExpirationFromOutput = (raw) => {\n  const parsed =\n    parseJsonSafe(raw, null, { trim: true }) ||\n    parseJsonObjectFromNoisyOutput(raw);\n  if (parsed?.expiration) {\n    const numeric = Number.parseInt(String(parsed.expiration), 10);\n    if (Number.isFinite(numeric) && numeric > 0) return numeric;\n  }\n  const text = String(raw || \"\");\n  const epochMatch = text.match(/\"expiration\"\\s*:\\s*\"?(\\d{10,})\"?/i);\n  if (epochMatch?.[1]) {\n    const numeric = Number.parseInt(epochMatch[1], 10);\n    if (Number.isFinite(numeric) && numeric > 0) return numeric;\n  }\n  return null;\n};\n\nconst createTopicNameForClient = (client = \"default\") => {\n  const normalizedClient = String(client || \"default\")\n    .trim()\n    .toLowerCase()\n    .replace(/[^a-z0-9-]/g, \"-\")\n    .replace(/-+/g, \"-\")\n    .replace(/^-|-$/g, \"\");\n  if (!normalizedClient || normalizedClient === \"default\") {\n    return \"gog-gmail-watch\";\n  }\n  return `gog-gmail-watch-${normalizedClient}`;\n};\n\nconst createSubscriptionNameForClient = (client = \"default\") =>\n  `${createTopicNameForClient(client)}-push`;\n\nconst parseTopicName = (topicPath = \"\") => {\n  const match = String(topicPath || \"\").match(/\\/topics\\/([^/]+)$/);\n  return match?.[1] ? String(match[1]) : \"\";\n};\n\nconst parseProjectIdFromTopicPath = (topicPath = \"\") => {\n  const match = String(topicPath || \"\").match(/^projects\\/([^/]+)\\/topics\\/[^/]+$/);\n  return match?.[1] ? String(match[1]) : \"\";\n};\n\nconst normalizeDestination = (destination = null) => {\n  if (!destination || typeof destination !== \"object\") return null;\n  const channel = String(destination?.channel || \"\").trim();\n  const to = String(destination?.to || \"\").trim();\n  const agentId = String(destination?.agentId || \"\").trim();\n  if (!channel && !to && !agentId) return null;\n  if (!channel || !to) {\n    throw new Error(\"destination.channel and destination.to are required\");\n  }\n  return {\n    channel,\n    to,\n    ...(agentId ? { agentId } : {}),\n  };\n};\n\nconst buildGmailTransformSource = (destination = null) => {\n  const normalizedDestination = normalizeDestination(destination);\n  return [\n    \"export default async function transform(payload) {\",\n    \"  const data = payload?.payload || payload || {};\",\n    \"  const messages = Array.isArray(data.messages) ? data.messages : [];\",\n    \"  const first = messages[0] || {};\",\n    \"  const from = String(first.from || \\\"unknown sender\\\").trim();\",\n    \"  const subject = String(first.subject || \\\"(no subject)\\\").trim();\",\n    \"  const snippet = String(first.snippet || \\\"\\\").trim();\",\n    \"  return {\",\n    \"    message: `New email from ${from}\\\\nSubject: ${subject}\\\\n${snippet}`.trim(),\",\n    \"    messages,\",\n    '    name: \"Gmail\",',\n    '    wakeMode: \"now\",',\n    ...(normalizedDestination\n      ? [\n          `    channel: ${JSON.stringify(normalizedDestination.channel)},`,\n          `    to: ${JSON.stringify(normalizedDestination.to)},`,\n          ...(normalizedDestination.agentId\n            ? [`    agentId: ${JSON.stringify(normalizedDestination.agentId)},`]\n            : []),\n        ]\n      : []),\n    \"  };\",\n    \"}\",\n    \"\",\n  ].join(\"\\n\");\n};\n\nconst hasGmailWebhookMapping = ({ fs, openclawDir }) => {\n  const cfg = readOpenclawConfig({\n    fsModule: fs,\n    openclawDir,\n    fallback: {},\n  });\n  const mappings = Array.isArray(cfg?.hooks?.mappings) ? cfg.hooks.mappings : [];\n  return mappings.some(\n    (mapping) => String(mapping?.match?.path || \"\").trim().toLowerCase() === \"gmail\",\n  );\n};\n\nconst getGmailTransformAbsolutePath = (constants) =>\n  path.join(constants.OPENCLAW_DIR, \"hooks/transforms/gmail/gmail-transform.mjs\");\n\nconst ensureTopicPathForClient = ({\n  state,\n  client,\n  readGoogleCredentials,\n  projectIdOverride = \"\",\n}) => {\n  const normalizedClient = String(client || \"default\").trim() || \"default\";\n  const push = getGmailPushConfig(state);\n  const existingTopic = String(push.topics?.[normalizedClient] || \"\").trim();\n  const requestedProjectId = String(projectIdOverride || \"\").trim();\n  const existingProjectId = parseProjectIdFromTopicPath(existingTopic);\n  if (existingTopic && (!requestedProjectId || requestedProjectId === existingProjectId)) {\n    return { state, topicPath: existingTopic };\n  }\n  const credentials = readGoogleCredentials(normalizedClient);\n  const projectId =\n    requestedProjectId ||\n    String(credentials?.projectId || \"\").trim();\n  if (!projectId) {\n    throw new Error(\n      `Could not detect GCP project_id for client \"${normalizedClient}\". Save Google credentials first.`,\n    );\n  }\n  const topicName =\n    parseTopicName(existingTopic) || createTopicNameForClient(normalizedClient);\n  const topicPath = `projects/${projectId}/topics/${topicName}`;\n  const updated = setGmailPushConfig({\n    state,\n    config: {\n      topics: {\n        [normalizedClient]: topicPath,\n      },\n    },\n  });\n  return {\n    state: updated.state,\n    topicPath,\n  };\n};\n\nconst createGmailWatchService = ({\n  fs,\n  constants,\n  gogCmd,\n  getBaseUrl,\n  readGoogleCredentials,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  restartRequiredState,\n}) => {\n  const ensureAccountClientMappings = ({ state }) => {\n    const configDir = String(constants.GOG_CONFIG_DIR || \"\").trim();\n    if (!configDir) return;\n    const configPath = path.join(configDir, \"config.json\");\n    let currentConfig = {};\n    try {\n      if (fs.existsSync(configPath)) {\n        const raw = String(fs.readFileSync(configPath, \"utf8\") || \"\").trim();\n        if (raw) {\n          const parsed = JSON.parse(raw);\n          if (parsed && typeof parsed === \"object\") {\n            currentConfig = parsed;\n          }\n        }\n      }\n    } catch {}\n\n    const nextAccountClients = {\n      ...(currentConfig.account_clients &&\n      typeof currentConfig.account_clients === \"object\" &&\n      !Array.isArray(currentConfig.account_clients)\n        ? currentConfig.account_clients\n        : {}),\n    };\n    for (const account of listGoogleAccounts(state)) {\n      const email = String(account?.email || \"\").trim().toLowerCase();\n      const client = String(account?.client || \"default\").trim() || \"default\";\n      if (!email) continue;\n      nextAccountClients[email] = client;\n    }\n    const nextConfig = {\n      ...currentConfig,\n      account_clients: nextAccountClients,\n    };\n    const previousSerialized = JSON.stringify(currentConfig);\n    const nextSerialized = JSON.stringify(nextConfig);\n    if (previousSerialized === nextSerialized) return;\n    fs.mkdirSync(configDir, { recursive: true });\n    fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\\n`);\n  };\n\n  const readState = () =>\n    readGoogleState({\n      fs,\n      statePath: constants.GOG_STATE_PATH,\n    });\n\n  const saveState = (state) => {\n    ensureAccountClientMappings({ state });\n    writeGoogleState({\n      fs,\n      statePath: constants.GOG_STATE_PATH,\n      state,\n    });\n  };\n\n  const markRestartRequired = (source = \"gmail-watch\") => {\n    try {\n      restartRequiredState?.markRequired?.(source);\n    } catch {}\n  };\n\n  const ensurePushToken = ({ state, forceRegenerate = false }) => {\n    const current = getGmailPushConfig(state);\n    if (current.token && !forceRegenerate) {\n      return { state, token: current.token };\n    }\n    const token = generatePushToken();\n    const updated = setGmailPushConfig({\n      state,\n      config: {\n        ...current,\n        token,\n      },\n    });\n    return { state: updated.state, token };\n  };\n\n  const ensureWebhookToken = () => {\n    const existing = String(process.env.WEBHOOK_TOKEN || \"\").trim();\n    if (existing) return { token: existing, changed: false };\n    const vars = readEnvFile();\n    const tokenFromFile = String(\n      vars.find((entry) => entry.key === \"WEBHOOK_TOKEN\")?.value || \"\",\n    ).trim();\n    if (tokenFromFile) {\n      process.env.WEBHOOK_TOKEN = tokenFromFile;\n      return { token: tokenFromFile, changed: false };\n    }\n    const token = generatePushToken();\n    const nextVars = vars.filter((entry) => entry.key !== \"WEBHOOK_TOKEN\");\n    nextVars.push({ key: \"WEBHOOK_TOKEN\", value: token });\n    writeEnvFile(nextVars);\n    reloadEnv();\n    return { token, changed: true };\n  };\n\n  const ensureHooksPreset = ({ destination = null } = {}) => {\n    const configPath = path.join(constants.OPENCLAW_DIR, \"openclaw.json\");\n    if (!fs.existsSync(configPath)) {\n      throw new Error(\"openclaw.json not found. Complete onboarding first.\");\n    }\n    const gmailTransformModulePath = \"gmail/gmail-transform.mjs\";\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    let changed = false;\n    if (!cfg.hooks || typeof cfg.hooks !== \"object\") {\n      cfg.hooks = {};\n      changed = true;\n    }\n    if (cfg.hooks.enabled !== true) {\n      cfg.hooks.enabled = true;\n      changed = true;\n    }\n    if (typeof cfg.hooks.token !== \"string\" || !cfg.hooks.token.trim()) {\n      cfg.hooks.token = \"${WEBHOOK_TOKEN}\";\n      changed = true;\n    }\n    if (!Array.isArray(cfg.hooks.presets)) {\n      cfg.hooks.presets = [];\n      changed = true;\n    }\n    if (!cfg.hooks.presets.includes(\"gmail\")) {\n      cfg.hooks.presets = [...cfg.hooks.presets, \"gmail\"];\n      changed = true;\n    }\n    if (changed) {\n      fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));\n    }\n    const webhookBefore = fs.readFileSync(configPath, \"utf8\");\n    createWebhook({\n      fs,\n      constants,\n      name: \"gmail\",\n      upsert: true,\n      allowManagedName: true,\n      mapping: {\n        action: \"agent\",\n        name: \"Gmail\",\n        wakeMode: \"now\",\n        transform: { module: gmailTransformModulePath },\n      },\n      transformSource: buildGmailTransformSource(destination),\n    });\n    const webhookAfter = fs.readFileSync(configPath, \"utf8\");\n    if (webhookBefore !== webhookAfter) {\n      changed = true;\n    }\n    return { changed };\n  };\n\n  const ensureHookWiring = ({ destination = null } = {}) => {\n    const webhook = ensureWebhookToken();\n    const hooks = ensureHooksPreset({ destination });\n    const changed = webhook.changed || hooks.changed;\n    if (changed) markRestartRequired(\"gmail-watch\");\n    return { webhookToken: webhook.token, changed };\n  };\n\n  const runGogForAccount = async ({ account, command, quiet = true }) => {\n    const client = String(account?.client || \"default\").trim() || \"default\";\n    const prefix = client === \"default\" ? \"\" : `--client ${quoteShellArg(client)} `;\n    return await gogCmd(`${prefix}${command}`, { quiet });\n  };\n\n  let serviceRef = null;\n  const serveManager = createGmailServeManager({\n    constants,\n    onServeExit: (payload) => {\n      const accountId = String(payload?.accountId || \"\").trim();\n      if (!accountId) return;\n      setTimeout(async () => {\n        try {\n          const state = readState();\n          const account = getGoogleAccountById(state, accountId);\n          const watch = getAccountGmailWatch(account || {});\n          if (!account || !watch.enabled || !watch.port) return;\n          const token = String(\n            process.env.WEBHOOK_TOKEN || \"\",\n          ).trim();\n          if (!token) return;\n          const status = await serveManager.startServe({\n            account,\n            port: watch.port,\n            webhookToken: token,\n          });\n          const updated = setAccountGmailWatch({\n            state,\n            accountId,\n            watch: {\n              pid: status.pid || null,\n            },\n          });\n          saveState(updated.state);\n        } catch (err) {\n          console.error(\"[alphaclaw] Gmail serve auto-restart failed:\", err);\n        }\n      }, 5000);\n    },\n  });\n\n  const buildClientConfig = ({ state, client, baseUrl }) => {\n    const normalizedClient = String(client || \"default\").trim() || \"default\";\n    const push = getGmailPushConfig(state);\n    const topicPath = String(push.topics?.[normalizedClient] || \"\").trim();\n    const credentials = readGoogleCredentials(normalizedClient);\n    const projectId =\n      String(credentials?.projectId || \"\").trim() ||\n      parseProjectIdFromTopicPath(topicPath);\n    const topicName = parseTopicName(topicPath) || createTopicNameForClient(normalizedClient);\n    const subscriptionName = createSubscriptionNameForClient(normalizedClient);\n    const pushEndpoint = `${baseUrl}/gmail-pubsub?token=${encodeURIComponent(\n      String(push.token || \"\"),\n    )}`;\n    const transformExists = fs.existsSync(getGmailTransformAbsolutePath(constants));\n    const webhookExists = hasGmailWebhookMapping({\n      fs,\n      openclawDir: constants.OPENCLAW_DIR,\n    });\n    const commands =\n      projectId && push.token\n        ? {\n            enableApis: `gcloud --project ${projectId} services enable gmail.googleapis.com pubsub.googleapis.com`,\n            createTopic: `gcloud --project ${projectId} pubsub topics create ${topicName}`,\n            grantPublisher: `gcloud --project ${projectId} pubsub topics add-iam-policy-binding ${topicName} --member=serviceAccount:gmail-api-push@system.gserviceaccount.com --role=roles/pubsub.publisher`,\n            createSubscription: `gcloud --project ${projectId} pubsub subscriptions create ${subscriptionName} --topic ${topicName} --push-endpoint \"${pushEndpoint}\"`,\n          }\n        : null;\n    return {\n      client: normalizedClient,\n      projectId: projectId || null,\n      topicPath: topicPath || null,\n      topicName,\n      subscriptionName,\n      pushEndpoint,\n      commands,\n      transformExists,\n      webhookExists,\n      configured: Boolean(topicPath && push.token && projectId),\n    };\n  };\n\n  const getConfig = ({ req }) => {\n    let state = readState();\n    const ensuredPush = ensurePushToken({ state });\n    state = ensuredPush.state;\n    saveState(state);\n    const baseUrl = getBaseUrl(req);\n    const clients = Array.from(\n      new Set(\n        listGoogleAccounts(state).map(\n          (account) => String(account.client || \"default\").trim() || \"default\",\n        ),\n      ),\n    );\n    const clientConfigs = clients.map((client) =>\n      buildClientConfig({ state, client, baseUrl }),\n    );\n    const serveStatuses = new Map(\n      serveManager\n        .listServeStatuses()\n        .map((status) => [String(status.accountId || \"\"), status]),\n    );\n    const accounts = listGoogleAccounts(state).map((account) => {\n      const watch = getAccountGmailWatch(account);\n      const serve = serveStatuses.get(String(account.id || \"\")) || null;\n      return {\n        accountId: account.id,\n        email: account.email,\n        client: account.client || \"default\",\n        enabled: watch.enabled,\n        port: watch.port || null,\n        expiration: watch.expiration || null,\n        lastPushAt: watch.lastPushAt || null,\n        pid: serve?.pid || watch.pid || null,\n        running: Boolean(serve?.running),\n      };\n    });\n    return {\n      ok: true,\n      pushToken: ensuredPush.token,\n      pushEndpoint: `${baseUrl}/gmail-pubsub?token=${encodeURIComponent(\n        ensuredPush.token,\n      )}`,\n      clients: clientConfigs,\n      accounts,\n    };\n  };\n\n  const saveClientConfig = ({ req, body = {} }) => {\n    let state = readState();\n    const client = String(body.client || \"default\").trim() || \"default\";\n    const ensuredPush = ensurePushToken({\n      state,\n      forceRegenerate: Boolean(body.regeneratePushToken),\n    });\n    state = ensuredPush.state;\n    let topicPath = String(body.topicPath || \"\").trim();\n    if (!topicPath) {\n      const ensuredTopic = ensureTopicPathForClient({\n        state,\n        client,\n        readGoogleCredentials,\n        projectIdOverride: String(body.projectId || \"\").trim(),\n      });\n      state = ensuredTopic.state;\n      topicPath = ensuredTopic.topicPath;\n    } else {\n      const updatedPush = setGmailPushConfig({\n        state,\n        config: {\n          topics: {\n            [client]: topicPath,\n          },\n        },\n      });\n      state = updatedPush.state;\n    }\n    saveState(state);\n    const baseUrl = getBaseUrl(req);\n    return {\n      ok: true,\n      client: buildClientConfig({ state, client, baseUrl }),\n      topicPath,\n      pushToken: getGmailPushConfig(state).token,\n    };\n  };\n\n  const startWatch = async ({ accountId, req, destination = null }) => {\n    let state = readState();\n    const account = getGoogleAccountById(state, accountId);\n    if (!account) throw new Error(\"Google account not found\");\n    if (!Array.isArray(account.services) || !account.services.includes(\"gmail:read\")) {\n      throw new Error(\"Account is missing gmail:read permission\");\n    }\n    const client = String(account.client || \"default\").trim() || \"default\";\n    const ensuredPush = ensurePushToken({ state });\n    state = ensuredPush.state;\n    const ensuredTopic = ensureTopicPathForClient({\n      state,\n      client,\n      readGoogleCredentials,\n    });\n    state = ensuredTopic.state;\n    const topicPath = ensuredTopic.topicPath;\n\n    const { webhookToken } = ensureHookWiring({ destination });\n    const watchStart = await runGogForAccount({\n      account,\n      command:\n        `gmail watch start --json --account ${quoteShellArg(account.email)} ` +\n        `--topic ${quoteShellArg(topicPath)} --label INBOX`,\n    });\n    if (!watchStart.ok) {\n      throw new Error(watchStart.stderr || \"Failed to start Gmail watch\");\n    }\n\n    const currentWatch = getAccountGmailWatch(account);\n    const selectedPort =\n      currentWatch.port ||\n      allocateServePort({\n        state,\n        basePort: constants.kGmailServeBasePort,\n        maxAccounts: constants.kMaxGoogleAccounts,\n      });\n    if (!selectedPort) {\n      throw new Error(\"No available Gmail watch serve ports\");\n    }\n    const serveStatus = await serveManager.startServe({\n      account,\n      port: selectedPort,\n      webhookToken,\n    });\n    const expiration = parseExpirationFromOutput(watchStart.stdout);\n    const updated = setAccountGmailWatch({\n      state,\n      accountId,\n      watch: {\n        enabled: true,\n        port: selectedPort,\n        expiration,\n        pid: serveStatus.pid || null,\n      },\n    });\n    state = updated.state;\n    saveState(state);\n    return {\n      ok: true,\n      accountId,\n      client,\n      topicPath,\n      watch: getAccountGmailWatch(updated.account),\n      serve: serveStatus,\n    };\n  };\n\n  const stopWatch = async ({ accountId }) => {\n    let state = readState();\n    const account = getGoogleAccountById(state, accountId);\n    if (!account) return { ok: true, accountId, skipped: true };\n\n    await serveManager.stopServe({ accountId });\n    const watchStop = await runGogForAccount({\n      account,\n      command: `gmail watch stop --account ${quoteShellArg(account.email)} --force`,\n    });\n    if (!watchStop.ok) {\n      console.log(\n        `[alphaclaw] Gmail watch stop warning (${account.email}): ${watchStop.stderr || \"unknown\"}`,\n      );\n    }\n    const updated = setAccountGmailWatch({\n      state,\n      accountId,\n      watch: {\n        enabled: false,\n        pid: null,\n      },\n    });\n    state = updated.state;\n    saveState(state);\n    return { ok: true, accountId, watch: getAccountGmailWatch(updated.account) };\n  };\n\n  const renewWatch = async ({ accountId = \"\", force = false }) => {\n    let state = readState();\n    const now = Date.now();\n    const allTargets = accountId\n      ? [getGoogleAccountById(state, accountId)].filter(Boolean)\n      : listWatchEnabledAccounts(state);\n    const results = [];\n    for (const account of allTargets) {\n      const watch = getAccountGmailWatch(account);\n      const shouldRenew =\n        force ||\n        !watch.expiration ||\n        watch.expiration - now <= constants.kGmailWatchRenewalThresholdMs;\n      if (!shouldRenew) {\n        results.push({\n          accountId: account.id,\n          skipped: true,\n          reason: \"not_due\",\n        });\n        continue;\n      }\n      try {\n        // eslint-disable-next-line no-await-in-loop\n        const renewed = await startWatch({ accountId: account.id, req: null });\n        results.push({\n          accountId: account.id,\n          renewed: true,\n          expiration: renewed.watch.expiration || null,\n        });\n      } catch (err) {\n        results.push({\n          accountId: account.id,\n          renewed: false,\n          error: err.message || \"renew_failed\",\n        });\n      }\n    }\n    return { ok: true, results };\n  };\n\n  let renewalTimer = null;\n  const start = () => {\n    const run = async () => {\n      try {\n        await renewWatch({ force: false });\n      } catch (err) {\n        console.error(\"[alphaclaw] Gmail watch renewal error:\", err);\n      }\n    };\n    if (renewalTimer) clearInterval(renewalTimer);\n    renewalTimer = setInterval(run, constants.kGmailWatchRenewalIntervalMs);\n    renewalTimer.unref?.();\n\n    setTimeout(async () => {\n      try {\n        let state = readState();\n        const hookToken = String(\n          process.env.WEBHOOK_TOKEN || \"\",\n        ).trim();\n        const enabled = listWatchEnabledAccounts(state);\n        for (const account of enabled) {\n          const watch = getAccountGmailWatch(account);\n          if (!watch.enabled || !watch.port || !hookToken) continue;\n          try {\n            // eslint-disable-next-line no-await-in-loop\n            const serveStatus = await serveManager.startServe({\n              account,\n              port: watch.port,\n              webhookToken: hookToken,\n            });\n            const updated = setAccountGmailWatch({\n              state,\n              accountId: account.id,\n              watch: { pid: serveStatus.pid || null },\n            });\n            state = updated.state;\n          } catch (err) {\n            console.error(\n              `[alphaclaw] Failed to restore Gmail serve for ${account.email}: ${err.message || \"unknown\"}`,\n            );\n          }\n        }\n        saveState(state);\n        await run();\n      } catch (err) {\n        console.error(\"[alphaclaw] Failed to bootstrap Gmail watch services:\", err);\n      }\n    }, 0);\n  };\n\n  const stop = async () => {\n    if (renewalTimer) {\n      clearInterval(renewalTimer);\n      renewalTimer = null;\n    }\n    await serveManager.stopAll();\n  };\n\n  const getTargetByEmail = (email = \"\") => {\n    const state = readState();\n    const account = getGoogleAccountByEmail(state, email);\n    if (!account) return null;\n    const watch = getAccountGmailWatch(account);\n    if (!watch.enabled || !watch.port) return null;\n    return {\n      accountId: account.id,\n      port: watch.port,\n      email: account.email,\n      client: account.client || \"default\",\n    };\n  };\n\n  const markPushReceived = ({ accountId, at }) => {\n    const state = readState();\n    const updated = setAccountGmailWatch({\n      state,\n      accountId,\n      watch: {\n        lastPushAt: Number.parseInt(String(at || Date.now()), 10),\n      },\n    });\n    saveState(updated.state);\n  };\n\n  serviceRef = {\n    start,\n    stop,\n    getConfig,\n    saveClientConfig,\n    startWatch,\n    stopWatch,\n    renewWatch,\n    getTargetByEmail,\n    markPushReceived,\n    resolvePushToken: () => getGmailPushConfig(readState()).token,\n    getServeStatus: (accountId) => serveManager.getServeStatus(accountId),\n    ensureHookWiring,\n  };\n\n  return serviceRef;\n};\n\nmodule.exports = {\n  createGmailWatchService,\n  createTopicNameForClient,\n  createSubscriptionNameForClient,\n};\n"
  },
  {
    "path": "lib/server/gog-skill.js",
    "content": "const path = require(\"path\");\nconst { readGoogleState } = require(\"./google-state\");\n\nconst kSkillPartsDir = path.join(__dirname, \"..\", \"setup\", \"skills\", \"gog-cli\");\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nconst uniqueServiceLabels = (scopes) =>\n  Array.from(\n    new Set(\n      (scopes || [])\n        .map((scope) => String(scope || \"\").split(\":\")[0])\n        .filter(Boolean),\n    ),\n  );\n\nconst collectConnectedServices = (accounts) => {\n  const serviceSet = new Set();\n  for (const account of accounts) {\n    if (!account.authenticated) continue;\n    for (const label of uniqueServiceLabels(account.services)) {\n      serviceSet.add(label);\n    }\n  }\n  return serviceSet;\n};\n\nconst kServiceDisplayNames = {\n  gmail: \"Gmail\",\n  calendar: \"Calendar\",\n  drive: \"Drive\",\n  sheets: \"Sheets\",\n  docs: \"Docs\",\n  tasks: \"Tasks\",\n  contacts: \"Contacts\",\n  meet: \"Meet\",\n};\n\n// Stable ordering for service sections\nconst kServiceOrder = [\n  \"gmail\",\n  \"calendar\",\n  \"drive\",\n  \"sheets\",\n  \"docs\",\n  \"tasks\",\n  \"contacts\",\n  \"meet\",\n];\n\nconst readServiceSection = (fs, service) => {\n  try {\n    return fs.readFileSync(path.join(kSkillPartsDir, `${service}.md`), \"utf8\");\n  } catch {\n    return null;\n  }\n};\n\n// ---------------------------------------------------------------------------\n// Skill content builder\n// ---------------------------------------------------------------------------\n\nconst buildGogSkillContent = ({ fs, accounts }) => {\n  const authenticatedAccounts = accounts.filter((a) => a.authenticated);\n  if (!authenticatedAccounts.length) return null;\n\n  const connectedServices = collectConnectedServices(authenticatedAccounts);\n  if (!connectedServices.size) return null;\n\n  const serviceNames = kServiceOrder\n    .filter((svc) => connectedServices.has(svc))\n    .map((svc) => kServiceDisplayNames[svc] || svc);\n\n  const lines = [];\n\n  // Frontmatter\n  lines.push(\"---\");\n  lines.push(\"name: gog-cli\");\n  lines.push(\n    `description: Google Workspace CLI (gog) — command reference for ${serviceNames.join(\", \")}.`,\n  );\n  lines.push(\"---\");\n  lines.push(\"\");\n\n  // Header\n  lines.push(\"# gog — Google Workspace CLI\");\n  lines.push(\"\");\n  lines.push(\n    \"Fast, script-friendly CLI for Google Workspace. All commands output structured JSON with `--json` or stable TSV with `--plain`.\",\n  );\n  lines.push(\"\");\n\n  // Global flags\n  lines.push(\"## Global Flags\");\n  lines.push(\"\");\n  lines.push(\"```\");\n  lines.push(\"--account <email>   Account to use (or set GOG_ACCOUNT)\");\n  lines.push(\"--client <name>     OAuth client (default: \\\"default\\\")\");\n  lines.push(\"--json              Structured JSON output\");\n  lines.push(\"--plain             Stable TSV output (no colors)\");\n  lines.push(\"--force             Skip confirmations\");\n  lines.push(\"--verbose           Verbose logging\");\n  lines.push(\"```\");\n  lines.push(\"\");\n\n  lines.push(\"## Runtime Notes\");\n  lines.push(\"\");\n  lines.push(\n    \"- In AlphaClaw-managed deployments, gog state lives under `$OPENCLAW_STATE_DIR` (typically `/data/.openclaw`).\",\n  );\n  lines.push(\n    \"- If a direct shell `gog ...` command falls back to `/root/.config/gogcli` or `/root/.openclaw`, rerun it with `XDG_CONFIG_HOME=\\\"${OPENCLAW_STATE_DIR:-$OPENCLAW_HOME/.openclaw}\\\"` so gog uses the managed state dir.\",\n  );\n  lines.push(\n    \"- Always pass `--account <email>` (and `--client <name>` if not \\\"default\\\") so gog targets the correct account.\",\n  );\n  lines.push(\"\");\n\n  // Account table\n  lines.push(\"## Connected Accounts\");\n  lines.push(\"\");\n  lines.push(\"| Email | Client | Services |\");\n  lines.push(\"| ----- | ------ | -------- |\");\n  for (const account of authenticatedAccounts) {\n    const email = String(account.email || \"\").trim() || \"(unknown)\";\n    const client = String(account.client || \"default\").trim();\n    const services = uniqueServiceLabels(account.services).join(\", \");\n    lines.push(`| ${email} | ${client} | ${services} |`);\n  }\n  lines.push(\"\");\n\n  // Per-service sections (read from markdown files)\n  for (const svc of kServiceOrder) {\n    if (!connectedServices.has(svc)) continue;\n    const section = readServiceSection(fs, svc);\n    if (section) {\n      lines.push(section.trimEnd());\n      lines.push(\"\");\n    }\n  }\n\n  return lines.join(\"\\n\");\n};\n\n// ---------------------------------------------------------------------------\n// Installer (reads state, writes SKILL.md)\n// ---------------------------------------------------------------------------\n\nconst installGogCliSkill = ({ fs, openclawDir }) => {\n  try {\n    const statePath = path.join(openclawDir, \"gogcli\", \"state.json\");\n    const state = readGoogleState({ fs, statePath });\n    const accounts = Array.isArray(state.accounts) ? state.accounts : [];\n    const content = buildGogSkillContent({ fs, accounts });\n\n    const skillDir = path.join(openclawDir, \"skills\", \"gog-cli\");\n\n    if (!content) {\n      // No authenticated accounts — remove stale skill if present\n      const skillPath = path.join(skillDir, \"SKILL.md\");\n      if (fs.existsSync(skillPath)) {\n        fs.unlinkSync(skillPath);\n        console.log(\"[gog-skill] Removed stale gog-cli skill (no connected accounts)\");\n      }\n      return;\n    }\n\n    fs.mkdirSync(skillDir, { recursive: true });\n    fs.writeFileSync(path.join(skillDir, \"SKILL.md\"), content);\n    console.log(\"[gog-skill] gog-cli skill installed\");\n  } catch (e) {\n    console.error(\"[gog-skill] Install error:\", e.message);\n  }\n};\n\nmodule.exports = { buildGogSkillContent, installGogCliSkill };\n"
  },
  {
    "path": "lib/server/google-state.js",
    "content": "const crypto = require(\"crypto\");\n\nconst kGoogleStateVersion = 2;\nconst kDefaultGoogleClient = \"default\";\nconst kDefaultGoogleScopes = [\n  \"gmail:read\",\n  \"calendar:read\",\n  \"calendar:write\",\n  \"drive:read\",\n  \"sheets:read\",\n  \"docs:read\",\n];\n\nconst createEmptyGoogleState = () => ({\n  version: kGoogleStateVersion,\n  accounts: [],\n  gmailPush: {\n    token: \"\",\n    topics: {},\n  },\n});\n\nconst createGoogleAccountId = () => crypto.randomBytes(4).toString(\"hex\");\n\nconst normalizeScopes = (services) => {\n  if (!Array.isArray(services)) return [...kDefaultGoogleScopes];\n  const deduped = Array.from(\n    new Set(\n      services\n        .map((scope) => String(scope || \"\").trim())\n        .filter(Boolean),\n    ),\n  );\n  return deduped.length ? deduped : [...kDefaultGoogleScopes];\n};\n\nconst normalizePositiveInt = (value, fallbackValue = null) => {\n  const parsed = Number.parseInt(String(value ?? \"\"), 10);\n  if (Number.isFinite(parsed) && parsed > 0) return parsed;\n  return fallbackValue;\n};\n\nconst normalizeTimestamp = (value) => {\n  const parsed = Number.parseInt(String(value ?? \"\"), 10);\n  if (Number.isFinite(parsed) && parsed > 0) return parsed;\n  return null;\n};\n\nconst normalizeGmailWatch = (gmailWatch = {}) => {\n  const enabled = Boolean(gmailWatch?.enabled);\n  return {\n    enabled,\n    port: enabled ? normalizePositiveInt(gmailWatch?.port) : null,\n    expiration: normalizeTimestamp(gmailWatch?.expiration),\n    lastPushAt: normalizeTimestamp(gmailWatch?.lastPushAt),\n    pid: enabled ? normalizePositiveInt(gmailWatch?.pid) : null,\n  };\n};\n\nconst normalizeGmailPush = (gmailPush = {}) => {\n  const rawTopics = gmailPush?.topics;\n  const topics = Object.fromEntries(\n    Object.entries(rawTopics && typeof rawTopics === \"object\" ? rawTopics : {})\n      .map(([client, topic]) => [\n        String(client || \"\").trim(),\n        String(topic || \"\").trim(),\n      ])\n      .filter(([client, topic]) => client && topic),\n  );\n  return {\n    token: String(gmailPush?.token || \"\").trim(),\n    topics,\n  };\n};\n\nconst isLikelyPersonalEmail = (email = \"\") => {\n  const normalized = String(email || \"\").trim().toLowerCase();\n  return normalized.endsWith(\"@gmail.com\") || normalized.endsWith(\"@googlemail.com\");\n};\n\nconst normalizePersonalFlag = ({ account = {}, client = kDefaultGoogleClient }) => {\n  if (typeof account.personal === \"boolean\") return account.personal;\n  if (client === \"personal\") return true;\n  return isLikelyPersonalEmail(account.email);\n};\n\nconst normalizeGoogleAccount = (account = {}) => ({\n  // Backward-compatible migration path for older state entries that predate\n  // explicit personal flags or were saved before the personal marker existed.\n  ...(() => {\n    const client =\n      String(account.client || kDefaultGoogleClient).trim() || kDefaultGoogleClient;\n    return {\n      id: String(account.id || createGoogleAccountId()),\n      email: String(account.email || \"\").trim(),\n      client,\n      personal: normalizePersonalFlag({ account, client }),\n      services: normalizeScopes(account.services),\n      authenticated: Boolean(account.authenticated),\n      gmailWatch: normalizeGmailWatch(account.gmailWatch),\n    };\n  })(),\n});\n\nconst normalizeGoogleStateV2 = (state = {}) => {\n  const accounts = Array.isArray(state.accounts)\n    ? state.accounts.map((account) => normalizeGoogleAccount(account))\n    : [];\n  return {\n    version: kGoogleStateVersion,\n    accounts,\n    gmailPush: normalizeGmailPush(state.gmailPush),\n  };\n};\n\nconst hasPersonalGoogleAccount = (state = {}) =>\n  (state.accounts || []).some((account) => account.personal);\n\nconst writeGoogleState = ({ fs, statePath, state }) => {\n  const normalized = normalizeGoogleStateV2(state);\n  fs.writeFileSync(statePath, JSON.stringify(normalized, null, 2));\n  return normalized;\n};\n\nconst migrateGoogleStateV1 = ({ fs, statePath, rawState = {} }) => {\n  const email = String(rawState.email || \"\").trim();\n  const accounts = email\n    ? [\n        normalizeGoogleAccount({\n          id: createGoogleAccountId(),\n          email,\n          services: rawState.services,\n          authenticated: Boolean(rawState.authenticated),\n          client: kDefaultGoogleClient,\n          personal: false,\n        }),\n      ]\n    : [];\n  const migrated = {\n    version: kGoogleStateVersion,\n    accounts,\n    gmailPush: normalizeGmailPush({}),\n  };\n  fs.writeFileSync(statePath, JSON.stringify(migrated, null, 2));\n  return migrated;\n};\n\nconst readGoogleState = ({ fs, statePath }) => {\n  if (!fs.existsSync(statePath)) return createEmptyGoogleState();\n  try {\n    const raw = JSON.parse(fs.readFileSync(statePath, \"utf8\"));\n    if (raw && raw.version === kGoogleStateVersion && Array.isArray(raw.accounts)) {\n      const normalized = normalizeGoogleStateV2(raw);\n      if (JSON.stringify(raw) !== JSON.stringify(normalized)) {\n        fs.writeFileSync(statePath, JSON.stringify(normalized, null, 2));\n      }\n      return normalized;\n    }\n    return migrateGoogleStateV1({ fs, statePath, rawState: raw || {} });\n  } catch {\n    return createEmptyGoogleState();\n  }\n};\n\nconst listGoogleAccounts = (state = {}) => [...(state.accounts || [])];\n\nconst getGoogleAccountById = (state = {}, accountId = \"\") =>\n  (state.accounts || []).find((account) => account.id === accountId) || null;\n\nconst getGoogleAccountByEmailAndClient = (\n  state = {},\n  email = \"\",\n  client = kDefaultGoogleClient,\n) =>\n  (state.accounts || []).find(\n    (account) => account.email === email && account.client === client,\n  ) || null;\n\nconst getGoogleAccountByEmail = (state = {}, email = \"\") => {\n  const normalizedEmail = String(email || \"\").trim().toLowerCase();\n  if (!normalizedEmail) return null;\n  return (\n    (state.accounts || []).find(\n      (account) =>\n        String(account.email || \"\").trim().toLowerCase() === normalizedEmail,\n    ) || null\n  );\n};\n\nconst getGmailPushConfig = (state = {}) => normalizeGmailPush(state.gmailPush);\n\nconst setGmailPushConfig = ({ state = {}, config = {} }) => {\n  const nextState = normalizeGoogleStateV2(state);\n  nextState.gmailPush = normalizeGmailPush({\n    ...nextState.gmailPush,\n    ...config,\n    topics: {\n      ...(nextState.gmailPush?.topics || {}),\n      ...((config?.topics && typeof config.topics === \"object\")\n        ? config.topics\n        : {}),\n    },\n  });\n  return { state: nextState, gmailPush: nextState.gmailPush };\n};\n\nconst getAccountGmailWatch = (account = {}) =>\n  normalizeGmailWatch(account?.gmailWatch);\n\nconst setAccountGmailWatch = ({ state = {}, accountId = \"\", watch = {} }) => {\n  const nextState = normalizeGoogleStateV2(state);\n  const targetId = String(accountId || \"\").trim();\n  if (!targetId) return { state: nextState, account: null };\n  const accountIndex = nextState.accounts.findIndex(\n    (account) => account.id === targetId,\n  );\n  if (accountIndex === -1) return { state: nextState, account: null };\n  const account = nextState.accounts[accountIndex];\n  const mergedWatch = normalizeGmailWatch({\n    ...(account.gmailWatch || {}),\n    ...watch,\n  });\n  const nextAccount = {\n    ...account,\n    gmailWatch: mergedWatch,\n  };\n  nextState.accounts[accountIndex] = nextAccount;\n  return { state: nextState, account: nextAccount };\n};\n\nconst listWatchEnabledAccounts = (state = {}) =>\n  (state.accounts || []).filter((account) =>\n    Boolean(normalizeGmailWatch(account.gmailWatch).enabled),\n  );\n\nconst generatePushToken = () => crypto.randomBytes(24).toString(\"base64url\");\n\nconst allocateServePort = ({\n  state = {},\n  basePort = 18801,\n  maxAccounts = 5,\n}) => {\n  const usedPorts = new Set(\n    (state.accounts || [])\n      .map((account) => normalizePositiveInt(account?.gmailWatch?.port))\n      .filter(Boolean),\n  );\n  for (let offset = 0; offset < maxAccounts; offset += 1) {\n    const candidate = basePort + offset;\n    if (!usedPorts.has(candidate)) return candidate;\n  }\n  return null;\n};\n\nconst upsertGoogleAccount = ({\n  state,\n  account,\n  maxAccounts = 5,\n}) => {\n  const nextState = normalizeGoogleStateV2(state);\n  const normalized = normalizeGoogleAccount(account);\n  if (!normalized.email) throw new Error(\"Account email is required\");\n  const existingIdx = nextState.accounts.findIndex((item) => item.id === normalized.id);\n\n  if (normalized.personal) {\n    const personalExists = nextState.accounts.some(\n      (item, idx) => item.personal && idx !== existingIdx,\n    );\n    if (personalExists) {\n      throw new Error(\"Only one personal account is allowed\");\n    }\n  }\n\n  if (existingIdx >= 0) {\n    nextState.accounts[existingIdx] = normalized;\n    return { state: nextState, account: normalized };\n  }\n\n  if (nextState.accounts.length >= maxAccounts) {\n    throw new Error(`Maximum ${maxAccounts} Google accounts allowed`);\n  }\n\n  nextState.accounts.push(normalized);\n  return { state: nextState, account: normalized };\n};\n\nconst removeGoogleAccount = ({ state, accountId }) => {\n  const nextState = normalizeGoogleStateV2(state);\n  const removed = getGoogleAccountById(nextState, accountId);\n  if (!removed) return { state: nextState, account: null };\n  nextState.accounts = nextState.accounts.filter((account) => account.id !== accountId);\n  return { state: nextState, account: removed };\n};\n\nmodule.exports = {\n  kGoogleStateVersion,\n  kDefaultGoogleClient,\n  kDefaultGoogleScopes,\n  createGoogleAccountId,\n  createEmptyGoogleState,\n  readGoogleState,\n  writeGoogleState,\n  listGoogleAccounts,\n  getGoogleAccountById,\n  getGoogleAccountByEmailAndClient,\n  getGoogleAccountByEmail,\n  upsertGoogleAccount,\n  removeGoogleAccount,\n  hasPersonalGoogleAccount,\n  getGmailPushConfig,\n  setGmailPushConfig,\n  getAccountGmailWatch,\n  setAccountGmailWatch,\n  listWatchEnabledAccounts,\n  generatePushToken,\n  allocateServePort,\n};\n"
  },
  {
    "path": "lib/server/helpers.js",
    "content": "const fs = require(\"fs\");\nconst crypto = require(\"crypto\");\nconst {\n  CODEX_JWT_CLAIM_PATH,\n  kOnboardingModelProviders,\n  gogClientCredentialsPath,\n} = require(\"./constants\");\nconst { isTruthyFlag } = require(\"./utils/boolean\");\nconst { parseJsonObjectFromNoisyOutput } = require(\"./utils/json\");\nconst { normalizeIp } = require(\"./utils/network\");\n\nconst normalizeOpenclawVersion = (rawVersion) => {\n  if (!rawVersion) return null;\n  return (\n    String(rawVersion)\n      .trim()\n      .replace(/^openclaw\\s*/i, \"\") || null\n  );\n};\n\nconst compareVersionParts = (a, b) => {\n  const aParts = String(a || \"\")\n    .split(\".\")\n    .map((part) => Number.parseInt(part, 10) || 0);\n  const bParts = String(b || \"\")\n    .split(\".\")\n    .map((part) => Number.parseInt(part, 10) || 0);\n  const maxParts = Math.max(aParts.length, bParts.length);\n  for (let i = 0; i < maxParts; i += 1) {\n    const aPart = aParts[i] ?? 0;\n    const bPart = bParts[i] ?? 0;\n    if (aPart > bPart) return 1;\n    if (aPart < bPart) return -1;\n  }\n  return 0;\n};\n\nconst parseJsonFromNoisyOutput = (raw) => parseJsonObjectFromNoisyOutput(raw);\n\nconst parseJwtPayload = (token) => {\n  try {\n    const parts = String(token || \"\").split(\".\");\n    if (parts.length !== 3) return null;\n    return JSON.parse(Buffer.from(parts[1], \"base64url\").toString(\"utf8\"));\n  } catch {\n    return null;\n  }\n};\n\nconst getCodexAccountId = (accessToken) => {\n  const payload = parseJwtPayload(accessToken);\n  const auth = payload?.[CODEX_JWT_CLAIM_PATH];\n  const accountId = auth?.chatgpt_account_id;\n  return typeof accountId === \"string\" && accountId ? accountId : null;\n};\n\nconst isTruthyEnvFlag = (value) => isTruthyFlag(value);\nconst isDebugEnabled = () =>\n  isTruthyEnvFlag(process.env.ALPHACLAW_DEBUG) ||\n  isTruthyEnvFlag(process.env.DEBUG);\n\nconst getClientKey = (req) =>\n  normalizeIp(\n    req.ip || req.headers[\"x-forwarded-for\"] || req.socket?.remoteAddress || \"\",\n  ) || \"unknown\";\n\nconst resolveGithubRepoUrl = (repoInput) => {\n  const cleaned = String(repoInput || \"\")\n    .trim()\n    .replace(/^git@github\\.com:/, \"\")\n    .replace(/^https:\\/\\/github\\.com\\//, \"\")\n    .replace(/\\.git$/, \"\");\n  if (!cleaned) return \"\";\n  if (!cleaned.includes(\"/\")) {\n    throw new Error('GITHUB_WORKSPACE_REPO must be in \"owner/repo\" format.');\n  }\n  return cleaned;\n};\n\nconst createPkcePair = () => {\n  const verifier = crypto.randomBytes(48).toString(\"base64url\");\n  const challenge = crypto\n    .createHash(\"sha256\")\n    .update(verifier)\n    .digest(\"base64url\");\n  return { verifier, challenge };\n};\n\nconst resolveModelProvider = (modelKey) =>\n  String(modelKey || \"\").split(\"/\")[0] || \"\";\n\nconst parseCodexAuthorizationInput = (input) => {\n  const value = String(input || \"\").trim();\n  if (!value) return {};\n  try {\n    const url = new URL(value);\n    return {\n      code: url.searchParams.get(\"code\") || \"\",\n      state: url.searchParams.get(\"state\") || \"\",\n    };\n  } catch {}\n  if (value.includes(\"#\")) {\n    const [code, state] = value.split(\"#\", 2);\n    return { code: code || \"\", state: state || \"\" };\n  }\n  if (value.includes(\"code=\")) {\n    const params = new URLSearchParams(value);\n    return {\n      code: params.get(\"code\") || \"\",\n      state: params.get(\"state\") || \"\",\n    };\n  }\n  return { code: value, state: \"\" };\n};\n\nconst normalizeOnboardingModels = (models) => {\n  const deduped = new Map();\n  for (const model of models || []) {\n    if (!model?.key || typeof model.key !== \"string\") continue;\n    const provider = resolveModelProvider(model.key);\n    if (!kOnboardingModelProviders.has(provider)) continue;\n    if (!deduped.has(model.key)) {\n      deduped.set(model.key, {\n        key: model.key,\n        provider,\n        label: model.name || model.key,\n      });\n    }\n  }\n  return Array.from(deduped.values()).sort((a, b) =>\n    a.key.localeCompare(b.key),\n  );\n};\n\nconst getBaseUrl = (req) => {\n  const proto = req.headers[\"x-forwarded-proto\"] || req.protocol || \"https\";\n  const host = req.headers[\"x-forwarded-host\"] || req.headers.host;\n  return `${proto}://${host}`;\n};\n\nconst getApiEnableUrl = (svc, projectId) => {\n  const apiMap = {\n    gmail: \"gmail.googleapis.com\",\n    calendar: \"calendar-json.googleapis.com\",\n    tasks: \"tasks.googleapis.com\",\n    docs: \"docs.googleapis.com\",\n    meet: \"meet.googleapis.com\",\n    drive: \"drive.googleapis.com\",\n    contacts: \"people.googleapis.com\",\n    sheets: \"sheets.googleapis.com\",\n  };\n  const api = apiMap[svc] || \"\";\n  const project = projectId ? `?project=${projectId}` : \"\";\n  return `https://console.developers.google.com/apis/api/${api}/overview${project}`;\n};\n\nconst readGoogleCredentials = (clientName = \"default\") => {\n  try {\n    const credentialsPath = gogClientCredentialsPath(clientName);\n    const c = JSON.parse(fs.readFileSync(credentialsPath, \"utf8\"));\n    const webCredentials = c.web || c.installed || c;\n    return {\n      clientId: webCredentials?.client_id || null,\n      clientSecret: webCredentials?.client_secret || null,\n      projectId: webCredentials?.project_id || null,\n      path: credentialsPath,\n      client: clientName,\n    };\n  } catch {\n    return {\n      clientId: null,\n      clientSecret: null,\n      projectId: null,\n      path: gogClientCredentialsPath(clientName),\n      client: clientName,\n    };\n  }\n};\n\nconst kSecretKeyMatchers = [\n  /(?:^|_)TOKEN(?:$|_)/i,\n  /(?:^|_)API_KEY(?:$|_)/i,\n  /(?:^|_)PASSWORD(?:$|_)/i,\n  /(?:^|_)SECRET(?:$|_)/i,\n  /(?:^|_)PRIVATE_KEY(?:$|_)/i,\n];\n\nconst isSensitiveKey = (key) =>\n  kSecretKeyMatchers.some((matcher) => matcher.test(String(key || \"\")));\n\nconst buildSecretReplacements = (...sources) => {\n  const replacements = [];\n  const seen = new Set();\n  for (const source of sources) {\n    for (const [rawKey, rawValue] of Object.entries(source || {})) {\n      const key = String(rawKey || \"\").trim();\n      const value = String(rawValue || \"\");\n      if (!key || !value || !isSensitiveKey(key)) continue;\n      if (seen.has(value)) continue;\n      seen.add(value);\n      replacements.push([value, `\\${${key}}`]);\n    }\n  }\n  return replacements.sort((a, b) => b[0].length - a[0].length);\n};\n\nmodule.exports = {\n  normalizeOpenclawVersion,\n  compareVersionParts,\n  parseJsonFromNoisyOutput,\n  parseJwtPayload,\n  getCodexAccountId,\n  normalizeIp,\n  isTruthyEnvFlag,\n  isDebugEnabled,\n  getClientKey,\n  resolveGithubRepoUrl,\n  createPkcePair,\n  resolveModelProvider,\n  parseCodexAuthorizationInput,\n  normalizeOnboardingModels,\n  getBaseUrl,\n  getApiEnableUrl,\n  readGoogleCredentials,\n  isSensitiveKey,\n  buildSecretReplacements,\n};\n"
  },
  {
    "path": "lib/server/init/register-server-routes.js",
    "content": "const { registerAuthRoutes } = require(\"../routes/auth\");\nconst { registerPageRoutes } = require(\"../routes/pages\");\nconst { registerModelRoutes } = require(\"../routes/models\");\nconst { registerOnboardingRoutes } = require(\"../routes/onboarding\");\nconst { registerSystemRoutes } = require(\"../routes/system\");\nconst { registerPairingRoutes } = require(\"../routes/pairings\");\nconst { registerCodexRoutes } = require(\"../routes/codex\");\nconst { registerGoogleRoutes } = require(\"../routes/google\");\nconst { registerBrowseRoutes } = require(\"../routes/browse\");\nconst { registerProxyRoutes } = require(\"../routes/proxy\");\nconst { registerTelegramRoutes } = require(\"../routes/telegram\");\nconst { registerWebhookRoutes } = require(\"../routes/webhooks\");\nconst { registerWatchdogRoutes } = require(\"../routes/watchdog\");\nconst { registerUsageRoutes } = require(\"../routes/usage\");\nconst { registerGmailRoutes } = require(\"../routes/gmail\");\nconst { registerDoctorRoutes } = require(\"../routes/doctor\");\nconst { registerAgentRoutes } = require(\"../routes/agents\");\nconst { registerCronRoutes } = require(\"../routes/cron\");\nconst { registerNodeRoutes } = require(\"../routes/nodes\");\nconst {\n  createOauthCallbackMiddleware,\n} = require(\"../oauth-callback-middleware\");\n\nconst registerServerRoutes = ({\n  app,\n  fs,\n  constants,\n  loginThrottle,\n  shellCmd,\n  clawCmd,\n  gogCmd,\n  gatewayEnv,\n  parseJsonFromNoisyOutput,\n  normalizeOnboardingModels,\n  authProfiles,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  isOnboarded,\n  isGatewayRunning,\n  resolveGithubRepoUrl,\n  resolveModelProvider,\n  ensureGatewayProxyConfig,\n  getBaseUrl,\n  startGateway,\n  syncChannelConfig,\n  getChannelStatus,\n  openclawVersionService,\n  alphaclawVersionService,\n  restartGateway,\n  restartRequiredState,\n  topicRegistry,\n  createPkcePair,\n  parseCodexAuthorizationInput,\n  getCodexAccountId,\n  readGoogleCredentials,\n  getApiEnableUrl,\n  telegramApi,\n  doSyncPromptFiles,\n  getRequests,\n  getRequestById,\n  getHookSummaries,\n  deleteRequestsByHook,\n  createOauthCallback,\n  getOauthCallbackByHook,\n  getOauthCallbackById,\n  rotateOauthCallback,\n  deleteOauthCallback,\n  markOauthCallbackUsed,\n  watchdog,\n  watchdogNotifier,\n  getRecentEvents,\n  readLogTail,\n  watchdogTerminal,\n  getDailySummary,\n  getSessionsList,\n  getSessionDetail,\n  getSessionTimeSeries,\n  cronService,\n  doctorService,\n  agentsService,\n  operationEvents,\n  proxy,\n  getGatewayUrl,\n  SETUP_API_PREFIXES,\n  webhookMiddleware,\n}) => {\n  const { requireAuth, isAuthorizedRequest } = registerAuthRoutes({\n    app,\n    loginThrottle,\n  });\n\n  registerPageRoutes({ app, requireAuth, isGatewayRunning });\n  registerModelRoutes({\n    app,\n    shellCmd,\n    gatewayEnv,\n    parseJsonFromNoisyOutput,\n    normalizeOnboardingModels,\n    readOpenclawVersion: (options) =>\n      openclawVersionService?.readOpenclawVersion(options),\n    isOnboarded,\n    authProfiles,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n  });\n  registerOnboardingRoutes({\n    app,\n    fs,\n    constants,\n    shellCmd,\n    gatewayEnv,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    isOnboarded,\n    resolveGithubRepoUrl,\n    resolveModelProvider,\n    hasCodexOauthProfile: authProfiles.hasCodexOauthProfile,\n    authProfiles,\n    ensureGatewayProxyConfig,\n    getBaseUrl,\n    startGateway,\n  });\n  registerSystemRoutes({\n    app,\n    fs,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    kKnownVars: constants.kKnownVars,\n    kKnownKeys: constants.kKnownKeys,\n    kSystemVars: constants.kSystemVars,\n    syncChannelConfig,\n    isGatewayRunning,\n    isOnboarded,\n    getChannelStatus,\n    openclawVersionService,\n    alphaclawVersionService,\n    kAlphaclawGithubReleasesBaseUrl: constants.kAlphaclawGithubReleasesBaseUrl,\n    clawCmd,\n    restartGateway,\n    OPENCLAW_DIR: constants.OPENCLAW_DIR,\n    restartRequiredState,\n    topicRegistry,\n    authProfiles,\n    watchdog,\n    doctorService,\n  });\n  registerBrowseRoutes({\n    app,\n    fs,\n    kRootDir: constants.OPENCLAW_DIR,\n  });\n  registerPairingRoutes({ app, clawCmd, isOnboarded });\n  registerCodexRoutes({\n    app,\n    createPkcePair,\n    parseCodexAuthorizationInput,\n    getCodexAccountId,\n    authProfiles,\n  });\n  registerGoogleRoutes({\n    app,\n    fs,\n    isGatewayRunning,\n    gogCmd,\n    getBaseUrl,\n    readGoogleCredentials,\n    getApiEnableUrl,\n    constants,\n  });\n  const gmailWatchService = registerGmailRoutes({\n    app,\n    fs,\n    constants,\n    gogCmd,\n    getBaseUrl,\n    readGoogleCredentials,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    restartRequiredState,\n  });\n  registerTelegramRoutes({\n    app,\n    telegramApi,\n    syncPromptFiles: doSyncPromptFiles,\n    shellCmd,\n  });\n  registerWebhookRoutes({\n    app,\n    fs,\n    constants,\n    getBaseUrl,\n    shellCmd,\n    webhooksDb: {\n      getRequests,\n      getRequestById,\n      getHookSummaries,\n      deleteRequestsByHook,\n      createOauthCallback,\n      getOauthCallbackByHook,\n      rotateOauthCallback,\n      deleteOauthCallback,\n    },\n    restartRequiredState,\n  });\n  const oauthCallbackMiddleware = createOauthCallbackMiddleware({\n    getOauthCallbackById,\n    markOauthCallbackUsed,\n    webhookMiddleware,\n  });\n  registerWatchdogRoutes({\n    app,\n    requireAuth,\n    watchdog,\n    watchdogNotifier,\n    getRecentEvents,\n    readLogTail,\n    watchdogTerminal,\n  });\n  registerUsageRoutes({\n    app,\n    requireAuth,\n    getDailySummary,\n    getSessionsList,\n    getSessionDetail,\n    getSessionTimeSeries,\n  });\n  registerCronRoutes({\n    app,\n    requireAuth,\n    cronService,\n  });\n  registerDoctorRoutes({\n    app,\n    requireAuth,\n    doctorService,\n  });\n  registerAgentRoutes({\n    app,\n    agentsService,\n    restartRequiredState,\n    operationEvents,\n  });\n  registerNodeRoutes({\n    app,\n    clawCmd,\n    openclawDir: constants.OPENCLAW_DIR,\n    gatewayToken: constants.GATEWAY_TOKEN,\n    fsModule: fs,\n  });\n  registerProxyRoutes({\n    app,\n    proxy,\n    getGatewayUrl,\n    SETUP_API_PREFIXES,\n    requireAuth,\n    oauthCallbackMiddleware,\n    webhookMiddleware,\n  });\n\n  return {\n    requireAuth,\n    isAuthorizedRequest,\n    gmailWatchService,\n  };\n};\n\nmodule.exports = {\n  registerServerRoutes,\n};\n"
  },
  {
    "path": "lib/server/init/runtime-init.js",
    "content": "const initializeServerRuntime = ({\n  fs,\n  constants,\n  ensureOpenclawStartupEnv,\n  startEnvWatcher,\n  attachGatewaySignalHandlers,\n  cleanupStaleImportTempDirs,\n  migrateManagedInternalFiles,\n}) => {\n  ensureOpenclawStartupEnv?.({ fsModule: fs });\n  startEnvWatcher();\n  attachGatewaySignalHandlers();\n  cleanupStaleImportTempDirs();\n  migrateManagedInternalFiles({\n    fs,\n    openclawDir: constants.OPENCLAW_DIR,\n  });\n};\n\nconst initializeServerDatabases = ({\n  constants,\n  initWebhooksDb,\n  initWatchdogDb,\n  initUsageDb,\n  initDoctorDb,\n}) => {\n  initWebhooksDb({\n    rootDir: constants.kRootDir,\n    pruneDays: constants.kWebhookPruneDays,\n  });\n  initWatchdogDb({\n    rootDir: constants.kRootDir,\n    pruneDays: constants.kWatchdogLogRetentionDays,\n  });\n  initUsageDb({\n    rootDir: constants.kRootDir,\n  });\n  initDoctorDb({\n    rootDir: constants.kRootDir,\n  });\n};\n\nmodule.exports = {\n  initializeServerRuntime,\n  initializeServerDatabases,\n};\n"
  },
  {
    "path": "lib/server/init/server-lifecycle.js",
    "content": "const startServerLifecycle = ({\n  server,\n  PORT,\n  isOnboarded,\n  runOnboardedBootSequence,\n  ensureManagedExecDefaults,\n  ensureUsageTrackerPluginConfig,\n  doSyncPromptFiles,\n  reloadEnv,\n  syncChannelConfig,\n  readEnvFile,\n  ensureGatewayProxyConfig,\n  resolveSetupUrl,\n  startGateway,\n  watchdog,\n  gmailWatchService,\n}) => {\n  server.listen(PORT, \"0.0.0.0\", () => {\n    console.log(`[alphaclaw] Express listening on :${PORT}`);\n    if (isOnboarded()) {\n      runOnboardedBootSequence({\n        ensureManagedExecDefaults,\n        ensureUsageTrackerPluginConfig,\n        doSyncPromptFiles,\n        reloadEnv,\n        syncChannelConfig,\n        readEnvFile,\n        ensureGatewayProxyConfig,\n        resolveSetupUrl,\n        startGateway,\n        watchdog,\n        gmailWatchService,\n      });\n    } else {\n      console.log(\"[alphaclaw] Awaiting onboarding via Setup UI\");\n    }\n  });\n};\n\nconst registerServerShutdown = ({ gmailWatchService, watchdogTerminal }) => {\n  const shutdownGmailWatchService = async () => {\n    try {\n      await gmailWatchService.stop();\n    } catch {}\n    watchdogTerminal.disposeSession();\n  };\n\n  process.on(\"SIGTERM\", () => {\n    shutdownGmailWatchService();\n  });\n  process.on(\"SIGINT\", () => {\n    shutdownGmailWatchService();\n  });\n};\n\nmodule.exports = {\n  startServerLifecycle,\n  registerServerShutdown,\n};\n"
  },
  {
    "path": "lib/server/internal-files-migration.js",
    "content": "const path = require(\"path\");\n\nconst kInternalDirName = \".alphaclaw\";\nconst kHourlyGitSyncFileName = \"hourly-git-sync.sh\";\nconst kCliDeviceAutoApprovedFileName = \".cli-device-auto-approved\";\nconst kOpenclawGitignoreHookEntries = [\n  \"!hooks/\",\n  \"!hooks/transforms/\",\n  \"!hooks/transforms/**\",\n];\n\nconst kOpenclawGitignoreCronRuntimeEntries = [\n  \"# OpenClaw cron runtime state (local only; job definitions stay in cron/jobs.json)\",\n  \"cron/jobs-state.json\",\n];\n\nconst kOpenclawGitignoreAppendEntries = [\n  ...kOpenclawGitignoreHookEntries,\n  ...kOpenclawGitignoreCronRuntimeEntries,\n];\n\nconst buildManagedPaths = ({ openclawDir, pathModule = path }) => {\n  const internalDir = pathModule.join(openclawDir, kInternalDirName);\n  return {\n    internalDir,\n    hourlyGitSyncPath: pathModule.join(internalDir, kHourlyGitSyncFileName),\n    cliDeviceAutoApprovedPath: pathModule.join(\n      internalDir,\n      kCliDeviceAutoApprovedFileName,\n    ),\n    legacyHourlyGitSyncPath: pathModule.join(\n      openclawDir,\n      kHourlyGitSyncFileName,\n    ),\n    legacyCliDeviceAutoApprovedPath: pathModule.join(\n      openclawDir,\n      kCliDeviceAutoApprovedFileName,\n    ),\n  };\n};\n\nconst moveFile = ({ fs, sourcePath, targetPath, mode }) => {\n  try {\n    fs.renameSync(sourcePath, targetPath);\n    return true;\n  } catch {\n    fs.copyFileSync(sourcePath, targetPath);\n    if (Number.isFinite(mode)) {\n      fs.chmodSync(targetPath, mode);\n    }\n    fs.rmSync(sourcePath, { force: true });\n    return true;\n  }\n};\n\nconst migrateManagedInternalFiles = ({\n  fs,\n  openclawDir,\n  pathModule = path,\n  logger = console,\n}) => {\n  const managedPaths = buildManagedPaths({ openclawDir, pathModule });\n  fs.mkdirSync(managedPaths.internalDir, { recursive: true });\n\n  const migrateOne = ({ sourcePaths, targetPath }) => {\n    const existingSourcePath = sourcePaths.find((sourcePath) =>\n      fs.existsSync(sourcePath),\n    );\n    if (fs.existsSync(targetPath)) {\n      sourcePaths.forEach((sourcePath) => {\n        if (sourcePath !== targetPath && fs.existsSync(sourcePath)) {\n          fs.rmSync(sourcePath, { force: true });\n        }\n      });\n      return;\n    }\n    if (!existingSourcePath) return;\n    const sourceStats = fs.statSync(existingSourcePath);\n    moveFile({\n      fs,\n      sourcePath: existingSourcePath,\n      targetPath,\n      mode: sourceStats.mode,\n    });\n    sourcePaths.forEach((sourcePath) => {\n      if (\n        sourcePath !== existingSourcePath &&\n        sourcePath !== targetPath &&\n        fs.existsSync(sourcePath)\n      ) {\n        fs.rmSync(sourcePath, { force: true });\n      }\n    });\n  };\n\n  try {\n    const gitignorePath = pathModule.join(openclawDir, \".gitignore\");\n    if (fs.existsSync(gitignorePath)) {\n      const raw = String(fs.readFileSync(gitignorePath, \"utf8\") || \"\");\n      const existingLines = raw.split(/\\r?\\n/);\n      const existingSet = new Set(existingLines.map((line) => line.trim()));\n      const missing = kOpenclawGitignoreAppendEntries.filter(\n        (line) => !existingSet.has(line),\n      );\n      if (missing.length) {\n        const separator = raw.endsWith(\"\\n\") || !raw.length ? \"\" : \"\\n\";\n        const next = `${raw}${separator}${missing.join(\"\\n\")}\\n`;\n        fs.writeFileSync(gitignorePath, next);\n      }\n    }\n    migrateOne({\n      sourcePaths: [managedPaths.legacyHourlyGitSyncPath],\n      targetPath: managedPaths.hourlyGitSyncPath,\n    });\n    migrateOne({\n      sourcePaths: [managedPaths.legacyCliDeviceAutoApprovedPath],\n      targetPath: managedPaths.cliDeviceAutoApprovedPath,\n    });\n  } catch (error) {\n    logger.error?.(\n      `[alphaclaw] Failed to migrate internal managed files: ${error.message || String(error)}`,\n    );\n  }\n\n  return managedPaths;\n};\n\nmodule.exports = {\n  buildManagedPaths,\n  migrateManagedInternalFiles,\n};\n"
  },
  {
    "path": "lib/server/log-writer.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\n\nlet logPath = \"\";\nlet linesSinceSizeCheck = 0;\nlet lastSizeCheckAtMs = 0;\n\nconst kTruncateCheckEveryLines = 25;\nconst kTruncateCheckMinIntervalMs = 2000;\n\nconst shouldCheckTruncate = () => {\n  linesSinceSizeCheck += 1;\n  const now = Date.now();\n  if (\n    linesSinceSizeCheck >= kTruncateCheckEveryLines ||\n    now - lastSizeCheckAtMs >= kTruncateCheckMinIntervalMs\n  ) {\n    linesSinceSizeCheck = 0;\n    lastSizeCheckAtMs = now;\n    return true;\n  }\n  return false;\n};\n\nconst appendLine = (line, maxBytes) => {\n  if (!logPath) return;\n  const prefixed = /^\\d{4}-\\d{2}-\\d{2}T/.test(line)\n    ? line\n    : `${new Date().toISOString()} ${line}`;\n  fs.appendFileSync(logPath, prefixed.endsWith(\"\\n\") ? prefixed : `${prefixed}\\n`);\n  if (shouldCheckTruncate()) truncateIfNeeded(maxBytes);\n};\n\nconst truncateIfNeeded = (maxBytes) => {\n  try {\n    const stat = fs.statSync(logPath);\n    if (stat.size <= maxBytes) return;\n    const keepBytes = Math.floor(maxBytes / 2);\n    const fd = fs.openSync(logPath, \"r\");\n    const buffer = Buffer.alloc(keepBytes);\n    const startPos = Math.max(0, stat.size - keepBytes);\n    const bytesRead = fs.readSync(fd, buffer, 0, keepBytes, startPos);\n    fs.closeSync(fd);\n    const chunk = buffer.subarray(0, bytesRead).toString(\"utf8\");\n    const firstNewLine = chunk.indexOf(\"\\n\");\n    const safeChunk = firstNewLine === -1 ? chunk : chunk.slice(firstNewLine + 1);\n    fs.writeFileSync(logPath, safeChunk, \"utf8\");\n  } catch (err) {\n    console.error(`[alphaclaw] log truncate error: ${err.message}`);\n  }\n};\n\nconst initLogWriter = ({ rootDir, maxBytes }) => {\n  const logsDir = path.join(rootDir, \"logs\");\n  fs.mkdirSync(logsDir, { recursive: true });\n  logPath = path.join(logsDir, \"process.log\");\n  if (!fs.existsSync(logPath)) fs.writeFileSync(logPath, \"\", \"utf8\");\n  linesSinceSizeCheck = 0;\n  lastSizeCheckAtMs = Date.now();\n\n  const stdoutWrite = process.stdout.write.bind(process.stdout);\n  const stderrWrite = process.stderr.write.bind(process.stderr);\n\n  process.stdout.write = (chunk, encoding, cb) => {\n    const text = Buffer.isBuffer(chunk) ? chunk.toString(\"utf8\") : String(chunk ?? \"\");\n    for (const line of text.split(\"\\n\")) {\n      if (!line) continue;\n      appendLine(line, maxBytes);\n    }\n    return stdoutWrite(chunk, encoding, cb);\n  };\n\n  process.stderr.write = (chunk, encoding, cb) => {\n    const text = Buffer.isBuffer(chunk) ? chunk.toString(\"utf8\") : String(chunk ?? \"\");\n    for (const line of text.split(\"\\n\")) {\n      if (!line) continue;\n      appendLine(line, maxBytes);\n    }\n    return stderrWrite(chunk, encoding, cb);\n  };\n};\n\nconst getLogPath = () => logPath;\n\nconst readLogTail = (tailBytes = 65536) => {\n  if (!logPath || !fs.existsSync(logPath)) return \"\";\n  const stat = fs.statSync(logPath);\n  const readBytes = Math.max(1024, Number.parseInt(String(tailBytes || 65536), 10) || 65536);\n  const startPos = Math.max(0, stat.size - readBytes);\n  const len = stat.size - startPos;\n  const fd = fs.openSync(logPath, \"r\");\n  const buffer = Buffer.alloc(len);\n  fs.readSync(fd, buffer, 0, len, startPos);\n  fs.closeSync(fd);\n  return buffer.toString(\"utf8\");\n};\n\nmodule.exports = {\n  initLogWriter,\n  getLogPath,\n  readLogTail,\n};\n"
  },
  {
    "path": "lib/server/login-throttle.js",
    "content": "const { kLoginWindowMs, kLoginMaxAttempts, kLoginBaseLockMs, kLoginMaxLockMs, kLoginStateTtlMs } = require(\"./constants\");\n\nconst createLoginThrottle = () => {\n  const kLoginAttemptStates = new Map();\n\n  const getOrCreateLoginAttemptState = (clientKey, now) => {\n    const existing = kLoginAttemptStates.get(clientKey);\n    if (existing) {\n      existing.lastSeenAt = now;\n      return existing;\n    }\n    const next = {\n      attempts: 0,\n      windowStart: now,\n      lockUntil: 0,\n      failStreak: 0,\n      lastSeenAt: now,\n    };\n    kLoginAttemptStates.set(clientKey, next);\n    return next;\n  };\n\n  const evaluateLoginThrottle = (state, now) => {\n    if (!state) return { blocked: false, retryAfterSec: 0 };\n    if (state.lockUntil > now) {\n      return {\n        blocked: true,\n        retryAfterSec: Math.max(1, Math.ceil((state.lockUntil - now) / 1000)),\n      };\n    }\n    if (now - state.windowStart >= kLoginWindowMs) {\n      state.attempts = 0;\n      state.windowStart = now;\n    }\n    return { blocked: false, retryAfterSec: 0 };\n  };\n\n  const recordLoginFailure = (state, now) => {\n    if (!state) return { lockMs: 0, locked: false };\n    if (now - state.windowStart >= kLoginWindowMs) {\n      state.attempts = 0;\n      state.windowStart = now;\n    }\n    state.attempts += 1;\n    state.lastSeenAt = now;\n    if (state.attempts < kLoginMaxAttempts) {\n      return { lockMs: 0, locked: false };\n    }\n    state.failStreak += 1;\n    state.attempts = 0;\n    state.windowStart = now;\n    const lockMultiplier = Math.max(1, 2 ** (state.failStreak - 1));\n    const lockMs = Math.min(kLoginBaseLockMs * lockMultiplier, kLoginMaxLockMs);\n    state.lockUntil = now + lockMs;\n    return { lockMs, locked: true };\n  };\n\n  const recordLoginSuccess = (clientKey) => {\n    if (!clientKey) return;\n    kLoginAttemptStates.delete(clientKey);\n  };\n\n  const cleanupLoginAttemptStates = () => {\n    const now = Date.now();\n    for (const [key, state] of kLoginAttemptStates.entries()) {\n      if (!state) {\n        kLoginAttemptStates.delete(key);\n        continue;\n      }\n      if (state.lockUntil > now) continue;\n      if (now - state.lastSeenAt > kLoginStateTtlMs) {\n        kLoginAttemptStates.delete(key);\n      }\n    }\n  };\n\n  return {\n    getOrCreateLoginAttemptState,\n    evaluateLoginThrottle,\n    recordLoginFailure,\n    recordLoginSuccess,\n    cleanupLoginAttemptStates,\n  };\n};\n\nmodule.exports = { createLoginThrottle };\n"
  },
  {
    "path": "lib/server/model-catalog-bootstrap.json",
    "content": "{\n  \"version\": 1,\n  \"source\": \"openclaw models list --all --json\",\n  \"generatedAt\": \"2026-04-26T15:23:20.263Z\",\n  \"openclawVersion\": \"2026.4.24 (cbcfdf6)\",\n  \"models\": [\n    {\n      \"key\": \"amazon-bedrock/amazon.nova-2-lite-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Nova 2 Lite\"\n    },\n    {\n      \"key\": \"amazon-bedrock/amazon.nova-lite-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Nova Lite\"\n    },\n    {\n      \"key\": \"amazon-bedrock/amazon.nova-micro-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Nova Micro\"\n    },\n    {\n      \"key\": \"amazon-bedrock/amazon.nova-premier-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Nova Premier\"\n    },\n    {\n      \"key\": \"amazon-bedrock/amazon.nova-pro-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Nova Pro\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-3-5-haiku-20241022-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Haiku 3.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 3.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 3.5 v2\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 3.7\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-3-haiku-20240307-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Haiku 3\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Haiku 4.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-opus-4-1-20250805-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.1\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-opus-4-6-v1\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-opus-4-7\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.7\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/anthropic.claude-sonnet-4-6\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"amazon-bedrock/au.anthropic.claude-opus-4-6-v1\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"AU Anthropic Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"amazon-bedrock/au.anthropic.claude-sonnet-4-6\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"AU Anthropic Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"amazon-bedrock/deepseek.r1-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"DeepSeek-R1\"\n    },\n    {\n      \"key\": \"amazon-bedrock/deepseek.v3-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"DeepSeek-V3.1\"\n    },\n    {\n      \"key\": \"amazon-bedrock/deepseek.v3.2\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"DeepSeek-V3.2\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-haiku-4-5-20251001-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Haiku 4.5 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-opus-4-5-20251101-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.5 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-opus-4-6-v1\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.6 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-opus-4-7\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.7 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-sonnet-4-20250514-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.5 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/eu.anthropic.claude-sonnet-4-6\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.6 (EU)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Haiku 4.5 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-opus-4-5-20251101-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.5 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-opus-4-6-v1\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.6 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-opus-4-7\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.7 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-sonnet-4-20250514-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.5 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/global.anthropic.claude-sonnet-4-6\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.6 (Global)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/google.gemma-3-27b-it\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Google Gemma 3 27B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/google.gemma-3-4b-it\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Gemma 3 4B IT\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-1-405b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.1 405B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-1-70b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.1 70B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-1-8b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.1 8B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-2-11b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.2 11B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-2-1b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.2 1B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-2-3b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.2 3B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-2-90b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.2 90B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama3-3-70b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 3.3 70B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 4 Maverick 17B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/meta.llama4-scout-17b-instruct-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Llama 4 Scout 17B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/minimax.minimax-m2\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"MiniMax M2\"\n    },\n    {\n      \"key\": \"amazon-bedrock/minimax.minimax-m2.1\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"MiniMax M2.1\"\n    },\n    {\n      \"key\": \"amazon-bedrock/minimax.minimax-m2.5\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"MiniMax M2.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.devstral-2-123b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Devstral 2 123B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.magistral-small-2509\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Magistral Small 1.2\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.ministral-3-14b-instruct\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Ministral 14B 3.0\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.ministral-3-3b-instruct\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Ministral 3 3B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.ministral-3-8b-instruct\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Ministral 3 8B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.mistral-large-3-675b-instruct\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Mistral Large 3\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.pixtral-large-2502-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Pixtral Large (25.02)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.voxtral-mini-3b-2507\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Voxtral Mini 3B 2507\"\n    },\n    {\n      \"key\": \"amazon-bedrock/mistral.voxtral-small-24b-2507\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Voxtral Small 24B 2507\"\n    },\n    {\n      \"key\": \"amazon-bedrock/moonshot.kimi-k2-thinking\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Kimi K2 Thinking\"\n    },\n    {\n      \"key\": \"amazon-bedrock/moonshotai.kimi-k2.5\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Kimi K2.5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/nvidia.nemotron-nano-12b-v2\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"NVIDIA Nemotron Nano 12B v2 VL BF16\"\n    },\n    {\n      \"key\": \"amazon-bedrock/nvidia.nemotron-nano-3-30b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"NVIDIA Nemotron Nano 3 30B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/nvidia.nemotron-nano-9b-v2\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"NVIDIA Nemotron Nano 9B v2\"\n    },\n    {\n      \"key\": \"amazon-bedrock/nvidia.nemotron-super-3-120b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"NVIDIA Nemotron 3 Super 120B A12B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/openai.gpt-oss-120b-1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"gpt-oss-120b\"\n    },\n    {\n      \"key\": \"amazon-bedrock/openai.gpt-oss-20b-1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"gpt-oss-20b\"\n    },\n    {\n      \"key\": \"amazon-bedrock/openai.gpt-oss-safeguard-120b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"GPT OSS Safeguard 120B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/openai.gpt-oss-safeguard-20b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"GPT OSS Safeguard 20B\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen3 235B A22B 2507\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-32b-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen3 32B (dense)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-coder-30b-a3b-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen3 Coder 30B A3B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen3 Coder 480B A35B Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-coder-next\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen3 Coder Next\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-next-80b-a3b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen/Qwen3-Next-80B-A3B-Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/qwen.qwen3-vl-235b-a22b\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Qwen/Qwen3-VL-235B-A22B-Instruct\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Haiku 4.5 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-opus-4-1-20250805-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.1 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-opus-4-20250514-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-opus-4-5-20251101-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.5 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-opus-4-6-v1\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.6 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-opus-4-7\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Opus 4.7 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.5 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/us.anthropic.claude-sonnet-4-6\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Claude Sonnet 4.6 (US)\"\n    },\n    {\n      \"key\": \"amazon-bedrock/writer.palmyra-x4-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Palmyra X4\"\n    },\n    {\n      \"key\": \"amazon-bedrock/writer.palmyra-x5-v1:0\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"Palmyra X5\"\n    },\n    {\n      \"key\": \"amazon-bedrock/zai.glm-4.7\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"GLM-4.7\"\n    },\n    {\n      \"key\": \"amazon-bedrock/zai.glm-4.7-flash\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"GLM-4.7-Flash\"\n    },\n    {\n      \"key\": \"amazon-bedrock/zai.glm-5\",\n      \"provider\": \"amazon-bedrock\",\n      \"label\": \"GLM-5\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-5-haiku-20241022\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Haiku 3.5\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-5-haiku-latest\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Haiku 3.5 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-5-sonnet-20240620\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 3.5\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-5-sonnet-20241022\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 3.5 v2\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-7-sonnet-20250219\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 3.7\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-haiku-20240307\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Haiku 3\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-opus-20240229\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 3\"\n    },\n    {\n      \"key\": \"anthropic/claude-3-sonnet-20240229\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 3\"\n    },\n    {\n      \"key\": \"anthropic/claude-haiku-4-5\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Haiku 4.5 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-haiku-4-5-20251001\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Haiku 4.5\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-0\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-1\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4.1 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-1-20250805\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4.1\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-20250514\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-5\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4.5 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-5-20251101\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4.5\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-6\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"anthropic/claude-opus-4-7\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Opus 4.7\"\n    },\n    {\n      \"key\": \"anthropic/claude-sonnet-4-0\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 4 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-sonnet-4-20250514\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 4\"\n    },\n    {\n      \"key\": \"anthropic/claude-sonnet-4-5\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 4.5 (latest)\"\n    },\n    {\n      \"key\": \"anthropic/claude-sonnet-4-5-20250929\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 4.5\"\n    },\n    {\n      \"key\": \"anthropic/claude-sonnet-4-6\",\n      \"provider\": \"anthropic\",\n      \"label\": \"Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"anthropic-vertex/claude-opus-4-6\",\n      \"provider\": \"anthropic-vertex\",\n      \"label\": \"Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"anthropic-vertex/claude-sonnet-4-6\",\n      \"provider\": \"anthropic-vertex\",\n      \"label\": \"Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4-turbo\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4 Turbo\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4.1\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4.1\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4.1-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4.1 mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4.1-nano\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4.1 nano\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4o\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4o\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4o-2024-05-13\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4o (2024-05-13)\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4o-2024-08-06\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4o (2024-08-06)\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4o-2024-11-20\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4o (2024-11-20)\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-4o-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-4o mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5-chat-latest\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5 Chat Latest\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5-codex\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5-Codex\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5 Mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5-nano\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5 Nano\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5-pro\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5 Pro\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.1\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.1\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.1-chat-latest\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.1 Chat\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.1-codex\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.1 Codex\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.1-codex-max\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.1 Codex Max\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.1-codex-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.1 Codex mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.2\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.2\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.2-chat-latest\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.2 Chat\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.2-codex\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.2 Codex\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.2-pro\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.2 Pro\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.3-chat-latest\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.3 Chat (latest)\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.3-codex\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.3 Codex\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.3-codex-spark\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.3 Codex Spark\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.4\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.4\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.4-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.4 mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.4-nano\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.4 nano\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.4-pro\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.4 Pro\"\n    },\n    {\n      \"key\": \"azure-openai-responses/gpt-5.5\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"GPT-5.5\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o1\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o1\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o1-pro\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o1-pro\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o3\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o3\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o3-deep-research\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o3-deep-research\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o3-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o3-mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o3-pro\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o3-pro\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o4-mini\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o4-mini\"\n    },\n    {\n      \"key\": \"azure-openai-responses/o4-mini-deep-research\",\n      \"provider\": \"azure-openai-responses\",\n      \"label\": \"o4-mini-deep-research\"\n    },\n    {\n      \"key\": \"cerebras/gpt-oss-120b\",\n      \"provider\": \"cerebras\",\n      \"label\": \"GPT OSS 120B\"\n    },\n    {\n      \"key\": \"cerebras/llama3.1-8b\",\n      \"provider\": \"cerebras\",\n      \"label\": \"Llama 3.1 8B\"\n    },\n    {\n      \"key\": \"cerebras/qwen-3-235b-a22b-instruct-2507\",\n      \"provider\": \"cerebras\",\n      \"label\": \"Qwen 3 235B Instruct\"\n    },\n    {\n      \"key\": \"cerebras/zai-glm-4.7\",\n      \"provider\": \"cerebras\",\n      \"label\": \"Z.AI GLM-4.7\"\n    },\n    {\n      \"key\": \"deepseek/deepseek-v4-flash\",\n      \"provider\": \"deepseek\",\n      \"label\": \"DeepSeek V4 Flash\"\n    },\n    {\n      \"key\": \"deepseek/deepseek-v4-pro\",\n      \"provider\": \"deepseek\",\n      \"label\": \"DeepSeek V4 Pro\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/deepseek-v3p1\",\n      \"provider\": \"fireworks\",\n      \"label\": \"DeepSeek V3.1\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/deepseek-v3p2\",\n      \"provider\": \"fireworks\",\n      \"label\": \"DeepSeek V3.2\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/glm-4p5\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GLM 4.5\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/glm-4p5-air\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GLM 4.5 Air\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/glm-4p7\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GLM 4.7\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/glm-5\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GLM 5\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/glm-5p1\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GLM 5.1\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/gpt-oss-120b\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GPT OSS 120B\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/gpt-oss-20b\",\n      \"provider\": \"fireworks\",\n      \"label\": \"GPT OSS 20B\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/kimi-k2-instruct\",\n      \"provider\": \"fireworks\",\n      \"label\": \"Kimi K2 Instruct\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/kimi-k2-thinking\",\n      \"provider\": \"fireworks\",\n      \"label\": \"Kimi K2 Thinking\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/kimi-k2p5\",\n      \"provider\": \"fireworks\",\n      \"label\": \"Kimi K2.5\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/kimi-k2p6\",\n      \"provider\": \"fireworks\",\n      \"label\": \"Kimi K2.6\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/minimax-m2p1\",\n      \"provider\": \"fireworks\",\n      \"label\": \"MiniMax-M2.1\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/minimax-m2p5\",\n      \"provider\": \"fireworks\",\n      \"label\": \"MiniMax-M2.5\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/minimax-m2p7\",\n      \"provider\": \"fireworks\",\n      \"label\": \"MiniMax-M2.7\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/models/qwen3p6-plus\",\n      \"provider\": \"fireworks\",\n      \"label\": \"Qwen 3.6 Plus\"\n    },\n    {\n      \"key\": \"fireworks/accounts/fireworks/routers/kimi-k2p5-turbo\",\n      \"provider\": \"fireworks\",\n      \"label\": \"Kimi K2.5 Turbo (firepass)\"\n    },\n    {\n      \"key\": \"github-copilot/claude-haiku-4.5\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Haiku 4.5\"\n    },\n    {\n      \"key\": \"github-copilot/claude-opus-4.5\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Opus 4.5\"\n    },\n    {\n      \"key\": \"github-copilot/claude-opus-4.6\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"github-copilot/claude-opus-4.7\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Opus 4.7\"\n    },\n    {\n      \"key\": \"github-copilot/claude-sonnet-4\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Sonnet 4\"\n    },\n    {\n      \"key\": \"github-copilot/claude-sonnet-4.5\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Sonnet 4.5\"\n    },\n    {\n      \"key\": \"github-copilot/claude-sonnet-4.6\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"github-copilot/gemini-2.5-pro\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Gemini 2.5 Pro\"\n    },\n    {\n      \"key\": \"github-copilot/gemini-3-flash-preview\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Gemini 3 Flash\"\n    },\n    {\n      \"key\": \"github-copilot/gemini-3-pro-preview\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Gemini 3 Pro Preview\"\n    },\n    {\n      \"key\": \"github-copilot/gemini-3.1-pro-preview\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Gemini 3.1 Pro Preview\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-4.1\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-4.1\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-4o\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-4o\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5-mini\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5-mini\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.1\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.1\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.1-codex\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.1-Codex\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.1-codex-max\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.1-Codex-max\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.1-codex-mini\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.1-Codex-mini\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.2\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.2\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.2-codex\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.2-Codex\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.3-codex\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.3-Codex\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.4\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.4\"\n    },\n    {\n      \"key\": \"github-copilot/gpt-5.4-mini\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"GPT-5.4 Mini\"\n    },\n    {\n      \"key\": \"github-copilot/grok-code-fast-1\",\n      \"provider\": \"github-copilot\",\n      \"label\": \"Grok Code Fast 1\"\n    },\n    {\n      \"key\": \"google/gemini-1.5-flash\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 1.5 Flash\"\n    },\n    {\n      \"key\": \"google/gemini-1.5-flash-8b\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 1.5 Flash-8B\"\n    },\n    {\n      \"key\": \"google/gemini-1.5-pro\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 1.5 Pro\"\n    },\n    {\n      \"key\": \"google/gemini-2.0-flash\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.0 Flash\"\n    },\n    {\n      \"key\": \"google/gemini-2.0-flash-lite\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.0 Flash Lite\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash-lite\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash Lite\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash-lite-preview-06-17\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash Lite Preview 06-17\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash-lite-preview-09-2025\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash Lite Preview 09-25\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash-preview-04-17\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash Preview 04-17\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash-preview-05-20\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash Preview 05-20\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-flash-preview-09-2025\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Flash Preview 09-25\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-pro\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Pro\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-pro-preview-05-06\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Pro Preview 05-06\"\n    },\n    {\n      \"key\": \"google/gemini-2.5-pro-preview-06-05\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 2.5 Pro Preview 06-05\"\n    },\n    {\n      \"key\": \"google/gemini-3-flash-preview\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 3 Flash Preview\"\n    },\n    {\n      \"key\": \"google/gemini-3-pro-preview\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 3 Pro Preview\"\n    },\n    {\n      \"key\": \"google/gemini-3.1-flash-lite-preview\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 3.1 Flash Lite Preview\"\n    },\n    {\n      \"key\": \"google/gemini-3.1-pro-preview\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 3.1 Pro Preview\"\n    },\n    {\n      \"key\": \"google/gemini-3.1-pro-preview-customtools\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini 3.1 Pro Preview Custom Tools\"\n    },\n    {\n      \"key\": \"google/gemini-flash-latest\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini Flash Latest\"\n    },\n    {\n      \"key\": \"google/gemini-flash-lite-latest\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini Flash-Lite Latest\"\n    },\n    {\n      \"key\": \"google/gemini-live-2.5-flash\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini Live 2.5 Flash\"\n    },\n    {\n      \"key\": \"google/gemini-live-2.5-flash-preview-native-audio\",\n      \"provider\": \"google\",\n      \"label\": \"Gemini Live 2.5 Flash Preview Native Audio\"\n    },\n    {\n      \"key\": \"google/gemma-3-27b-it\",\n      \"provider\": \"google\",\n      \"label\": \"Gemma 3 27B\"\n    },\n    {\n      \"key\": \"google/gemma-4-26b-a4b-it\",\n      \"provider\": \"google\",\n      \"label\": \"Gemma 4 26B\"\n    },\n    {\n      \"key\": \"google/gemma-4-31b-it\",\n      \"provider\": \"google\",\n      \"label\": \"Gemma 4 31B\"\n    },\n    {\n      \"key\": \"google-antigravity/claude-opus-4-5-thinking\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Claude Opus 4.5 Thinking (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/claude-opus-4-6-thinking\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Claude Opus 4.6 Thinking (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/claude-sonnet-4-5\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Claude Sonnet 4.5 (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/claude-sonnet-4-5-thinking\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Claude Sonnet 4.5 Thinking (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/claude-sonnet-4-6\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Claude Sonnet 4.6 (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/gemini-3-flash\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Gemini 3 Flash (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/gemini-3.1-pro-high\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Gemini 3.1 Pro High (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/gemini-3.1-pro-low\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"Gemini 3.1 Pro Low (Antigravity)\"\n    },\n    {\n      \"key\": \"google-antigravity/gpt-oss-120b-medium\",\n      \"provider\": \"google-antigravity\",\n      \"label\": \"GPT-OSS 120B Medium (Antigravity)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-2.0-flash\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 2.0 Flash (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-2.5-flash\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 2.5 Flash (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-2.5-pro\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 2.5 Pro (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-3-flash-preview\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 3 Flash Preview (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-3-pro-preview\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 3 Pro Preview (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-3.1-flash-lite-preview\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 3.1 Flash Lite Preview (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-gemini-cli/gemini-3.1-pro-preview\",\n      \"provider\": \"google-gemini-cli\",\n      \"label\": \"Gemini 3.1 Pro Preview (Cloud Code Assist)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-1.5-flash\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 1.5 Flash (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-1.5-flash-8b\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 1.5 Flash-8B (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-1.5-pro\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 1.5 Pro (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-2.0-flash\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 2.0 Flash (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-2.0-flash-lite\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 2.0 Flash Lite (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-2.5-flash\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 2.5 Flash (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-2.5-flash-lite\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 2.5 Flash Lite (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-2.5-flash-lite-preview-09-2025\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 2.5 Flash Lite Preview 09-25 (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-2.5-pro\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 2.5 Pro (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-3-flash-preview\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 3 Flash Preview (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-3-pro-preview\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 3 Pro Preview (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-3.1-pro-preview\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 3.1 Pro Preview (Vertex)\"\n    },\n    {\n      \"key\": \"google-vertex/gemini-3.1-pro-preview-customtools\",\n      \"provider\": \"google-vertex\",\n      \"label\": \"Gemini 3.1 Pro Preview Custom Tools (Vertex)\"\n    },\n    {\n      \"key\": \"groq/deepseek-r1-distill-llama-70b\",\n      \"provider\": \"groq\",\n      \"label\": \"DeepSeek R1 Distill Llama 70B\"\n    },\n    {\n      \"key\": \"groq/gemma2-9b-it\",\n      \"provider\": \"groq\",\n      \"label\": \"Gemma 2 9B\"\n    },\n    {\n      \"key\": \"groq/compound\",\n      \"provider\": \"groq\",\n      \"label\": \"Compound\"\n    },\n    {\n      \"key\": \"groq/compound-mini\",\n      \"provider\": \"groq\",\n      \"label\": \"Compound Mini\"\n    },\n    {\n      \"key\": \"groq/llama-3.1-8b-instant\",\n      \"provider\": \"groq\",\n      \"label\": \"Llama 3.1 8B Instant\"\n    },\n    {\n      \"key\": \"groq/llama-3.3-70b-versatile\",\n      \"provider\": \"groq\",\n      \"label\": \"Llama 3.3 70B Versatile\"\n    },\n    {\n      \"key\": \"groq/llama3-70b-8192\",\n      \"provider\": \"groq\",\n      \"label\": \"Llama 3 70B\"\n    },\n    {\n      \"key\": \"groq/llama3-8b-8192\",\n      \"provider\": \"groq\",\n      \"label\": \"Llama 3 8B\"\n    },\n    {\n      \"key\": \"groq/meta-llama/llama-4-maverick-17b-128e-instruct\",\n      \"provider\": \"groq\",\n      \"label\": \"Llama 4 Maverick 17B\"\n    },\n    {\n      \"key\": \"groq/meta-llama/llama-4-scout-17b-16e-instruct\",\n      \"provider\": \"groq\",\n      \"label\": \"Llama 4 Scout 17B\"\n    },\n    {\n      \"key\": \"groq/mistral-saba-24b\",\n      \"provider\": \"groq\",\n      \"label\": \"Mistral Saba 24B\"\n    },\n    {\n      \"key\": \"groq/moonshotai/kimi-k2-instruct\",\n      \"provider\": \"groq\",\n      \"label\": \"Kimi K2 Instruct\"\n    },\n    {\n      \"key\": \"groq/moonshotai/kimi-k2-instruct-0905\",\n      \"provider\": \"groq\",\n      \"label\": \"Kimi K2 Instruct 0905\"\n    },\n    {\n      \"key\": \"groq/openai/gpt-oss-120b\",\n      \"provider\": \"groq\",\n      \"label\": \"GPT OSS 120B\"\n    },\n    {\n      \"key\": \"groq/openai/gpt-oss-20b\",\n      \"provider\": \"groq\",\n      \"label\": \"GPT OSS 20B\"\n    },\n    {\n      \"key\": \"groq/openai/gpt-oss-safeguard-20b\",\n      \"provider\": \"groq\",\n      \"label\": \"Safety GPT OSS 20B\"\n    },\n    {\n      \"key\": \"groq/qwen-qwq-32b\",\n      \"provider\": \"groq\",\n      \"label\": \"Qwen QwQ 32B\"\n    },\n    {\n      \"key\": \"groq/qwen/qwen3-32b\",\n      \"provider\": \"groq\",\n      \"label\": \"Qwen3 32B\"\n    },\n    {\n      \"key\": \"huggingface/deepseek-ai/DeepSeek-R1-0528\",\n      \"provider\": \"huggingface\",\n      \"label\": \"DeepSeek-R1-0528\"\n    },\n    {\n      \"key\": \"huggingface/deepseek-ai/DeepSeek-V3.2\",\n      \"provider\": \"huggingface\",\n      \"label\": \"DeepSeek-V3.2\"\n    },\n    {\n      \"key\": \"huggingface/MiniMaxAI/MiniMax-M2.1\",\n      \"provider\": \"huggingface\",\n      \"label\": \"MiniMax-M2.1\"\n    },\n    {\n      \"key\": \"huggingface/MiniMaxAI/MiniMax-M2.5\",\n      \"provider\": \"huggingface\",\n      \"label\": \"MiniMax-M2.5\"\n    },\n    {\n      \"key\": \"huggingface/MiniMaxAI/MiniMax-M2.7\",\n      \"provider\": \"huggingface\",\n      \"label\": \"MiniMax-M2.7\"\n    },\n    {\n      \"key\": \"huggingface/moonshotai/Kimi-K2-Instruct\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Kimi-K2-Instruct\"\n    },\n    {\n      \"key\": \"huggingface/moonshotai/Kimi-K2-Instruct-0905\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Kimi-K2-Instruct-0905\"\n    },\n    {\n      \"key\": \"huggingface/moonshotai/Kimi-K2-Thinking\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Kimi-K2-Thinking\"\n    },\n    {\n      \"key\": \"huggingface/moonshotai/Kimi-K2.5\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Kimi-K2.5\"\n    },\n    {\n      \"key\": \"huggingface/moonshotai/Kimi-K2.6\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Kimi-K2.6\"\n    },\n    {\n      \"key\": \"huggingface/Qwen/Qwen3-235B-A22B-Thinking-2507\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Qwen3-235B-A22B-Thinking-2507\"\n    },\n    {\n      \"key\": \"huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Qwen3-Coder-480B-A35B-Instruct\"\n    },\n    {\n      \"key\": \"huggingface/Qwen/Qwen3-Coder-Next\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Qwen3-Coder-Next\"\n    },\n    {\n      \"key\": \"huggingface/Qwen/Qwen3-Next-80B-A3B-Instruct\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Qwen3-Next-80B-A3B-Instruct\"\n    },\n    {\n      \"key\": \"huggingface/Qwen/Qwen3-Next-80B-A3B-Thinking\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Qwen3-Next-80B-A3B-Thinking\"\n    },\n    {\n      \"key\": \"huggingface/Qwen/Qwen3.5-397B-A17B\",\n      \"provider\": \"huggingface\",\n      \"label\": \"Qwen3.5-397B-A17B\"\n    },\n    {\n      \"key\": \"huggingface/XiaomiMiMo/MiMo-V2-Flash\",\n      \"provider\": \"huggingface\",\n      \"label\": \"MiMo-V2-Flash\"\n    },\n    {\n      \"key\": \"huggingface/zai-org/GLM-4.7\",\n      \"provider\": \"huggingface\",\n      \"label\": \"GLM-4.7\"\n    },\n    {\n      \"key\": \"huggingface/zai-org/GLM-4.7-Flash\",\n      \"provider\": \"huggingface\",\n      \"label\": \"GLM-4.7-Flash\"\n    },\n    {\n      \"key\": \"huggingface/zai-org/GLM-5\",\n      \"provider\": \"huggingface\",\n      \"label\": \"GLM-5\"\n    },\n    {\n      \"key\": \"huggingface/zai-org/GLM-5.1\",\n      \"provider\": \"huggingface\",\n      \"label\": \"GLM-5.1\"\n    },\n    {\n      \"key\": \"kimi-coding/k2p6\",\n      \"provider\": \"kimi-coding\",\n      \"label\": \"Kimi K2.6\"\n    },\n    {\n      \"key\": \"kimi-coding/kimi-for-coding\",\n      \"provider\": \"kimi-coding\",\n      \"label\": \"Kimi For Coding\"\n    },\n    {\n      \"key\": \"kimi-coding/kimi-k2-thinking\",\n      \"provider\": \"kimi-coding\",\n      \"label\": \"Kimi K2 Thinking\"\n    },\n    {\n      \"key\": \"minimax/MiniMax-M2.7\",\n      \"provider\": \"minimax\",\n      \"label\": \"MiniMax-M2.7\"\n    },\n    {\n      \"key\": \"minimax/MiniMax-M2.7-highspeed\",\n      \"provider\": \"minimax\",\n      \"label\": \"MiniMax-M2.7-highspeed\"\n    },\n    {\n      \"key\": \"minimax-cn/MiniMax-M2.7\",\n      \"provider\": \"minimax-cn\",\n      \"label\": \"MiniMax-M2.7\"\n    },\n    {\n      \"key\": \"minimax-cn/MiniMax-M2.7-highspeed\",\n      \"provider\": \"minimax-cn\",\n      \"label\": \"MiniMax-M2.7-highspeed\"\n    },\n    {\n      \"key\": \"mistral/codestral-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Codestral (latest)\"\n    },\n    {\n      \"key\": \"mistral/devstral-2512\",\n      \"provider\": \"mistral\",\n      \"label\": \"Devstral 2\"\n    },\n    {\n      \"key\": \"mistral/devstral-medium-2507\",\n      \"provider\": \"mistral\",\n      \"label\": \"Devstral Medium\"\n    },\n    {\n      \"key\": \"mistral/devstral-medium-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Devstral 2 (latest)\"\n    },\n    {\n      \"key\": \"mistral/devstral-small-2505\",\n      \"provider\": \"mistral\",\n      \"label\": \"Devstral Small 2505\"\n    },\n    {\n      \"key\": \"mistral/devstral-small-2507\",\n      \"provider\": \"mistral\",\n      \"label\": \"Devstral Small\"\n    },\n    {\n      \"key\": \"mistral/labs-devstral-small-2512\",\n      \"provider\": \"mistral\",\n      \"label\": \"Devstral Small 2\"\n    },\n    {\n      \"key\": \"mistral/magistral-medium-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Magistral Medium (latest)\"\n    },\n    {\n      \"key\": \"mistral/magistral-small\",\n      \"provider\": \"mistral\",\n      \"label\": \"Magistral Small\"\n    },\n    {\n      \"key\": \"mistral/ministral-3b-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Ministral 3B (latest)\"\n    },\n    {\n      \"key\": \"mistral/ministral-8b-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Ministral 8B (latest)\"\n    },\n    {\n      \"key\": \"mistral/mistral-large-2411\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Large 2.1\"\n    },\n    {\n      \"key\": \"mistral/mistral-large-2512\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Large 3\"\n    },\n    {\n      \"key\": \"mistral/mistral-large-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Large (latest)\"\n    },\n    {\n      \"key\": \"mistral/mistral-medium-2505\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Medium 3\"\n    },\n    {\n      \"key\": \"mistral/mistral-medium-2508\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Medium 3.1\"\n    },\n    {\n      \"key\": \"mistral/mistral-medium-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Medium (latest)\"\n    },\n    {\n      \"key\": \"mistral/mistral-nemo\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Nemo\"\n    },\n    {\n      \"key\": \"mistral/mistral-small-2506\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Small 3.2\"\n    },\n    {\n      \"key\": \"mistral/mistral-small-2603\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Small 4\"\n    },\n    {\n      \"key\": \"mistral/mistral-small-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral Small (latest)\"\n    },\n    {\n      \"key\": \"mistral/open-mistral-7b\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mistral 7B\"\n    },\n    {\n      \"key\": \"mistral/open-mixtral-8x22b\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mixtral 8x22B\"\n    },\n    {\n      \"key\": \"mistral/open-mixtral-8x7b\",\n      \"provider\": \"mistral\",\n      \"label\": \"Mixtral 8x7B\"\n    },\n    {\n      \"key\": \"mistral/pixtral-12b\",\n      \"provider\": \"mistral\",\n      \"label\": \"Pixtral 12B\"\n    },\n    {\n      \"key\": \"mistral/pixtral-large-latest\",\n      \"provider\": \"mistral\",\n      \"label\": \"Pixtral Large (latest)\"\n    },\n    {\n      \"key\": \"ollama/gemma4:31b\",\n      \"provider\": \"ollama\",\n      \"label\": \"gemma4:31b\"\n    },\n    {\n      \"key\": \"ollama/llama3.2:latest\",\n      \"provider\": \"ollama\",\n      \"label\": \"llama3.2:latest\"\n    },\n    {\n      \"key\": \"openai/gpt-4\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4\"\n    },\n    {\n      \"key\": \"openai/gpt-4-turbo\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4 Turbo\"\n    },\n    {\n      \"key\": \"openai/gpt-4.1\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4.1\"\n    },\n    {\n      \"key\": \"openai/gpt-4.1-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4.1 mini\"\n    },\n    {\n      \"key\": \"openai/gpt-4.1-nano\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4.1 nano\"\n    },\n    {\n      \"key\": \"openai/gpt-4o\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4o\"\n    },\n    {\n      \"key\": \"openai/gpt-4o-2024-05-13\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4o (2024-05-13)\"\n    },\n    {\n      \"key\": \"openai/gpt-4o-2024-08-06\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4o (2024-08-06)\"\n    },\n    {\n      \"key\": \"openai/gpt-4o-2024-11-20\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4o (2024-11-20)\"\n    },\n    {\n      \"key\": \"openai/gpt-4o-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-4o mini\"\n    },\n    {\n      \"key\": \"openai/gpt-5\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5\"\n    },\n    {\n      \"key\": \"openai/gpt-5-chat-latest\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5 Chat Latest\"\n    },\n    {\n      \"key\": \"openai/gpt-5-codex\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5-Codex\"\n    },\n    {\n      \"key\": \"openai/gpt-5-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5 Mini\"\n    },\n    {\n      \"key\": \"openai/gpt-5-nano\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5 Nano\"\n    },\n    {\n      \"key\": \"openai/gpt-5-pro\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5 Pro\"\n    },\n    {\n      \"key\": \"openai/gpt-5.1\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.1\"\n    },\n    {\n      \"key\": \"openai/gpt-5.1-chat-latest\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.1 Chat\"\n    },\n    {\n      \"key\": \"openai/gpt-5.1-codex\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.1 Codex\"\n    },\n    {\n      \"key\": \"openai/gpt-5.1-codex-max\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.1 Codex Max\"\n    },\n    {\n      \"key\": \"openai/gpt-5.1-codex-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.1 Codex mini\"\n    },\n    {\n      \"key\": \"openai/gpt-5.2\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.2\"\n    },\n    {\n      \"key\": \"openai/gpt-5.2-chat-latest\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.2 Chat\"\n    },\n    {\n      \"key\": \"openai/gpt-5.2-codex\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.2 Codex\"\n    },\n    {\n      \"key\": \"openai/gpt-5.2-pro\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.2 Pro\"\n    },\n    {\n      \"key\": \"openai/gpt-5.3-chat-latest\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.3 Chat (latest)\"\n    },\n    {\n      \"key\": \"openai/gpt-5.3-codex\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.3 Codex\"\n    },\n    {\n      \"key\": \"openai/gpt-5.3-codex-spark\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.3 Codex Spark\"\n    },\n    {\n      \"key\": \"openai/gpt-5.4\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.4\"\n    },\n    {\n      \"key\": \"openai/gpt-5.4-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.4 mini\"\n    },\n    {\n      \"key\": \"openai/gpt-5.4-nano\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.4 nano\"\n    },\n    {\n      \"key\": \"openai/gpt-5.4-pro\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.4 Pro\"\n    },\n    {\n      \"key\": \"openai/gpt-5.5\",\n      \"provider\": \"openai\",\n      \"label\": \"GPT-5.5\"\n    },\n    {\n      \"key\": \"openai/o1\",\n      \"provider\": \"openai\",\n      \"label\": \"o1\"\n    },\n    {\n      \"key\": \"openai/o1-pro\",\n      \"provider\": \"openai\",\n      \"label\": \"o1-pro\"\n    },\n    {\n      \"key\": \"openai/o3\",\n      \"provider\": \"openai\",\n      \"label\": \"o3\"\n    },\n    {\n      \"key\": \"openai/o3-deep-research\",\n      \"provider\": \"openai\",\n      \"label\": \"o3-deep-research\"\n    },\n    {\n      \"key\": \"openai/o3-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"o3-mini\"\n    },\n    {\n      \"key\": \"openai/o3-pro\",\n      \"provider\": \"openai\",\n      \"label\": \"o3-pro\"\n    },\n    {\n      \"key\": \"openai/o4-mini\",\n      \"provider\": \"openai\",\n      \"label\": \"o4-mini\"\n    },\n    {\n      \"key\": \"openai/o4-mini-deep-research\",\n      \"provider\": \"openai\",\n      \"label\": \"o4-mini-deep-research\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.1\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.1\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.1-codex-max\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.1 Codex Max\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.1-codex-mini\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.1 Codex Mini\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.2\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.2\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.2-codex\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.2 Codex\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.3-codex\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.3 Codex\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.3-codex-spark\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.3 Codex Spark\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.4\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.4\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.4-mini\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.4 Mini\"\n    },\n    {\n      \"key\": \"openai-codex/gpt-5.5\",\n      \"provider\": \"openai-codex\",\n      \"label\": \"GPT-5.5\"\n    },\n    {\n      \"key\": \"opencode/big-pickle\",\n      \"provider\": \"opencode\",\n      \"label\": \"Big Pickle\"\n    },\n    {\n      \"key\": \"opencode/claude-3-5-haiku\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Haiku 3.5\"\n    },\n    {\n      \"key\": \"opencode/claude-haiku-4-5\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Haiku 4.5\"\n    },\n    {\n      \"key\": \"opencode/claude-opus-4-1\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Opus 4.1\"\n    },\n    {\n      \"key\": \"opencode/claude-opus-4-5\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Opus 4.5\"\n    },\n    {\n      \"key\": \"opencode/claude-opus-4-6\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"opencode/claude-opus-4-7\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Opus 4.7\"\n    },\n    {\n      \"key\": \"opencode/claude-sonnet-4\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Sonnet 4\"\n    },\n    {\n      \"key\": \"opencode/claude-sonnet-4-5\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Sonnet 4.5\"\n    },\n    {\n      \"key\": \"opencode/claude-sonnet-4-6\",\n      \"provider\": \"opencode\",\n      \"label\": \"Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"opencode/gemini-3-flash\",\n      \"provider\": \"opencode\",\n      \"label\": \"Gemini 3 Flash\"\n    },\n    {\n      \"key\": \"opencode/gemini-3.1-pro\",\n      \"provider\": \"opencode\",\n      \"label\": \"Gemini 3.1 Pro Preview\"\n    },\n    {\n      \"key\": \"opencode/glm-5\",\n      \"provider\": \"opencode\",\n      \"label\": \"GLM-5\"\n    },\n    {\n      \"key\": \"opencode/glm-5.1\",\n      \"provider\": \"opencode\",\n      \"label\": \"GLM-5.1\"\n    },\n    {\n      \"key\": \"opencode/gpt-5\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5\"\n    },\n    {\n      \"key\": \"opencode/gpt-5-codex\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5 Codex\"\n    },\n    {\n      \"key\": \"opencode/gpt-5-nano\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5 Nano\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.1\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.1\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.1-codex\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.1 Codex\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.1-codex-max\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.1 Codex Max\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.1-codex-mini\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.1 Codex Mini\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.2\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.2\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.2-codex\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.2 Codex\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.3-codex\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.3 Codex\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.4\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.4\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.4-mini\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.4 Mini\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.4-nano\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.4 Nano\"\n    },\n    {\n      \"key\": \"opencode/gpt-5.4-pro\",\n      \"provider\": \"opencode\",\n      \"label\": \"GPT-5.4 Pro\"\n    },\n    {\n      \"key\": \"opencode/hy3-preview-free\",\n      \"provider\": \"opencode\",\n      \"label\": \"Hy3 preview Free\"\n    },\n    {\n      \"key\": \"opencode/kimi-k2.5\",\n      \"provider\": \"opencode\",\n      \"label\": \"Kimi K2.5\"\n    },\n    {\n      \"key\": \"opencode/kimi-k2.6\",\n      \"provider\": \"opencode\",\n      \"label\": \"Kimi K2.6\"\n    },\n    {\n      \"key\": \"opencode/ling-2.6-flash-free\",\n      \"provider\": \"opencode\",\n      \"label\": \"Ling 2.6 Flash Free\"\n    },\n    {\n      \"key\": \"opencode/minimax-m2.5\",\n      \"provider\": \"opencode\",\n      \"label\": \"MiniMax M2.5\"\n    },\n    {\n      \"key\": \"opencode/minimax-m2.5-free\",\n      \"provider\": \"opencode\",\n      \"label\": \"MiniMax M2.5 Free\"\n    },\n    {\n      \"key\": \"opencode/minimax-m2.7\",\n      \"provider\": \"opencode\",\n      \"label\": \"MiniMax M2.7\"\n    },\n    {\n      \"key\": \"opencode/nemotron-3-super-free\",\n      \"provider\": \"opencode\",\n      \"label\": \"Nemotron 3 Super Free\"\n    },\n    {\n      \"key\": \"opencode/qwen3.5-plus\",\n      \"provider\": \"opencode\",\n      \"label\": \"Qwen3.5 Plus\"\n    },\n    {\n      \"key\": \"opencode/qwen3.6-plus\",\n      \"provider\": \"opencode\",\n      \"label\": \"Qwen3.6 Plus\"\n    },\n    {\n      \"key\": \"opencode-go/glm-5\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"GLM-5\"\n    },\n    {\n      \"key\": \"opencode-go/glm-5.1\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"GLM-5.1\"\n    },\n    {\n      \"key\": \"opencode-go/kimi-k2.5\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"Kimi K2.5\"\n    },\n    {\n      \"key\": \"opencode-go/kimi-k2.6\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"Kimi K2.6 (3x limits)\"\n    },\n    {\n      \"key\": \"opencode-go/mimo-v2-omni\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"MiMo V2 Omni\"\n    },\n    {\n      \"key\": \"opencode-go/mimo-v2-pro\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"MiMo V2 Pro\"\n    },\n    {\n      \"key\": \"opencode-go/mimo-v2.5\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"MiMo V2.5\"\n    },\n    {\n      \"key\": \"opencode-go/mimo-v2.5-pro\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"MiMo V2.5 Pro\"\n    },\n    {\n      \"key\": \"opencode-go/minimax-m2.5\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"MiniMax M2.5\"\n    },\n    {\n      \"key\": \"opencode-go/minimax-m2.7\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"MiniMax M2.7\"\n    },\n    {\n      \"key\": \"opencode-go/qwen3.5-plus\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"Qwen3.5 Plus\"\n    },\n    {\n      \"key\": \"opencode-go/qwen3.6-plus\",\n      \"provider\": \"opencode-go\",\n      \"label\": \"Qwen3.6 Plus\"\n    },\n    {\n      \"key\": \"openrouter/~anthropic/claude-opus-latest\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus Latest\"\n    },\n    {\n      \"key\": \"openrouter/ai21/jamba-large-1.7\",\n      \"provider\": \"openrouter\",\n      \"label\": \"AI21: Jamba Large 1.7\"\n    },\n    {\n      \"key\": \"openrouter/alibaba/tongyi-deepresearch-30b-a3b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Tongyi DeepResearch 30B A3B\"\n    },\n    {\n      \"key\": \"openrouter/allenai/olmo-3.1-32b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"AllenAI: Olmo 3.1 32B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/amazon/nova-2-lite-v1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Amazon: Nova 2 Lite\"\n    },\n    {\n      \"key\": \"openrouter/amazon/nova-lite-v1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Amazon: Nova Lite 1.0\"\n    },\n    {\n      \"key\": \"openrouter/amazon/nova-micro-v1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Amazon: Nova Micro 1.0\"\n    },\n    {\n      \"key\": \"openrouter/amazon/nova-premier-v1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Amazon: Nova Premier 1.0\"\n    },\n    {\n      \"key\": \"openrouter/amazon/nova-pro-v1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Amazon: Nova Pro 1.0\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-3-haiku\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude 3 Haiku\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-3.5-haiku\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude 3.5 Haiku\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-3.7-sonnet\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude 3.7 Sonnet\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-3.7-sonnet:thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude 3.7 Sonnet (thinking)\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-haiku-4.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Haiku 4.5\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-opus-4\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus 4\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-opus-4.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus 4.1\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-opus-4.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus 4.5\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-opus-4.6\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-opus-4.6-fast\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus 4.6 (Fast)\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-opus-4.7\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Opus 4.7\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-sonnet-4\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Sonnet 4\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-sonnet-4.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Sonnet 4.5\"\n    },\n    {\n      \"key\": \"openrouter/anthropic/claude-sonnet-4.6\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Anthropic: Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"openrouter/arcee-ai/trinity-large-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Arcee AI: Trinity Large Preview\"\n    },\n    {\n      \"key\": \"openrouter/arcee-ai/trinity-large-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Arcee AI: Trinity Large Thinking\"\n    },\n    {\n      \"key\": \"openrouter/arcee-ai/trinity-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Arcee AI: Trinity Mini\"\n    },\n    {\n      \"key\": \"openrouter/arcee-ai/virtuoso-large\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Arcee AI: Virtuoso Large\"\n    },\n    {\n      \"key\": \"openrouter/auto\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Auto Router\"\n    },\n    {\n      \"key\": \"openrouter/baidu/ernie-4.5-21b-a3b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Baidu: ERNIE 4.5 21B A3B\"\n    },\n    {\n      \"key\": \"openrouter/baidu/ernie-4.5-vl-28b-a3b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Baidu: ERNIE 4.5 VL 28B A3B\"\n    },\n    {\n      \"key\": \"openrouter/bytedance-seed/seed-1.6\",\n      \"provider\": \"openrouter\",\n      \"label\": \"ByteDance Seed: Seed 1.6\"\n    },\n    {\n      \"key\": \"openrouter/bytedance-seed/seed-1.6-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"ByteDance Seed: Seed 1.6 Flash\"\n    },\n    {\n      \"key\": \"openrouter/bytedance-seed/seed-2.0-lite\",\n      \"provider\": \"openrouter\",\n      \"label\": \"ByteDance Seed: Seed-2.0-Lite\"\n    },\n    {\n      \"key\": \"openrouter/bytedance-seed/seed-2.0-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"ByteDance Seed: Seed-2.0-Mini\"\n    },\n    {\n      \"key\": \"openrouter/cohere/command-r-08-2024\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Cohere: Command R (08-2024)\"\n    },\n    {\n      \"key\": \"openrouter/cohere/command-r-plus-08-2024\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Cohere: Command R+ (08-2024)\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-chat\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V3\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-chat-v3-0324\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V3 0324\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-chat-v3.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V3.1\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-r1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: R1\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-r1-0528\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: R1 0528\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-v3.1-terminus\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V3.1 Terminus\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-v3.2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V3.2\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-v3.2-exp\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V3.2 Exp\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-v4-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V4 Flash\"\n    },\n    {\n      \"key\": \"openrouter/deepseek/deepseek-v4-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"DeepSeek: DeepSeek V4 Pro\"\n    },\n    {\n      \"key\": \"openrouter/essentialai/rnj-1-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"EssentialAI: Rnj 1 Instruct\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.0-flash-001\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.0 Flash\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.0-flash-lite-001\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.0 Flash Lite\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.5-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.5 Flash\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.5-flash-lite\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.5 Flash Lite\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.5-flash-lite-preview-09-2025\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.5 Flash Lite Preview 09-2025\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.5-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.5 Pro\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.5-pro-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.5 Pro Preview 06-05\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-2.5-pro-preview-05-06\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 2.5 Pro Preview 05-06\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-3-flash-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 3 Flash Preview\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-3.1-flash-lite-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 3.1 Flash Lite Preview\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-3.1-pro-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 3.1 Pro Preview\"\n    },\n    {\n      \"key\": \"openrouter/google/gemini-3.1-pro-preview-customtools\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemini 3.1 Pro Preview Custom Tools\"\n    },\n    {\n      \"key\": \"openrouter/google/gemma-4-26b-a4b-it\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemma 4 26B A4B \"\n    },\n    {\n      \"key\": \"openrouter/google/gemma-4-26b-a4b-it:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemma 4 26B A4B  (free)\"\n    },\n    {\n      \"key\": \"openrouter/google/gemma-4-31b-it\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemma 4 31B\"\n    },\n    {\n      \"key\": \"openrouter/google/gemma-4-31b-it:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Google: Gemma 4 31B (free)\"\n    },\n    {\n      \"key\": \"openrouter/inception/mercury-2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Inception: Mercury 2\"\n    },\n    {\n      \"key\": \"openrouter/inclusionai/ling-2.6-1t:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"inclusionAI: Ling-2.6-1T (free)\"\n    },\n    {\n      \"key\": \"openrouter/inclusionai/ling-2.6-flash:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"inclusionAI: Ling-2.6-flash (free)\"\n    },\n    {\n      \"key\": \"openrouter/kwaipilot/kat-coder-pro-v2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Kwaipilot: KAT-Coder-Pro V2\"\n    },\n    {\n      \"key\": \"openrouter/meta-llama/llama-3-8b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Meta: Llama 3 8B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/meta-llama/llama-3.1-70b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Meta: Llama 3.1 70B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/meta-llama/llama-3.1-8b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Meta: Llama 3.1 8B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/meta-llama/llama-3.3-70b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Meta: Llama 3.3 70B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/meta-llama/llama-3.3-70b-instruct:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Meta: Llama 3.3 70B Instruct (free)\"\n    },\n    {\n      \"key\": \"openrouter/meta-llama/llama-4-scout\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Meta: Llama 4 Scout\"\n    },\n    {\n      \"key\": \"openrouter/minimax/minimax-m1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MiniMax: MiniMax M1\"\n    },\n    {\n      \"key\": \"openrouter/minimax/minimax-m2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MiniMax: MiniMax M2\"\n    },\n    {\n      \"key\": \"openrouter/minimax/minimax-m2.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MiniMax: MiniMax M2.1\"\n    },\n    {\n      \"key\": \"openrouter/minimax/minimax-m2.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MiniMax: MiniMax M2.5\"\n    },\n    {\n      \"key\": \"openrouter/minimax/minimax-m2.5:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MiniMax: MiniMax M2.5 (free)\"\n    },\n    {\n      \"key\": \"openrouter/minimax/minimax-m2.7\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MiniMax: MiniMax M2.7\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/codestral-2508\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Codestral 2508\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/devstral-2512\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Devstral 2 2512\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/devstral-medium\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Devstral Medium\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/devstral-small\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Devstral Small 1.1\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/ministral-14b-2512\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Ministral 3 14B 2512\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/ministral-3b-2512\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Ministral 3 3B 2512\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/ministral-8b-2512\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Ministral 3 8B 2512\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-large\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral Large\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-large-2407\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral Large 2407\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-large-2411\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral Large 2411\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-large-2512\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Large 3 2512\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-medium-3\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Medium 3\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-medium-3.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Medium 3.1\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-nemo\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Nemo\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-saba\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Saba\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-small-2603\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Small 4\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-small-3.2-24b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Small 3.2 24B\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mistral-small-creative\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mistral Small Creative\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mixtral-8x22b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mixtral 8x22B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/mixtral-8x7b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Mixtral 8x7B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/pixtral-large-2411\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Pixtral Large 2411\"\n    },\n    {\n      \"key\": \"openrouter/mistralai/voxtral-small-24b-2507\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Mistral: Voxtral Small 24B 2507\"\n    },\n    {\n      \"key\": \"openrouter/moonshotai/kimi-k2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MoonshotAI: Kimi K2 0711\"\n    },\n    {\n      \"key\": \"openrouter/moonshotai/kimi-k2-0905\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MoonshotAI: Kimi K2 0905\"\n    },\n    {\n      \"key\": \"openrouter/moonshotai/kimi-k2-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MoonshotAI: Kimi K2 Thinking\"\n    },\n    {\n      \"key\": \"openrouter/moonshotai/kimi-k2.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MoonshotAI: Kimi K2.5\"\n    },\n    {\n      \"key\": \"openrouter/moonshotai/kimi-k2.6\",\n      \"provider\": \"openrouter\",\n      \"label\": \"MoonshotAI: Kimi K2.6\"\n    },\n    {\n      \"key\": \"openrouter/nex-agi/deepseek-v3.1-nex-n1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Nex AGI: DeepSeek V3.1 Nex N1\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/llama-3.1-nemotron-70b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Llama 3.1 Nemotron 70B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/llama-3.3-nemotron-super-49b-v1.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Llama 3.3 Nemotron Super 49B V1.5\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-3-nano-30b-a3b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron 3 Nano 30B A3B\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-3-nano-30b-a3b:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron 3 Nano 30B A3B (free)\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-3-super-120b-a12b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron 3 Super\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-3-super-120b-a12b:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron 3 Super (free)\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-nano-12b-v2-vl:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron Nano 12B 2 VL (free)\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-nano-9b-v2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron Nano 9B V2\"\n    },\n    {\n      \"key\": \"openrouter/nvidia/nemotron-nano-9b-v2:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"NVIDIA: Nemotron Nano 9B V2 (free)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-3.5-turbo\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-3.5 Turbo\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-3.5-turbo-0613\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-3.5 Turbo (older v0613)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-3.5-turbo-16k\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-3.5 Turbo 16k\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4-0314\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4 (older v0314)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4-1106-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4 Turbo (older v1106)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4-turbo\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4 Turbo\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4-turbo-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4 Turbo Preview\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4.1\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4.1-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4.1 Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4.1-nano\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4.1 Nano\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o-2024-05-13\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o (2024-05-13)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o-2024-08-06\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o (2024-08-06)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o-2024-11-20\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o (2024-11-20)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o-audio-preview\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o Audio\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o-mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-4o-mini-2024-07-18\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-4o-mini (2024-07-18)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5-codex\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5 Codex\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5 Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5-nano\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5 Nano\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5 Pro\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.1\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.1-chat\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.1 Chat\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.1-codex\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.1-Codex\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.1-codex-max\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.1-Codex-Max\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.1-codex-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.1-Codex-Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.2\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.2\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.2-chat\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.2 Chat\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.2-codex\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.2-Codex\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.2-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.2 Pro\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.3-chat\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.3 Chat\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.3-codex\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.3-Codex\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.4\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.4\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.4-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.4 Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.4-nano\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.4 Nano\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-5.4-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT-5.4 Pro\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-audio\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT Audio\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-audio-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: GPT Audio Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-oss-120b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: gpt-oss-120b\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-oss-120b:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: gpt-oss-120b (free)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-oss-20b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: gpt-oss-20b\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-oss-20b:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: gpt-oss-20b (free)\"\n    },\n    {\n      \"key\": \"openrouter/openai/gpt-oss-safeguard-20b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: gpt-oss-safeguard-20b\"\n    },\n    {\n      \"key\": \"openrouter/openai/o1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o1\"\n    },\n    {\n      \"key\": \"openrouter/openai/o3\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o3\"\n    },\n    {\n      \"key\": \"openrouter/openai/o3-deep-research\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o3 Deep Research\"\n    },\n    {\n      \"key\": \"openrouter/openai/o3-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o3 Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/o3-mini-high\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o3 Mini High\"\n    },\n    {\n      \"key\": \"openrouter/openai/o3-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o3 Pro\"\n    },\n    {\n      \"key\": \"openrouter/openai/o4-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o4 Mini\"\n    },\n    {\n      \"key\": \"openrouter/openai/o4-mini-deep-research\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o4 Mini Deep Research\"\n    },\n    {\n      \"key\": \"openrouter/openai/o4-mini-high\",\n      \"provider\": \"openrouter\",\n      \"label\": \"OpenAI: o4 Mini High\"\n    },\n    {\n      \"key\": \"openrouter/free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Free Models Router\"\n    },\n    {\n      \"key\": \"openrouter/prime-intellect/intellect-3\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Prime Intellect: INTELLECT-3\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-2.5-72b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen2.5 72B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-2.5-7b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen2.5 7B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-max\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen-Max \"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-plus\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen-Plus\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-plus-2025-07-28\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen Plus 0728\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-plus-2025-07-28:thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen Plus 0728 (thinking)\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-turbo\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen-Turbo\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen-vl-max\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen VL Max\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-14b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 14B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-235b-a22b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 235B A22B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-235b-a22b-2507\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 235B A22B Instruct 2507\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-235b-a22b-thinking-2507\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 235B A22B Thinking 2507\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-30b-a3b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 30B A3B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-30b-a3b-instruct-2507\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 30B A3B Instruct 2507\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-30b-a3b-thinking-2507\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 30B A3B Thinking 2507\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-32b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 32B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-8b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 8B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-coder\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Coder 480B A35B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-coder-30b-a3b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Coder 30B A3B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-coder-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Coder Flash\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-coder-next\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Coder Next\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-coder-plus\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Coder Plus\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-coder:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Coder 480B A35B (free)\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-max\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Max\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-max-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Max Thinking\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-next-80b-a3b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Next 80B A3B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-next-80b-a3b-instruct:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Next 80B A3B Instruct (free)\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-next-80b-a3b-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 Next 80B A3B Thinking\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-235b-a22b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 235B A22B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-235b-a22b-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 235B A22B Thinking\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-30b-a3b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 30B A3B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-30b-a3b-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 30B A3B Thinking\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-32b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 32B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-8b-instruct\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 8B Instruct\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3-vl-8b-thinking\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3 VL 8B Thinking\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-122b-a10b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5-122B-A10B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-27b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5-27B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-35b-a3b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5-35B-A3B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-397b-a17b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5 397B A17B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-9b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5-9B\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-flash-02-23\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5-Flash\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.5-plus-02-15\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.5 Plus 2026-02-15\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwen3.6-plus\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: Qwen3.6 Plus\"\n    },\n    {\n      \"key\": \"openrouter/qwen/qwq-32b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Qwen: QwQ 32B\"\n    },\n    {\n      \"key\": \"openrouter/rekaai/reka-edge\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Reka Edge\"\n    },\n    {\n      \"key\": \"openrouter/relace/relace-search\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Relace: Relace Search\"\n    },\n    {\n      \"key\": \"openrouter/sao10k/l3-euryale-70b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Sao10k: Llama 3 Euryale 70B v2.1\"\n    },\n    {\n      \"key\": \"openrouter/sao10k/l3.1-euryale-70b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Sao10K: Llama 3.1 Euryale 70B v2.2\"\n    },\n    {\n      \"key\": \"openrouter/stepfun/step-3.5-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"StepFun: Step 3.5 Flash\"\n    },\n    {\n      \"key\": \"openrouter/tencent/hy3-preview:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Tencent: Hy3 preview (free)\"\n    },\n    {\n      \"key\": \"openrouter/thedrummer/rocinante-12b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"TheDrummer: Rocinante 12B\"\n    },\n    {\n      \"key\": \"openrouter/thedrummer/unslopnemo-12b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"TheDrummer: UnslopNemo 12B\"\n    },\n    {\n      \"key\": \"openrouter/tngtech/deepseek-r1t2-chimera\",\n      \"provider\": \"openrouter\",\n      \"label\": \"TNG: DeepSeek R1T2 Chimera\"\n    },\n    {\n      \"key\": \"openrouter/upstage/solar-pro-3\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Upstage: Solar Pro 3\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-3\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 3\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-3-beta\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 3 Beta\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-3-mini\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 3 Mini\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-3-mini-beta\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 3 Mini Beta\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-4\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 4\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-4-fast\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 4 Fast\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-4.1-fast\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 4.1 Fast\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-4.20\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok 4.20\"\n    },\n    {\n      \"key\": \"openrouter/x-ai/grok-code-fast-1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"xAI: Grok Code Fast 1\"\n    },\n    {\n      \"key\": \"openrouter/xiaomi/mimo-v2-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Xiaomi: MiMo-V2-Flash\"\n    },\n    {\n      \"key\": \"openrouter/xiaomi/mimo-v2-omni\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Xiaomi: MiMo-V2-Omni\"\n    },\n    {\n      \"key\": \"openrouter/xiaomi/mimo-v2-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Xiaomi: MiMo-V2-Pro\"\n    },\n    {\n      \"key\": \"openrouter/xiaomi/mimo-v2.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Xiaomi: MiMo-V2.5\"\n    },\n    {\n      \"key\": \"openrouter/xiaomi/mimo-v2.5-pro\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Xiaomi: MiMo-V2.5-Pro\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4-32b\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4 32B \"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.5\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.5-air\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.5 Air\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.5-air:free\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.5 Air (free)\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.5v\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.5V\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.6\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.6\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.6v\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.6V\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.7\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.7\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-4.7-flash\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 4.7 Flash\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-5\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 5\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-5-turbo\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 5 Turbo\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-5.1\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 5.1\"\n    },\n    {\n      \"key\": \"openrouter/z-ai/glm-5v-turbo\",\n      \"provider\": \"openrouter\",\n      \"label\": \"Z.ai: GLM 5V Turbo\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen-3-14b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3-14B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen-3-235b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 235B A22b Instruct 2507\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen-3-30b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3-30B-A3B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen-3-32b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3 32B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen-3.6-max-preview\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3.6 Max Preview\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-235b-a22b-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 235B A22B Thinking 2507\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-coder\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 Coder 480B A35B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-coder-30b-a3b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3 Coder 30B A3B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-coder-next\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 Coder Next\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-coder-plus\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 Coder Plus\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-max\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 Max\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-max-preview\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 Max Preview\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-max-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3 Max Thinking\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3-vl-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen3 VL 235B A22B Thinking\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3.5-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3.5 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3.5-plus\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3.5 Plus\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/alibaba/qwen3.6-plus\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Qwen 3.6 Plus\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-3-haiku\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude 3 Haiku\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-3.5-haiku\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude 3.5 Haiku\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-3.7-sonnet\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude 3.7 Sonnet\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-haiku-4.5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Haiku 4.5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-opus-4\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Opus 4\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-opus-4.1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Opus 4.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-opus-4.5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Opus 4.5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-opus-4.6\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Opus 4.6\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-opus-4.7\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Opus 4.7\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-sonnet-4\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Sonnet 4\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-sonnet-4.5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Sonnet 4.5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/anthropic/claude-sonnet-4.6\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Claude Sonnet 4.6\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/arcee-ai/trinity-large-preview\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Trinity Large Preview\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/arcee-ai/trinity-large-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Trinity Large Thinking\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/bytedance/seed-1.6\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Seed 1.6\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/cohere/command-a\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Command A\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-r1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek-R1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v3\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek V3 0324\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v3.1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek-V3.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v3.1-terminus\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek V3.1 Terminus\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v3.2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek V3.2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v3.2-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek V3.2 Thinking\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v4-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek V4 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/deepseek/deepseek-v4-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"DeepSeek V4 Pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-2.0-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 2.0 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-2.0-flash-lite\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 2.0 Flash Lite\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-2.5-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 2.5 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-2.5-flash-lite\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 2.5 Flash Lite\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-2.5-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 2.5 Pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-3-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 3 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-3-pro-preview\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 3 Pro Preview\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-3.1-flash-lite-preview\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 3.1 Flash Lite Preview\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemini-3.1-pro-preview\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemini 3.1 Pro Preview\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemma-4-26b-a4b-it\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemma 4 26B A4B IT\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/google/gemma-4-31b-it\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Gemma 4 31B IT\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/inception/mercury-2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Mercury 2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/inception/mercury-coder-small\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Mercury Coder Small Beta\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/kwaipilot/kat-coder-pro-v2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kat Coder Pro V2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meituan/longcat-flash-chat\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"LongCat Flash Chat\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-3.1-70b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 3.1 70B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-3.1-8b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 3.1 8B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-3.2-11b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 3.2 11B Vision Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-3.2-90b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 3.2 90B Vision Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-3.3-70b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 3.3 70B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-4-maverick\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 4 Maverick 17B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/meta/llama-4-scout\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Llama 4 Scout 17B Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiniMax M2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2.1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiniMax M2.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2.1-lightning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiniMax M2.1 Lightning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2.5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiniMax M2.5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2.5-highspeed\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiniMax M2.5 High Speed\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2.7\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Minimax M2.7\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/minimax/minimax-m2.7-highspeed\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiniMax M2.7 High Speed\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/codestral\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Mistral Codestral\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/devstral-2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Devstral 2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/devstral-small\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Devstral Small 1.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/devstral-small-2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Devstral Small 2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/ministral-3b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Ministral 3B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/ministral-8b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Ministral 8B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/mistral-medium\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Mistral Medium 3.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/mistral-small\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Mistral Small\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/pixtral-12b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Pixtral 12B 2409\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/mistral/pixtral-large\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Pixtral Large\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2 Instruct\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2-0905\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2 0905\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2 Thinking\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2-thinking-turbo\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2 Thinking Turbo\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2-turbo\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2 Turbo\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2.5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2.5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/moonshotai/kimi-k2.6\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Kimi K2.6\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/nvidia/nemotron-nano-12b-v2-vl\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Nvidia Nemotron Nano 12B V2 VL\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/nvidia/nemotron-nano-9b-v2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Nvidia Nemotron Nano 9B V2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-4-turbo\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-4 Turbo\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-4.1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-4.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-4.1-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-4.1 mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-4.1-nano\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-4.1 nano\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-4o\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-4o\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-4o-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-4o mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5-chat\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5 Chat\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5-codex\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5-Codex\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5 mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5-nano\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5 nano\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5 pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.1-codex\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5.1-Codex\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.1-codex-max\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.1 Codex Max\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.1-codex-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.1 Codex Mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.1-instant\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5.1 Instant\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.1-thinking\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.1 Thinking\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.2\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.2\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.2-chat\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.2 Chat\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.2-codex\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.2 Codex\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.2-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.2 \"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.3-chat\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT-5.3 Chat\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.3-codex\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.3 Codex\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.4\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.4\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.4-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.4 Mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.4-nano\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.4 Nano\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-5.4-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT 5.4 Pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-oss-20b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT OSS 120B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/gpt-oss-safeguard-20b\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GPT OSS Safeguard 20B\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/o1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"o1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/o3\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"o3\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/o3-deep-research\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"o3-deep-research\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/o3-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"o3-mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/o3-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"o3 Pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/openai/o4-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"o4-mini\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/perplexity/sonar\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Sonar\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/perplexity/sonar-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Sonar Pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/prime-intellect/intellect-3\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"INTELLECT 3\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-3\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 3 Beta\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-3-fast\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 3 Fast Beta\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-3-mini\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 3 Mini Beta\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-3-mini-fast\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 3 Mini Fast Beta\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4-fast-non-reasoning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4 Fast Non-Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4-fast-reasoning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4 Fast Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.1-fast-non-reasoning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.1 Fast Non-Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.1-fast-reasoning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.1 Fast Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.20-multi-agent\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.20 Multi-Agent\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.20-multi-agent-beta\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.20 Multi Agent Beta\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.20-non-reasoning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.20 Non-Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.20-non-reasoning-beta\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.20 Beta Non-Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.20-reasoning\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.20 Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-4.20-reasoning-beta\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok 4.20 Beta Reasoning\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xai/grok-code-fast-1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"Grok Code Fast 1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xiaomi/mimo-v2-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiMo V2 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/xiaomi/mimo-v2-pro\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"MiMo V2 Pro\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM-4.5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.5-air\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 4.5 Air\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.5v\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 4.5V\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.6\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 4.6\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.6v\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM-4.6V\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.6v-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM-4.6V-Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.7\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 4.7\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.7-flash\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 4.7 Flash\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-4.7-flashx\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 4.7 FlashX\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-5\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 5\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-5-turbo\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 5 Turbo\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-5.1\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 5.1\"\n    },\n    {\n      \"key\": \"vercel-ai-gateway/zai/glm-5v-turbo\",\n      \"provider\": \"vercel-ai-gateway\",\n      \"label\": \"GLM 5V Turbo\"\n    },\n    {\n      \"key\": \"xai/grok-2\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 2\"\n    },\n    {\n      \"key\": \"xai/grok-2-1212\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 2 (1212)\"\n    },\n    {\n      \"key\": \"xai/grok-2-latest\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 2 Latest\"\n    },\n    {\n      \"key\": \"xai/grok-2-vision\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 2 Vision\"\n    },\n    {\n      \"key\": \"xai/grok-2-vision-1212\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 2 Vision (1212)\"\n    },\n    {\n      \"key\": \"xai/grok-2-vision-latest\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 2 Vision Latest\"\n    },\n    {\n      \"key\": \"xai/grok-3\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3\"\n    },\n    {\n      \"key\": \"xai/grok-3-fast\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Fast\"\n    },\n    {\n      \"key\": \"xai/grok-3-fast-latest\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Fast Latest\"\n    },\n    {\n      \"key\": \"xai/grok-3-latest\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Latest\"\n    },\n    {\n      \"key\": \"xai/grok-3-mini\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Mini\"\n    },\n    {\n      \"key\": \"xai/grok-3-mini-fast\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Mini Fast\"\n    },\n    {\n      \"key\": \"xai/grok-3-mini-fast-latest\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Mini Fast Latest\"\n    },\n    {\n      \"key\": \"xai/grok-3-mini-latest\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 3 Mini Latest\"\n    },\n    {\n      \"key\": \"xai/grok-4\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4\"\n    },\n    {\n      \"key\": \"xai/grok-4-1-fast\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4.1 Fast\"\n    },\n    {\n      \"key\": \"xai/grok-4-1-fast-non-reasoning\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4.1 Fast (Non-Reasoning)\"\n    },\n    {\n      \"key\": \"xai/grok-4-fast\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4 Fast\"\n    },\n    {\n      \"key\": \"xai/grok-4-fast-non-reasoning\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4 Fast (Non-Reasoning)\"\n    },\n    {\n      \"key\": \"xai/grok-4.20-0309-non-reasoning\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4.20 (Non-Reasoning)\"\n    },\n    {\n      \"key\": \"xai/grok-4.20-0309-reasoning\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok 4.20 (Reasoning)\"\n    },\n    {\n      \"key\": \"xai/grok-beta\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok Beta\"\n    },\n    {\n      \"key\": \"xai/grok-code-fast-1\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok Code Fast 1\"\n    },\n    {\n      \"key\": \"xai/grok-vision-beta\",\n      \"provider\": \"xai\",\n      \"label\": \"Grok Vision Beta\"\n    },\n    {\n      \"key\": \"zai/glm-4.5\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.5\"\n    },\n    {\n      \"key\": \"zai/glm-4.5-air\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.5-Air\"\n    },\n    {\n      \"key\": \"zai/glm-4.5-flash\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.5-Flash\"\n    },\n    {\n      \"key\": \"zai/glm-4.5v\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.5V\"\n    },\n    {\n      \"key\": \"zai/glm-4.6\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.6\"\n    },\n    {\n      \"key\": \"zai/glm-4.6v\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.6V\"\n    },\n    {\n      \"key\": \"zai/glm-4.7\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.7\"\n    },\n    {\n      \"key\": \"zai/glm-4.7-flash\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.7-Flash\"\n    },\n    {\n      \"key\": \"zai/glm-4.7-flashx\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-4.7-FlashX\"\n    },\n    {\n      \"key\": \"zai/glm-5\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-5\"\n    },\n    {\n      \"key\": \"zai/glm-5-turbo\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-5-Turbo\"\n    },\n    {\n      \"key\": \"zai/glm-5.1\",\n      \"provider\": \"zai\",\n      \"label\": \"GLM-5.1\"\n    },\n    {\n      \"key\": \"zai/glm-5v-turbo\",\n      \"provider\": \"zai\",\n      \"label\": \"glm-5v-turbo\"\n    }\n  ]\n}\n"
  },
  {
    "path": "lib/server/model-catalog-cache.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { ALPHACLAW_DIR, kFallbackOnboardingModels } = require(\"./constants\");\nconst { getCommandOutputCandidates } = require(\"./utils/command-output\");\n\nconst kModelCatalogCacheVersion = 1;\nconst kModelCatalogRefreshBackoffMs = 30 * 1000;\nconst kModelCatalogLoadTimeoutMs = 120 * 1000;\nconst kModelCatalogBootstrapSource = \"bootstrap\";\nconst kDefaultCachePath = path.join(ALPHACLAW_DIR, \"cache\", \"model-catalog.json\");\n\nconst createResponse = ({\n  source = \"fallback\",\n  fetchedAt = null,\n  stale = false,\n  refreshing = false,\n  models = [],\n} = {}) => ({\n  ok: true,\n  source,\n  fetchedAt,\n  stale,\n  refreshing,\n  models,\n});\n\nconst normalizeOpenclawVersion = (value) => {\n  if (typeof value !== \"string\") return null;\n  const normalized = value.trim();\n  return normalized || null;\n};\n\nconst normalizeCachedModels = ({\n  models,\n  normalizeOnboardingModels = (items) => items,\n} = {}) =>\n  normalizeOnboardingModels(\n    (Array.isArray(models) ? models : []).map((model) => ({\n      key: model?.key,\n      name: model?.label || model?.name || model?.key,\n    })),\n  );\n\nconst normalizeCacheEntry = ({\n  raw,\n  normalizeOnboardingModels = (items) => items,\n} = {}) => {\n  if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return null;\n  const fetchedAt = Number(raw.fetchedAt || 0);\n  const models = normalizeCachedModels({\n    models: raw.models,\n    normalizeOnboardingModels,\n  });\n  if (!Number.isFinite(fetchedAt) || fetchedAt <= 0 || models.length === 0) {\n    return null;\n  }\n  return {\n    version: kModelCatalogCacheVersion,\n    fetchedAt,\n    openclawVersion: normalizeOpenclawVersion(raw.openclawVersion),\n    models,\n  };\n};\n\nconst parseCatalogModelsFromOutput = ({\n  rawOutput,\n  parseJsonFromNoisyOutput = () => ({}),\n  normalizeOnboardingModels = (items) => items,\n} = {}) => {\n  const parsed = parseJsonFromNoisyOutput(rawOutput);\n  return normalizeOnboardingModels(parsed?.models || []);\n};\n\nconst createModelCatalogCache = ({\n  fsModule = fs,\n  pathModule = path,\n  shellCmd,\n  gatewayEnv = () => ({}),\n  parseJsonFromNoisyOutput = () => ({}),\n  normalizeOnboardingModels = (items) => items,\n  readOpenclawVersion = () => null,\n  shouldStartDynamicRefresh = () => true,\n  fallbackModels = kFallbackOnboardingModels,\n  cachePath = kDefaultCachePath,\n  refreshBackoffMs = kModelCatalogRefreshBackoffMs,\n  now = () => Date.now(),\n  setTimeoutFn = setTimeout,\n  clearTimeoutFn = clearTimeout,\n  logger = console,\n} = {}) => {\n  let cacheLoaded = false;\n  let memoryCache = null;\n  let cacheIsStale = false;\n  let refreshPromise = null;\n  let retryTimer = null;\n  let backoffUntilMs = 0;\n\n  const readCurrentOpenclawVersion = ({ refresh = false } = {}) => {\n    try {\n      return normalizeOpenclawVersion(readOpenclawVersion({ refresh }));\n    } catch {\n      return null;\n    }\n  };\n\n  const isCompatibleWithCurrentOpenclaw = ({\n    entry,\n    currentOpenclawVersion,\n  } = {}) => {\n    if (!entry) return false;\n    if (!currentOpenclawVersion) return true;\n    return entry.openclawVersion === currentOpenclawVersion;\n  };\n\n  const clearRetryTimer = () => {\n    if (!retryTimer) return;\n    clearTimeoutFn(retryTimer);\n    retryTimer = null;\n  };\n\n  const isRefreshPending = () => !!refreshPromise || !!retryTimer;\n\n  const canStartDynamicRefresh = () => {\n    try {\n      return shouldStartDynamicRefresh() !== false;\n    } catch {\n      return false;\n    }\n  };\n\n  const setCacheEntry = (entry, { fresh = false } = {}) => {\n    memoryCache = entry;\n    cacheLoaded = true;\n    cacheIsStale = !fresh;\n    backoffUntilMs = 0;\n    clearRetryTimer();\n    return memoryCache;\n  };\n\n  const readDiskCache = () => {\n    if (cacheLoaded) return memoryCache;\n    cacheLoaded = true;\n    try {\n      const raw = JSON.parse(fsModule.readFileSync(cachePath, \"utf8\"));\n      const entry = normalizeCacheEntry({\n        raw,\n        normalizeOnboardingModels,\n      });\n      if (!entry) return null;\n      memoryCache = entry;\n      cacheIsStale = true;\n      return memoryCache;\n    } catch {\n      memoryCache = null;\n      cacheIsStale = false;\n      return null;\n    }\n  };\n\n  const writeDiskCache = (entry) => {\n    fsModule.mkdirSync(pathModule.dirname(cachePath), { recursive: true });\n    fsModule.writeFileSync(\n      cachePath,\n      `${JSON.stringify(entry, null, 2)}\\n`,\n      \"utf8\",\n    );\n  };\n\n  const loadFreshCatalog = async () => {\n    const openclawVersion = readCurrentOpenclawVersion({ refresh: true });\n    let models = [];\n    let recoveredFromCommandError = false;\n    try {\n      const output = await shellCmd(\"openclaw models list --all --json\", {\n        env: gatewayEnv(),\n        timeout: kModelCatalogLoadTimeoutMs,\n      });\n      models = parseCatalogModelsFromOutput({\n        rawOutput: output,\n        parseJsonFromNoisyOutput,\n        normalizeOnboardingModels,\n      });\n    } catch (err) {\n      for (const rawOutput of getCommandOutputCandidates(err)) {\n        models = parseCatalogModelsFromOutput({\n          rawOutput,\n          parseJsonFromNoisyOutput,\n          normalizeOnboardingModels,\n        });\n        if (models.length > 0) {\n          recoveredFromCommandError = true;\n          logger.warn?.(\n            `[models] Recovered model catalog from failed command output: ${err.message || String(err)}`,\n          );\n          break;\n        }\n      }\n      if (models.length === 0) throw err;\n    }\n    if (models.length === 0) {\n      throw new Error(\"No models found\");\n    }\n    const entry = {\n      version: kModelCatalogCacheVersion,\n      fetchedAt: now(),\n      openclawVersion,\n      models,\n    };\n    writeDiskCache(entry);\n    setCacheEntry(entry, { fresh: true });\n    if (recoveredFromCommandError) {\n      backoffUntilMs = 0;\n      clearRetryTimer();\n    }\n    return entry;\n  };\n\n  const scheduleRetry = () => {\n    if (!canStartDynamicRefresh()) {\n      clearRetryTimer();\n      return;\n    }\n    if (retryTimer) return;\n    const delayMs = Math.max(backoffUntilMs - now(), 0);\n    retryTimer = setTimeoutFn(() => {\n      retryTimer = null;\n      if (!canStartDynamicRefresh()) return;\n      if (refreshPromise) return;\n      if (memoryCache && !cacheIsStale) return;\n      void startBackgroundRefresh();\n    }, delayMs);\n    if (typeof retryTimer?.unref === \"function\") retryTimer.unref();\n  };\n\n  const handleRefreshFailure = (err) => {\n    backoffUntilMs = now() + refreshBackoffMs;\n    scheduleRetry();\n    if (memoryCache) {\n      cacheIsStale = true;\n      logger.error?.(\n        `[models] Failed to refresh cached models: ${err.message || String(err)}`,\n      );\n      return;\n    }\n    logger.error?.(\n      `[models] Failed to load dynamic models: ${err.message || String(err)}`,\n    );\n  };\n\n  const startBackgroundRefresh = () => {\n    if (!canStartDynamicRefresh()) {\n      clearRetryTimer();\n      return null;\n    }\n    readDiskCache();\n    if (refreshPromise) return refreshPromise;\n    if (retryTimer) return null;\n    if (backoffUntilMs > now()) {\n      scheduleRetry();\n      return null;\n    }\n    refreshPromise = Promise.resolve()\n      .then(() => loadFreshCatalog())\n      .catch((err) => {\n        handleRefreshFailure(err);\n        return null;\n      })\n      .finally(() => {\n        refreshPromise = null;\n      });\n    return refreshPromise;\n  };\n\n  return {\n    async getCatalogResponse() {\n      readDiskCache();\n      if (memoryCache && !cacheIsStale) {\n        const currentOpenclawVersion = readCurrentOpenclawVersion({\n          refresh: true,\n        });\n        if (\n          !isCompatibleWithCurrentOpenclaw({\n            entry: memoryCache,\n            currentOpenclawVersion,\n          })\n        ) {\n          cacheIsStale = true;\n          backoffUntilMs = 0;\n          clearRetryTimer();\n        }\n      }\n      if (memoryCache && !cacheIsStale) {\n        return createResponse({\n          source: \"openclaw\",\n          fetchedAt: memoryCache.fetchedAt,\n          stale: false,\n          refreshing: false,\n          models: memoryCache.models,\n        });\n      }\n      if (memoryCache) {\n        const didStartRefresh = !!startBackgroundRefresh();\n        return createResponse({\n          source: \"cache\",\n          fetchedAt: memoryCache.fetchedAt,\n          stale: true,\n          refreshing:\n            canStartDynamicRefresh() && (didStartRefresh || isRefreshPending()),\n          models: memoryCache.models,\n        });\n      }\n      const didStartRefresh = !!startBackgroundRefresh();\n      return createResponse({\n        source: kModelCatalogBootstrapSource,\n        fetchedAt: null,\n        stale: true,\n        refreshing:\n          canStartDynamicRefresh() && (didStartRefresh || isRefreshPending()),\n        models: fallbackModels,\n      });\n    },\n\n    markStale() {\n      readDiskCache();\n      if (!memoryCache) return;\n      cacheIsStale = true;\n      backoffUntilMs = 0;\n      clearRetryTimer();\n    },\n  };\n};\n\nmodule.exports = {\n  createModelCatalogCache,\n  createResponse,\n  normalizeCachedModels,\n  normalizeCacheEntry,\n  kModelCatalogCacheVersion,\n  kModelCatalogRefreshBackoffMs,\n  kModelCatalogLoadTimeoutMs,\n  kModelCatalogBootstrapSource,\n  kDefaultCachePath,\n};\n"
  },
  {
    "path": "lib/server/oauth-callback-middleware.js",
    "content": "const createOauthCallbackMiddleware = ({\n  getOauthCallbackById,\n  markOauthCallbackUsed = () => {},\n  webhookMiddleware,\n}) => {\n  return (req, res) => {\n    const callbackId = String(req.params?.id || \"\").trim();\n    if (!callbackId) {\n      return res.status(404).json({ error: \"Not found\" });\n    }\n    const callback = getOauthCallbackById(callbackId);\n    if (!callback?.hookName) {\n      return res.status(404).json({ error: \"Not found\" });\n    }\n    try {\n      markOauthCallbackUsed(callbackId);\n    } catch {}\n    const originalUrl = String(req.originalUrl || req.url || \"\");\n    const queryIndex = originalUrl.indexOf(\"?\");\n    const querySuffix = queryIndex >= 0 ? originalUrl.slice(queryIndex) : \"\";\n    const rewrittenUrl = `/hooks/${callback.hookName}${querySuffix}`;\n    req.url = rewrittenUrl;\n    req.originalUrl = rewrittenUrl;\n    const webhookToken = String(process.env.WEBHOOK_TOKEN || \"\").trim();\n    if (webhookToken) {\n      req.headers.authorization = `Bearer ${webhookToken}`;\n    }\n    return webhookMiddleware(req, res);\n  };\n};\n\nmodule.exports = {\n  createOauthCallbackMiddleware,\n};\n"
  },
  {
    "path": "lib/server/onboarding/cron.js",
    "content": "const path = require(\"path\");\nconst { kSetupDir } = require(\"../constants\");\nconst { buildManagedPaths } = require(\"../internal-files-migration\");\n\nconst kHourlyGitSyncTemplatePath = path.join(kSetupDir, \"hourly-git-sync.sh\");\nconst kSystemCronPath = \"/etc/cron.d/openclaw-hourly-sync\";\nconst kSystemCronConfigDir = \"cron\";\nconst kSystemCronConfigFile = \"system-sync.json\";\nconst kDefaultSystemCronSchedule = \"0 * * * *\";\n\nconst buildSystemCronFile = ({ schedule, scriptPath }) =>\n  [\n    \"SHELL=/bin/bash\",\n    \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\n    `${schedule} root bash \"${scriptPath}\" >> /var/log/openclaw-hourly-sync.log 2>&1`,\n    \"\",\n  ].join(\"\\n\");\n\nconst installHourlyGitSyncScript = ({ fs, openclawDir }) => {\n  try {\n    const { internalDir, hourlyGitSyncPath } = buildManagedPaths({ openclawDir });\n    const hourlyGitSyncScript = fs.readFileSync(kHourlyGitSyncTemplatePath, \"utf8\");\n    fs.mkdirSync(internalDir, { recursive: true });\n    fs.writeFileSync(hourlyGitSyncPath, hourlyGitSyncScript, { mode: 0o755 });\n    console.log(\"[onboard] Installed deterministic hourly git sync script\");\n  } catch (e) {\n    console.error(\"[onboard] Hourly git sync script install error:\", e.message);\n  }\n};\n\nconst installHourlyGitSyncCron = async ({ fs, openclawDir }) => {\n  try {\n    const { hourlyGitSyncPath } = buildManagedPaths({ openclawDir });\n    const configDir = `${openclawDir}/${kSystemCronConfigDir}`;\n    const configPath = `${configDir}/${kSystemCronConfigFile}`;\n    const config = { enabled: true, schedule: kDefaultSystemCronSchedule };\n    fs.mkdirSync(configDir, { recursive: true });\n    fs.writeFileSync(configPath, JSON.stringify(config, null, 2));\n\n    const cronContent = buildSystemCronFile({\n      schedule: config.schedule,\n      scriptPath: hourlyGitSyncPath,\n    });\n    fs.writeFileSync(kSystemCronPath, cronContent, { mode: 0o644 });\n    console.log(`[onboard] Installed system cron job at ${kSystemCronPath} (${configPath})`);\n    return true;\n  } catch (e) {\n    console.error(\"[onboard] System cron install error:\", e.message);\n    return false;\n  }\n};\n\nmodule.exports = { installHourlyGitSyncScript, installHourlyGitSyncCron };\n"
  },
  {
    "path": "lib/server/onboarding/github.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\nconst {\n  kImportTempPrefix,\n  isValidImportTempDir,\n} = require(\"./import/import-temp\");\n\nconst buildGithubHeaders = (githubToken) => ({\n  Authorization: `token ${githubToken}`,\n  \"User-Agent\": \"openclaw-railway\",\n  Accept: \"application/vnd.github+json\",\n});\n\nconst parseGithubErrorMessage = async (response) => {\n  try {\n    const payload = await response.json();\n    const base =\n      typeof payload?.message === \"string\" ? payload.message.trim() : \"\";\n    const detail = Array.isArray(payload?.errors)\n      ? payload.errors\n          .map((e) => (typeof e?.message === \"string\" ? e.message.trim() : \"\"))\n          .filter(Boolean)\n          .join(\"; \")\n      : \"\";\n    if (base && detail) return `${base} (${detail})`;\n    if (base) return base;\n    if (detail) return detail;\n  } catch {}\n  return response.statusText || `HTTP ${response.status}`;\n};\n\n// Files GitHub may auto-create when initializing a repo — a repo containing\n// only these is treated as empty for onboarding purposes.\nconst kBoilerplateNames = new Set([\n  \"readme\",\n  \"readme.md\",\n  \"readme.txt\",\n  \"readme.rst\",\n  \"license\",\n  \"license.md\",\n  \"license.txt\",\n  \".gitignore\",\n  \".gitattributes\",\n]);\n\nconst repoContainsOnlyBoilerplate = async (repoUrl, ghHeaders) => {\n  try {\n    const res = await fetch(\n      `https://api.github.com/repos/${repoUrl}/contents/`,\n      { headers: ghHeaders },\n    );\n    if (!res.ok) return false;\n    const entries = await res.json();\n    if (!Array.isArray(entries)) return false;\n    if (entries.length === 0) return true;\n    return entries.every(\n      (e) => e.type === \"file\" && kBoilerplateNames.has(e.name.toLowerCase()),\n    );\n  } catch {\n    return false;\n  }\n};\n\nconst getNextGithubPageUrl = (linkHeader = \"\") => {\n  const nextLink = String(linkHeader || \"\")\n    .split(\",\")\n    .map((entry) => entry.trim())\n    .find((entry) => entry.endsWith('rel=\"next\"'));\n  const match = nextLink?.match(/<([^>]+)>/);\n  return match?.[1] || \"\";\n};\n\nconst findOwnedRepoByName = async ({\n  repoUrl,\n  repoOwner,\n  repoName,\n  viewerLogin,\n  ghHeaders,\n}) => {\n  if (\n    !repoOwner ||\n    !repoName ||\n    !viewerLogin ||\n    repoOwner.toLowerCase() !== viewerLogin.toLowerCase()\n  ) {\n    return null;\n  }\n\n  let nextUrl =\n    \"https://api.github.com/user/repos?affiliation=owner&per_page=100&page=1\";\n  const normalizedRepoUrl = String(repoUrl || \"\").trim().toLowerCase();\n  const normalizedRepoName = String(repoName || \"\").trim().toLowerCase();\n\n  while (nextUrl) {\n    const res = await fetch(nextUrl, { headers: ghHeaders });\n    if (!res.ok) return null;\n\n    const repos = await res.json();\n    if (!Array.isArray(repos)) return null;\n\n    const existingRepo = repos.find((repo) => {\n      const fullName = String(repo?.full_name || \"\").trim().toLowerCase();\n      const name = String(repo?.name || \"\").trim().toLowerCase();\n      return fullName === normalizedRepoUrl || name === normalizedRepoName;\n    });\n    if (existingRepo) return existingRepo;\n\n    nextUrl = getNextGithubPageUrl(res.headers?.get?.(\"link\"));\n  }\n\n  return null;\n};\n\nconst findAccessibleOrgByLogin = async ({ repoOwner, ghHeaders }) => {\n  const normalizedRepoOwner = String(repoOwner || \"\").trim().toLowerCase();\n  if (!normalizedRepoOwner) return null;\n\n  let nextUrl = \"https://api.github.com/user/orgs?per_page=100&page=1\";\n  while (nextUrl) {\n    const res = await fetch(nextUrl, { headers: ghHeaders });\n    if (!res.ok) {\n      const details = await parseGithubErrorMessage(res);\n      return {\n        error:\n          `Cannot verify organization \"${repoOwner}\" access: ${details}. ` +\n          \"Check the owner name or use a token that can access that organization.\",\n      };\n    }\n\n    const orgs = await res.json();\n    if (!Array.isArray(orgs)) {\n      return {\n        error: `Cannot verify organization \"${repoOwner}\" access from GitHub response.`,\n      };\n    }\n\n    const org = orgs.find(\n      (item) =>\n        String(item?.login || \"\").trim().toLowerCase() ===\n        normalizedRepoOwner,\n    );\n    if (org) return { org };\n\n    nextUrl = getNextGithubPageUrl(res.headers?.get?.(\"link\"));\n  }\n\n  return null;\n};\n\nconst isClassicPat = (token) => String(token || \"\").startsWith(\"ghp_\");\nconst isFineGrainedPat = (token) =>\n  String(token || \"\").startsWith(\"github_pat_\");\n\nconst verifyGithubRepoForOnboarding = async ({\n  repoUrl,\n  githubToken,\n  mode = \"new\",\n}) => {\n  const ghHeaders = buildGithubHeaders(githubToken);\n  const [repoOwner = \"\", repoName = \"\"] = String(repoUrl || \"\").split(\"/\");\n  const isExisting = mode === \"existing\";\n  let viewerLogin = \"\";\n\n  try {\n    const userRes = await fetch(\"https://api.github.com/user\", {\n      headers: ghHeaders,\n    });\n    if (!userRes.ok) {\n      const details = await parseGithubErrorMessage(userRes);\n      return {\n        ok: false,\n        status: 400,\n        error: `Cannot verify GitHub token: ${details}`,\n      };\n    }\n    if (isClassicPat(githubToken)) {\n      const oauthScopes = (userRes.headers?.get?.(\"x-oauth-scopes\") || \"\")\n        .toLowerCase()\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean);\n      if (\n        oauthScopes.length > 0 &&\n        !oauthScopes.includes(\"repo\") &&\n        !oauthScopes.includes(\"public_repo\")\n      ) {\n        return {\n          ok: false,\n          status: 400,\n          error: `Your token needs the \"repo\" scope. Current scopes: ${oauthScopes.join(\", \")}`,\n        };\n      }\n    }\n    const userPayload = await userRes.json().catch(() => ({}));\n    viewerLogin = String(userPayload?.login || \"\").trim();\n\n    const checkRes = await fetch(`https://api.github.com/repos/${repoUrl}`, {\n      headers: ghHeaders,\n    });\n    if (checkRes.status === 404) {\n      const hiddenOwnedRepo = await findOwnedRepoByName({\n        repoUrl,\n        repoOwner,\n        repoName,\n        viewerLogin,\n        ghHeaders,\n      });\n      if (hiddenOwnedRepo) {\n        return {\n          ok: false,\n          status: 400,\n          error:\n            `Repository \"${repoUrl}\" already exists, but this token cannot inspect it. ` +\n            \"Choose a different repo name or use a token that can access that repo.\",\n        };\n      }\n      if (isExisting) {\n        return {\n          ok: false,\n          status: 400,\n          error: `Repository \"${repoUrl}\" not found. Check the repo name and token permissions.`,\n        };\n      }\n      if (!viewerLogin) {\n        return {\n          ok: false,\n          status: 400,\n          error: \"Cannot verify GitHub account owner for this token.\",\n        };\n      }\n      if (repoOwner.toLowerCase() !== viewerLogin.toLowerCase()) {\n        const orgLookup = await findAccessibleOrgByLogin({\n          repoOwner,\n          ghHeaders,\n        });\n        if (!orgLookup?.org) {\n          return {\n            ok: false,\n            status: 400,\n            error:\n              orgLookup?.error ||\n              `Repository owner \"${repoOwner}\" does not match the authenticated GitHub user \"${viewerLogin}\" ` +\n                \"and was not found in the token's accessible organizations. Check the owner name or use a token that can create repositories for that organization.\",\n          };\n        }\n        return {\n          ok: true,\n          repoExists: false,\n          repoIsEmpty: false,\n          createOwnerType: \"org\",\n          viewerLogin,\n        };\n      }\n      return {\n        ok: true,\n        repoExists: false,\n        repoIsEmpty: false,\n        createOwnerType: \"user\",\n        viewerLogin,\n      };\n    }\n    if (checkRes.ok) {\n      const commitsRes = await fetch(\n        `https://api.github.com/repos/${repoUrl}/commits?per_page=1`,\n        { headers: ghHeaders },\n      );\n      if (commitsRes.status === 409) {\n        return { ok: true, repoExists: true, repoIsEmpty: true };\n      }\n      if (commitsRes.ok) {\n        const onlyBoilerplate = await repoContainsOnlyBoilerplate(\n          repoUrl,\n          ghHeaders,\n        );\n        if (onlyBoilerplate) {\n          return { ok: true, repoExists: true, repoIsEmpty: true };\n        }\n        if (isExisting) {\n          return { ok: true, repoExists: true, repoIsEmpty: false };\n        }\n        return {\n          ok: false,\n          status: 400,\n          error: `Repository \"${repoUrl}\" already exists and is not empty. To import, use \"Import existing setup\" instead.`,\n        };\n      }\n      const commitCheckDetails = await parseGithubErrorMessage(commitsRes);\n      return {\n        ok: false,\n        status: 400,\n        error: `Cannot verify whether repo \"${repoUrl}\" is empty: ${commitCheckDetails}`,\n      };\n    }\n\n    const details = await parseGithubErrorMessage(checkRes);\n    if (isFineGrainedPat(githubToken) && checkRes.status === 403) {\n      return {\n        ok: false,\n        status: 400,\n        error: `Your fine-grained token needs Contents (read/write) and Metadata (read) permissions for \"${repoUrl}\".`,\n      };\n    }\n    return {\n      ok: false,\n      status: 400,\n      error: `Cannot verify repo \"${repoUrl}\": ${details}`,\n    };\n  } catch (e) {\n    return {\n      ok: false,\n      status: 400,\n      error: `GitHub verification error: ${e.message}`,\n    };\n  }\n};\n\nconst ensureGithubRepoAccessible = async ({\n  repoUrl,\n  repoName,\n  githubToken,\n}) => {\n  const ghHeaders = buildGithubHeaders(githubToken);\n  const [repoOwner = \"\"] = String(repoUrl || \"\").split(\"/\");\n  const verification = await verifyGithubRepoForOnboarding({\n    repoUrl,\n    githubToken,\n  });\n  if (!verification.ok) return verification;\n  if (verification.repoExists && verification.repoIsEmpty) {\n    console.log(`[onboard] Using existing empty repo ${repoUrl}`);\n    return { ok: true };\n  }\n\n  try {\n    console.log(`[onboard] Creating repo ${repoUrl}...`);\n    const createUrl =\n      verification.createOwnerType === \"org\"\n        ? `https://api.github.com/orgs/${repoOwner}/repos`\n        : \"https://api.github.com/user/repos\";\n    const createRes = await fetch(createUrl, {\n      method: \"POST\",\n      headers: { ...ghHeaders, \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        name: repoName,\n        private: true,\n        auto_init: false,\n      }),\n    });\n    if (!createRes.ok) {\n      const details = await parseGithubErrorMessage(createRes);\n      if (\n        String(details || \"\")\n          .toLowerCase()\n          .includes(\"name already exists on this account\")\n      ) {\n        return {\n          ok: false,\n          status: 400,\n          error:\n            `Repository \"${repoUrl}\" already exists. ` +\n            \"Choose a different repo name or use a token that can access that repo.\",\n        };\n      }\n      const hint =\n        createRes.status === 404 || createRes.status === 403\n          ? ' Ensure your token is a classic PAT with the \"repo\" scope.'\n          : \"\";\n      return {\n        ok: false,\n        status: 400,\n        error: `Failed to create repo: ${details.replace(/\\.$/, \"\")}${hint ? `. ${hint.trim()}` : \"\"}`,\n      };\n    }\n    console.log(`[onboard] Repo ${repoUrl} created`);\n    return { ok: true };\n  } catch (e) {\n    return { ok: false, status: 400, error: `GitHub error: ${e.message}` };\n  }\n};\n\nconst cloneRepoToTemp = async ({ repoUrl, githubToken, shellCmd }) => {\n  const tempId = crypto.randomUUID().slice(0, 8);\n  const tempDir = path.join(os.tmpdir(), `${kImportTempPrefix}${tempId}`);\n  const askPassPath = path.join(\n    os.tmpdir(),\n    `alphaclaw-import-askpass-${tempId}.sh`,\n  );\n\n  try {\n    fs.writeFileSync(\n      askPassPath,\n      [\n        \"#!/bin/sh\",\n        'case \"$1\" in',\n        '  *Username*) printf \"%s\\\\n\" \"x-access-token\" ;;',\n        '  *) printf \"%s\\\\n\" \"$ALPHACLAW_GITHUB_TOKEN\" ;;',\n        \"esac\",\n        \"\",\n      ].join(\"\\n\"),\n      { mode: 0o700 },\n    );\n    await shellCmd(\n      `git clone --depth=1 \"https://github.com/${repoUrl}.git\" \"${tempDir}\"`,\n      {\n        timeout: 60000,\n        env: {\n          ...process.env,\n          GIT_ASKPASS: askPassPath,\n          GIT_TERMINAL_PROMPT: \"0\",\n          ALPHACLAW_GITHUB_TOKEN: githubToken,\n        },\n      },\n    );\n    console.log(`[onboard] Cloned ${repoUrl} to ${tempDir}`);\n    return { ok: true, tempDir };\n  } catch (e) {\n    return {\n      ok: false,\n      error: `Failed to clone repo: ${e.message}`,\n    };\n  } finally {\n    try {\n      fs.rmSync(askPassPath, { force: true });\n    } catch {}\n  }\n};\n\nconst cleanupTempClone = (tempDir) => {\n  try {\n    if (isValidImportTempDir(tempDir)) {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n      console.log(`[onboard] Cleaned up temp clone ${tempDir}`);\n    }\n  } catch (e) {\n    console.error(`[onboard] Temp cleanup error: ${e.message}`);\n  }\n};\n\nmodule.exports = {\n  ensureGithubRepoAccessible,\n  verifyGithubRepoForOnboarding,\n  cloneRepoToTemp,\n  cleanupTempClone,\n};\n"
  },
  {
    "path": "lib/server/onboarding/import/import-applier.js",
    "content": "const path = require(\"path\");\nconst { isValidImportTempDir } = require(\"./import-temp\");\nconst {\n  normalizeHookPath,\n  normalizeTransformModulePath,\n} = require(\"./import-config\");\nconst {\n  getCanonicalEnvVarForConfigPath,\n  getEnvRefName,\n  isAlreadyEnvRef,\n} = require(\"./secret-detector\");\n\nconst kEnvVarNamePattern = /^[A-Z_][A-Z0-9_]*$/;\n\nconst isValidTempDir = (tempDir) => isValidImportTempDir(tempDir);\n\nconst kTransformsRoot = path.join(\"hooks\", \"transforms\");\nconst kTransformsBackupRoot = path.join(kTransformsRoot, \"_backup\");\n\nconst kReplaceableBootstrapPaths = [\n  \".env\",\n  \".alphaclaw\",\n  \"gogcli\",\n  path.join(\"workspace\", \"hooks\", \"bootstrap\"),\n  path.join(\"skills\", \"gog-cli\"),\n];\n\nconst removeIfExists = (fs, targetPath) => {\n  try {\n    if (fs.existsSync(targetPath)) {\n      fs.rmSync(targetPath, { recursive: true, force: true });\n    }\n  } catch {}\n};\n\nconst removeEmptyParents = (fs, rootDir, targetPath) => {\n  let current = path.dirname(targetPath);\n  while (current.startsWith(rootDir) && current !== rootDir) {\n    try {\n      const entries = fs.readdirSync(current);\n      if (entries.length > 0) break;\n      fs.rmSync(current, { recursive: true, force: true });\n      current = path.dirname(current);\n    } catch {\n      break;\n    }\n  }\n};\n\nconst cleanupBootstrapArtifacts = (fs, openclawDir) => {\n  for (const relPath of kReplaceableBootstrapPaths) {\n    const absolutePath = path.join(openclawDir, relPath);\n    removeIfExists(fs, absolutePath);\n    removeEmptyParents(fs, openclawDir, absolutePath);\n  }\n};\n\nconst getDirectoryEntryName = (entry) => {\n  if (typeof entry === \"string\") return entry;\n  if (entry && typeof entry.name === \"string\") return entry.name;\n  return \"\";\n};\n\nconst promoteCloneToTarget = ({\n  fs,\n  tempDir,\n  targetDir,\n  sourceSubdir = \"\",\n  cleanupBootstrap = false,\n}) => {\n  if (!isValidTempDir(tempDir)) {\n    return { ok: false, error: \"Invalid temp directory\" };\n  }\n\n  const sourceDir = sourceSubdir ? path.join(tempDir, sourceSubdir) : tempDir;\n\n  try {\n    if (!fs.existsSync(sourceDir)) {\n      return { ok: false, error: \"Import source directory not found\" };\n    }\n    if (fs.existsSync(targetDir)) {\n      if (cleanupBootstrap) {\n        cleanupBootstrapArtifacts(fs, targetDir);\n      }\n      const existingEntries = fs.readdirSync(targetDir);\n      if (existingEntries.length > 0) {\n        promoteCloneContentsToExistingTarget({ fs, sourceDir, targetDir });\n        if (sourceDir !== tempDir) {\n          fs.rmSync(tempDir, { recursive: true, force: true });\n        }\n        console.log(`[import] Merged ${sourceDir} into ${targetDir}`);\n        return { ok: true };\n      }\n      fs.rmSync(targetDir, { recursive: true, force: true });\n    }\n    fs.mkdirSync(path.dirname(targetDir), { recursive: true });\n    fs.renameSync(sourceDir, targetDir);\n    if (sourceDir !== tempDir) {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    }\n    console.log(`[import] Promoted ${sourceDir} to ${targetDir}`);\n    return { ok: true };\n  } catch (e) {\n    // Cross-device rename falls back to copy\n    if (e.code === \"EXDEV\") {\n      try {\n        copyDirRecursive(fs, sourceDir, targetDir);\n        fs.rmSync(tempDir, { recursive: true, force: true });\n        console.log(\n          `[import] Copied ${sourceDir} to ${targetDir} (cross-device)`,\n        );\n        return { ok: true };\n      } catch (copyErr) {\n        return { ok: false, error: `Failed to copy clone: ${copyErr.message}` };\n      }\n    }\n    return { ok: false, error: `Failed to promote clone: ${e.message}` };\n  }\n};\n\nconst copyDirRecursive = (fs, src, dest) => {\n  fs.mkdirSync(dest, { recursive: true });\n  const entries = fs.readdirSync(src, { withFileTypes: true });\n  for (const entry of entries) {\n    const srcPath = path.join(src, entry.name);\n    const destPath = path.join(dest, entry.name);\n    if (entry.isDirectory()) {\n      copyDirRecursive(fs, srcPath, destPath);\n    } else {\n      fs.copyFileSync(srcPath, destPath);\n    }\n  }\n};\n\nconst toPosixPath = (value) => String(value || \"\").replace(/\\\\/g, \"/\");\nconst ensureRelativeImportPath = (value) => {\n  const normalized = toPosixPath(value);\n  if (normalized.startsWith(\".\")) return normalized;\n  return `./${normalized}`;\n};\nconst pathExists = (fs, targetPath) => {\n  try {\n    return fs.existsSync(targetPath);\n  } catch {\n    return false;\n  }\n};\n\nconst resolveExtractionTargetPath = (baseDir, file) => {\n  const relativeFile = String(file || \"\").trim();\n  if (!relativeFile || path.isAbsolute(relativeFile)) return \"\";\n  const resolvedBaseDir = path.resolve(baseDir);\n  const resolvedFilePath = path.resolve(resolvedBaseDir, relativeFile);\n  if (\n    resolvedFilePath !== resolvedBaseDir &&\n    !resolvedFilePath.startsWith(`${resolvedBaseDir}${path.sep}`)\n  ) {\n    return \"\";\n  }\n  return resolvedFilePath;\n};\nconst isConfigPathIndex = (segment) => /^\\d+$/.test(String(segment || \"\"));\nconst setConfigValueAtPath = ({\n  root,\n  dotPath,\n  expectedValue,\n  nextValue,\n}) => {\n  const pathSegments = String(dotPath || \"\")\n    .split(\".\")\n    .filter(Boolean);\n  if (!root || typeof root !== \"object\" || pathSegments.length === 0) {\n    return false;\n  }\n\n  let current = root;\n  for (let index = 0; index < pathSegments.length - 1; index += 1) {\n    const segment = pathSegments[index];\n    const nextNode = isConfigPathIndex(segment)\n      ? current?.[Number(segment)]\n      : current?.[segment];\n    if (!nextNode || typeof nextNode !== \"object\") {\n      return false;\n    }\n    current = nextNode;\n  }\n\n  const lastSegment = pathSegments[pathSegments.length - 1];\n  const targetKey = isConfigPathIndex(lastSegment)\n    ? Number(lastSegment)\n    : lastSegment;\n  if (typeof current?.[targetKey] !== \"string\") return false;\n  if (current[targetKey] !== expectedValue) return false;\n  current[targetKey] = nextValue;\n  return true;\n};\nconst movePath = (fs, src, dest) => {\n  fs.mkdirSync(path.dirname(dest), { recursive: true });\n  try {\n    fs.renameSync(src, dest);\n    return;\n  } catch (error) {\n    if (error?.code !== \"EXDEV\") throw error;\n  }\n  const stats = fs.statSync(src);\n  if (stats.isDirectory()) {\n    copyDirRecursive(fs, src, dest);\n    fs.rmSync(src, { recursive: true, force: true });\n    return;\n  }\n  fs.copyFileSync(src, dest);\n  fs.rmSync(src, { force: true });\n};\nconst promoteCloneContentsToExistingTarget = ({ fs, sourceDir, targetDir }) => {\n  fs.mkdirSync(targetDir, { recursive: true });\n  const sourceEntries = fs.readdirSync(sourceDir, { withFileTypes: true });\n  for (const entry of sourceEntries) {\n    const entryName = getDirectoryEntryName(entry);\n    if (!entryName) continue;\n    const sourcePath = path.join(sourceDir, entryName);\n    const targetPath = path.join(targetDir, entryName);\n    removeIfExists(fs, targetPath);\n    movePath(fs, sourcePath, targetPath);\n  }\n  fs.rmSync(sourceDir, { recursive: true, force: true });\n};\nconst buildTransformShim = (targetImportPath) =>\n  [\n    `export { default } from ${JSON.stringify(targetImportPath)};`,\n    `export * from ${JSON.stringify(targetImportPath)};`,\n    \"\",\n  ].join(\"\\n\");\n\nconst alignHookTransforms = ({ fs, baseDir, configFiles = [] }) => {\n  const movedRoots = new Map();\n  let alignedCount = 0;\n\n  for (const configFile of configFiles) {\n    const fullConfigPath = path.join(baseDir, configFile);\n    let cfg = null;\n    try {\n      cfg = JSON.parse(fs.readFileSync(fullConfigPath, \"utf8\"));\n    } catch {\n      continue;\n    }\n    const mappings = Array.isArray(cfg?.hooks?.mappings)\n      ? cfg.hooks.mappings\n      : [];\n    let changed = false;\n\n    mappings.forEach((mapping, index) => {\n      const hookPath = normalizeHookPath(mapping?.match?.path);\n      const actualModule = normalizeTransformModulePath(\n        mapping?.transform?.module,\n      );\n      if (!hookPath) return;\n      if (mapping?.match?.path !== hookPath) {\n        mappings[index] = {\n          ...mapping,\n          match: {\n            ...(mapping?.match || {}),\n            path: hookPath,\n          },\n        };\n        changed = true;\n      }\n      if (!actualModule) return;\n\n      const expectedModule = `${hookPath}/${hookPath}-transform.mjs`;\n      if (actualModule === expectedModule) return;\n\n      const actualRelativePath = path.join(kTransformsRoot, actualModule);\n      const expectedRelativePath = path.join(kTransformsRoot, expectedModule);\n      const actualAbsolutePath = path.join(baseDir, actualRelativePath);\n      const expectedAbsolutePath = path.join(baseDir, expectedRelativePath);\n      if (!pathExists(fs, actualAbsolutePath)) return;\n\n      const actualParts = actualModule.split(\"/\").filter(Boolean);\n      const sourceRootRelativePath =\n        actualParts.length > 1\n          ? path.join(kTransformsRoot, actualParts[0])\n          : actualRelativePath;\n      const sourceRootAbsolutePath = path.join(baseDir, sourceRootRelativePath);\n      const backupRootRelativePath = path.join(\n        kTransformsBackupRoot,\n        sourceRootRelativePath.slice(kTransformsRoot.length + 1),\n      );\n      const backupRootAbsolutePath = path.join(baseDir, backupRootRelativePath);\n\n      if (!movedRoots.has(sourceRootAbsolutePath)) {\n        if (\n          !pathExists(fs, backupRootAbsolutePath) &&\n          pathExists(fs, sourceRootAbsolutePath)\n        ) {\n          movePath(fs, sourceRootAbsolutePath, backupRootAbsolutePath);\n        }\n        movedRoots.set(sourceRootAbsolutePath, backupRootAbsolutePath);\n      }\n\n      const backupActualAbsolutePath = path.join(\n        movedRoots.get(sourceRootAbsolutePath),\n        path.relative(sourceRootAbsolutePath, actualAbsolutePath),\n      );\n\n      fs.mkdirSync(path.dirname(expectedAbsolutePath), { recursive: true });\n      const shimImportPath = ensureRelativeImportPath(\n        path.relative(\n          path.dirname(expectedAbsolutePath),\n          backupActualAbsolutePath,\n        ),\n      );\n      fs.writeFileSync(\n        expectedAbsolutePath,\n        buildTransformShim(shimImportPath),\n      );\n\n      const currentMapping = mappings[index] || mapping;\n      mappings[index] = {\n        ...currentMapping,\n        transform: {\n          ...(currentMapping?.transform || {}),\n          module: expectedModule,\n        },\n      };\n      changed = true;\n      alignedCount += 1;\n    });\n\n    if (changed) {\n      fs.writeFileSync(fullConfigPath, JSON.stringify(cfg, null, 2));\n    }\n  }\n\n  return { alignedCount };\n};\n\nconst applySecretExtraction = ({ fs, baseDir, approvedSecrets }) => {\n  const envVars = [];\n  const rewriteMap = new Map();\n\n  for (const secret of approvedSecrets) {\n    const envVar = String(secret.suggestedEnvVar || \"\").trim();\n    const value = String(secret.value || \"\").trim();\n    if (!envVar || !value || !kEnvVarNamePattern.test(envVar)) continue;\n\n    envVars.push({ key: envVar, value });\n\n    if (secret.file && !secret.file.startsWith(\".env\")) {\n      const fullPath = resolveExtractionTargetPath(baseDir, secret.file);\n      if (!fullPath) continue;\n      if (!rewriteMap.has(fullPath)) {\n        rewriteMap.set(fullPath, []);\n      }\n      rewriteMap.get(fullPath).push({\n        configPath: secret.configPath,\n        value,\n        envRef: `\\${${envVar}}`,\n        relativeFile: secret.file,\n      });\n    }\n  }\n\n  for (const [fullPath, replacements] of rewriteMap) {\n    try {\n      let content = fs.readFileSync(fullPath, \"utf8\");\n      let parsed = null;\n      try {\n        parsed = JSON.parse(content);\n      } catch {}\n      const sorted = [...replacements].sort(\n        (a, b) => b.value.length - a.value.length,\n      );\n      let structuredChanged = false;\n      if (parsed && typeof parsed === \"object\") {\n        for (const { configPath, value, envRef } of sorted) {\n          if (\n            setConfigValueAtPath({\n              root: parsed,\n              dotPath: configPath,\n              expectedValue: value,\n              nextValue: envRef,\n            })\n          ) {\n            structuredChanged = true;\n          }\n        }\n      }\n      if (structuredChanged) {\n        content = JSON.stringify(parsed, null, 2);\n      }\n      for (const { value, envRef } of sorted) {\n        const secretJson = JSON.stringify(value);\n        const envRefJson = JSON.stringify(envRef);\n        content = content.replace(\n          new RegExp(secretJson.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, \"\\\\$&\"), \"g\"),\n          envRefJson,\n        );\n      }\n      fs.writeFileSync(fullPath, content);\n      console.log(\n        `[import] Rewrote secrets in ${replacements[0]?.relativeFile || fullPath}`,\n      );\n    } catch (e) {\n      console.error(`[import] Rewrite error for ${fullPath}: ${e.message}`);\n    }\n  }\n\n  return { envVars };\n};\n\nconst remapEnvVars = (envVars, renameMap) => {\n  const mapped = [];\n  for (const entry of envVars) {\n    const key = String(entry?.key || \"\").trim();\n    if (!key) continue;\n    const nextKey = renameMap.get(key) || key;\n    const existing = mapped.find((item) => item.key === nextKey);\n    if (existing) {\n      existing.value = entry.value;\n    } else {\n      mapped.push({ key: nextKey, value: entry.value });\n    }\n  }\n  return mapped;\n};\n\nconst canonicalizeConfigEnvRefs = ({ fs, baseDir, configFiles = [], envVars = [] }) => {\n  const renameMap = new Map();\n  let rewrittenRefs = 0;\n\n  const rewriteNode = (node, parentPath = \"\") => {\n    if (!node || typeof node !== \"object\") return false;\n    let changed = false;\n    for (const [key, value] of Object.entries(node)) {\n      const dotPath = parentPath ? `${parentPath}.${key}` : key;\n      if (typeof value === \"string\" && isAlreadyEnvRef(value)) {\n        const currentEnvRef = getEnvRefName(value);\n        const canonicalEnvVar = getCanonicalEnvVarForConfigPath(dotPath);\n        if (canonicalEnvVar && currentEnvRef && currentEnvRef !== canonicalEnvVar) {\n          node[key] = `\\${${canonicalEnvVar}}`;\n          renameMap.set(currentEnvRef, canonicalEnvVar);\n          rewrittenRefs += 1;\n          changed = true;\n        }\n        continue;\n      }\n      if (value && typeof value === \"object\") {\n        changed = rewriteNode(value, dotPath) || changed;\n      }\n    }\n    return changed;\n  };\n\n  for (const configFile of configFiles) {\n    const fullPath = resolveExtractionTargetPath(baseDir, configFile);\n    if (!fullPath) continue;\n    try {\n      const parsed = JSON.parse(fs.readFileSync(fullPath, \"utf8\"));\n      if (!parsed || typeof parsed !== \"object\") continue;\n      const changed = rewriteNode(parsed);\n      if (changed) {\n        fs.writeFileSync(fullPath, JSON.stringify(parsed, null, 2));\n      }\n    } catch {}\n  }\n\n  return {\n    envVars: remapEnvVars(envVars, renameMap),\n    rewrittenRefs,\n    renamedEnvVars: renameMap.size,\n  };\n};\n\nmodule.exports = {\n  promoteCloneToTarget,\n  alignHookTransforms,\n  applySecretExtraction,\n  canonicalizeConfigEnvRefs,\n  isValidTempDir,\n};\n"
  },
  {
    "path": "lib/server/onboarding/import/import-config.js",
    "content": "const path = require(\"path\");\n\nconst normalizeHookPath = (value) =>\n  String(value || \"\")\n    .trim()\n    .replace(/^\\/+/, \"\");\n\nconst normalizeTransformModulePath = (value) =>\n  String(value || \"\")\n    .trim()\n    .replace(/^\\/+/, \"\")\n    .replace(/^hooks\\/transforms\\/+/, \"\");\n\nconst resolveConfigIncludes = ({ fs, absoluteConfigPath }) => {\n  const includes = [];\n  try {\n    const raw = fs.readFileSync(absoluteConfigPath, \"utf8\");\n    const cfg = JSON.parse(raw);\n    const walk = (entry) => {\n      if (!entry || typeof entry !== \"object\") return;\n      for (const [key, value] of Object.entries(entry)) {\n        if (key === \"$include\" && typeof value === \"string\" && value.trim()) {\n          includes.push(value.trim());\n          continue;\n        }\n        walk(value);\n      }\n    };\n    walk(cfg);\n  } catch {}\n  return includes;\n};\n\nconst resolveImportedConfigPaths = ({ fs, openclawDir }) => {\n  const discovered = new Set();\n  const queue = [path.join(openclawDir, \"openclaw.json\")].filter((configPath) =>\n    fs.existsSync(configPath),\n  );\n\n  while (queue.length > 0) {\n    const absoluteConfigPath = queue.shift();\n    if (!absoluteConfigPath || discovered.has(absoluteConfigPath)) continue;\n    if (!fs.existsSync(absoluteConfigPath)) continue;\n    discovered.add(absoluteConfigPath);\n\n    const includes = resolveConfigIncludes({ fs, absoluteConfigPath });\n    for (const includePath of includes) {\n      const candidatePaths = [\n        path.join(openclawDir, includePath),\n        path.join(path.dirname(absoluteConfigPath), includePath),\n      ];\n      for (const candidatePath of candidatePaths) {\n        if (fs.existsSync(candidatePath) && !discovered.has(candidatePath)) {\n          queue.push(candidatePath);\n          break;\n        }\n      }\n    }\n  }\n\n  return [...discovered];\n};\n\nmodule.exports = {\n  normalizeHookPath,\n  normalizeTransformModulePath,\n  resolveConfigIncludes,\n  resolveImportedConfigPaths,\n};\n"
  },
  {
    "path": "lib/server/onboarding/import/import-scanner.js",
    "content": "const path = require(\"path\");\nconst { kSystemVars } = require(\"../../constants\");\nconst {\n  normalizeHookPath,\n  normalizeTransformModulePath,\n  resolveConfigIncludes,\n} = require(\"./import-config\");\n\nconst kWorkspaceFiles = [\n  \"AGENTS.md\",\n  \"SOUL.md\",\n  \"USER.md\",\n  \"TOOLS.md\",\n  \"MEMORY.md\",\n  \"IDENTITY.md\",\n  \"HEARTBEAT.md\",\n  \"BOOTSTRAP.md\",\n];\n\nconst kConfigLocations = [\"openclaw.json\"];\n\nconst kEnvFileLocations = [\n  \".env\",\n  \".env.local\",\n  \".env.production\",\n  \".env.development\",\n];\n\nconst kUnsupportedNestedLocations = [\n  \".openclaw/openclaw.json\",\n  \".openclaw/.env\",\n];\n\nconst kCredentialDirs = [\"credentials\", \"identity\", \"devices\", \"gogcli\"];\n\nconst kManagedFiles = [\n  \"hooks/bootstrap/AGENTS.md\",\n  \"hooks/bootstrap/TOOLS.md\",\n  \"cron/system-sync.json\",\n  \".gitignore\",\n];\n\nconst kManagedDirs = [\".alphaclaw\"];\n\nconst fileExists = (fs, filePath) => {\n  try {\n    return fs.statSync(filePath).isFile();\n  } catch {\n    return false;\n  }\n};\n\nconst dirExists = (fs, dirPath) => {\n  try {\n    return fs.statSync(dirPath).isDirectory();\n  } catch {\n    return false;\n  }\n};\n\nconst globDir = (fs, dirPath, pattern) => {\n  const results = [];\n  try {\n    const entries = fs.readdirSync(dirPath, { withFileTypes: true });\n    for (const entry of entries) {\n      if (entry.isDirectory()) {\n        const subPath = path.join(dirPath, entry.name);\n        const target = path.join(subPath, pattern);\n        if (fileExists(fs, target)) {\n          results.push(path.relative(dirPath, target));\n        }\n      }\n    }\n  } catch {}\n  return results;\n};\n\nconst listRootMarkdown = (fs, baseDir) => {\n  const files = [];\n  try {\n    const entries = fs.readdirSync(baseDir, { withFileTypes: true });\n    for (const entry of entries) {\n      if (\n        entry.isFile() &&\n        entry.name.endsWith(\".md\") &&\n        !kWorkspaceFiles.includes(entry.name)\n      ) {\n        files.push(entry.name);\n      }\n    }\n  } catch {}\n  return files;\n};\n\nconst scanCategory = (fs, baseDir, relativePaths) => {\n  const found = [];\n  for (const rel of relativePaths) {\n    if (fileExists(fs, path.join(baseDir, rel))) {\n      found.push(rel);\n    }\n  }\n  return { found: found.length > 0, files: found };\n};\n\nconst scanDirCategory = (fs, baseDir, relativeDirs) => {\n  const found = [];\n  for (const rel of relativeDirs) {\n    if (dirExists(fs, path.join(baseDir, rel))) {\n      found.push(rel);\n    }\n  }\n  return { found: found.length > 0, dirs: found };\n};\n\nconst parseCronJobs = (fs, baseDir, cronFile) => {\n  try {\n    const raw = fs.readFileSync(path.join(baseDir, cronFile), \"utf8\");\n    const parsed = JSON.parse(raw);\n    const jobs = Array.isArray(parsed) ? parsed : parsed?.jobs;\n    if (!Array.isArray(jobs)) return [];\n    return jobs\n      .filter((job) => job && typeof job === \"object\")\n      .map((job, index) => {\n        const name = String(job.name || \"\").trim();\n        const id = String(job.id || \"\").trim();\n        return name || id || `Job ${index + 1}`;\n      });\n  } catch {\n    return [];\n  }\n};\n\nconst parseHookDefinitions = (fs, baseDir, configFiles) => {\n  const hookNames = [];\n  const transformWarnings = [];\n  const seen = new Set();\n\n  const addHookName = (value) => {\n    const name = String(value || \"\").trim();\n    if (!name || seen.has(name)) return;\n    seen.add(name);\n    hookNames.push(name);\n    return name;\n  };\n\n  for (const configFile of configFiles) {\n    try {\n      const raw = fs.readFileSync(path.join(baseDir, configFile), \"utf8\");\n      const cfg = JSON.parse(raw);\n      const mappings = Array.isArray(cfg?.hooks?.mappings)\n        ? cfg.hooks.mappings\n        : [];\n      mappings.forEach((mapping, index) => {\n        const name = String(\n          mapping?.name ||\n            mapping?.id ||\n            mapping?.match?.path ||\n            `Hook ${index + 1}`,\n        ).trim();\n        const matchPath = normalizeHookPath(mapping?.match?.path);\n        const hookLabel = addHookName(\n          matchPath ? `${name} (${matchPath})` : name,\n        );\n        if (matchPath) {\n          const actualModule = normalizeTransformModulePath(\n            mapping?.transform?.module,\n          );\n          const expectedModule = `${matchPath}/${matchPath}-transform.mjs`;\n          if (hookLabel && actualModule && actualModule !== expectedModule) {\n            transformWarnings.push({\n              hookLabel,\n              actualPath: `hooks/transforms/${actualModule}`,\n              expectedPath: `hooks/transforms/${expectedModule}`,\n              message: `Uses hooks/transforms/${actualModule}; expected hooks/transforms/${expectedModule}`,\n            });\n          }\n        }\n      });\n\n      const internalEntries = cfg?.hooks?.internal?.entries;\n      if (internalEntries && typeof internalEntries === \"object\") {\n        for (const [key, entry] of Object.entries(internalEntries)) {\n          const enabled = entry?.enabled;\n          addHookName(\n            enabled === false\n              ? `internal:${key} (disabled)`\n              : `internal:${key}`,\n          );\n        }\n      }\n    } catch {}\n  }\n\n  return { hookNames, transformWarnings };\n};\n\nconst kEnvRefPattern = /\\$\\{([A-Z_][A-Z0-9_]*)\\}/g;\n\nconst collectManagedEnvRefs = (value, found) => {\n  if (typeof value === \"string\") {\n    for (const match of value.matchAll(kEnvRefPattern)) {\n      const envKey = match[1];\n      if (kSystemVars.has(envKey)) {\n        found.add(envKey);\n      }\n    }\n    return;\n  }\n  if (Array.isArray(value)) {\n    value.forEach((entry) => collectManagedEnvRefs(entry, found));\n    return;\n  }\n  if (value && typeof value === \"object\") {\n    Object.values(value).forEach((entry) =>\n      collectManagedEnvRefs(entry, found),\n    );\n  }\n};\n\nconst collectManagedEnvConflicts = (fs, baseDir, configFiles, envFiles) => {\n  const managedVars = new Set();\n  let gatewayAuthNormalized = false;\n  let webhookTokenNormalized = false;\n\n  for (const configFile of configFiles) {\n    try {\n      const raw = fs.readFileSync(path.join(baseDir, configFile), \"utf8\");\n      const cfg = JSON.parse(raw);\n      collectManagedEnvRefs(cfg, managedVars);\n      const gatewayToken = String(cfg?.gateway?.auth?.token || \"\").trim();\n      if (gatewayToken && gatewayToken !== \"${OPENCLAW_GATEWAY_TOKEN}\") {\n        gatewayAuthNormalized = true;\n        managedVars.add(\"OPENCLAW_GATEWAY_TOKEN\");\n      }\n      const webhookToken = String(cfg?.hooks?.token || \"\").trim();\n      if (webhookToken && webhookToken !== \"${WEBHOOK_TOKEN}\") {\n        webhookTokenNormalized = true;\n        managedVars.add(\"WEBHOOK_TOKEN\");\n      }\n    } catch {}\n  }\n\n  for (const envFile of envFiles) {\n    try {\n      const raw = fs.readFileSync(path.join(baseDir, envFile), \"utf8\");\n      for (const line of String(raw || \"\").split(\"\\n\")) {\n        const trimmed = line.trim();\n        if (!trimmed || trimmed.startsWith(\"#\")) continue;\n        const eqIndex = trimmed.indexOf(\"=\");\n        if (eqIndex === -1) continue;\n        const key = trimmed.slice(0, eqIndex).trim();\n        if (kSystemVars.has(key)) {\n          managedVars.add(key);\n        }\n      }\n    } catch {}\n  }\n\n  return {\n    found: managedVars.size > 0 || gatewayAuthNormalized || webhookTokenNormalized,\n    vars: [...managedVars].sort(),\n    gatewayAuthNormalized,\n    webhookTokenNormalized,\n  };\n};\n\nconst detectImportSourceLayout = ({\n  gatewayConfig,\n  workspaceFiles,\n  skills,\n  cronJobs,\n  webhooks,\n  memory,\n  credentials,\n  unsupportedNested,\n}) => {\n  const configFiles = Array.isArray(gatewayConfig?.files)\n    ? gatewayConfig.files\n    : [];\n  const hasRootConfig = configFiles.some(\n    (filePath) => filePath === \"openclaw.json\",\n  );\n  const hasUnsupportedNested = !!unsupportedNested?.found;\n  const hasWorkspaceOnlyContent = !!(\n    workspaceFiles?.found ||\n    skills?.found ||\n    cronJobs?.found ||\n    webhooks?.found ||\n    memory?.found ||\n    credentials?.found\n  );\n\n  if (hasUnsupportedNested) {\n    return {\n      kind: \"unsupported-nested-openclaw\",\n      supported: false,\n      error:\n        \"This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.\",\n    };\n  }\n  if (hasRootConfig) {\n    return {\n      kind: \"full-openclaw-root\",\n      supported: true,\n      promoteSourceSubdir: \"\",\n    };\n  }\n  if (hasWorkspaceOnlyContent) {\n    return {\n      kind: \"workspace-only\",\n      supported: true,\n      promoteSourceSubdir: \"\",\n    };\n  }\n  return {\n    kind: \"empty\",\n    supported: true,\n    promoteSourceSubdir: \"\",\n  };\n};\n\nconst scanWorkspace = ({ fs, baseDir }) => {\n  const gatewayConfig = scanCategory(fs, baseDir, kConfigLocations);\n  for (const cfgFile of gatewayConfig.files) {\n    const includes = resolveConfigIncludes({\n      fs,\n      absoluteConfigPath: path.join(baseDir, cfgFile),\n    });\n    for (const inc of includes) {\n      if (\n        fileExists(fs, path.join(baseDir, inc)) &&\n        !gatewayConfig.files.includes(inc)\n      ) {\n        gatewayConfig.files.push(inc);\n      }\n    }\n  }\n  for (const loc of kConfigLocations) {\n    const bakPath = `${loc}.bak`;\n    if (fileExists(fs, path.join(baseDir, bakPath))) {\n      if (!gatewayConfig.backups) gatewayConfig.backups = [];\n      gatewayConfig.backups.push(bakPath);\n    }\n  }\n\n  const envFiles = scanCategory(fs, baseDir, kEnvFileLocations);\n  const unsupportedNested = scanCategory(\n    fs,\n    baseDir,\n    kUnsupportedNestedLocations,\n  );\n\n  const workspaceFilesScan = scanCategory(fs, baseDir, kWorkspaceFiles);\n  const extraMarkdown = listRootMarkdown(fs, baseDir);\n  const workspaceFiles = {\n    found: workspaceFilesScan.found || extraMarkdown.length > 0,\n    files: workspaceFilesScan.files,\n    extraMarkdown,\n  };\n\n  const workspaceDir = path.join(baseDir, \"workspace\");\n  const skillsBase = dirExists(fs, path.join(workspaceDir, \"skills\"))\n    ? path.join(workspaceDir, \"skills\")\n    : dirExists(fs, path.join(baseDir, \"skills\"))\n      ? path.join(baseDir, \"skills\")\n      : null;\n  const skillFiles = skillsBase ? globDir(fs, skillsBase, \"SKILL.md\") : [];\n  const skills = { found: skillFiles.length > 0, files: skillFiles };\n\n  const cronJobs = scanCategory(fs, baseDir, [\n    \"cron/jobs.json\",\n    \"config/cron-definitions.json\",\n  ]);\n  const cronJobNames = [];\n  for (const cronFile of cronJobs.files) {\n    for (const jobName of parseCronJobs(fs, baseDir, cronFile)) {\n      cronJobNames.push(jobName);\n    }\n  }\n  cronJobs.jobNames = cronJobNames;\n  cronJobs.jobCount = cronJobNames.length;\n  cronJobs.found = cronJobs.found || cronJobNames.length > 0;\n  if (fileExists(fs, path.join(baseDir, \"cron/jobs.json.bak\"))) {\n    cronJobs.backups = [\"cron/jobs.json.bak\"];\n  }\n\n  const hooksTransformsDir = path.join(baseDir, \"hooks\", \"transforms\");\n  const webhookDirs = [];\n  if (dirExists(fs, hooksTransformsDir)) {\n    try {\n      const entries = fs.readdirSync(hooksTransformsDir, {\n        withFileTypes: true,\n      });\n      for (const entry of entries) {\n        if (entry.isDirectory()) {\n          webhookDirs.push(`hooks/transforms/${entry.name}`);\n        }\n      }\n    } catch {}\n  }\n  const { hookNames, transformWarnings } = parseHookDefinitions(\n    fs,\n    baseDir,\n    gatewayConfig.files,\n  );\n  const webhooks = {\n    found: webhookDirs.length > 0 || hookNames.length > 0,\n    dirs: webhookDirs,\n    hookNames,\n    hookCount: hookNames.length,\n    transformWarnings,\n    warningCount: transformWarnings.length,\n  };\n\n  const memory = scanDirCategory(fs, baseDir, [\"memory\"]);\n\n  const credentials = scanDirCategory(fs, baseDir, kCredentialDirs);\n\n  const managedFileConflicts = scanCategory(fs, baseDir, kManagedFiles);\n  const managedDirConflicts = scanDirCategory(fs, baseDir, kManagedDirs);\n  const managedConflicts = {\n    found: managedFileConflicts.found || managedDirConflicts.found,\n    files: managedFileConflicts.files,\n    dirs: managedDirConflicts.dirs || [],\n  };\n  const managedEnvConflicts = collectManagedEnvConflicts(\n    fs,\n    baseDir,\n    gatewayConfig.files,\n    envFiles.files,\n  );\n\n  const hasOpenclawSetup = gatewayConfig.found;\n  const isEmpty =\n    !gatewayConfig.found &&\n    !envFiles.found &&\n    !workspaceFiles.found &&\n    !skills.found;\n  const sourceLayout = detectImportSourceLayout({\n    gatewayConfig,\n    workspaceFiles,\n    skills,\n    cronJobs,\n    webhooks,\n    memory,\n    credentials,\n    unsupportedNested,\n  });\n  if (sourceLayout.kind === \"workspace-only\") {\n    sourceLayout.promoteSourceSubdir = dirExists(\n      fs,\n      path.join(baseDir, \"workspace\"),\n    )\n      ? \"workspace\"\n      : \"\";\n  }\n\n  return {\n    hasOpenclawSetup,\n    isEmpty,\n    sourceLayout,\n    gatewayConfig,\n    envFiles,\n    unsupportedNested,\n    workspaceFiles,\n    skills,\n    cronJobs,\n    webhooks,\n    memory,\n    credentials,\n    managedConflicts,\n    managedEnvConflicts,\n  };\n};\n\nmodule.exports = { scanWorkspace, detectImportSourceLayout };\n"
  },
  {
    "path": "lib/server/onboarding/import/import-temp.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst kImportTempPrefix = \"alphaclaw-import-\";\nconst kImportTempTtlMs = 24 * 60 * 60 * 1000;\n\nconst resolveImportTempDir = (tempDir) => {\n  const value = String(tempDir || \"\").trim();\n  if (!value) return \"\";\n  return path.resolve(value);\n};\n\nconst isValidImportTempDir = (tempDir) => {\n  const resolved = resolveImportTempDir(tempDir);\n  if (!resolved) return false;\n  const tempRoot = path.resolve(os.tmpdir());\n  const relative = path.relative(tempRoot, resolved);\n  if (!relative || relative.startsWith(\"..\") || path.isAbsolute(relative)) {\n    return false;\n  }\n  return path.basename(resolved).startsWith(kImportTempPrefix);\n};\n\nconst cleanupStaleImportTempDirs = ({\n  fsModule = fs,\n  maxAgeMs = kImportTempTtlMs,\n  nowMs = Date.now(),\n} = {}) => {\n  const tempRoot = path.resolve(os.tmpdir());\n  let removedCount = 0;\n\n  let entries = [];\n  try {\n    entries = fsModule.readdirSync(tempRoot, { withFileTypes: true });\n  } catch {\n    return { removedCount };\n  }\n\n  for (const entry of entries) {\n    if (!entry?.isDirectory?.()) continue;\n    if (!String(entry.name || \"\").startsWith(kImportTempPrefix)) continue;\n    const candidate = path.join(tempRoot, entry.name);\n    if (!isValidImportTempDir(candidate)) continue;\n    try {\n      const stats = fsModule.statSync(candidate);\n      const ageMs = nowMs - Number(stats?.mtimeMs || 0);\n      if (ageMs < maxAgeMs) continue;\n      fsModule.rmSync(candidate, { recursive: true, force: true });\n      removedCount += 1;\n    } catch {}\n  }\n\n  return { removedCount };\n};\n\nmodule.exports = {\n  kImportTempPrefix,\n  kImportTempTtlMs,\n  resolveImportTempDir,\n  isValidImportTempDir,\n  cleanupStaleImportTempDirs,\n};\n"
  },
  {
    "path": "lib/server/onboarding/import/secret-detector.js",
    "content": "const path = require(\"path\");\n\nconst kSecretKeyPatterns = [\n  /token$/i,\n  /^bot_?token$/i,\n  /api_?key$/i,\n  /secret$/i,\n  /password$/i,\n  /private_?key$/i,\n  /credential/i,\n];\n\nconst kSafeKeyExclusions = [\n  /^auth_?dir$/i,\n  /^auth_?store$/i,\n  /^auto_?select/i,\n  /^public_?key$/i,\n];\n\nconst kValuePrefixes = [\n  { prefix: \"sk-\", label: \"OpenAI/Anthropic/Stripe\" },\n  { prefix: \"sk-ant-\", label: \"Anthropic\" },\n  { prefix: \"sk-proj-\", label: \"OpenAI project\" },\n  { prefix: \"ghp_\", label: \"GitHub classic PAT\" },\n  { prefix: \"github_pat_\", label: \"GitHub fine-grained PAT\" },\n  { prefix: \"ghs_\", label: \"GitHub App token\" },\n  { prefix: \"gho_\", label: \"GitHub OAuth\" },\n  { prefix: \"xoxb-\", label: \"Slack bot\" },\n  { prefix: \"xoxp-\", label: \"Slack user\" },\n  { prefix: \"xoxe-\", label: \"Slack enterprise\" },\n  { prefix: \"xoxa-\", label: \"Slack app\" },\n  { prefix: \"AIza\", label: \"Google API key\" },\n  { prefix: \"ya29.\", label: \"Google OAuth\" },\n  { prefix: \"AKIA\", label: \"AWS access key\" },\n  { prefix: \"ntn_\", label: \"Notion\" },\n  { prefix: \"nvapi-\", label: \"NVIDIA\" },\n  { prefix: \"r8_\", label: \"Replicate\" },\n  { prefix: \"hf_\", label: \"Hugging Face\" },\n  { prefix: \"pk_live_\", label: \"Stripe publishable\" },\n  { prefix: \"sk_live_\", label: \"Stripe secret\" },\n  { prefix: \"pk_test_\", label: \"Stripe test pub\" },\n  { prefix: \"sk_test_\", label: \"Stripe test secret\" },\n  { prefix: \"whsec_\", label: \"Stripe webhook\" },\n  { prefix: \"SG.\", label: \"SendGrid\" },\n  { prefix: \"xai-\", label: \"xAI/Grok\" },\n  { prefix: \"eyJ\", label: \"JWT\" },\n];\n\n// Explicit config path -> env var name mapping\nconst kConfigPathToEnvVar = {\n  \"channels.telegram.botToken\": \"TELEGRAM_BOT_TOKEN\",\n  \"channels.discord.token\": \"DISCORD_BOT_TOKEN\",\n  \"channels.slack.botToken\": \"SLACK_BOT_TOKEN\",\n  \"channels.slack.appToken\": \"SLACK_APP_TOKEN\",\n  \"channels.googlechat.serviceAccount\": \"GOOGLE_CHAT_SERVICE_ACCOUNT\",\n  \"channels.mattermost.botToken\": \"MATTERMOST_BOT_TOKEN\",\n  \"channels.mattermost.url\": \"MATTERMOST_URL\",\n  \"channels.twitch.accessToken\": \"OPENCLAW_TWITCH_ACCESS_TOKEN\",\n  \"models.providers.openai.apiKey\": \"OPENAI_API_KEY\",\n  \"models.providers.anthropic.apiKey\": \"ANTHROPIC_API_KEY\",\n  \"models.providers.google.apiKey\": \"GEMINI_API_KEY\",\n  \"models.providers.opencode.apiKey\": \"OPENCODE_API_KEY\",\n  \"models.providers.openrouter.apiKey\": \"OPENROUTER_API_KEY\",\n  \"models.providers.zai.apiKey\": \"ZAI_API_KEY\",\n  \"models.providers.vercel-ai-gateway.apiKey\": \"AI_GATEWAY_API_KEY\",\n  \"models.providers.kilocode.apiKey\": \"KILOCODE_API_KEY\",\n  \"models.providers.xai.apiKey\": \"XAI_API_KEY\",\n  \"models.providers.mistral.apiKey\": \"MISTRAL_API_KEY\",\n  \"models.providers.groq.apiKey\": \"GROQ_API_KEY\",\n  \"models.providers.cerebras.apiKey\": \"CEREBRAS_API_KEY\",\n  \"models.providers.moonshot.apiKey\": \"MOONSHOT_API_KEY\",\n  \"models.providers.kimi-coding.apiKey\": \"KIMI_API_KEY\",\n  \"models.providers.volcengine.apiKey\": \"VOLCANO_ENGINE_API_KEY\",\n  \"models.providers.byteplus.apiKey\": \"BYTEPLUS_API_KEY\",\n  \"models.providers.synthetic.apiKey\": \"SYNTHETIC_API_KEY\",\n  \"models.providers.minimax.apiKey\": \"MINIMAX_API_KEY\",\n  \"models.providers.voyage.apiKey\": \"VOYAGE_API_KEY\",\n  \"models.providers.vllm.apiKey\": \"VLLM_API_KEY\",\n  \"tools.web.search.apiKey\": \"BRAVE_API_KEY\",\n  \"audio.apiKey\": \"ELEVENLABS_API_KEY\",\n  \"talk.apiKey\": \"ELEVENLABS_API_KEY\",\n  \"hooks.token\": null, // Dropped — normalized to WEBHOOK_TOKEN at deploy/import time\n  \"gateway.auth.token\": null, // Dropped — set at deploy time\n};\n\nconst isSensitiveKey = (key) => {\n  const str = String(key || \"\");\n  if (kSafeKeyExclusions.some((p) => p.test(str))) return false;\n  return kSecretKeyPatterns.some((p) => p.test(str));\n};\n\nconst matchesValuePrefix = (value) => {\n  const str = String(value || \"\");\n  for (const { prefix, label } of kValuePrefixes) {\n    if (str.startsWith(prefix)) return { matched: true, label };\n  }\n  return { matched: false };\n};\n\nconst isLikelyNonSecret = (value) => {\n  const str = String(value || \"\").trim();\n  if (str.length < 16) return true;\n  if (/^(true|false)$/i.test(str)) return true;\n  if (/^https?:\\/\\//.test(str) && !str.includes(\"token\") && !str.includes(\"key\")) return true;\n  if (/^[a-z0-9/-]+$/.test(str) && str.includes(\"/\")) return true;\n  return false;\n};\n\nconst maskValue = (value) => {\n  const str = String(value || \"\");\n  if (str.length <= 8) return \"****\";\n  return str.slice(0, 4) + \"****\" + str.slice(-4);\n};\n\nconst toEnvSegment = (value) =>\n  String(value || \"\")\n    .replace(/([a-z])([A-Z])/g, \"$1_$2\")\n    .toUpperCase()\n    .replace(/[^A-Z0-9_]/g, \"_\");\n\nconst getCanonicalEnvVarForConfigPath = (dotPath) => {\n  if (kConfigPathToEnvVar[dotPath] !== undefined) {\n    return kConfigPathToEnvVar[dotPath];\n  }\n  const providerPathMatch = String(dotPath || \"\").match(\n    /^models\\.providers\\.([^.]+)\\.([^.]+)$/,\n  );\n  if (providerPathMatch) {\n    const [, providerKey, fieldKey] = providerPathMatch;\n    return `${toEnvSegment(providerKey)}_${toEnvSegment(fieldKey)}`;\n  }\n  return \"\";\n};\n\nconst configPathToEnvName = (dotPath) => {\n  const canonicalName = getCanonicalEnvVarForConfigPath(dotPath);\n  if (canonicalName) return canonicalName;\n  const lastKey = dotPath.split(\".\").pop() || \"\";\n  return toEnvSegment(lastKey);\n};\n\nconst walkConfig = (obj, parentPath, results) => {\n  if (!obj || typeof obj !== \"object\") return;\n  for (const [key, value] of Object.entries(obj)) {\n    const dotPath = parentPath ? `${parentPath}.${key}` : key;\n\n    if (typeof value === \"string\" && value.trim()) {\n      const explicitEnvVar = kConfigPathToEnvVar[dotPath];\n      if (explicitEnvVar !== undefined) {\n        if (explicitEnvVar === null) continue;\n        if (!isAlreadyEnvRef(value)) {\n          results.push({\n            configPath: dotPath,\n            key,\n            value,\n            maskedValue: maskValue(value),\n            suggestedEnvVar: explicitEnvVar,\n            confidence: \"high\",\n            source: \"config-path\",\n          });\n        }\n        continue;\n      }\n\n      const prefixMatch = matchesValuePrefix(value);\n      if (prefixMatch.matched) {\n        if (!isAlreadyEnvRef(value)) {\n          results.push({\n            configPath: dotPath,\n            key,\n            value,\n            maskedValue: maskValue(value),\n            suggestedEnvVar: configPathToEnvName(dotPath),\n            confidence: \"high\",\n            source: \"value-prefix\",\n            prefixLabel: prefixMatch.label,\n          });\n        }\n        continue;\n      }\n\n      if (isSensitiveKey(key) && !isLikelyNonSecret(value)) {\n        if (!isAlreadyEnvRef(value)) {\n          results.push({\n            configPath: dotPath,\n            key,\n            value,\n            maskedValue: maskValue(value),\n            suggestedEnvVar: configPathToEnvName(dotPath),\n            confidence: \"medium\",\n            source: \"key-name\",\n          });\n        }\n      }\n    } else if (typeof value === \"object\" && value !== null) {\n      walkConfig(value, dotPath, results);\n    }\n  }\n};\n\nconst isAlreadyEnvRef = (value) =>\n  /^\\$\\{[A-Z_][A-Z0-9_]*\\}$/.test(String(value || \"\").trim());\n\nconst getEnvRefName = (value) => {\n  const match = String(value || \"\")\n    .trim()\n    .match(/^\\$\\{([A-Z_][A-Z0-9_]*)\\}$/);\n  return match?.[1] || \"\";\n};\n\nconst parseEnvFileSecrets = (content, fileName) => {\n  const results = [];\n  const lines = String(content || \"\").split(\"\\n\");\n  for (const line of lines) {\n    const trimmed = line.trim();\n    if (!trimmed || trimmed.startsWith(\"#\")) continue;\n    const eqIdx = trimmed.indexOf(\"=\");\n    if (eqIdx === -1) continue;\n    const key = trimmed.slice(0, eqIdx).trim();\n    const value = trimmed.slice(eqIdx + 1).trim();\n    if (!key || !value) continue;\n    results.push({\n      configPath: `${fileName}:${key}`,\n      key,\n      value,\n      maskedValue: maskValue(value),\n      suggestedEnvVar: key,\n      confidence: \"high\",\n      source: \"env-file\",\n      fileName,\n    });\n  }\n  return results;\n};\n\nconst detectSecrets = ({ fs, baseDir, configFiles = [], envFiles = [] }) => {\n  const secrets = [];\n  const seen = new Set();\n\n  for (const cfgFile of configFiles) {\n    try {\n      const fullPath = path.join(baseDir, cfgFile);\n      const raw = fs.readFileSync(fullPath, \"utf8\");\n      const cfg = JSON.parse(raw);\n      const configSecrets = [];\n      walkConfig(cfg, \"\", configSecrets);\n      for (const secret of configSecrets) {\n        const dedupeKey = `${secret.suggestedEnvVar}:${secret.value}`;\n        if (seen.has(dedupeKey)) continue;\n        seen.add(dedupeKey);\n        secrets.push({ ...secret, file: cfgFile });\n      }\n    } catch {}\n  }\n\n  for (const envFile of envFiles) {\n    try {\n      const fullPath = path.join(baseDir, envFile);\n      const content = fs.readFileSync(fullPath, \"utf8\");\n      const envSecrets = parseEnvFileSecrets(content, envFile);\n      for (const secret of envSecrets) {\n        const dedupeKey = `${secret.suggestedEnvVar}:${secret.value}`;\n        if (seen.has(dedupeKey)) {\n          const existing = secrets.find(\n            (s) => s.suggestedEnvVar === secret.suggestedEnvVar,\n          );\n          if (existing) {\n            existing.duplicateIn = envFile;\n          }\n          continue;\n        }\n        seen.add(dedupeKey);\n        secrets.push({ ...secret, file: envFile });\n      }\n    } catch {}\n  }\n\n  return secrets;\n};\n\nconst extractPreFillValues = ({ fs, baseDir, configFiles = [] }) => {\n  const preFill = {};\n  for (const cfgFile of configFiles) {\n    try {\n      const raw = fs.readFileSync(path.join(baseDir, cfgFile), \"utf8\");\n      const cfg = JSON.parse(raw);\n      const configFileName = path.basename(String(cfgFile || \"\")).toLowerCase();\n\n      if (cfg.models?.active) preFill.MODEL_KEY = cfg.models.active;\n\n      const providers = cfg.models?.providers || {};\n      if (providers.anthropic?.apiKey && !isAlreadyEnvRef(providers.anthropic.apiKey)) {\n        preFill.ANTHROPIC_API_KEY = providers.anthropic.apiKey;\n      }\n      if (providers.openai?.apiKey && !isAlreadyEnvRef(providers.openai.apiKey)) {\n        preFill.OPENAI_API_KEY = providers.openai.apiKey;\n      }\n      if (providers.google?.apiKey && !isAlreadyEnvRef(providers.google.apiKey)) {\n        preFill.GEMINI_API_KEY = providers.google.apiKey;\n      }\n\n      const channels =\n        cfg.channels && typeof cfg.channels === \"object\"\n          ? cfg.channels\n          : configFileName.includes(\"channel\")\n            ? cfg\n            : {};\n      if (channels.telegram?.botToken && !isAlreadyEnvRef(channels.telegram.botToken)) {\n        preFill.TELEGRAM_BOT_TOKEN = channels.telegram.botToken;\n      }\n      if (channels.discord?.token && !isAlreadyEnvRef(channels.discord.token)) {\n        preFill.DISCORD_BOT_TOKEN = channels.discord.token;\n      }\n      const whatsAppAllowFrom = Array.isArray(channels.whatsapp?.allowFrom)\n        ? channels.whatsapp.allowFrom\n        : [];\n      const whatsAppOwner = whatsAppAllowFrom.find(\n        (v) => v && !isAlreadyEnvRef(String(v)),\n      );\n      if (whatsAppOwner) {\n        preFill.WHATSAPP_OWNER_NUMBER = String(whatsAppOwner);\n      }\n\n      const braveKey = cfg.tools?.web?.search?.apiKey;\n      if (braveKey && !isAlreadyEnvRef(braveKey)) {\n        preFill.BRAVE_API_KEY = braveKey;\n      }\n    } catch {}\n  }\n  return preFill;\n};\n\nmodule.exports = {\n  configPathToEnvName,\n  detectSecrets,\n  extractPreFillValues,\n  getCanonicalEnvVarForConfigPath,\n  getEnvRefName,\n  isSensitiveKey,\n  isAlreadyEnvRef,\n  matchesValuePrefix,\n  maskValue,\n  parseEnvFileSecrets,\n};\n"
  },
  {
    "path": "lib/server/onboarding/index.js",
    "content": "const path = require(\"path\");\nconst { kSetupDir } = require(\"../constants\");\nconst {\n  resolveConfigIncludes,\n  resolveImportedConfigPaths,\n} = require(\"./import/import-config\");\nconst { validateOnboardingInput } = require(\"./validation\");\nconst {\n  ensureGithubRepoAccessible,\n  verifyGithubRepoForOnboarding,\n  cloneRepoToTemp,\n} = require(\"./github\");\nconst {\n  buildOnboardArgs,\n  writeManagedImportOpenclawConfig,\n  writeSanitizedOpenclawConfig,\n} = require(\"./openclaw\");\nconst {\n  ensureOpenclawRuntimeArtifacts,\n  syncBootstrapPromptFiles,\n} = require(\"./workspace\");\nconst {\n  installHourlyGitSyncScript,\n  installHourlyGitSyncCron,\n} = require(\"./cron\");\nconst { migrateManagedInternalFiles } = require(\"../internal-files-migration\");\nconst { installGogCliSkill } = require(\"../gog-skill\");\nconst { ensureManagedExecDefaults } = require(\"../exec-defaults-config\");\n\nconst kPlaceholderEnvValue = \"placeholder\";\nconst kEnvRefPattern = /\\$\\{([A-Z_][A-Z0-9_]*)\\}/g;\nconst kImportedPairingKeys = [\"allowFrom\", \"groupAllowFrom\"];\n\nconst upsertEnvVar = (items, key, value) => {\n  const normalizedKey = String(key || \"\").trim();\n  if (!normalizedKey) return items;\n  const normalizedValue = String(value || \"\");\n  const existing = items.find((entry) => entry.key === normalizedKey);\n  if (existing) {\n    existing.value = normalizedValue;\n    return items;\n  }\n  items.push({ key: normalizedKey, value: normalizedValue });\n  return items;\n};\n\nconst removeEnvVar = (items, key) => {\n  const normalizedKey = String(key || \"\").trim();\n  if (!normalizedKey) return items;\n  const idx = items.findIndex((entry) => entry.key === normalizedKey);\n  if (idx !== -1) items.splice(idx, 1);\n  return items;\n};\n\nconst applySubmittedEnvVars = (items, vars = []) => {\n  for (const entry of vars || []) {\n    const key = String(entry?.key || \"\").trim();\n    if (!key || key === \"GITHUB_WORKSPACE_REPO\") continue;\n    const value = String(entry?.value || \"\");\n    if (value) {\n      upsertEnvVar(items, key, value);\n    } else {\n      removeEnvVar(items, key);\n    }\n  }\n  return items;\n};\n\nconst pruneConflictingProviderAuthVars = (items, { selectedProvider, varMap }) => {\n  if (selectedProvider !== \"anthropic\") return items;\n  const hasAnthropicToken = !!String(varMap.ANTHROPIC_TOKEN || \"\").trim();\n  const hasAnthropicApiKey = !!String(varMap.ANTHROPIC_API_KEY || \"\").trim();\n  if (hasAnthropicToken && !hasAnthropicApiKey) {\n    removeEnvVar(items, \"ANTHROPIC_API_KEY\");\n  } else if (hasAnthropicApiKey && !hasAnthropicToken) {\n    removeEnvVar(items, \"ANTHROPIC_TOKEN\");\n  }\n  return items;\n};\n\nconst clearImportedChannelPairingState = (channelsRoot) => {\n  if (!channelsRoot || typeof channelsRoot !== \"object\") return false;\n  let changed = false;\n  for (const [channelKey, channelConfig] of Object.entries(channelsRoot)) {\n    if (!channelConfig || typeof channelConfig !== \"object\") continue;\n    if (\n      channelKey === \"telegram\" &&\n      Object.prototype.hasOwnProperty.call(channelConfig, \"accounts\")\n    ) {\n      delete channelConfig.accounts;\n      changed = true;\n    }\n    for (const pairingKey of kImportedPairingKeys) {\n      if (\n        Object.prototype.hasOwnProperty.call(channelConfig, pairingKey) &&\n        (!Array.isArray(channelConfig[pairingKey]) ||\n          channelConfig[pairingKey].length > 0)\n      ) {\n        channelConfig[pairingKey] = [];\n        changed = true;\n      }\n    }\n    if (\n      channelConfig.dmPolicy === \"allowlist\" &&\n      (!Array.isArray(channelConfig.allowFrom) ||\n        channelConfig.allowFrom.length === 0)\n    ) {\n      channelConfig.dmPolicy = \"pairing\";\n      changed = true;\n    }\n  }\n  return changed;\n};\n\nconst clearImportedCredentialPairings = ({ fs, openclawDir }) => {\n  const credentialsDir = path.join(openclawDir, \"credentials\");\n  if (!fs.existsSync(credentialsDir)) return;\n  let entries = [];\n  try {\n    entries = fs.readdirSync(credentialsDir);\n  } catch {\n    return;\n  }\n  for (const entry of entries) {\n    const fileName = typeof entry === \"string\" ? entry : entry?.name;\n    if (!fileName || !fileName.endsWith(\"-allowFrom.json\")) continue;\n    const filePath = path.join(credentialsDir, fileName);\n    try {\n      const parsed = JSON.parse(fs.readFileSync(filePath, \"utf8\"));\n      if (!parsed || typeof parsed !== \"object\") continue;\n      if (Array.isArray(parsed.allowFrom) && parsed.allowFrom.length === 0) {\n        continue;\n      }\n      parsed.allowFrom = [];\n      fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2));\n    } catch {}\n  }\n};\n\nconst collectEnvRefs = (value, found = new Set()) => {\n  if (typeof value === \"string\") {\n    for (const match of value.matchAll(kEnvRefPattern)) {\n      found.add(match[1]);\n    }\n    return found;\n  }\n  if (Array.isArray(value)) {\n    value.forEach((entry) => collectEnvRefs(entry, found));\n    return found;\n  }\n  if (value && typeof value === \"object\") {\n    Object.values(value).forEach((entry) => collectEnvRefs(entry, found));\n  }\n  return found;\n};\n\nconst getEnvVarValue = (items, key) =>\n  items.find((entry) => entry.key === key)?.value || \"\";\n\nconst syncApiKeyAuthProfilesFromEnvVars = (authProfiles, envVars = []) => {\n  if (!authProfiles?.getEnvVarForApiKeyProvider) return;\n  const providers = [\n    \"anthropic\",\n    \"openai\",\n    \"google\",\n    \"opencode\",\n    \"openrouter\",\n    \"zai\",\n    \"vercel-ai-gateway\",\n    \"kilocode\",\n    \"xai\",\n    \"mistral\",\n    \"cerebras\",\n    \"moonshot\",\n    \"kimi-coding\",\n    \"volcengine\",\n    \"byteplus\",\n    \"synthetic\",\n    \"minimax\",\n    \"voyage\",\n    \"groq\",\n    \"deepgram\",\n    \"vllm\",\n  ];\n  const envMap = new Map(\n    (envVars || []).map((entry) => [\n      String(entry?.key || \"\").trim(),\n      String(entry?.value || \"\"),\n    ]),\n  );\n  for (const provider of providers) {\n    const envKey = authProfiles.getEnvVarForApiKeyProvider(provider);\n    if (!envKey) continue;\n    const value = String(envMap.get(envKey) || \"\").trim();\n    if (!value || value === kPlaceholderEnvValue) continue;\n    authProfiles.upsertApiKeyProfileForEnvVar?.(provider, value);\n  }\n};\n\nconst buildPlaceholderReview = ({\n  referencedEnvVars,\n  envVars = [],\n  systemVars = new Set(),\n}) => {\n  const vars = Array.from(referencedEnvVars)\n    .filter((envKey) => !systemVars.has(envKey))\n    .sort()\n    .map((envKey) => {\n      const currentValue = String(getEnvVarValue(envVars, envKey) || \"\").trim();\n      const status =\n        currentValue === kPlaceholderEnvValue\n          ? \"placeholder\"\n          : currentValue\n            ? \"resolved\"\n            : \"missing\";\n      if (status === \"resolved\") return null;\n      return {\n        key: envKey,\n        status,\n      };\n    })\n    .filter(Boolean);\n  return {\n    found: vars.length > 0,\n    count: vars.length,\n    vars,\n  };\n};\n\nconst normalizeImportedConfig = ({ fs, openclawDir }) => {\n  const configPaths = resolveImportedConfigPaths({ fs, openclawDir });\n  for (const configPath of configPaths) {\n    let cfg = null;\n    try {\n      cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    } catch {\n      continue;\n    }\n    if (!cfg || typeof cfg !== \"object\") continue;\n    let changed = false;\n    const currentToken = String(cfg?.gateway?.auth?.token || \"\").trim();\n    const expectedTokenRef = \"${OPENCLAW_GATEWAY_TOKEN}\";\n    if (cfg.gateway?.auth && currentToken !== expectedTokenRef) {\n      cfg.gateway = {\n        ...(cfg.gateway || {}),\n        auth: {\n          ...(cfg.gateway.auth || {}),\n          token: expectedTokenRef,\n        },\n      };\n      changed = true;\n    }\n    const currentWebhookToken = String(cfg?.hooks?.token || \"\").trim();\n    const expectedWebhookTokenRef = \"${WEBHOOK_TOKEN}\";\n    if (cfg.hooks && currentWebhookToken !== expectedWebhookTokenRef) {\n      cfg.hooks = {\n        ...(cfg.hooks || {}),\n        token: expectedWebhookTokenRef,\n      };\n      changed = true;\n    }\n    if (\n      cfg.hooks &&\n      Object.prototype.hasOwnProperty.call(cfg.hooks, \"transformsDir\")\n    ) {\n      const { transformsDir, ...nextHooks } = cfg.hooks;\n      void transformsDir;\n      cfg.hooks = nextHooks;\n      changed = true;\n    }\n    const configFileName = path.basename(configPath).toLowerCase();\n    const channelsRoot =\n      cfg.channels && typeof cfg.channels === \"object\"\n        ? cfg.channels\n        : configFileName.includes(\"channel\")\n          ? cfg\n          : null;\n    changed = clearImportedChannelPairingState(channelsRoot) || changed;\n    if (changed) {\n      fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));\n    }\n  }\n  clearImportedCredentialPairings({ fs, openclawDir });\n};\n\nconst getImportedConfigEnvRefs = ({ fs, openclawDir }) => {\n  const refs = new Set();\n  const configPaths = resolveImportedConfigPaths({ fs, openclawDir });\n  for (const configPath of configPaths) {\n    try {\n      const raw = fs.readFileSync(configPath, \"utf8\");\n      collectEnvRefs(JSON.parse(raw), refs);\n    } catch {}\n  }\n  return refs;\n};\n\nconst getImportedPlaceholderReview = ({\n  fs,\n  openclawDir,\n  envVars = [],\n  systemVars = new Set(),\n  normalizeConfig = false,\n}) => {\n  if (normalizeConfig) {\n    normalizeImportedConfig({ fs, openclawDir });\n  }\n  const referencedEnvVars = getImportedConfigEnvRefs({ fs, openclawDir });\n  return buildPlaceholderReview({\n    referencedEnvVars,\n    envVars,\n    systemVars,\n  });\n};\n\nconst createOnboardingService = ({\n  fs,\n  constants,\n  shellCmd,\n  gatewayEnv,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  resolveGithubRepoUrl,\n  resolveModelProvider,\n  hasCodexOauthProfile,\n  authProfiles,\n  ensureGatewayProxyConfig,\n  getBaseUrl,\n  startGateway,\n}) => {\n  const { OPENCLAW_DIR, WORKSPACE_DIR, kOnboardingMarkerPath } = constants;\n\n  const verifyGithubSetup = async ({\n    githubRepoInput,\n    githubToken,\n    mode = \"new\",\n    resolveGithubRepoUrl,\n  }) => {\n    const repoUrl = resolveGithubRepoUrl(githubRepoInput);\n    const verification = await verifyGithubRepoForOnboarding({\n      repoUrl,\n      githubToken,\n      mode,\n    });\n    if (!verification.ok) return verification;\n\n    if (\n      mode === \"existing\" &&\n      verification.repoExists &&\n      !verification.repoIsEmpty\n    ) {\n      const cloneResult = await cloneRepoToTemp({\n        repoUrl,\n        githubToken,\n        shellCmd,\n      });\n      if (!cloneResult.ok) {\n        return { ok: false, status: 400, error: cloneResult.error };\n      }\n      return { ...verification, tempDir: cloneResult.tempDir };\n    }\n\n    return verification;\n  };\n\n  const completeOnboarding = async ({\n    req,\n    vars,\n    modelKey,\n    importMode = false,\n  }) => {\n    const validation = validateOnboardingInput({\n      vars,\n      modelKey,\n      resolveModelProvider,\n      hasCodexOauthProfile,\n    });\n    if (!validation.ok) {\n      return {\n        status: validation.status,\n        body: { ok: false, error: validation.error },\n      };\n    }\n\n    const {\n      varMap,\n      githubToken,\n      githubRepoInput,\n      selectedProvider,\n      hasCodexOauth,\n    } = validation.data;\n\n    const repoUrl = resolveGithubRepoUrl(githubRepoInput);\n    const remoteUrl = `https://github.com/${repoUrl}.git`;\n    const existingConfigPresent =\n      importMode && fs.existsSync(`${OPENCLAW_DIR}/openclaw.json`);\n    const existingEnvVars =\n      typeof readEnvFile === \"function\" ? readEnvFile() : [];\n    const varsToSave = [...existingEnvVars];\n    applySubmittedEnvVars(varsToSave, vars);\n    upsertEnvVar(varsToSave, \"GITHUB_WORKSPACE_REPO\", repoUrl);\n    pruneConflictingProviderAuthVars(varsToSave, {\n      selectedProvider,\n      varMap,\n    });\n    if (importMode && existingConfigPresent) {\n      const systemVars =\n        constants.kSystemVars instanceof Set\n          ? constants.kSystemVars\n          : new Set();\n      const placeholderReview = getImportedPlaceholderReview({\n        fs,\n        openclawDir: OPENCLAW_DIR,\n        envVars: varsToSave,\n        systemVars,\n        normalizeConfig: true,\n      });\n      for (const placeholderVar of placeholderReview.vars) {\n        upsertEnvVar(varsToSave, placeholderVar.key, kPlaceholderEnvValue);\n      }\n    }\n    writeEnvFile(varsToSave);\n    reloadEnv();\n    syncApiKeyAuthProfilesFromEnvVars(authProfiles, varsToSave);\n\n    const [, repoName] = repoUrl.split(\"/\");\n    const repoCheck = await ensureGithubRepoAccessible({\n      repoUrl,\n      repoName,\n      githubToken,\n    });\n    if (!repoCheck.ok) {\n      return {\n        status: repoCheck.status,\n        body: { ok: false, error: repoCheck.error },\n      };\n    }\n\n    fs.mkdirSync(OPENCLAW_DIR, { recursive: true });\n    fs.mkdirSync(WORKSPACE_DIR, { recursive: true });\n    migrateManagedInternalFiles({\n      fs,\n      openclawDir: OPENCLAW_DIR,\n    });\n    syncBootstrapPromptFiles({\n      fs,\n      workspaceDir: WORKSPACE_DIR,\n      baseUrl: getBaseUrl(req),\n    });\n    ensureOpenclawRuntimeArtifacts({\n      fs,\n      openclawDir: OPENCLAW_DIR,\n    });\n\n    const hadImportedGit = importMode && fs.existsSync(`${OPENCLAW_DIR}/.git`);\n    if (hadImportedGit) {\n      try {\n        fs.rmSync(`${OPENCLAW_DIR}/.git`, { recursive: true, force: true });\n      } catch {}\n    }\n\n    if (hadImportedGit || !fs.existsSync(`${OPENCLAW_DIR}/.git`)) {\n      await shellCmd(\n        `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\"`,\n      );\n      console.log(\"[onboard] Git initialized\");\n    } else if (importMode) {\n      // Ensure remote points to the correct URL for imported repos\n      try {\n        await shellCmd(\n          `cd ${OPENCLAW_DIR} && git remote set-url origin \"${remoteUrl}\" && git config user.email \"agent@alphaclaw.md\" && git config user.name \"AlphaClaw Agent\"`,\n        );\n      } catch {}\n    }\n\n    if (!fs.existsSync(`${OPENCLAW_DIR}/.gitignore`)) {\n      fs.copyFileSync(\n        path.join(kSetupDir, \"gitignore\"),\n        `${OPENCLAW_DIR}/.gitignore`,\n      );\n    }\n\n    if (!existingConfigPresent) {\n      const onboardArgs = buildOnboardArgs({\n        varMap,\n        selectedProvider,\n        hasCodexOauth,\n        workspaceDir: WORKSPACE_DIR,\n      });\n      await shellCmd(\n        `openclaw onboard ${onboardArgs.map((a) => `\"${a}\"`).join(\" \")}`,\n        {\n          env: gatewayEnv(),\n          timeout: 120000,\n        },\n      );\n      console.log(\"[onboard] Onboard complete\");\n    } else {\n      console.log(\n        \"[onboard] Skipped openclaw onboard (existing config present)\",\n      );\n    }\n\n    await shellCmd(`openclaw models set \"${modelKey}\"`, {\n      env: gatewayEnv(),\n      timeout: 30000,\n    }).catch((e) => {\n      console.error(\"[onboard] Failed to set model:\", e.message);\n      throw new Error(\n        `Onboarding completed but failed to set model \"${modelKey}\"`,\n      );\n    });\n\n    try {\n      fs.rmSync(`${WORKSPACE_DIR}/.git`, { recursive: true, force: true });\n    } catch {}\n\n    if (!existingConfigPresent) {\n      writeSanitizedOpenclawConfig({ fs, openclawDir: OPENCLAW_DIR, varMap });\n    } else if (importMode) {\n      writeManagedImportOpenclawConfig({\n        fs,\n        openclawDir: OPENCLAW_DIR,\n        varMap,\n      });\n    }\n    authProfiles?.syncConfigAuthReferencesForAgent?.();\n    ensureManagedExecDefaults({\n      fsModule: fs,\n      openclawDir: OPENCLAW_DIR,\n    });\n\n    installGogCliSkill({ fs, openclawDir: OPENCLAW_DIR });\n\n    installHourlyGitSyncScript({ fs, openclawDir: OPENCLAW_DIR });\n    await installHourlyGitSyncCron({ fs, openclawDir: OPENCLAW_DIR });\n    fs.mkdirSync(path.dirname(kOnboardingMarkerPath), { recursive: true });\n    fs.writeFileSync(\n      kOnboardingMarkerPath,\n      JSON.stringify(\n        {\n          onboarded: true,\n          reason: importMode ? \"import_complete\" : \"onboarding_complete\",\n          markedAt: new Date().toISOString(),\n        },\n        null,\n        2,\n      ),\n    );\n\n    ensureGatewayProxyConfig(getBaseUrl(req));\n\n    try {\n      const commitMsg = importMode\n        ? \"imported existing setup via AlphaClaw\"\n        : \"initial setup\";\n      await shellCmd(`alphaclaw git-sync -m \"${commitMsg}\"`, {\n        timeout: 30000,\n        env: {\n          ...process.env,\n          GITHUB_TOKEN: githubToken,\n        },\n      });\n      console.log(\"[onboard] Initial state committed and pushed\");\n    } catch (e) {\n      console.error(\"[onboard] Git push error:\", e.message);\n    }\n\n    startGateway();\n    return { status: 200, body: { ok: true } };\n  };\n\n  return { completeOnboarding, verifyGithubSetup };\n};\n\nmodule.exports = {\n  createOnboardingService,\n  getImportedPlaceholderReview,\n};\n"
  },
  {
    "path": "lib/server/onboarding/openclaw.js",
    "content": "const { buildSecretReplacements } = require(\"../helpers\");\nconst {\n  ensurePluginsShell,\n  ensurePluginAllowed,\n  ensureUsageTrackerPluginEntry,\n} = require(\"../usage-tracker-config\");\n\nconst kDefaultToolsProfile = \"full\";\nconst kBootstrapExtraFiles = [\n  \"hooks/bootstrap/AGENTS.md\",\n  \"hooks/bootstrap/TOOLS.md\",\n];\n\nconst buildOnboardArgs = ({\n  varMap,\n  selectedProvider,\n  hasCodexOauth,\n  workspaceDir,\n}) => {\n  const openclawGatewayToken =\n    varMap.OPENCLAW_GATEWAY_TOKEN || process.env.OPENCLAW_GATEWAY_TOKEN || \"\";\n  const anthropicToken = varMap.ANTHROPIC_TOKEN || \"\";\n  const anthropicApiKey = varMap.ANTHROPIC_API_KEY || \"\";\n  const openaiApiKey = varMap.OPENAI_API_KEY || \"\";\n  const geminiApiKey = varMap.GEMINI_API_KEY || \"\";\n  const onboardArgs = [\n    \"--non-interactive\",\n    \"--accept-risk\",\n    \"--flow\",\n    \"quickstart\",\n    \"--gateway-bind\",\n    \"loopback\",\n    \"--gateway-port\",\n    \"18789\",\n    \"--gateway-auth\",\n    \"token\",\n    \"--gateway-token\",\n    openclawGatewayToken,\n    \"--no-install-daemon\",\n    \"--skip-health\",\n    \"--workspace\",\n    workspaceDir,\n  ];\n\n  if (\n    selectedProvider === \"openai-codex\" &&\n    openaiApiKey\n  ) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"openai-api-key\",\n      \"--openai-api-key\",\n      openaiApiKey,\n    );\n  } else if (selectedProvider === \"openai-codex\" && hasCodexOauth) {\n    onboardArgs.push(\"--auth-choice\", \"skip\");\n  } else if (\n    (selectedProvider === \"anthropic\" || !selectedProvider) &&\n    anthropicToken\n  ) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"token\",\n      \"--token-provider\",\n      \"anthropic\",\n      \"--token\",\n      anthropicToken,\n    );\n  } else if (\n    (selectedProvider === \"anthropic\" || !selectedProvider) &&\n    anthropicApiKey\n  ) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"apiKey\",\n      \"--anthropic-api-key\",\n      anthropicApiKey,\n    );\n  } else if (\n    (selectedProvider === \"openai\" || !selectedProvider) &&\n    openaiApiKey\n  ) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"openai-api-key\",\n      \"--openai-api-key\",\n      openaiApiKey,\n    );\n  } else if (\n    (selectedProvider === \"google\" || !selectedProvider) &&\n    geminiApiKey\n  ) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"gemini-api-key\",\n      \"--gemini-api-key\",\n      geminiApiKey,\n    );\n  } else if (anthropicToken) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"token\",\n      \"--token-provider\",\n      \"anthropic\",\n      \"--token\",\n      anthropicToken,\n    );\n  } else if (anthropicApiKey) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"apiKey\",\n      \"--anthropic-api-key\",\n      anthropicApiKey,\n    );\n  } else if (openaiApiKey) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"openai-api-key\",\n      \"--openai-api-key\",\n      openaiApiKey,\n    );\n  } else if (geminiApiKey) {\n    onboardArgs.push(\n      \"--auth-choice\",\n      \"gemini-api-key\",\n      \"--gemini-api-key\",\n      geminiApiKey,\n    );\n  } else if (hasCodexOauth) {\n    onboardArgs.push(\"--auth-choice\", \"skip\");\n  }\n\n  return onboardArgs;\n};\n\nconst ensureManagedConfigShell = (cfg) => {\n  if (!cfg.channels) cfg.channels = {};\n  ensurePluginsShell(cfg);\n  if (!cfg.commands) cfg.commands = {};\n  if (!cfg.tools) cfg.tools = {};\n  if (!cfg.hooks) cfg.hooks = {};\n  if (!cfg.hooks.internal) cfg.hooks.internal = {};\n  if (!cfg.hooks.internal.entries) cfg.hooks.internal.entries = {};\n  cfg.commands.restart = true;\n  cfg.tools.profile = kDefaultToolsProfile;\n  cfg.hooks.internal.enabled = true;\n  cfg.hooks.internal.entries[\"bootstrap-extra-files\"] = {\n    ...(cfg.hooks.internal.entries[\"bootstrap-extra-files\"] || {}),\n    enabled: true,\n    paths: kBootstrapExtraFiles,\n  };\n};\n\nconst getSafeImportedDmPolicy = (channelConfig = {}) => {\n  if (\n    channelConfig?.dmPolicy === \"allowlist\" &&\n    (!Array.isArray(channelConfig?.allowFrom) ||\n      channelConfig.allowFrom.length === 0)\n  ) {\n    return \"pairing\";\n  }\n  return channelConfig?.dmPolicy || \"pairing\";\n};\n\nconst applyFreshOnboardingChannels = ({\n  cfg,\n  varMap,\n}) => {\n  if (varMap.TELEGRAM_BOT_TOKEN) {\n    cfg.channels.telegram = {\n      enabled: true,\n      botToken: varMap.TELEGRAM_BOT_TOKEN,\n      dmPolicy: \"pairing\",\n      groupPolicy: \"allowlist\",\n    };\n    cfg.plugins.entries.telegram = { enabled: true };\n    ensurePluginAllowed({ cfg, pluginKey: \"telegram\" });\n    console.log(\"[onboard] Telegram configured\");\n  }\n  if (varMap.DISCORD_BOT_TOKEN) {\n    cfg.channels.discord = {\n      enabled: true,\n      token: varMap.DISCORD_BOT_TOKEN,\n      dmPolicy: \"pairing\",\n      groupPolicy: \"allowlist\",\n    };\n    cfg.plugins.entries.discord = { enabled: true };\n    ensurePluginAllowed({ cfg, pluginKey: \"discord\" });\n    console.log(\"[onboard] Discord configured\");\n  }\n  if (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) {\n    cfg.channels.slack = {\n      enabled: true,\n      botToken: varMap.SLACK_BOT_TOKEN,\n      appToken: varMap.SLACK_APP_TOKEN,\n      mode: \"socket\",\n      dmPolicy: \"pairing\",\n      groupPolicy: \"open\",\n    };\n    cfg.plugins.entries.slack = { enabled: true };\n    ensurePluginAllowed({ cfg, pluginKey: \"slack\" });\n    console.log(\"[onboard] Slack configured\");\n  }\n  if (varMap.WHATSAPP_OWNER_NUMBER) {\n    cfg.channels.whatsapp = {\n      enabled: true,\n      allowFrom: [varMap.WHATSAPP_OWNER_NUMBER],\n      groupAllowFrom: [varMap.WHATSAPP_OWNER_NUMBER],\n      dmPolicy: \"allowlist\",\n      groupPolicy: \"allowlist\",\n      selfChatMode: true,\n    };\n    cfg.plugins.entries.whatsapp = { enabled: true };\n    ensurePluginAllowed({ cfg, pluginKey: \"whatsapp\" });\n    console.log(\"[onboard] WhatsApp configured\");\n  }\n  ensureUsageTrackerPluginEntry(cfg);\n};\n\nconst writeSanitizedOpenclawConfig = ({\n  fs,\n  openclawDir,\n  varMap,\n}) => {\n  const configPath = `${openclawDir}/openclaw.json`;\n  const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n  ensureManagedConfigShell(cfg);\n  applyFreshOnboardingChannels({\n    cfg,\n    varMap,\n  });\n\n  let content = JSON.stringify(cfg, null, 2);\n  const replacements = buildSecretReplacements(varMap, process.env);\n  for (const [secret, envRef] of replacements) {\n    if (secret) {\n      // Only replace exact JSON string values so path substrings are never mutated.\n      const secretJson = JSON.stringify(secret);\n      content = content.replace(\n        new RegExp(\n          secretJson.replace(/[-\\/\\\\^$*+?.()|[\\]{}]/g, \"\\\\$&\"),\n          \"g\",\n        ),\n        JSON.stringify(envRef),\n      );\n    }\n  }\n  fs.writeFileSync(configPath, content);\n  console.log(\"[onboard] Config sanitized\");\n};\n\nconst writeManagedImportOpenclawConfig = ({\n  fs,\n  openclawDir,\n  varMap,\n}) => {\n  const configPath = `${openclawDir}/openclaw.json`;\n  const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n  ensureManagedConfigShell(cfg);\n\n  ensureUsageTrackerPluginEntry(cfg);\n\n  if (varMap.TELEGRAM_BOT_TOKEN) {\n    cfg.channels.telegram = {\n      ...(cfg.channels.telegram || {}),\n      enabled: true,\n      botToken: \"${TELEGRAM_BOT_TOKEN}\",\n      dmPolicy: getSafeImportedDmPolicy(cfg.channels.telegram),\n      groupPolicy: cfg.channels.telegram?.groupPolicy || \"allowlist\",\n    };\n    cfg.plugins.entries.telegram = {\n      ...(cfg.plugins.entries.telegram || {}),\n      enabled: true,\n    };\n    ensurePluginAllowed({ cfg, pluginKey: \"telegram\" });\n  }\n\n  if (varMap.DISCORD_BOT_TOKEN) {\n    cfg.channels.discord = {\n      ...(cfg.channels.discord || {}),\n      enabled: true,\n      token: \"${DISCORD_BOT_TOKEN}\",\n      dmPolicy: getSafeImportedDmPolicy(cfg.channels.discord),\n      groupPolicy: cfg.channels.discord?.groupPolicy || \"allowlist\",\n    };\n    cfg.plugins.entries.discord = {\n      ...(cfg.plugins.entries.discord || {}),\n      enabled: true,\n    };\n    ensurePluginAllowed({ cfg, pluginKey: \"discord\" });\n  }\n\n  if (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) {\n    cfg.channels.slack = {\n      ...(cfg.channels.slack || {}),\n      enabled: true,\n      botToken: \"${SLACK_BOT_TOKEN}\",\n      appToken: \"${SLACK_APP_TOKEN}\",\n      mode: cfg.channels.slack?.mode || \"socket\",\n      dmPolicy: getSafeImportedDmPolicy(cfg.channels.slack),\n      groupPolicy: cfg.channels.slack?.groupPolicy || \"open\",\n    };\n    cfg.plugins.entries.slack = {\n      ...(cfg.plugins.entries.slack || {}),\n      enabled: true,\n    };\n    ensurePluginAllowed({ cfg, pluginKey: \"slack\" });\n  }\n\n  if (varMap.WHATSAPP_OWNER_NUMBER) {\n    const existingWhatsApp = cfg.channels.whatsapp || {};\n    const existingAllowFrom = Array.isArray(existingWhatsApp.allowFrom)\n      ? existingWhatsApp.allowFrom\n      : [];\n    const ownerRef = \"${WHATSAPP_OWNER_NUMBER}\";\n    cfg.channels.whatsapp = {\n      ...existingWhatsApp,\n      enabled: true,\n      allowFrom: existingAllowFrom.includes(ownerRef)\n        ? existingAllowFrom\n        : [...existingAllowFrom, ownerRef],\n      groupAllowFrom: existingAllowFrom.includes(ownerRef)\n        ? existingAllowFrom\n        : [...existingAllowFrom, ownerRef],\n      dmPolicy: \"allowlist\",\n      groupPolicy: \"allowlist\",\n      selfChatMode: true,\n    };\n    cfg.plugins.entries.whatsapp = {\n      ...(cfg.plugins.entries.whatsapp || {}),\n      enabled: true,\n    };\n    ensurePluginAllowed({ cfg, pluginKey: \"whatsapp\" });\n  }\n\n  fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));\n};\n\nmodule.exports = {\n  buildOnboardArgs,\n  writeManagedImportOpenclawConfig,\n  writeSanitizedOpenclawConfig,\n};\n"
  },
  {
    "path": "lib/server/onboarding/validation.js",
    "content": "const { getEnvVarForApiKeyProvider } = require(\"../auth-profiles\");\n\nconst kAnthropicSetupTokenPrefix = \"sk-ant-oat01-\";\nconst kAnthropicApiKeyPrefix = \"sk-ant-api\";\n\nconst validateAnthropicCredentialShape = (varMap) => {\n  const anthropicToken = String(varMap.ANTHROPIC_TOKEN || \"\").trim();\n  const anthropicApiKey = String(varMap.ANTHROPIC_API_KEY || \"\").trim();\n  if (\n    anthropicToken &&\n    !anthropicToken.startsWith(kAnthropicSetupTokenPrefix)\n  ) {\n    return {\n      ok: false,\n      status: 400,\n      error: `ANTHROPIC_TOKEN must start with ${kAnthropicSetupTokenPrefix}`,\n    };\n  }\n  if (anthropicApiKey && !anthropicApiKey.startsWith(kAnthropicApiKeyPrefix)) {\n    return {\n      ok: false,\n      status: 400,\n      error: `ANTHROPIC_API_KEY must start with ${kAnthropicApiKeyPrefix}`,\n    };\n  }\n  return { ok: true };\n};\n\nconst validateOnboardingInput = ({ vars, modelKey, resolveModelProvider, hasCodexOauthProfile }) => {\n  const kMaxOnboardingVars = 64;\n  const kMaxEnvKeyLength = 128;\n  const kMaxEnvValueLength = 4096;\n  if (!Array.isArray(vars)) {\n    return { ok: false, status: 400, error: \"Missing vars array\" };\n  }\n  if (vars.length > kMaxOnboardingVars) {\n    return {\n      ok: false,\n      status: 400,\n      error: `Too many environment variables (max ${kMaxOnboardingVars})`,\n    };\n  }\n  if (!modelKey || typeof modelKey !== \"string\" || !modelKey.includes(\"/\")) {\n    return { ok: false, status: 400, error: \"A model selection is required\" };\n  }\n\n  for (const entry of vars) {\n    const key = String(entry?.key || \"\");\n    const value = String(entry?.value || \"\");\n    if (!key) {\n      return { ok: false, status: 400, error: \"Each variable must include a key\" };\n    }\n    if (key.length > kMaxEnvKeyLength) {\n      return {\n        ok: false,\n        status: 400,\n        error: `Variable key is too long: ${key.slice(0, 32)}...`,\n      };\n    }\n    if (value.length > kMaxEnvValueLength) {\n      return {\n        ok: false,\n        status: 400,\n        error: `Value too long for ${key} (max ${kMaxEnvValueLength} chars)`,\n      };\n    }\n  }\n\n  const varMap = Object.fromEntries(vars.map((v) => [v.key, v.value]));\n  const anthropicValidation = validateAnthropicCredentialShape(varMap);\n  if (!anthropicValidation.ok) return anthropicValidation;\n  const githubToken = String(varMap.GITHUB_TOKEN || \"\");\n  const githubRepoInput = String(varMap.GITHUB_WORKSPACE_REPO || \"\").trim();\n  const selectedProvider = resolveModelProvider(modelKey);\n  const hasCodexOauth = hasCodexOauthProfile();\n  const hasAnyAi = !!(\n    varMap.ANTHROPIC_API_KEY ||\n    varMap.ANTHROPIC_TOKEN ||\n    varMap.OPENAI_API_KEY ||\n    varMap.GEMINI_API_KEY ||\n    hasCodexOauth\n  );\n  const hasAi = (() => {\n    if (selectedProvider === \"openai-codex\") {\n      return hasCodexOauth;\n    }\n    if (selectedProvider === \"anthropic\") {\n      return !!(varMap.ANTHROPIC_API_KEY || varMap.ANTHROPIC_TOKEN);\n    }\n    const envKey = getEnvVarForApiKeyProvider(selectedProvider);\n    if (envKey) {\n      return !!String(varMap[envKey] || \"\").trim();\n    }\n    return hasAnyAi;\n  })();\n  const hasGithub = !!(githubToken && githubRepoInput);\n  const hasChannel = !!(varMap.TELEGRAM_BOT_TOKEN || varMap.DISCORD_BOT_TOKEN || (varMap.SLACK_BOT_TOKEN && varMap.SLACK_APP_TOKEN) || varMap.WHATSAPP_OWNER_NUMBER);\n\n  if (!hasAi) {\n    if (selectedProvider === \"openai-codex\") {\n      return {\n        ok: false,\n        status: 400,\n        error: \"Connect OpenAI Codex OAuth before continuing\",\n      };\n    }\n    return {\n      ok: false,\n      status: 400,\n      error: `Missing credentials for selected provider \"${selectedProvider}\"`,\n    };\n  }\n  if (!hasGithub) {\n    return {\n      ok: false,\n      status: 400,\n      error: \"GitHub token and workspace repo are required\",\n    };\n  }\n  if (!hasChannel) {\n    return {\n      ok: false,\n      status: 400,\n      error: \"At least one channel token is required\",\n    };\n  }\n\n  return {\n    ok: true,\n    data: {\n      varMap,\n      githubToken,\n      githubRepoInput,\n      selectedProvider,\n      hasCodexOauth,\n    },\n  };\n};\n\nmodule.exports = { validateOnboardingInput };\n"
  },
  {
    "path": "lib/server/onboarding/workspace.js",
    "content": "const path = require(\"path\");\nconst { execSync } = require(\"child_process\");\nconst {\n  kSetupDir,\n  OPENCLAW_DIR,\n  ENV_FILE_PATH,\n} = require(\"../constants\");\nconst { renderTopicRegistryMarkdown } = require(\"../topic-registry\");\nconst { readGoogleState } = require(\"../google-state\");\n\nconst resolveSetupUiUrl = (baseUrl) => {\n  const normalizedBaseUrl = String(baseUrl || \"\").trim().replace(/\\/+$/, \"\");\n  if (normalizedBaseUrl) return normalizedBaseUrl;\n\n  const railwayPublicDomain = String(process.env.RAILWAY_PUBLIC_DOMAIN || \"\").trim();\n  if (railwayPublicDomain) {\n    return `https://${railwayPublicDomain}`;\n  }\n\n  const railwayStaticUrl = String(process.env.RAILWAY_STATIC_URL || \"\").trim().replace(\n    /\\/+$/,\n    \"\",\n  );\n  if (railwayStaticUrl) return railwayStaticUrl;\n\n  return \"http://localhost:3000\";\n};\n\n// Single assembly point for TOOLS.md: template + topic registry.\n// Idempotent — always rebuilds from source so deploys never clobber topic data.\nconst isTelegramWorkspaceEnabled = (fs) => {\n  try {\n    const configPath = `${OPENCLAW_DIR}/openclaw.json`;\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const telegramConfig = cfg.channels?.telegram || {};\n    const topLevelGroupCount = Object.keys(telegramConfig.groups || {}).length;\n    if (topLevelGroupCount > 0) return true;\n    const accounts =\n      telegramConfig.accounts && typeof telegramConfig.accounts === \"object\"\n        ? telegramConfig.accounts\n        : {};\n    for (const accountConfig of Object.values(accounts)) {\n      if (Object.keys(accountConfig?.groups || {}).length > 0) return true;\n    }\n    return false;\n  } catch {\n    return false;\n  }\n};\n\nconst renderGoogleAccountsMarkdown = (fs) => {\n  try {\n    const googleStatePath = `${OPENCLAW_DIR}/gogcli/state.json`;\n    const state = readGoogleState({ fs, statePath: googleStatePath });\n    const accounts = Array.isArray(state.accounts) ? state.accounts : [];\n    let section = \"\\n\\n## Available Google Accounts\\n\\n\";\n    if (!accounts.length) {\n      section += \"No Google accounts are currently configured.\\n\";\n      return section;\n    }\n    section +=\n      \"Configured in AlphaClaw (use `--client <client> --account <email>` for gog commands):\\n\\n\";\n    section += accounts\n      .map((account) => {\n        const email = String(account.email || \"\").trim() || \"(unknown email)\";\n        const client = String(account.client || \"default\").trim() || \"default\";\n        const personal = account.personal ? \"personal\" : \"company\";\n        const auth = account.authenticated ? \"authenticated\" : \"awaiting sign-in\";\n        const services = Array.isArray(account.services) ? account.services.join(\", \") : \"\";\n        const metaParts = [\n          `type: ${personal}`,\n          `client: ${client}`,\n          `status: ${auth}`,\n          services ? `services: ${services}` : null,\n        ].filter(Boolean);\n        return `- ${email} (${metaParts.join(\"; \")})`;\n      })\n      .join(\"\\n\");\n    section += \"\\n\";\n    return section;\n  } catch {\n    return \"\";\n  }\n};\n\nconst resolveAllAgentWorkspaces = (fs) => {\n  try {\n    const configPath = path.join(OPENCLAW_DIR, \"openclaw.json\");\n    const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const list = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];\n    return list\n      .map((entry) => {\n        const agentId = String(entry.id || \"\").trim();\n        const workspace = String(entry.workspace || \"\").trim();\n        if (!agentId || !workspace) return null;\n        return {\n          agentId,\n          workspace,\n        };\n      })\n      .filter(Boolean);\n  } catch {\n    return [];\n  }\n};\n\nconst syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => {\n  try {\n    const setupUiUrl = resolveSetupUiUrl(baseUrl);\n\n    const toolsTemplate = fs.readFileSync(\n      path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\"),\n      \"utf8\",\n    );\n    const includeSyncGuidance = isTelegramWorkspaceEnabled(fs);\n    const googleAccountsSection = renderGoogleAccountsMarkdown(fs);\n    const buildToolsContent = ({ agentId = \"\" } = {}) => {\n      let toolsContent = toolsTemplate.replace(/\\{\\{SETUP_UI_URL\\}\\}/g, setupUiUrl);\n      const topicSection = renderTopicRegistryMarkdown({\n        includeSyncGuidance,\n        agentId,\n      });\n      if (topicSection) {\n        toolsContent += topicSection;\n      }\n      if (googleAccountsSection) {\n        toolsContent += googleAccountsSection;\n      }\n      return toolsContent;\n    };\n\n    const agentsSourcePath = path.join(kSetupDir, \"core-prompts\", \"AGENTS.md\");\n\n    const writeToWorkspace = (targetDir, toolsContent) => {\n      const bootstrapDir = path.join(targetDir, \"hooks\", \"bootstrap\");\n      fs.mkdirSync(bootstrapDir, { recursive: true });\n      fs.copyFileSync(agentsSourcePath, path.join(bootstrapDir, \"AGENTS.md\"));\n      fs.writeFileSync(path.join(bootstrapDir, \"TOOLS.md\"), toolsContent);\n    };\n\n    writeToWorkspace(workspaceDir, buildToolsContent());\n\n    const otherWorkspaces = resolveAllAgentWorkspaces(fs).filter(\n      (entry) => path.resolve(entry.workspace) !== path.resolve(workspaceDir),\n    );\n    for (const entry of otherWorkspaces) {\n      try {\n        writeToWorkspace(\n          entry.workspace,\n          buildToolsContent({ agentId: entry.agentId }),\n        );\n      } catch (e) {\n        console.error(\n          `[onboard] Bootstrap sync skipped for ${entry.workspace}: ${e.message}`,\n        );\n      }\n    }\n\n    console.log(\"[onboard] Bootstrap prompt files synced\");\n  } catch (e) {\n    console.error(\"[onboard] Bootstrap prompt sync error:\", e.message);\n  }\n};\n\nconst ensureOpenclawRuntimeArtifacts = ({\n  fs,\n  openclawDir,\n  envFilePath = ENV_FILE_PATH,\n}) => {\n  try {\n    const openclawEnvLink = path.join(openclawDir, \".env\");\n    if (!fs.existsSync(openclawEnvLink) && fs.existsSync(envFilePath)) {\n      fs.symlinkSync(envFilePath, openclawEnvLink);\n      console.log(`[alphaclaw] Symlinked ${openclawEnvLink} -> ${envFilePath}`);\n    }\n  } catch (e) {\n    console.log(`[alphaclaw] .env symlink skipped: ${e.message}`);\n  }\n\n  const gogConfigFile = path.join(openclawDir, \"gogcli\", \"config.json\");\n  if (!fs.existsSync(gogConfigFile)) {\n    fs.mkdirSync(path.join(openclawDir, \"gogcli\"), { recursive: true });\n    try {\n      execSync(\"gog auth keyring file\", { stdio: \"ignore\" });\n      console.log(\"[alphaclaw] gog keyring configured (file backend)\");\n    } catch {}\n  }\n};\n\nmodule.exports = {\n  ensureOpenclawRuntimeArtifacts,\n  resolveSetupUiUrl,\n  syncBootstrapPromptFiles,\n};\n"
  },
  {
    "path": "lib/server/openclaw-config.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst resolveOpenclawConfigPath = ({ openclawDir }) =>\n  path.join(openclawDir, \"openclaw.json\");\n\nconst readOpenclawConfig = ({\n  fsModule = fs,\n  openclawDir,\n  fallback = {},\n} = {}) => {\n  const configPath = resolveOpenclawConfigPath({ openclawDir });\n  try {\n    return JSON.parse(fsModule.readFileSync(configPath, \"utf8\"));\n  } catch {\n    return fallback;\n  }\n};\n\nconst writeOpenclawConfig = ({\n  fsModule = fs,\n  openclawDir,\n  config = {},\n  spacing = 2,\n} = {}) => {\n  const configPath = resolveOpenclawConfigPath({ openclawDir });\n  fsModule.mkdirSync(path.dirname(configPath), { recursive: true });\n  fsModule.writeFileSync(configPath, JSON.stringify(config, null, spacing));\n  return configPath;\n};\n\nmodule.exports = {\n  resolveOpenclawConfigPath,\n  readOpenclawConfig,\n  writeOpenclawConfig,\n};\n"
  },
  {
    "path": "lib/server/openclaw-runtime-env.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { kRootDir } = require(\"./constants\");\n\nconst kDefaultOpenclawCompileCacheDir = path.join(\n  kRootDir,\n  \"cache\",\n  \"openclaw-compile-cache\",\n);\n\nconst normalizeEnvValue = (value) => String(value || \"\").trim();\n\nconst resolveOpenclawCompileCacheDir = (env = process.env) =>\n  normalizeEnvValue(env.NODE_COMPILE_CACHE) || kDefaultOpenclawCompileCacheDir;\n\nconst resolveOpenclawNoRespawn = (env = process.env) =>\n  normalizeEnvValue(env.OPENCLAW_NO_RESPAWN) || \"1\";\n\nconst withOpenclawStartupEnv = (env = process.env) => ({\n  ...env,\n  NODE_COMPILE_CACHE: resolveOpenclawCompileCacheDir(env),\n  OPENCLAW_NO_RESPAWN: resolveOpenclawNoRespawn(env),\n});\n\nconst ensureOpenclawStartupEnv = ({\n  fsModule = fs,\n  env = process.env,\n  logger = console,\n} = {}) => {\n  const nextEnv = withOpenclawStartupEnv(env);\n  try {\n    fsModule.mkdirSync(nextEnv.NODE_COMPILE_CACHE, { recursive: true });\n  } catch (err) {\n    logger?.warn?.(\n      `[alphaclaw] OpenClaw compile cache directory unavailable: ${err.message}`,\n    );\n  }\n\n  if (!normalizeEnvValue(env.NODE_COMPILE_CACHE)) {\n    env.NODE_COMPILE_CACHE = nextEnv.NODE_COMPILE_CACHE;\n  }\n  if (!normalizeEnvValue(env.OPENCLAW_NO_RESPAWN)) {\n    env.OPENCLAW_NO_RESPAWN = nextEnv.OPENCLAW_NO_RESPAWN;\n  }\n\n  return nextEnv;\n};\n\nmodule.exports = {\n  kDefaultOpenclawCompileCacheDir,\n  ensureOpenclawStartupEnv,\n  resolveOpenclawCompileCacheDir,\n  resolveOpenclawNoRespawn,\n  withOpenclawStartupEnv,\n};\n"
  },
  {
    "path": "lib/server/openclaw-version.js",
    "content": "const { exec, execSync } = require(\"child_process\");\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst {\n  kVersionCacheTtlMs,\n  kLatestVersionCacheTtlMs,\n  kNpmPackageRoot,\n  kOpenclawUpdateCopyTimeoutMs,\n} = require(\"./constants\");\nconst { normalizeOpenclawVersion } = require(\"./helpers\");\nconst { parseJsonObjectFromNoisyOutput } = require(\"./utils/json\");\n\nconst createOpenclawVersionService = ({\n  gatewayEnv,\n  restartGateway,\n  isOnboarded,\n}) => {\n  let kOpenclawVersionCache = { value: null, fetchedAt: 0 };\n  let kOpenclawUpdateStatusCache = {\n    latestVersion: null,\n    hasUpdate: false,\n    fetchedAt: 0,\n  };\n  let kOpenclawUpdateInProgress = false;\n\n  const readOpenclawVersion = ({ refresh = false } = {}) => {\n    const now = Date.now();\n    if (\n      !refresh &&\n      kOpenclawVersionCache.value &&\n      now - kOpenclawVersionCache.fetchedAt < kVersionCacheTtlMs\n    ) {\n      return kOpenclawVersionCache.value;\n    }\n    try {\n      const raw = execSync(\"openclaw --version\", {\n        env: gatewayEnv(),\n        timeout: 5000,\n        encoding: \"utf8\",\n      }).trim();\n      const version = normalizeOpenclawVersion(raw);\n      kOpenclawVersionCache = { value: version, fetchedAt: now };\n      return version;\n    } catch {\n      return kOpenclawVersionCache.value;\n    }\n  };\n\n  const readOpenclawUpdateStatus = ({ refresh = false } = {}) => {\n    const now = Date.now();\n    if (\n      !refresh &&\n      kOpenclawUpdateStatusCache.fetchedAt &&\n      now - kOpenclawUpdateStatusCache.fetchedAt < kLatestVersionCacheTtlMs\n    ) {\n      return {\n        latestVersion: kOpenclawUpdateStatusCache.latestVersion,\n        hasUpdate: kOpenclawUpdateStatusCache.hasUpdate,\n      };\n    }\n    try {\n      const raw = execSync(\"openclaw update status --json\", {\n        env: gatewayEnv(),\n        timeout: 8000,\n        encoding: \"utf8\",\n      }).trim();\n      const parsed = parseJsonObjectFromNoisyOutput(raw);\n      if (!parsed) {\n        throw new Error(\"openclaw update status returned invalid JSON payload\");\n      }\n      const latestVersion = normalizeOpenclawVersion(\n        parsed?.availability?.latestVersion ||\n          parsed?.update?.registry?.latestVersion,\n      );\n      const hasUpdate = !!parsed?.availability?.available;\n      kOpenclawUpdateStatusCache = {\n        latestVersion,\n        hasUpdate,\n        fetchedAt: now,\n      };\n      return { latestVersion, hasUpdate };\n    } catch (err) {\n      console.error(\n        `[alphaclaw] openclaw update status error: ${err.message || \"unknown error\"}`,\n      );\n      throw new Error(err.message || \"Failed to read OpenClaw update status\");\n    }\n  };\n\n  const findInstallDir = () => {\n    // Resolve the consumer app root (for example /app in Docker), not this package directory.\n    let dir = kNpmPackageRoot;\n    while (dir !== path.dirname(dir)) {\n      const parent = path.dirname(dir);\n      if (\n        path.basename(parent) === \"node_modules\" ||\n        parent.includes(`${path.sep}node_modules${path.sep}`)\n      ) {\n        dir = parent;\n        continue;\n      }\n      const pkgPath = path.join(parent, \"package.json\");\n      if (fs.existsSync(pkgPath)) {\n        try {\n          const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf8\"));\n          if (\n            pkg.dependencies?.[\"@chrysb/alphaclaw\"] ||\n            pkg.devDependencies?.[\"@chrysb/alphaclaw\"] ||\n            pkg.optionalDependencies?.[\"@chrysb/alphaclaw\"]\n          ) {\n            return parent;\n          }\n        } catch {}\n      }\n      dir = parent;\n    }\n    return kNpmPackageRoot;\n  };\n\n  // Install to a temp directory, then copy into the real node_modules.\n  // Running `npm install` directly in the app dir causes EBUSY on Docker\n  // because npm tries to rename directories that the running process holds open.\n  // Copying individual files (cp -af) avoids the rename syscall entirely.\n  const installLatestOpenclaw = () =>\n    new Promise((resolve, reject) => {\n      const installDir = findInstallDir();\n      const tmpDir = fs.mkdtempSync(\n        path.join(os.tmpdir(), \"openclaw-update-\"),\n      );\n      const cleanup = () => {\n        try {\n          fs.rmSync(tmpDir, { recursive: true, force: true });\n        } catch {}\n      };\n\n      fs.writeFileSync(\n        path.join(tmpDir, \"package.json\"),\n        JSON.stringify({\n          private: true,\n          dependencies: { openclaw: \"latest\" },\n        }),\n      );\n\n      const npmEnv = {\n        ...process.env,\n        npm_config_update_notifier: \"false\",\n        npm_config_fund: \"false\",\n        npm_config_audit: \"false\",\n      };\n\n      console.log(\n        `[alphaclaw] Running: npm install openclaw@latest in temp dir (target: ${installDir})`,\n      );\n      exec(\n        \"npm install --omit=dev --prefer-online --package-lock=false\",\n        { cwd: tmpDir, env: npmEnv, timeout: 180000 },\n        (installErr, stdout, stderr) => {\n          if (installErr) {\n            const message = String(stderr || installErr.message || \"\").trim();\n            console.log(\n              `[alphaclaw] openclaw install error: ${message.slice(0, 200)}`,\n            );\n            cleanup();\n            return reject(\n              new Error(message || \"Failed to install openclaw@latest\"),\n            );\n          }\n          if (stdout?.trim()) {\n            console.log(\n              `[alphaclaw] openclaw install stdout: ${stdout.trim().slice(0, 300)}`,\n            );\n          }\n\n          const src = path.join(tmpDir, \"node_modules\");\n          const dest = path.join(installDir, \"node_modules\");\n          exec(\n            `cp -af \"${src}/.\" \"${dest}/\"`,\n            { timeout: kOpenclawUpdateCopyTimeoutMs },\n            (cpErr) => {\n              cleanup();\n              if (cpErr) {\n                console.log(\n                  `[alphaclaw] openclaw copy error: ${(cpErr.message || \"\").slice(0, 200)}`,\n                );\n                return reject(\n                  new Error(\n                    `Failed to copy updated openclaw files: ${cpErr.message}`,\n                  ),\n                );\n              }\n              console.log(\"[alphaclaw] openclaw install completed\");\n              resolve({\n                stdout: stdout?.trim() || \"\",\n                stderr: stderr?.trim() || \"\",\n              });\n            },\n          );\n        },\n      );\n    });\n\n  const getVersionStatus = async (refresh) => {\n    const currentVersion = readOpenclawVersion();\n    try {\n      const { latestVersion, hasUpdate } = readOpenclawUpdateStatus({\n        refresh,\n      });\n      return { ok: true, currentVersion, latestVersion, hasUpdate };\n    } catch (err) {\n      return {\n        ok: false,\n        currentVersion,\n        latestVersion: kOpenclawUpdateStatusCache.latestVersion,\n        hasUpdate: kOpenclawUpdateStatusCache.hasUpdate,\n        error: err.message || \"Failed to fetch latest OpenClaw version\",\n      };\n    }\n  };\n\n  const updateOpenclaw = async () => {\n    if (kOpenclawUpdateInProgress) {\n      return {\n        status: 409,\n        body: { ok: false, error: \"OpenClaw update already in progress\" },\n      };\n    }\n\n    kOpenclawUpdateInProgress = true;\n    const previousVersion = readOpenclawVersion();\n    try {\n      await installLatestOpenclaw();\n      kOpenclawVersionCache = { value: null, fetchedAt: 0 };\n      const currentVersion = readOpenclawVersion();\n      const { latestVersion, hasUpdate } = readOpenclawUpdateStatus({\n        refresh: true,\n      });\n      let restarted = false;\n      if (isOnboarded()) {\n        restartGateway();\n        restarted = true;\n      }\n      return {\n        status: 200,\n        body: {\n          ok: true,\n          previousVersion,\n          currentVersion,\n          latestVersion,\n          hasUpdate,\n          restarted,\n          updated: previousVersion !== currentVersion,\n        },\n      };\n    } catch (err) {\n      return {\n        status: 500,\n        body: { ok: false, error: err.message || \"Failed to update OpenClaw\" },\n      };\n    } finally {\n      kOpenclawUpdateInProgress = false;\n    }\n  };\n\n  return {\n    readOpenclawVersion,\n    getVersionStatus,\n    updateOpenclaw,\n  };\n};\n\nmodule.exports = { createOpenclawVersionService };\n"
  },
  {
    "path": "lib/server/operation-events.js",
    "content": "const crypto = require(\"crypto\");\n\nconst kDefaultTtlMs = 5 * 60 * 1000;\nconst kMaxEventsPerOperation = 200;\n\nconst formatSseEvent = ({ id, event, data }) => {\n  const lines = [];\n  if (id) lines.push(`id: ${id}`);\n  if (event) lines.push(`event: ${event}`);\n  const payload = JSON.stringify(data === undefined ? {} : data);\n  for (const line of payload.split(\"\\n\")) {\n    lines.push(`data: ${line}`);\n  }\n  return `${lines.join(\"\\n\")}\\n\\n`;\n};\n\nconst createOperationEventsService = ({ ttlMs = kDefaultTtlMs } = {}) => {\n  const operations = new Map();\n  let sweepTimer = null;\n\n  const ensureSweeper = () => {\n    if (sweepTimer) return;\n    sweepTimer = setInterval(() => {\n      const now = Date.now();\n      for (const [operationId, state] of operations.entries()) {\n        if (state.expiresAt <= now && state.subscribers.size === 0) {\n          operations.delete(operationId);\n        }\n      }\n    }, 30_000);\n    sweepTimer.unref();\n  };\n\n  const getOperation = (operationId) => {\n    const normalized = String(operationId || \"\").trim();\n    if (!normalized) return null;\n    return operations.get(normalized) || null;\n  };\n\n  const createOperation = ({ type = \"operation\" } = {}) => {\n    const operationId = crypto.randomUUID();\n    operations.set(operationId, {\n      id: operationId,\n      type: String(type || \"operation\").trim() || \"operation\",\n      createdAt: Date.now(),\n      expiresAt: Date.now() + ttlMs,\n      status: \"pending\",\n      nextEventId: 1,\n      events: [],\n      subscribers: new Set(),\n    });\n    ensureSweeper();\n    return { operationId };\n  };\n\n  const publish = (operationId, { event = \"message\", data = {} } = {}) => {\n    const state = getOperation(operationId);\n    if (!state) return false;\n    const entry = {\n      id: String(state.nextEventId++),\n      event: String(event || \"message\").trim() || \"message\",\n      data: data === undefined ? {} : data,\n      ts: Date.now(),\n    };\n    state.events.push(entry);\n    if (state.events.length > kMaxEventsPerOperation) {\n      state.events = state.events.slice(-kMaxEventsPerOperation);\n    }\n    for (const res of state.subscribers) {\n      try {\n        res.write(formatSseEvent(entry));\n      } catch {}\n    }\n    return true;\n  };\n\n  const complete = (operationId, payload = {}) => {\n    const state = getOperation(operationId);\n    if (!state) return false;\n    state.status = \"completed\";\n    state.expiresAt = Date.now() + ttlMs;\n    publish(operationId, {\n      event: \"done\",\n      data: payload,\n    });\n    return true;\n  };\n\n  const fail = (operationId, error) => {\n    const state = getOperation(operationId);\n    if (!state) return false;\n    state.status = \"failed\";\n    state.expiresAt = Date.now() + ttlMs;\n    publish(operationId, {\n      event: \"error\",\n      data: {\n        error: String(error?.message || error || \"Operation failed\"),\n      },\n    });\n    return true;\n  };\n\n  const subscribe = ({ operationId, req, res }) => {\n    const state = getOperation(operationId);\n    if (!state) {\n      return false;\n    }\n    res.status(200);\n    res.setHeader(\"Content-Type\", \"text/event-stream\");\n    res.setHeader(\"Cache-Control\", \"no-cache, no-transform\");\n    res.setHeader(\"Connection\", \"keep-alive\");\n    res.setHeader(\"X-Accel-Buffering\", \"no\");\n    res.flushHeaders?.();\n    res.write(\": connected\\n\\n\");\n    for (const event of state.events) {\n      res.write(formatSseEvent(event));\n    }\n    state.subscribers.add(res);\n    const close = () => {\n      state.subscribers.delete(res);\n      if (state.expiresAt <= Date.now() && state.subscribers.size === 0) {\n        operations.delete(state.id);\n      }\n    };\n    req.on(\"close\", close);\n    return true;\n  };\n\n  return {\n    createOperation,\n    publish,\n    complete,\n    fail,\n    subscribe,\n    getOperation,\n  };\n};\n\nmodule.exports = {\n  createOperationEventsService,\n};\n"
  },
  {
    "path": "lib/server/restart-required-state.js",
    "content": "const createRestartRequiredState = ({ isGatewayRunning }) => {\n  const state = {\n    restartRequired: false,\n    restartInProgress: false,\n    sawGatewayDownSincePending: false,\n    updatedAt: Date.now(),\n    reason: \"\",\n  };\n\n  const touch = () => {\n    state.updatedAt = Date.now();\n  };\n\n  const markRequired = (reason = \"config_changed\") => {\n    state.restartRequired = true;\n    state.reason = reason;\n    state.sawGatewayDownSincePending = false;\n    touch();\n  };\n\n  const markRestartInProgress = () => {\n    state.restartInProgress = true;\n    touch();\n  };\n\n  const markRestartComplete = () => {\n    state.restartInProgress = false;\n    touch();\n  };\n\n  const clearRequired = () => {\n    state.restartRequired = false;\n    state.reason = \"\";\n    state.sawGatewayDownSincePending = false;\n    touch();\n  };\n\n  const checkAndClearIfRecovered = async () => {\n    const gatewayRunning = await isGatewayRunning();\n    if (state.restartRequired && !state.restartInProgress) {\n      if (!gatewayRunning) {\n        state.sawGatewayDownSincePending = true;\n        touch();\n      } else if (state.sawGatewayDownSincePending) {\n        clearRequired();\n      }\n    }\n    return gatewayRunning;\n  };\n\n  const getSnapshot = async () => {\n    const gatewayRunning = await checkAndClearIfRecovered();\n    return {\n      restartRequired: state.restartRequired,\n      restartInProgress: state.restartInProgress,\n      gatewayRunning,\n      updatedAt: state.updatedAt,\n    };\n  };\n\n  return {\n    markRequired,\n    markRestartInProgress,\n    markRestartComplete,\n    clearRequired,\n    getSnapshot,\n  };\n};\n\nconst waitForGatewayRunning = async ({\n  isGatewayRunning,\n  timeoutMs = 25000,\n  intervalMs = 400,\n}) => {\n  const deadline = Date.now() + timeoutMs;\n  while (Date.now() < deadline) {\n    if (await isGatewayRunning()) return true;\n    await new Promise((resolve) => setTimeout(resolve, intervalMs));\n  }\n  return isGatewayRunning();\n};\n\nmodule.exports = {\n  createRestartRequiredState,\n  waitForGatewayRunning,\n};\n"
  },
  {
    "path": "lib/server/routes/agents.js",
    "content": "const parseKeepWorkspace = (value) => {\n  if (value === undefined || value === null) return true;\n  const normalized = String(value).trim().toLowerCase();\n  if (!normalized) return true;\n  return ![\"0\", \"false\", \"no\", \"off\"].includes(normalized);\n};\n\nconst registerAgentRoutes = ({\n  app,\n  agentsService,\n  restartRequiredState = null,\n  operationEvents = null,\n}) => {\n  app.get(\"/api/channels/accounts\", (_req, res) => {\n    try {\n      res.json({\n        ok: true,\n        channels: agentsService.listConfiguredChannelAccounts(),\n      });\n    } catch (error) {\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/channels/accounts/token\", (req, res) => {\n    try {\n      const provider = String(req.query?.provider || \"\").trim();\n      const accountId = String(req.query?.accountId || \"\").trim() || \"default\";\n      const result = agentsService.getChannelAccountToken({\n        provider,\n        accountId,\n      });\n      return res.json({ ok: true, ...result });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/channels/accounts\", async (req, res) => {\n    try {\n      const body = req.body || {};\n      const result = await agentsService.createChannelAccount(body);\n      return res.status(201).json({ ok: true, ...result });\n    } catch (error) {\n      const message = String(error.message || \"\");\n      const status = message.includes(\"already exists\")\n        ? 409\n        : message.includes(\"already assigned\")\n          ? 409\n          : message.includes(\"not found\")\n            ? 404\n            : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/channels/accounts/jobs\", (req, res) => {\n    if (!operationEvents?.createOperation) {\n      return res\n        .status(503)\n        .json({ ok: false, error: \"Operation events unavailable\" });\n    }\n    const body = req.body || {};\n    const { operationId } = operationEvents.createOperation({\n      type: \"channel-account-create\",\n    });\n    (async () => {\n      try {\n        const result = await agentsService.createChannelAccount(body, {\n          onProgress: ({ phase = \"\", label = \"\" } = {}) => {\n            operationEvents.publish(operationId, {\n              event: \"phase\",\n              data: {\n                phase: String(phase || \"\").trim(),\n                label: String(label || \"\").trim(),\n              },\n            });\n          },\n        });\n        operationEvents.complete(operationId, { ok: true, ...result });\n      } catch (error) {\n        operationEvents.fail(operationId, error);\n      }\n    })();\n    return res.status(202).json({\n      ok: true,\n      operationId,\n      streamUrl: `/api/operations/${encodeURIComponent(operationId)}/events`,\n    });\n  });\n\n  app.get(\"/api/operations/:operationId/events\", (req, res) => {\n    if (!operationEvents?.subscribe) {\n      return res\n        .status(503)\n        .json({ ok: false, error: \"Operation events unavailable\" });\n    }\n    const subscribed = operationEvents.subscribe({\n      operationId: req.params.operationId,\n      req,\n      res,\n    });\n    if (!subscribed) {\n      return res.status(404).json({ ok: false, error: \"Operation not found\" });\n    }\n  });\n\n  app.put(\"/api/channels/accounts\", (req, res) => {\n    try {\n      const result = agentsService.updateChannelAccount(req.body || {});\n      const restartRequired = !!result?.tokenUpdated;\n      if (restartRequired) {\n        restartRequiredState?.markRequired?.(\"channel_token_updated\");\n      }\n      return res.json({ ok: true, restartRequired, ...result });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/channels/accounts/login\", async (req, res) => {\n    try {\n      const body = req.body || {};\n      const result = await agentsService.runChannelAccountLogin({\n        provider: body.provider,\n        accountId: body.accountId,\n      });\n      return res.json({\n        ok: true,\n        completed: !!result?.ok,\n        stdout: String(result?.stdout || \"\"),\n        stderr: String(result?.stderr || \"\"),\n        code: result?.code ?? null,\n      });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"only supported\")\n        ? 400\n        : 500;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/channels/accounts/login-status\", (req, res) => {\n    try {\n      const provider = String(req.query?.provider || \"\").trim();\n      const accountId = String(req.query?.accountId || \"\").trim() || \"default\";\n      const result = agentsService.getChannelAccountLoginStatus({\n        provider,\n        accountId,\n      });\n      return res.json({ ok: true, ...result });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"only supported\")\n        ? 400\n        : 500;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.delete(\"/api/channels/accounts\", async (req, res) => {\n    try {\n      const body = req.body || {};\n      await agentsService.deleteChannelAccount(body);\n      return res.json({ ok: true });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/agents\", (_req, res) => {\n    try {\n      res.json({ ok: true, agents: agentsService.listAgents() });\n    } catch (error) {\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/agents/:id\", (req, res) => {\n    try {\n      const agent = agentsService.getAgent(req.params.id);\n      if (!agent)\n        return res.status(404).json({ ok: false, error: \"Agent not found\" });\n      return res.json({ ok: true, agent });\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/agents/:id/workspace-size\", (req, res) => {\n    try {\n      const workspace = agentsService.getAgentWorkspaceSize(req.params.id);\n      return res.json({ ok: true, ...workspace });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 500;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/agents/:id/bindings\", (req, res) => {\n    try {\n      const agent = agentsService.getAgent(req.params.id);\n      if (!agent)\n        return res.status(404).json({ ok: false, error: \"Agent not found\" });\n      return res.json({\n        ok: true,\n        bindings: agentsService.getBindingsForAgent(req.params.id),\n      });\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/agents\", (req, res) => {\n    try {\n      const body = req.body || {};\n      if (!String(body.id || \"\").trim()) {\n        return res.status(400).json({ ok: false, error: \"id is required\" });\n      }\n      const agent = agentsService.createAgent(body);\n      return res.status(201).json({ ok: true, agent });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"already exists\")\n        ? 409\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.put(\"/api/agents/:id\", (req, res) => {\n    try {\n      const agent = agentsService.updateAgent(req.params.id, req.body || {});\n      return res.json({ ok: true, agent });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/agents/:id/bindings\", (req, res) => {\n    try {\n      const binding = agentsService.addBinding(req.params.id, req.body || {});\n      return res.status(201).json({ ok: true, binding });\n    } catch (error) {\n      const message = String(error.message || \"\");\n      const status = message.includes(\"not found\")\n        ? 404\n        : message.includes(\"already assigned\")\n          ? 409\n          : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.delete(\"/api/agents/:id/bindings\", (req, res) => {\n    try {\n      agentsService.removeBinding(req.params.id, req.body || {});\n      return res.json({ ok: true });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.delete(\"/api/agents/:id\", (req, res) => {\n    try {\n      const keepWorkspace = parseKeepWorkspace(req.query.keepWorkspace);\n      agentsService.deleteAgent(req.params.id, { keepWorkspace });\n      return res.json({ ok: true });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/agents/:id/default\", (req, res) => {\n    try {\n      const agent = agentsService.setDefaultAgent(req.params.id);\n      return res.json({ ok: true, agent });\n    } catch (error) {\n      const status = String(error.message || \"\").includes(\"not found\")\n        ? 404\n        : 400;\n      return res.status(status).json({ ok: false, error: error.message });\n    }\n  });\n};\n\nmodule.exports = { registerAgentRoutes };\n"
  },
  {
    "path": "lib/server/routes/auth.js",
    "content": "const crypto = require(\"crypto\");\nconst { kLoginCleanupIntervalMs } = require(\"../constants\");\n\nconst registerAuthRoutes = ({ app, loginThrottle }) => {\n  const SETUP_PASSWORD = String(process.env.SETUP_PASSWORD || \"\").trim();\n  const kAuthMisconfigured = !SETUP_PASSWORD;\n  const kSessionTtlMs = 7 * 24 * 60 * 60 * 1000;\n\n  const signSessionPayload = (payload) =>\n    crypto\n      .createHmac(\"sha256\", SETUP_PASSWORD)\n      .update(payload)\n      .digest(\"base64url\");\n\n  const createSessionToken = () => {\n    const now = Date.now();\n    const payload = Buffer.from(\n      JSON.stringify({\n        iat: now,\n        exp: now + kSessionTtlMs,\n        nonce: crypto.randomBytes(16).toString(\"hex\"),\n      }),\n    ).toString(\"base64url\");\n    const signature = signSessionPayload(payload);\n    return `${payload}.${signature}`;\n  };\n\n  const verifySessionToken = (token) => {\n    if (!SETUP_PASSWORD || !token || typeof token !== \"string\") return false;\n    const parts = token.split(\".\");\n    if (parts.length !== 2) return false;\n    const [payload, signature] = parts;\n    if (!payload || !signature) return false;\n    const expectedSignature = signSessionPayload(payload);\n    const expectedBuffer = Buffer.from(expectedSignature);\n    const signatureBuffer = Buffer.from(signature);\n    if (expectedBuffer.length !== signatureBuffer.length) return false;\n    if (!crypto.timingSafeEqual(expectedBuffer, signatureBuffer)) return false;\n    try {\n      const parsed = JSON.parse(\n        Buffer.from(payload, \"base64url\").toString(\"utf8\"),\n      );\n      return Number.isFinite(parsed?.exp) && parsed.exp > Date.now();\n    } catch {\n      return false;\n    }\n  };\n\n  const cookieParser = (req) => {\n    const cookies = {};\n    const cookieHeader =\n      req && req.headers && typeof req.headers.cookie === \"string\"\n        ? req.headers.cookie\n        : \"\";\n    cookieHeader.split(\";\").forEach((c) => {\n      const [k, ...v] = c.trim().split(\"=\");\n      if (k) cookies[k] = v.join(\"=\");\n    });\n    return cookies;\n  };\n\n  app.post(\"/api/auth/login\", (req, res) => {\n    if (kAuthMisconfigured) {\n      return res.status(503).json({\n        ok: false,\n        error:\n          \"Server misconfigured: SETUP_PASSWORD is missing. Set it in your deployment environment variables and restart.\",\n      });\n    }\n    const now = Date.now();\n    const clientKey = loginThrottle.getClientKey(req);\n    const state = loginThrottle.getOrCreateLoginAttemptState(clientKey, now);\n    const throttle = loginThrottle.evaluateLoginThrottle(state, now);\n    if (throttle.blocked) {\n      res.set(\"Retry-After\", String(throttle.retryAfterSec));\n      return res.status(429).json({\n        ok: false,\n        error: \"Too many attempts. Try again shortly.\",\n        retryAfterSec: throttle.retryAfterSec,\n      });\n    }\n    if (req.body.password !== SETUP_PASSWORD) {\n      const failure = loginThrottle.recordLoginFailure(state, now);\n      if (failure.locked) {\n        const retryAfterSec = Math.max(1, Math.ceil(failure.lockMs / 1000));\n        res.set(\"Retry-After\", String(retryAfterSec));\n        return res.status(429).json({\n          ok: false,\n          error: \"Too many attempts. Try again shortly.\",\n          retryAfterSec,\n        });\n      }\n      return res.status(401).json({ ok: false, error: \"Invalid credentials\" });\n    }\n    loginThrottle.recordLoginSuccess(clientKey);\n    const token = createSessionToken();\n    res.cookie(\"setup_token\", token, {\n      httpOnly: true,\n      sameSite: \"lax\",\n      path: \"/\",\n      maxAge: kSessionTtlMs,\n    });\n    res.json({ ok: true });\n  });\n\n  setInterval(() => {\n    loginThrottle.cleanupLoginAttemptStates();\n  }, kLoginCleanupIntervalMs).unref();\n\n  const isAuthorizedRequest = (req) => {\n    if (kAuthMisconfigured) return false;\n    const requestPath = req.path || \"\";\n    if (requestPath.startsWith(\"/auth/google/callback\")) return true;\n    if (requestPath.startsWith(\"/auth/codex/callback\")) return true;\n    const cookies = cookieParser(req);\n    const token = cookies.setup_token;\n    return verifySessionToken(token);\n  };\n\n  const requireAuth = (req, res, next) => {\n    if (kAuthMisconfigured) {\n      if (req.originalUrl.startsWith(\"/api/\")) {\n        return res.status(503).json({\n          error:\n            \"Server misconfigured: SETUP_PASSWORD is missing. Set it in your deployment environment variables and restart.\",\n        });\n      }\n      return res\n        .status(503)\n        .send(\n          \"Setup auth is not configured. Set SETUP_PASSWORD in your deployment environment and restart.\",\n        );\n    }\n    if (req.path.startsWith(\"/auth/google/callback\")) return next();\n    if (req.path.startsWith(\"/auth/codex/callback\")) return next();\n    if (isAuthorizedRequest(req)) return next();\n    if (req.originalUrl.startsWith(\"/api/\")) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n    return res.redirect(\"/login.html\");\n  };\n\n  app.get(\"/api/auth/status\", (req, res) => {\n    res.json({ authEnabled: !!SETUP_PASSWORD });\n  });\n\n  app.post(\"/api/auth/logout\", (req, res) => {\n    res.clearCookie(\"setup_token\", { path: \"/\" });\n    res.json({ ok: true });\n  });\n\n  app.use(\"/setup\", requireAuth);\n  app.use(\"/api\", requireAuth);\n  app.use(\"/auth\", requireAuth);\n\n  return { requireAuth, isAuthorizedRequest };\n};\n\nmodule.exports = { registerAuthRoutes };\n"
  },
  {
    "path": "lib/server/routes/browse/constants.js",
    "content": "const kDefaultTreeDepth = 10;\nconst kIgnoredDirectoryNames = new Set([\n  \".git\",\n  \".alphaclaw\",\n  \"node_modules\",\n  \".cache\",\n  \"dist\",\n  \"build\",\n]);\nconst kImageMimeTypeByExtension = new Map([\n  [\".png\", \"image/png\"],\n  [\".jpg\", \"image/jpeg\"],\n  [\".jpeg\", \"image/jpeg\"],\n  [\".gif\", \"image/gif\"],\n  [\".webp\", \"image/webp\"],\n  [\".svg\", \"image/svg+xml\"],\n  [\".bmp\", \"image/bmp\"],\n  [\".ico\", \"image/x-icon\"],\n  [\".avif\", \"image/avif\"],\n]);\nconst kCommitHistoryLimit = 12;\nconst kAudioMimeTypeByExtension = new Map([\n  [\".mp3\", \"audio/mpeg\"],\n  [\".wav\", \"audio/wav\"],\n  [\".ogg\", \"audio/ogg\"],\n  [\".oga\", \"audio/ogg\"],\n  [\".m4a\", \"audio/mp4\"],\n  [\".aac\", \"audio/aac\"],\n  [\".flac\", \"audio/flac\"],\n  [\".opus\", \"audio/opus\"],\n  [\".weba\", \"audio/webm\"],\n]);\nconst kSqliteFileExtensions = new Set([\n  \".sqlite\",\n  \".sqlite3\",\n  \".db\",\n  \".db3\",\n  \".sdb\",\n  \".sqlitedb\",\n]);\nconst kSqliteTablePageSize = 50;\n\nmodule.exports = {\n  kDefaultTreeDepth,\n  kIgnoredDirectoryNames,\n  kImageMimeTypeByExtension,\n  kCommitHistoryLimit,\n  kAudioMimeTypeByExtension,\n  kSqliteFileExtensions,\n  kSqliteTablePageSize,\n};\n"
  },
  {
    "path": "lib/server/routes/browse/file-helpers.js",
    "content": "const path = require(\"path\");\nconst {\n  kImageMimeTypeByExtension,\n  kAudioMimeTypeByExtension,\n  kSqliteFileExtensions,\n} = require(\"./constants\");\n\nconst isLikelyBinaryFile = (fs, targetPath) => {\n  let fileHandle = null;\n  try {\n    fileHandle = fs.openSync(targetPath, \"r\");\n    const sample = Buffer.alloc(512);\n    const bytesRead = fs.readSync(fileHandle, sample, 0, sample.length, 0);\n    for (let index = 0; index < bytesRead; index += 1) {\n      if (sample[index] === 0) return true;\n    }\n    return false;\n  } finally {\n    if (fileHandle !== null) fs.closeSync(fileHandle);\n  }\n};\n\nconst getImageMimeType = (targetPath) => {\n  const extension = String(path.extname(targetPath || \"\") || \"\").toLowerCase();\n  return kImageMimeTypeByExtension.get(extension) || \"\";\n};\n\nconst getAudioMimeType = (targetPath) => {\n  const extension = String(path.extname(targetPath || \"\") || \"\").toLowerCase();\n  return kAudioMimeTypeByExtension.get(extension) || \"\";\n};\n\nconst isSqliteFilePath = (targetPath) => {\n  const extension = String(path.extname(targetPath || \"\") || \"\").toLowerCase();\n  return kSqliteFileExtensions.has(extension);\n};\n\nmodule.exports = {\n  isLikelyBinaryFile,\n  getImageMimeType,\n  getAudioMimeType,\n  isSqliteFilePath,\n};\n"
  },
  {
    "path": "lib/server/routes/browse/git.js",
    "content": "const { execFile } = require(\"child_process\");\n\nconst runGitCommand = (args, kRootResolved) =>\n  new Promise((resolve) => {\n    execFile(\n      \"git\",\n      args,\n      { timeout: 10000, cwd: kRootResolved },\n      (error, stdout, stderr) => {\n        if (error) {\n          return resolve({\n            ok: false,\n            error: String(\n              stderr || stdout || error.message || \"git command failed\",\n            ).trim(),\n          });\n        }\n        return resolve({ ok: true, stdout: String(stdout || \"\") });\n      },\n    );\n  });\n\nconst runGitCommandWithExitCode = (args, kRootResolved) =>\n  new Promise((resolve) => {\n    execFile(\n      \"git\",\n      args,\n      { timeout: 10000, cwd: kRootResolved },\n      (error, stdout, stderr) => {\n        const safeStdout = String(stdout || \"\");\n        const safeStderr = String(stderr || \"\");\n        if (!error) {\n          return resolve({\n            ok: true,\n            stdout: safeStdout,\n            stderr: safeStderr,\n            exitCode: 0,\n          });\n        }\n        return resolve({\n          ok: false,\n          stdout: safeStdout,\n          stderr: safeStderr,\n          exitCode: Number.isInteger(error.code) ? error.code : 1,\n          error: String(error.message || \"git command failed\"),\n        });\n      },\n    );\n  });\n\nconst parseGithubRepoSlug = (value) => {\n  const raw = String(value || \"\").trim();\n  if (!raw) return \"\";\n  return raw\n    .replace(/^git@github\\.com:/i, \"\")\n    .replace(/^https:\\/\\/github\\.com\\//i, \"\")\n    .replace(/\\.git$/i, \"\")\n    .trim();\n};\n\nconst normalizeChangedPath = (rawPath) => {\n  const value = String(rawPath || \"\").trim();\n  if (!value) return \"\";\n  if (value.includes(\" -> \")) {\n    const segments = value.split(\" -> \");\n    return String(segments[segments.length - 1] || \"\").trim();\n  }\n  return value;\n};\n\nconst parseBranchTracking = (branchLine) => {\n  const safeBranchLine = String(branchLine || \"\").trim();\n  const withoutPrefix = safeBranchLine.replace(/^##\\s*/, \"\");\n  const [localBranchRaw = \"\", trackingRaw = \"\"] = withoutPrefix.split(\"...\");\n  const localBranch = localBranchRaw || \"unknown\";\n  const trackingSegment = String(trackingRaw || \"\").trim();\n  const upstreamBranch = trackingSegment.split(\" [\")[0]?.trim() || \"\";\n  const hasUpstream = upstreamBranch.length > 0;\n  const countsMatch = trackingSegment.match(/\\[([^\\]]+)\\]/);\n  const countsRaw = countsMatch?.[1] || \"\";\n  const countParts = countsRaw\n    .split(\",\")\n    .map((part) => String(part || \"\").trim())\n    .filter(Boolean);\n  let aheadCount = 0;\n  let behindCount = 0;\n  let upstreamGone = false;\n  countParts.forEach((part) => {\n    const aheadMatch = part.match(/^ahead\\s+(\\d+)$/i);\n    if (aheadMatch?.[1]) {\n      aheadCount = Number.parseInt(aheadMatch[1], 10) || 0;\n      return;\n    }\n    const behindMatch = part.match(/^behind\\s+(\\d+)$/i);\n    if (behindMatch?.[1]) {\n      behindCount = Number.parseInt(behindMatch[1], 10) || 0;\n      return;\n    }\n    if (part.toLowerCase() === \"gone\") {\n      upstreamGone = true;\n    }\n  });\n  const syncState = !hasUpstream\n    ? \"no-upstream\"\n    : upstreamGone\n      ? \"upstream-gone\"\n      : aheadCount > 0 && behindCount > 0\n        ? \"diverged\"\n        : aheadCount > 0\n          ? \"ahead\"\n          : behindCount > 0\n            ? \"behind\"\n            : \"up-to-date\";\n  return {\n    branch: localBranch,\n    upstreamBranch,\n    hasUpstream,\n    upstreamGone,\n    aheadCount,\n    behindCount,\n    syncState,\n  };\n};\n\nmodule.exports = {\n  runGitCommand,\n  runGitCommandWithExitCode,\n  parseGithubRepoSlug,\n  normalizeChangedPath,\n  parseBranchTracking,\n};\n"
  },
  {
    "path": "lib/server/routes/browse/index.js",
    "content": "const path = require(\"path\");\nconst { kLockedBrowsePaths, kProtectedBrowsePaths } = require(\"../../constants\");\nconst {\n  kDefaultTreeDepth,\n  kIgnoredDirectoryNames,\n  kCommitHistoryLimit,\n} = require(\"./constants\");\nconst {\n  normalizePolicyPath,\n  resolveSafePath,\n  toRelativePath,\n  matchesPolicyPath,\n} = require(\"./path-utils\");\nconst {\n  isLikelyBinaryFile,\n  getImageMimeType,\n  getAudioMimeType,\n  isSqliteFilePath,\n} = require(\"./file-helpers\");\nconst { readSqliteSummary, readSqliteTableData } = require(\"./sqlite\");\nconst {\n  runGitCommand,\n  runGitCommandWithExitCode,\n  parseGithubRepoSlug,\n  normalizeChangedPath,\n  parseBranchTracking,\n} = require(\"./git\");\n\nconst registerBrowseRoutes = ({ app, fs, kRootDir }) => {\n  const kRootResolved = path.resolve(kRootDir);\n  const kRootWithSep = `${kRootResolved}${path.sep}`;\n  const kRootDisplayName = \"kRootDir/.openclaw\";\n  if (!fs.existsSync(kRootResolved)) {\n    fs.mkdirSync(kRootResolved, { recursive: true });\n  }\n\n  const buildTreeNode = (absolutePath, depthRemaining) => {\n    const stats = fs.statSync(absolutePath);\n    const nodeName = path.basename(absolutePath);\n    const nodePath = toRelativePath(absolutePath, kRootResolved);\n\n    if (!stats.isDirectory()) {\n      return { type: \"file\", name: nodeName, path: nodePath };\n    }\n\n    if (depthRemaining <= 0) {\n      return { type: \"folder\", name: nodeName, path: nodePath, children: [] };\n    }\n\n    const children = fs\n      .readdirSync(absolutePath, { withFileTypes: true })\n      .filter((entry) => {\n        if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) {\n          return false;\n        }\n        return entry.isDirectory() || entry.isFile();\n      })\n      .map((entry) =>\n        buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1),\n      )\n      .sort((leftNode, rightNode) => {\n        if (leftNode.type !== rightNode.type) {\n          return leftNode.type === \"folder\" ? -1 : 1;\n        }\n        return leftNode.name.localeCompare(rightNode.name);\n      });\n\n    return { type: \"folder\", name: nodeName, path: nodePath, children };\n  };\n\n  app.get(\"/api/browse/tree\", (req, res) => {\n    const depthValue = Number.parseInt(String(req.query.depth || \"\"), 10);\n    const depth =\n      Number.isFinite(depthValue) && depthValue > 0\n        ? depthValue\n        : kDefaultTreeDepth;\n    try {\n      const tree = buildTreeNode(kRootResolved, depth);\n      return res.json({ ok: true, root: tree });\n    } catch (error) {\n      return res.status(500).json({\n        ok: false,\n        error: error.message || \"Could not build file tree\",\n      });\n    }\n  });\n\n  app.get(\"/api/browse/read\", (req, res) => {\n    const resolvedPath = resolveSafePath(\n      req.query.path,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n\n    try {\n      const stats = fs.statSync(resolvedPath.absolutePath);\n      if (!stats.isFile()) {\n        return res.status(400).json({ ok: false, error: \"Path is not a file\" });\n      }\n      if (isSqliteFilePath(resolvedPath.absolutePath)) {\n        const sqliteSummary = readSqliteSummary(resolvedPath.absolutePath);\n        return res.json({\n          ok: true,\n          path: resolvedPath.relativePath,\n          kind: \"sqlite\",\n          sqliteSummary,\n          content: \"\",\n        });\n      }\n      const audioMimeType = getAudioMimeType(resolvedPath.absolutePath);\n      if (audioMimeType) {\n        const audioBytes = fs.readFileSync(resolvedPath.absolutePath);\n        const audioDataUrl = `data:${audioMimeType};base64,${audioBytes.toString(\"base64\")}`;\n        return res.json({\n          ok: true,\n          path: resolvedPath.relativePath,\n          kind: \"audio\",\n          mimeType: audioMimeType,\n          audioDataUrl,\n          content: \"\",\n        });\n      }\n      if (isLikelyBinaryFile(fs, resolvedPath.absolutePath)) {\n        const imageMimeType = getImageMimeType(resolvedPath.absolutePath);\n        if (!imageMimeType) {\n          return res\n            .status(400)\n            .json({ ok: false, error: \"Binary files are not editable\" });\n        }\n        const imageBytes = fs.readFileSync(resolvedPath.absolutePath);\n        const imageDataUrl = `data:${imageMimeType};base64,${imageBytes.toString(\"base64\")}`;\n        return res.json({\n          ok: true,\n          path: resolvedPath.relativePath,\n          kind: \"image\",\n          mimeType: imageMimeType,\n          imageDataUrl,\n          content: \"\",\n        });\n      }\n      const content = fs.readFileSync(resolvedPath.absolutePath, \"utf8\");\n      return res.json({\n        ok: true,\n        path: resolvedPath.relativePath,\n        kind: \"text\",\n        content,\n      });\n    } catch (error) {\n      return res\n        .status(500)\n        .json({ ok: false, error: error.message || \"Could not read file\" });\n    }\n  });\n\n  app.get(\"/api/browse/download\", (req, res) => {\n    const resolvedPath = resolveSafePath(\n      req.query.path,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    try {\n      const stats = fs.statSync(resolvedPath.absolutePath);\n      if (!stats.isFile()) {\n        return res.status(400).json({ ok: false, error: \"Path is not a file\" });\n      }\n      const fileName = path.basename(resolvedPath.relativePath || resolvedPath.absolutePath);\n      return res.download(resolvedPath.absolutePath, fileName, (error) => {\n        if (!error || res.headersSent) return;\n        return res\n          .status(500)\n          .json({ ok: false, error: error.message || \"Could not download file\" });\n      });\n    } catch (error) {\n      return res\n        .status(500)\n        .json({ ok: false, error: error.message || \"Could not download file\" });\n    }\n  });\n\n  app.get(\"/api/browse/git-summary\", async (req, res) => {\n    try {\n      const envRepoSlug = parseGithubRepoSlug(\n        process.env.GITHUB_WORKSPACE_REPO || \"\",\n      );\n      const statusResult = await runGitCommand(\n        [\"status\", \"--porcelain\", \"--branch\"],\n        kRootResolved,\n      );\n      if (!statusResult.ok) {\n        if (/not a git repository/i.test(statusResult.error || \"\")) {\n          return res.json({\n            ok: true,\n            isRepo: false,\n            repoPath: kRootResolved,\n          });\n        }\n        return res.status(500).json({\n          ok: false,\n          error: statusResult.error || \"Could not read git status\",\n        });\n      }\n\n      const statusLines = statusResult.stdout\n        .split(\"\\n\")\n        .map((line) => line.trimEnd())\n        .filter(Boolean);\n      const branchLine = statusLines.find((line) => line.startsWith(\"##\")) || \"\";\n      const branchTracking = parseBranchTracking(branchLine);\n      const branch = branchTracking.branch;\n      const diffNumstatResult = await runGitCommand(\n        [\"diff\", \"--numstat\", \"HEAD\"],\n        kRootResolved,\n      );\n      const diffStatsByPath = new Map();\n      if (diffNumstatResult.ok) {\n        diffNumstatResult.stdout\n          .split(\"\\n\")\n          .map((line) => line.trim())\n          .filter(Boolean)\n          .forEach((line) => {\n            const [addedRaw = \"\", deletedRaw = \"\", rawPath = \"\"] =\n              line.split(\"\\t\");\n            const normalizedPath = normalizeChangedPath(rawPath);\n            if (!normalizedPath) return;\n            const addedLines = Number.parseInt(addedRaw, 10);\n            const deletedLines = Number.parseInt(deletedRaw, 10);\n            diffStatsByPath.set(normalizedPath, {\n              addedLines: Number.isFinite(addedLines) ? addedLines : null,\n              deletedLines: Number.isFinite(deletedLines) ? deletedLines : null,\n            });\n          });\n      }\n      const changedFiles = statusLines\n        .filter((line) => !line.startsWith(\"##\"))\n        .map((line) => {\n          const rawStatus = line.slice(0, 2);\n          const pathValue = normalizeChangedPath(line.slice(3));\n          const stats = diffStatsByPath.get(pathValue) || {\n            addedLines: null,\n            deletedLines: null,\n          };\n          const statusKind =\n            rawStatus === \"??\" || rawStatus.includes(\"A\")\n              ? \"U\"\n              : rawStatus.includes(\"D\")\n                ? \"D\"\n                : \"M\";\n          return {\n            status: rawStatus.trim() || \"M\",\n            statusKind,\n            path: pathValue,\n            addedLines: stats.addedLines,\n            deletedLines: stats.deletedLines,\n          };\n        });\n\n      let repoSlug = envRepoSlug;\n      if (!repoSlug) {\n        const remoteResult = await runGitCommand(\n          [\"remote\", \"get-url\", \"origin\"],\n          kRootResolved,\n        );\n        if (remoteResult.ok) {\n          repoSlug = parseGithubRepoSlug(remoteResult.stdout || \"\");\n        }\n      }\n      const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : \"\";\n\n      const logResult = await runGitCommand(\n        [\n          \"log\",\n          \"--pretty=format:%H%x09%h%x09%s%x09%ct\",\n          \"-n\",\n          String(kCommitHistoryLimit),\n        ],\n        kRootResolved,\n      );\n      const commits = logResult.ok\n        ? logResult.stdout\n            .split(\"\\n\")\n            .map((line) => line.trim())\n            .filter(Boolean)\n            .map((line) => {\n              const [hash = \"\", shortHash = \"\", message = \"\", unixTs = \"0\"] =\n                line.split(\"\\t\");\n              return {\n                hash,\n                shortHash,\n                message,\n                timestamp: Number.parseInt(unixTs, 10) || 0,\n                url: repoSlug && hash ? `${repoUrl}/commit/${hash}` : \"\",\n              };\n            })\n        : [];\n\n      return res.json({\n        ok: true,\n        isRepo: true,\n        repoPath: kRootResolved,\n        repoSlug,\n        repoUrl,\n        branch,\n        upstreamBranch: branchTracking.upstreamBranch,\n        hasUpstream: branchTracking.hasUpstream,\n        upstreamGone: branchTracking.upstreamGone,\n        aheadCount: branchTracking.aheadCount,\n        behindCount: branchTracking.behindCount,\n        syncState: branchTracking.syncState,\n        isDirty: changedFiles.length > 0,\n        changedFilesCount: changedFiles.length,\n        changedFiles: changedFiles.slice(0, 16),\n        commits,\n      });\n    } catch (error) {\n      return res.status(500).json({\n        ok: false,\n        error: error.message || \"Could not build git summary\",\n      });\n    }\n  });\n\n  app.get(\"/api/browse/sqlite-table\", (req, res) => {\n    const resolvedPath = resolveSafePath(\n      req.query.path,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    if (!isSqliteFilePath(resolvedPath.absolutePath)) {\n      return res.status(400).json({ ok: false, error: \"Path is not a sqlite file\" });\n    }\n    const tableName = String(req.query.table || \"\").trim();\n    const limit = req.query.limit;\n    const offset = req.query.offset;\n    const sqliteResult = readSqliteTableData(\n      resolvedPath.absolutePath,\n      tableName,\n      limit,\n      offset,\n    );\n    if (!sqliteResult.ok) {\n      return res.status(400).json({\n        ok: false,\n        error: sqliteResult.error || \"Could not read sqlite table\",\n      });\n    }\n    return res.json({\n      ok: true,\n      path: resolvedPath.relativePath,\n      table: sqliteResult.table,\n      columns: sqliteResult.columns,\n      rows: sqliteResult.rows,\n      limit: sqliteResult.limit,\n      offset: sqliteResult.offset,\n      totalRows: sqliteResult.totalRows,\n    });\n  });\n\n  app.get(\"/api/browse/git-diff\", async (req, res) => {\n    const resolvedPath = resolveSafePath(\n      req.query.path,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    const relativePath = String(resolvedPath.relativePath || \"\").trim();\n    if (!relativePath) {\n      return res.status(400).json({ ok: false, error: \"path is required\" });\n    }\n\n    try {\n      const statusResult = await runGitCommandWithExitCode(\n        [\"status\", \"--porcelain\", \"--\", relativePath],\n        kRootResolved,\n      );\n      if (\n        !statusResult.ok &&\n        /not a git repository/i.test(statusResult.stderr || \"\")\n      ) {\n        return res.status(400).json({ ok: false, error: \"No git repo at this root\" });\n      }\n      const statusLines = statusResult.stdout\n        .split(\"\\n\")\n        .map((line) => line.trim())\n        .filter(Boolean);\n      const rawStatus = statusLines[0]?.slice(0, 2) || \"\";\n      const isUntracked = statusLines.some((line) => line.startsWith(\"??\"));\n      const statusKind =\n        rawStatus === \"??\" || rawStatus.includes(\"A\")\n          ? \"U\"\n          : rawStatus.includes(\"D\")\n            ? \"D\"\n            : \"M\";\n\n      const diffResult = isUntracked\n        ? await runGitCommandWithExitCode(\n            [\"diff\", \"--no-index\", \"--\", \"/dev/null\", resolvedPath.absolutePath],\n            kRootResolved,\n          )\n        : await runGitCommandWithExitCode(\n            [\"diff\", \"HEAD\", \"--\", relativePath],\n            kRootResolved,\n          );\n\n      const untrackedAllowedFailure =\n        isUntracked && diffResult.exitCode === 1 && diffResult.stdout;\n      if (!diffResult.ok && !untrackedAllowedFailure) {\n        return res.status(500).json({\n          ok: false,\n          error: diffResult.stderr || diffResult.error || \"Could not load file diff\",\n        });\n      }\n\n      const content = String(diffResult.stdout || \"\")\n        .replaceAll(resolvedPath.absolutePath, relativePath)\n        .trimEnd();\n      return res.json({\n        ok: true,\n        path: relativePath,\n        content,\n        statusKind,\n        isDeleted: statusKind === \"D\",\n      });\n    } catch (error) {\n      return res.status(500).json({\n        ok: false,\n        error: error.message || \"Could not load file diff\",\n      });\n    }\n  });\n\n  app.post(\"/api/browse/git-sync\", async (req, res) => {\n    try {\n      const commitMessageRaw = String(req.body?.message || \"\").trim();\n      const commitMessage = commitMessageRaw || \"sync changes\";\n      const statusResult = await runGitCommand(\n        [\"status\", \"--porcelain\", \"--branch\"],\n        kRootResolved,\n      );\n      if (!statusResult.ok) {\n        if (/not a git repository/i.test(statusResult.error || \"\")) {\n          return res.status(400).json({ ok: false, error: \"No git repo at this root\" });\n        }\n        return res.status(500).json({\n          ok: false,\n          error: statusResult.error || \"Could not read git status\",\n        });\n      }\n      const statusLines = statusResult.stdout\n        .split(\"\\n\")\n        .map((line) => line.trimEnd())\n        .filter(Boolean);\n      const branchLine = statusLines.find((line) => line.startsWith(\"##\")) || \"\";\n      const branchTracking = parseBranchTracking(branchLine);\n      const hasChanges =\n        statusLines\n          .filter((line) => !line.startsWith(\"##\"))\n          .map((line) => line.trim())\n          .filter(Boolean).length > 0;\n      let committed = false;\n      let pushed = false;\n      let shortHash = \"\";\n      if (!hasChanges) {\n        const hasAheadCommits =\n          branchTracking.hasUpstream && branchTracking.aheadCount > 0;\n        if (!hasAheadCommits) {\n          return res.json({\n            ok: true,\n            committed: false,\n            pushed: false,\n            message: \"No changes to sync\",\n          });\n        }\n      }\n      if (hasChanges) {\n        const addResult = await runGitCommand([\"add\", \"-A\"], kRootResolved);\n        if (!addResult.ok) {\n          return res.status(500).json({\n            ok: false,\n            error: addResult.error || \"Could not stage changes\",\n          });\n        }\n        const commitResult = await runGitCommand(\n          [\"commit\", \"-m\", commitMessage],\n          kRootResolved,\n        );\n        if (!commitResult.ok) {\n          if (/nothing to commit/i.test(commitResult.error || \"\")) {\n            return res.json({\n              ok: true,\n              committed: false,\n              pushed: false,\n              message: \"No changes to sync\",\n            });\n          }\n          return res.status(500).json({\n            ok: false,\n            error: commitResult.error || \"Could not commit changes\",\n          });\n        }\n        committed = true;\n        const shortHashResult = await runGitCommand(\n          [\"rev-parse\", \"--short\", \"HEAD\"],\n          kRootResolved,\n        );\n        shortHash = shortHashResult.ok\n          ? String(shortHashResult.stdout || \"\").trim()\n          : \"\";\n      }\n      const shouldPush = branchTracking.hasUpstream\n        ? branchTracking.aheadCount > 0 || committed\n        : committed;\n      if (shouldPush) {\n        const pushArgs = branchTracking.hasUpstream\n          ? [\"push\"]\n          : [\"push\", \"-u\", \"origin\", \"HEAD\"];\n        const pushResult = await runGitCommand(pushArgs, kRootResolved);\n        if (pushResult.ok) {\n          pushed = true;\n        } else {\n          return res.json({\n            ok: true,\n            committed,\n            pushed: false,\n            shortHash,\n            message: committed\n              ? `Committed ${shortHash || \"changes\"} locally; push failed`\n              : \"Could not push commits\",\n            pushError: pushResult.error || \"Could not push commits\",\n          });\n        }\n      }\n      const syncMessage = pushed\n        ? committed\n          ? `Committed and pushed ${shortHash || \"changes\"}`\n          : \"Pushed local commits\"\n        : committed\n          ? `Committed ${shortHash || \"changes\"}`\n          : \"No changes to sync\";\n      return res.json({\n        ok: true,\n        committed,\n        pushed,\n        shortHash,\n        message: syncMessage,\n      });\n    } catch (error) {\n      return res.status(500).json({\n        ok: false,\n        error: error.message || \"Could not sync changes\",\n      });\n    }\n  });\n\n  app.put(\"/api/browse/write\", async (req, res) => {\n    const { path: targetPath, content } = req.body || {};\n    const resolvedPath = resolveSafePath(\n      targetPath,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath);\n    if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) {\n      return res.status(403).json({\n        ok: false,\n        error: \"This file is managed by AlphaClaw and cannot be edited.\",\n      });\n    }\n    if (typeof content !== \"string\") {\n      return res.status(400).json({ ok: false, error: \"content must be a string\" });\n    }\n\n    try {\n      const stats = fs.statSync(resolvedPath.absolutePath);\n      if (!stats.isFile()) {\n        return res.status(400).json({ ok: false, error: \"Path is not a file\" });\n      }\n      fs.writeFileSync(resolvedPath.absolutePath, content, \"utf8\");\n      return res.json({\n        ok: true,\n        path: resolvedPath.relativePath,\n      });\n    } catch (error) {\n      return res\n        .status(500)\n        .json({ ok: false, error: error.message || \"Could not save file\" });\n    }\n  });\n\n  app.post(\"/api/browse/create-file\", (req, res) => {\n    const targetPath = String(req.body?.path || \"\").trim();\n    if (!targetPath) {\n      return res.status(400).json({ ok: false, error: \"path is required\" });\n    }\n    const resolvedPath = resolveSafePath(\n      targetPath,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath);\n    if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) {\n      return res.status(403).json({\n        ok: false,\n        error: \"Cannot create files in a locked path.\",\n      });\n    }\n    try {\n      if (fs.existsSync(resolvedPath.absolutePath)) {\n        return res\n          .status(409)\n          .json({ ok: false, error: \"A file or folder already exists at this path\" });\n      }\n      const parentDir = path.dirname(resolvedPath.absolutePath);\n      fs.mkdirSync(parentDir, { recursive: true });\n      fs.writeFileSync(resolvedPath.absolutePath, \"\", \"utf8\");\n      return res.json({ ok: true, path: resolvedPath.relativePath });\n    } catch (error) {\n      return res\n        .status(500)\n        .json({ ok: false, error: error.message || \"Could not create file\" });\n    }\n  });\n\n  app.post(\"/api/browse/create-folder\", (req, res) => {\n    const targetPath = String(req.body?.path || \"\").trim();\n    if (!targetPath) {\n      return res.status(400).json({ ok: false, error: \"path is required\" });\n    }\n    const resolvedPath = resolveSafePath(\n      targetPath,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath);\n    if (matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath)) {\n      return res.status(403).json({\n        ok: false,\n        error: \"Cannot create folders in a locked path.\",\n      });\n    }\n    try {\n      if (fs.existsSync(resolvedPath.absolutePath)) {\n        return res\n          .status(409)\n          .json({ ok: false, error: \"A file or folder already exists at this path\" });\n      }\n      fs.mkdirSync(resolvedPath.absolutePath, { recursive: true });\n      return res.json({ ok: true, path: resolvedPath.relativePath });\n    } catch (error) {\n      return res\n        .status(500)\n        .json({ ok: false, error: error.message || \"Could not create folder\" });\n    }\n  });\n\n  app.post(\"/api/browse/move\", (req, res) => {\n    const fromPath = String(req.body?.from || \"\").trim();\n    const toPath = String(req.body?.to || \"\").trim();\n    if (!fromPath || !toPath) {\n      return res.status(400).json({ ok: false, error: \"from and to are required\" });\n    }\n    const resolvedFrom = resolveSafePath(fromPath, kRootResolved, kRootWithSep, kRootDisplayName);\n    if (!resolvedFrom.ok) {\n      return res.status(400).json({ ok: false, error: resolvedFrom.error });\n    }\n    const resolvedTo = resolveSafePath(toPath, kRootResolved, kRootWithSep, kRootDisplayName);\n    if (!resolvedTo.ok) {\n      return res.status(400).json({ ok: false, error: resolvedTo.error });\n    }\n    const normalizedFromPolicy = normalizePolicyPath(resolvedFrom.relativePath);\n    const normalizedToPolicy = normalizePolicyPath(resolvedTo.relativePath);\n    if (\n      matchesPolicyPath(kLockedBrowsePaths, normalizedFromPolicy) ||\n      matchesPolicyPath(kProtectedBrowsePaths, normalizedFromPolicy)\n    ) {\n      return res.status(403).json({ ok: false, error: \"Source path is protected and cannot be moved.\" });\n    }\n    if (matchesPolicyPath(kLockedBrowsePaths, normalizedToPolicy)) {\n      return res.status(403).json({ ok: false, error: \"Cannot move into a locked path.\" });\n    }\n    try {\n      if (!fs.existsSync(resolvedFrom.absolutePath)) {\n        return res.status(404).json({ ok: false, error: \"Source path does not exist\" });\n      }\n      if (fs.existsSync(resolvedTo.absolutePath)) {\n        return res.status(409).json({ ok: false, error: \"A file or folder already exists at the destination\" });\n      }\n      const parentDir = path.dirname(resolvedTo.absolutePath);\n      fs.mkdirSync(parentDir, { recursive: true });\n      fs.renameSync(resolvedFrom.absolutePath, resolvedTo.absolutePath);\n      return res.json({ ok: true, from: resolvedFrom.relativePath, to: resolvedTo.relativePath });\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message || \"Could not move path\" });\n    }\n  });\n\n  app.delete(\"/api/browse/delete\", (req, res) => {\n    const targetPath = String(req.body?.path || \"\").trim();\n    const resolvedPath = resolveSafePath(\n      targetPath,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    const normalizedPolicyPath = normalizePolicyPath(resolvedPath.relativePath);\n    if (\n      matchesPolicyPath(kLockedBrowsePaths, normalizedPolicyPath) ||\n      matchesPolicyPath(kProtectedBrowsePaths, normalizedPolicyPath)\n    ) {\n      return res.status(403).json({\n        ok: false,\n        error: \"This path cannot be deleted from the explorer.\",\n      });\n    }\n    try {\n      if (!fs.existsSync(resolvedPath.absolutePath)) {\n        return res.status(404).json({ ok: false, error: \"Path does not exist\" });\n      }\n      const stats = fs.statSync(resolvedPath.absolutePath);\n      const isDirectory = stats.isDirectory();\n      if (!stats.isFile() && !isDirectory) {\n        return res.status(400).json({ ok: false, error: \"Path is not a file or folder\" });\n      }\n      fs.rmSync(resolvedPath.absolutePath, { recursive: isDirectory, force: true });\n      return res.json({\n        ok: true,\n        path: resolvedPath.relativePath,\n        type: isDirectory ? \"folder\" : \"file\",\n      });\n    } catch (error) {\n      return res.status(500).json({\n        ok: false,\n        error: error.message || \"Could not delete path\",\n      });\n    }\n  });\n\n  app.post(\"/api/browse/restore\", async (req, res) => {\n    const { path: targetPath } = req.body || {};\n    const resolvedPath = resolveSafePath(\n      targetPath,\n      kRootResolved,\n      kRootWithSep,\n      kRootDisplayName,\n    );\n    if (!resolvedPath.ok) {\n      return res.status(400).json({ ok: false, error: resolvedPath.error });\n    }\n    const relativePath = String(resolvedPath.relativePath || \"\").trim();\n    if (!relativePath) {\n      return res.status(400).json({ ok: false, error: \"path is required\" });\n    }\n    const restoreResult = await runGitCommand(\n      [\"restore\", \"--staged\", \"--worktree\", \"--\", relativePath],\n      kRootResolved,\n    );\n    const fallbackResult = !restoreResult.ok\n      ? await runGitCommand([\"checkout\", \"--\", relativePath], kRootResolved)\n      : { ok: true };\n    if (!restoreResult.ok && !fallbackResult.ok) {\n      return res.status(500).json({\n        ok: false,\n        error:\n          restoreResult.error ||\n          fallbackResult.error ||\n          \"Could not restore file from git\",\n      });\n    }\n    return res.json({\n      ok: true,\n      path: relativePath,\n      restored: fs.existsSync(resolvedPath.absolutePath),\n    });\n  });\n};\n\nmodule.exports = { registerBrowseRoutes };\n"
  },
  {
    "path": "lib/server/routes/browse/path-utils.js",
    "content": "const path = require(\"path\");\n\nconst normalizeRelativePath = (inputPath) => {\n  const rawPath = String(inputPath || \"\").trim();\n  if (!rawPath) return \"\";\n  return rawPath.replace(/\\\\/g, \"/\").replace(/^\\/+/, \"\");\n};\n\nconst normalizePolicyPath = (inputPath) =>\n  String(inputPath || \"\")\n    .replace(/\\\\/g, \"/\")\n    .replace(/^\\.\\/+/, \"\")\n    .replace(/^\\/+/, \"\")\n    .trim()\n    .toLowerCase();\n\nconst resolveSafePath = (inputPath, kRootResolved, kRootWithSep, kRootDisplayName) => {\n  const relativePath = normalizeRelativePath(inputPath);\n  const absolutePath = path.resolve(kRootResolved, relativePath);\n  const isInsideRoot =\n    absolutePath === kRootResolved || absolutePath.startsWith(kRootWithSep);\n  if (!isInsideRoot) {\n    return { ok: false, error: `Path must stay within ${kRootDisplayName}` };\n  }\n  return { ok: true, relativePath, absolutePath };\n};\n\nconst toRelativePath = (absolutePath, kRootResolved) => {\n  const relative = path.relative(kRootResolved, absolutePath);\n  return relative === \"\" ? \"\" : relative.split(path.sep).join(\"/\");\n};\n\nconst matchesPolicyPath = (policyPathSet, normalizedPath) => {\n  const safeNormalizedPath = String(normalizedPath || \"\").trim();\n  if (!safeNormalizedPath) return false;\n  for (const policyPath of policyPathSet) {\n    if (\n      safeNormalizedPath === policyPath ||\n      safeNormalizedPath.endsWith(`/${policyPath}`) ||\n      safeNormalizedPath.startsWith(`${policyPath}/`) ||\n      safeNormalizedPath.includes(`/${policyPath}/`)\n    ) {\n      return true;\n    }\n  }\n  return false;\n};\n\nmodule.exports = {\n  normalizeRelativePath,\n  normalizePolicyPath,\n  resolveSafePath,\n  toRelativePath,\n  matchesPolicyPath,\n};\n"
  },
  {
    "path": "lib/server/routes/browse/sqlite.js",
    "content": "const { kSqliteTablePageSize } = require(\"./constants\");\n\nconst quoteSqliteIdentifier = (value) =>\n  `\"${String(value || \"\").replaceAll('\"', '\"\"')}\"`;\n\nconst readSqliteSummary = (targetPath) => {\n  let DatabaseSync = null;\n  try {\n    ({ DatabaseSync } = require(\"node:sqlite\"));\n  } catch {\n    throw new Error(\"SQLite preview is unavailable on this Node runtime\");\n  }\n  const database = new DatabaseSync(targetPath, { readOnly: true });\n  try {\n    const allObjects = database\n      .prepare(\n        `\n          SELECT name, type\n          FROM sqlite_master\n          WHERE type IN ('table', 'view')\n            AND name NOT LIKE 'sqlite_%'\n          ORDER BY type, name\n        `,\n      )\n      .all();\n    const maxObjects = 12;\n    const objects = allObjects.slice(0, maxObjects).map((entry) => {\n      const objectName = String(entry?.name || \"\").trim();\n      const objectType = String(entry?.type || \"table\").trim() || \"table\";\n      if (!objectName) return null;\n      const quotedName = quoteSqliteIdentifier(objectName);\n      const columns = database\n        .prepare(`PRAGMA table_info(${quotedName})`)\n        .all()\n        .map((column) => ({\n          name: String(column?.name || \"\").trim(),\n          type: String(column?.type || \"\").trim(),\n          notNull: Number(column?.notnull || 0) === 1,\n          isPrimaryKey: Number(column?.pk || 0) > 0,\n        }));\n      let sampleRows = [];\n      if (objectType === \"table\") {\n        try {\n          sampleRows = database.prepare(`SELECT * FROM ${quotedName} LIMIT 5`).all();\n        } catch {\n          sampleRows = [];\n        }\n      }\n      return {\n        name: objectName,\n        type: objectType,\n        columns,\n        sampleRows,\n      };\n    });\n    return {\n      totalObjects: allObjects.length,\n      truncated: allObjects.length > maxObjects,\n      objects: objects.filter(Boolean),\n    };\n  } finally {\n    database.close();\n  }\n};\n\nconst clampSqlitePageValue = (value, fallbackValue, maxValue) => {\n  const parsedValue = Number.parseInt(String(value ?? \"\"), 10);\n  if (!Number.isFinite(parsedValue)) return fallbackValue;\n  return Math.max(0, Math.min(maxValue, parsedValue));\n};\n\nconst readSqliteTableData = (targetPath, tableName, limit, offset) => {\n  const safeTableName = String(tableName || \"\").trim();\n  if (!safeTableName) {\n    return { ok: false, error: \"table is required\" };\n  }\n  let DatabaseSync = null;\n  try {\n    ({ DatabaseSync } = require(\"node:sqlite\"));\n  } catch {\n    return { ok: false, error: \"SQLite preview is unavailable on this Node runtime\" };\n  }\n  const database = new DatabaseSync(targetPath, { readOnly: true });\n  try {\n    const quotedTableName = quoteSqliteIdentifier(safeTableName);\n    const tableExists = database\n      .prepare(\n        `\n          SELECT 1\n          FROM sqlite_master\n          WHERE type IN ('table', 'view')\n            AND name = ?\n          LIMIT 1\n        `,\n      )\n      .get(safeTableName);\n    if (!tableExists) {\n      return { ok: false, error: \"table not found\" };\n    }\n    const columns = database\n      .prepare(`PRAGMA table_info(${quotedTableName})`)\n      .all()\n      .map((column) => ({\n        name: String(column?.name || \"\").trim(),\n        type: String(column?.type || \"\").trim(),\n        notNull: Number(column?.notnull || 0) === 1,\n        isPrimaryKey: Number(column?.pk || 0) > 0,\n      }));\n    const totalRowsResult = database\n      .prepare(`SELECT COUNT(*) AS count FROM ${quotedTableName}`)\n      .get();\n    const totalRows = Number(totalRowsResult?.count || 0);\n    const safeLimit =\n      clampSqlitePageValue(limit, kSqliteTablePageSize, 200) || kSqliteTablePageSize;\n    const safeOffset = clampSqlitePageValue(offset, 0, Number.MAX_SAFE_INTEGER);\n    const rows = database\n      .prepare(`SELECT * FROM ${quotedTableName} LIMIT ? OFFSET ?`)\n      .all(safeLimit, safeOffset);\n    return {\n      ok: true,\n      table: safeTableName,\n      columns,\n      rows,\n      limit: safeLimit,\n      offset: safeOffset,\n      totalRows,\n    };\n  } catch (error) {\n    return { ok: false, error: error.message || \"Could not read sqlite table\" };\n  } finally {\n    database.close();\n  }\n};\n\nmodule.exports = {\n  quoteSqliteIdentifier,\n  readSqliteSummary,\n  clampSqlitePageValue,\n  readSqliteTableData,\n};\n"
  },
  {
    "path": "lib/server/routes/codex.js",
    "content": "const crypto = require(\"crypto\");\nconst {\n  CODEX_OAUTH_REDIRECT_URI,\n  CODEX_OAUTH_AUTHORIZE_URL,\n  CODEX_OAUTH_CLIENT_ID,\n  CODEX_OAUTH_SCOPE,\n  CODEX_OAUTH_TOKEN_URL,\n  kCodexOauthStateTtlMs,\n} = require(\"../constants\");\n\nconst createCodexOauthState = () => {\n  const kCodexOauthStates = new Map();\n\n  const cleanupCodexOauthStates = () => {\n    const now = Date.now();\n    for (const [state, value] of kCodexOauthStates.entries()) {\n      if (!value || now - value.createdAt > kCodexOauthStateTtlMs) {\n        kCodexOauthStates.delete(state);\n      }\n    }\n  };\n\n  return { kCodexOauthStates, cleanupCodexOauthStates };\n};\n\nconst registerCodexRoutes = ({\n  app,\n  createPkcePair,\n  parseCodexAuthorizationInput,\n  getCodexAccountId,\n  authProfiles,\n}) => {\n  const { kCodexOauthStates, cleanupCodexOauthStates } = createCodexOauthState();\n\n  app.get(\"/api/codex/status\", (req, res) => {\n    const profile = authProfiles.getCodexProfile();\n    if (!profile) return res.json({ connected: false });\n    res.json({\n      connected: true,\n      profileId: profile.profileId,\n      accountId: profile.accountId || null,\n      expires: typeof profile.expires === \"number\" ? profile.expires : null,\n    });\n  });\n\n  app.get(\"/auth/codex/start\", (req, res) => {\n    try {\n      cleanupCodexOauthStates();\n      const redirectUri = CODEX_OAUTH_REDIRECT_URI;\n      const { verifier, challenge } = createPkcePair();\n      const state = crypto.randomBytes(16).toString(\"hex\");\n      kCodexOauthStates.set(state, { verifier, redirectUri, createdAt: Date.now() });\n\n      const authUrl = new URL(CODEX_OAUTH_AUTHORIZE_URL);\n      authUrl.searchParams.set(\"response_type\", \"code\");\n      authUrl.searchParams.set(\"client_id\", CODEX_OAUTH_CLIENT_ID);\n      authUrl.searchParams.set(\"redirect_uri\", redirectUri);\n      authUrl.searchParams.set(\"scope\", CODEX_OAUTH_SCOPE);\n      authUrl.searchParams.set(\"code_challenge\", challenge);\n      authUrl.searchParams.set(\"code_challenge_method\", \"S256\");\n      authUrl.searchParams.set(\"state\", state);\n      authUrl.searchParams.set(\"id_token_add_organizations\", \"true\");\n      authUrl.searchParams.set(\"codex_cli_simplified_flow\", \"true\");\n      // Keep this aligned with OpenClaw's own Codex OAuth flow.\n      authUrl.searchParams.set(\"originator\", \"pi\");\n      res.redirect(authUrl.toString());\n    } catch (err) {\n      console.error(\"[codex] Failed to start OAuth flow:\", err);\n      res.redirect(\"/setup?codex=error&message=\" + encodeURIComponent(err.message));\n    }\n  });\n\n  app.get(\"/auth/codex/callback\", async (req, res) => {\n    const { code, error, state } = req.query;\n    if (error) {\n      return res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ codex: 'error', message: '${String(error).replace(/'/g, \"\\\\'\")}' }, '*');\n      window.close();\n    </script><p>Codex auth failed. You can close this window.</p></body></html>`);\n    }\n    if (!code || !state) {\n      return res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ codex: 'error', message: 'Missing OAuth state/code' }, '*');\n      window.close();\n    </script><p>Missing OAuth state/code. You can close this window.</p></body></html>`);\n    }\n\n    cleanupCodexOauthStates();\n    const oauthState = kCodexOauthStates.get(String(state));\n    kCodexOauthStates.delete(String(state));\n    if (!oauthState) {\n      return res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ codex: 'error', message: 'State mismatch or expired login attempt' }, '*');\n      window.close();\n    </script><p>State mismatch. You can close this window.</p></body></html>`);\n    }\n\n    try {\n      const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n        body: new URLSearchParams({\n          grant_type: \"authorization_code\",\n          client_id: CODEX_OAUTH_CLIENT_ID,\n          code: String(code),\n          code_verifier: oauthState.verifier,\n          redirect_uri: oauthState.redirectUri,\n        }),\n      });\n      const json = await tokenRes.json().catch(() => ({}));\n      if (\n        !tokenRes.ok ||\n        !json.access_token ||\n        !json.refresh_token ||\n        typeof json.expires_in !== \"number\"\n      ) {\n        throw new Error(`Token exchange failed (${tokenRes.status})`);\n      }\n\n      const access = String(json.access_token);\n      const refresh = String(json.refresh_token);\n      const expires = Date.now() + Number(json.expires_in) * 1000;\n      const accountId = getCodexAccountId(access);\n\n      authProfiles.upsertCodexProfile({ access, refresh, expires, accountId });\n\n      return res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ codex: 'success' }, '*');\n      window.close();\n    </script><p>Codex connected. You can close this window.</p></body></html>`);\n    } catch (err) {\n      console.error(\"[codex] OAuth callback error:\", err);\n      return res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ codex: 'error', message: '${String(err.message || \"OAuth error\").replace(/'/g, \"\\\\'\")}' }, '*');\n      window.close();\n    </script><p>Error: ${String(err.message || \"OAuth error\")}. You can close this window.</p></body></html>`);\n    }\n  });\n\n  app.post(\"/api/codex/exchange\", async (req, res) => {\n    try {\n      cleanupCodexOauthStates();\n      const { input } = req.body || {};\n      const parsed = parseCodexAuthorizationInput(input);\n      const code = String(parsed.code || \"\");\n      const state = String(parsed.state || \"\");\n      if (!code || !state) {\n        return res.status(400).json({\n          ok: false,\n          error: \"Missing code/state. Paste the full redirect URL from your browser address bar.\",\n        });\n      }\n      const oauthState = kCodexOauthStates.get(state);\n      if (!oauthState) {\n        return res.status(400).json({\n          ok: false,\n          error: \"OAuth state expired or invalid. Start Codex OAuth again.\",\n        });\n      }\n      kCodexOauthStates.delete(state);\n      const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n        body: new URLSearchParams({\n          grant_type: \"authorization_code\",\n          client_id: CODEX_OAUTH_CLIENT_ID,\n          code,\n          code_verifier: oauthState.verifier,\n          redirect_uri: oauthState.redirectUri,\n        }),\n      });\n      const json = await tokenRes.json().catch(() => ({}));\n      if (\n        !tokenRes.ok ||\n        !json.access_token ||\n        !json.refresh_token ||\n        typeof json.expires_in !== \"number\"\n      ) {\n        return res.status(400).json({\n          ok: false,\n          error: `Token exchange failed (${tokenRes.status})`,\n        });\n      }\n      const access = String(json.access_token);\n      const refresh = String(json.refresh_token);\n      const expires = Date.now() + Number(json.expires_in) * 1000;\n      const accountId = getCodexAccountId(access);\n      authProfiles.upsertCodexProfile({ access, refresh, expires, accountId });\n      return res.json({ ok: true });\n    } catch (err) {\n      console.error(\"[codex] Manual exchange error:\", err);\n      return res\n        .status(500)\n        .json({ ok: false, error: err.message || \"Codex OAuth exchange failed\" });\n    }\n  });\n\n  app.post(\"/api/codex/disconnect\", (req, res) => {\n    const changed = authProfiles.removeCodexProfiles();\n    res.json({ ok: true, changed });\n  });\n};\n\nmodule.exports = { registerCodexRoutes };\n"
  },
  {
    "path": "lib/server/routes/cron.js",
    "content": "const { parsePositiveInt } = require(\"../utils/number\");\n\nconst registerCronRoutes = ({\n  app,\n  requireAuth,\n  cronService,\n}) => {\n  app.get(\"/api/cron/jobs\", requireAuth, (req, res) => {\n    try {\n      const sortBy = String(req.query.sortBy || \"nextRunAtMs\").trim();\n      const sortDir = String(req.query.sortDir || \"asc\").trim();\n      const result = cronService.listJobs({ sortBy, sortDir });\n      res.json({\n        ok: true,\n        storePath: result.storePath,\n        jobs: result.jobs,\n      });\n    } catch (error) {\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/cron/status\", requireAuth, (req, res) => {\n    try {\n      const status = cronService.getStatus();\n      res.json({ ok: true, status });\n    } catch (error) {\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/cron/jobs/:id/runs\", requireAuth, (req, res) => {\n    try {\n      const runs = cronService.getJobRuns({\n        jobId: req.params.id,\n        limit: parsePositiveInt(req.query.limit, 20),\n        offset: Math.max(0, Number.parseInt(String(req.query.offset || \"0\"), 10) || 0),\n        status: String(req.query.status || \"all\"),\n        deliveryStatus: String(req.query.deliveryStatus || \"all\"),\n        sortDir: String(req.query.sortDir || \"desc\"),\n        query: String(req.query.query || \"\"),\n      });\n      res.json({\n        ok: true,\n        runs: {\n          entries: runs.entries,\n          total: runs.total,\n          offset: runs.offset,\n          limit: runs.limit,\n          hasMore: runs.hasMore,\n          nextOffset: runs.nextOffset,\n        },\n      });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/cron/jobs/:id/run\", requireAuth, async (req, res) => {\n    try {\n      const result = await cronService.runJobNow(req.params.id);\n      res.json({ ok: true, result: result.parsed || result.raw || {} });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/cron/jobs/:id/enable\", requireAuth, async (req, res) => {\n    try {\n      const result = await cronService.setJobEnabled({\n        jobId: req.params.id,\n        enabled: true,\n      });\n      res.json({ ok: true, result: result.parsed || result.raw || {} });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/cron/jobs/:id/disable\", requireAuth, async (req, res) => {\n    try {\n      const result = await cronService.setJobEnabled({\n        jobId: req.params.id,\n        enabled: false,\n      });\n      res.json({ ok: true, result: result.parsed || result.raw || {} });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.put(\"/api/cron/jobs/:id/prompt\", requireAuth, async (req, res) => {\n    try {\n      const message = String(req.body?.message || \"\");\n      const result = await cronService.updateJobPrompt({\n        jobId: req.params.id,\n        message,\n      });\n      res.json({ ok: true, result: result.parsed || result.raw || {} });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.put(\"/api/cron/jobs/:id/routing\", requireAuth, async (req, res) => {\n    try {\n      const sessionTarget = String(req.body?.sessionTarget || \"\").trim();\n      const wakeMode = String(req.body?.wakeMode || \"\").trim();\n      const deliveryMode = String(req.body?.deliveryMode || \"\").trim();\n      const deliveryChannel = String(req.body?.deliveryChannel || \"\").trim();\n      const deliveryTo = String(req.body?.deliveryTo || \"\").trim();\n      const result = await cronService.updateJobRouting({\n        jobId: req.params.id,\n        sessionTarget,\n        wakeMode,\n        deliveryMode,\n        deliveryChannel,\n        deliveryTo,\n      });\n      res.json({ ok: true, result: result.parsed || result.raw || {} });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/cron/jobs/:id/usage\", requireAuth, (req, res) => {\n    try {\n      const days = parsePositiveInt(req.query.days, 0);\n      const sinceMs = days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;\n      const usage = cronService.getJobUsage({\n        jobId: req.params.id,\n        sinceMs,\n      });\n      res.json({ ok: true, usage });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n  app.get(\"/api/cron/jobs/:id/trends\", requireAuth, (req, res) => {\n    try {\n      const trends = cronService.getJobRunTrends({\n        jobId: req.params.id,\n        range: String(req.query.range || \"7d\"),\n      });\n      res.json({ ok: true, trends });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/cron/usage/bulk\", requireAuth, (req, res) => {\n    try {\n      const days = parsePositiveInt(req.query.days, 0);\n      const sinceMs = days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;\n      const usage = cronService.getBulkJobUsage({ sinceMs });\n      res.json({ ok: true, usage });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/cron/runs/bulk\", requireAuth, (req, res) => {\n    try {\n      const sinceMs = Math.max(0, Number.parseInt(String(req.query.sinceMs || \"0\"), 10) || 0);\n      const limitPerJob = parsePositiveInt(req.query.limitPerJob, 20);\n      const runs = cronService.getBulkJobRuns({\n        sinceMs,\n        limitPerJob,\n        status: String(req.query.status || \"all\"),\n        deliveryStatus: String(req.query.deliveryStatus || \"all\"),\n        sortDir: String(req.query.sortDir || \"desc\"),\n      });\n      res.json({ ok: true, runs });\n    } catch (error) {\n      res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n};\n\nmodule.exports = { registerCronRoutes };\n"
  },
  {
    "path": "lib/server/routes/doctor.js",
    "content": "const { kDoctorCardStatus, kDoctorDefaultRunsLimit } = require(\"../doctor/constants\");\n\nconst registerDoctorRoutes = ({ app, requireAuth, doctorService }) => {\n  app.get(\"/api/doctor/status\", requireAuth, (req, res) => {\n    try {\n      res.json({ ok: true, status: doctorService.buildStatus() });\n    } catch (error) {\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/doctor/run\", requireAuth, (req, res) => {\n    try {\n      const result = doctorService.runDoctor();\n      if (!result.ok && result.alreadyRunning) {\n        return res.status(409).json(result);\n      }\n      return res.status(result.reusedPreviousRun ? 200 : 202).json(result);\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/doctor/import\", requireAuth, (req, res) => {\n    try {\n      const result = doctorService.importDoctorResult({\n        rawOutput: req.body?.rawOutput,\n      });\n      return res.status(201).json(result);\n    } catch (error) {\n      return res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/doctor/runs\", requireAuth, (req, res) => {\n    try {\n      const limit = Number.parseInt(String(req.query.limit || kDoctorDefaultRunsLimit), 10);\n      const runs = doctorService.listDoctorRuns({ limit });\n      res.json({ ok: true, runs });\n    } catch (error) {\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/doctor/cards\", requireAuth, (req, res) => {\n    try {\n      const runId = String(req.query.runId || \"\").trim();\n      const cards = doctorService.listDoctorCards({\n        runId: runId || \"all\",\n      });\n      return res.json({ ok: true, cards });\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/doctor/runs/:id\", requireAuth, (req, res) => {\n    try {\n      const run = doctorService.getDoctorRun(req.params.id);\n      if (!run) {\n        return res.status(404).json({ ok: false, error: \"Doctor run not found\" });\n      }\n      return res.json({ ok: true, run });\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.get(\"/api/doctor/runs/:id/cards\", requireAuth, (req, res) => {\n    try {\n      const run = doctorService.getDoctorRun(req.params.id);\n      if (!run) {\n        return res.status(404).json({ ok: false, error: \"Doctor run not found\" });\n      }\n      const cards = doctorService.getDoctorCardsByRunId(req.params.id);\n      return res.json({ ok: true, cards });\n    } catch (error) {\n      return res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/doctor/cards/:id/status\", requireAuth, (req, res) => {\n    try {\n      const requestedStatus = String(req.body?.status || \"\").trim().toLowerCase();\n      if (\n        requestedStatus !== kDoctorCardStatus.open &&\n        requestedStatus !== kDoctorCardStatus.dismissed &&\n        requestedStatus !== kDoctorCardStatus.fixed\n      ) {\n        return res.status(400).json({ ok: false, error: \"Invalid Doctor card status\" });\n      }\n      const card = doctorService.setCardStatus({\n        cardId: req.params.id,\n        status: requestedStatus,\n      });\n      return res.json({ ok: true, card });\n    } catch (error) {\n      if (/not found/i.test(error.message || \"\")) {\n        return res.status(404).json({ ok: false, error: error.message });\n      }\n      return res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n\n  app.post(\"/api/doctor/findings/:id/fix\", requireAuth, async (req, res) => {\n    try {\n      const result = await doctorService.requestCardFix({\n        cardId: req.params.id,\n        sessionId: req.body?.sessionId,\n        replyChannel: req.body?.replyChannel,\n        replyTo: req.body?.replyTo,\n        prompt: req.body?.prompt,\n      });\n      return res.json(result);\n    } catch (error) {\n      if (/not found/i.test(error.message || \"\")) {\n        return res.status(404).json({ ok: false, error: error.message });\n      }\n      return res.status(400).json({ ok: false, error: error.message });\n    }\n  });\n};\n\nmodule.exports = { registerDoctorRoutes };\n"
  },
  {
    "path": "lib/server/routes/gmail.js",
    "content": "const { createGmailWatchService } = require(\"../gmail-watch\");\nconst { createGmailPushHandler } = require(\"../gmail-push\");\n\nconst registerGmailRoutes = ({\n  app,\n  fs,\n  constants,\n  gogCmd,\n  getBaseUrl,\n  readGoogleCredentials,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  restartRequiredState,\n}) => {\n  const getRestartSnapshot = async () => {\n    try {\n      return (await restartRequiredState?.getSnapshot?.()) || {\n        restartRequired: false,\n      };\n    } catch {\n      return { restartRequired: false };\n    }\n  };\n\n  const gmailWatchService = createGmailWatchService({\n    fs,\n    constants,\n    gogCmd,\n    getBaseUrl,\n    readGoogleCredentials,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    restartRequiredState,\n  });\n\n  app.get(\"/api/gmail/config\", (req, res) => {\n    try {\n      const data = gmailWatchService.getConfig({ req });\n      res.json(data);\n    } catch (err) {\n      res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/gmail/config\", (req, res) => {\n    try {\n      const data = gmailWatchService.saveClientConfig({\n        req,\n        body: req.body || {},\n      });\n      res.json(data);\n    } catch (err) {\n      res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/gmail/watch/start\", async (req, res) => {\n    try {\n      const accountId = String(req.body?.accountId || \"\").trim();\n      if (!accountId) return res.status(400).json({ ok: false, error: \"accountId is required\" });\n      const result = await gmailWatchService.startWatch({\n        accountId,\n        req,\n        destination: req.body?.destination || null,\n      });\n      const snapshot = await getRestartSnapshot();\n      return res.json({\n        ...result,\n        restartRequired: Boolean(snapshot?.restartRequired),\n      });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/gmail/watch/stop\", async (req, res) => {\n    try {\n      const accountId = String(req.body?.accountId || \"\").trim();\n      if (!accountId) return res.status(400).json({ ok: false, error: \"accountId is required\" });\n      const result = await gmailWatchService.stopWatch({ accountId });\n      return res.json(result);\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/gmail/watch/renew\", async (req, res) => {\n    try {\n      const accountId = String(req.body?.accountId || \"\").trim();\n      const force = Boolean(req.body?.force ?? true);\n      const result = await gmailWatchService.renewWatch({ accountId, force });\n      return res.json(result);\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/gmail/watch/status\", (req, res) => {\n    try {\n      const accountId = String(req.query?.accountId || \"\").trim();\n      const config = gmailWatchService.getConfig({ req });\n      const accountStatus = accountId\n        ? config.accounts.find((account) => account.accountId === accountId) || null\n        : null;\n      if (accountId && !accountStatus) {\n        return res.status(404).json({ ok: false, error: \"Account status not found\" });\n      }\n      return res.json({\n        ok: true,\n        account: accountStatus,\n        accounts: accountId ? undefined : config.accounts,\n      });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  const pushHandler = createGmailPushHandler({\n    resolvePushToken: () => gmailWatchService.resolvePushToken(),\n    resolveTargetByEmail: (email) => gmailWatchService.getTargetByEmail(email),\n    markPushReceived: (payload) => gmailWatchService.markPushReceived(payload),\n  });\n  app.post(\"/gmail-pubsub\", pushHandler);\n\n  return gmailWatchService;\n};\n\nmodule.exports = {\n  registerGmailRoutes,\n};\n"
  },
  {
    "path": "lib/server/routes/google.js",
    "content": "const {\n  kDefaultGoogleClient,\n  kDefaultGoogleScopes,\n  createGoogleAccountId,\n  readGoogleState,\n  writeGoogleState,\n  listGoogleAccounts,\n  getGoogleAccountById,\n  getGoogleAccountByEmailAndClient,\n  upsertGoogleAccount,\n  removeGoogleAccount,\n} = require(\"../google-state\");\nconst { syncBootstrapPromptFiles } = require(\"../onboarding/workspace\");\nconst { installGogCliSkill } = require(\"../gog-skill\");\nconst { parseJsonSafe } = require(\"../utils/json\");\nconst { quoteShellArg } = require(\"../utils/shell\");\n\nconst uniqueServiceLabels = (scopes) =>\n  Array.from(\n    new Set(\n      (scopes || [])\n        .map((scope) => String(scope || \"\").split(\":\")[0])\n        .filter(Boolean),\n    ),\n  );\n\nconst registerGoogleRoutes = ({\n  app,\n  fs,\n  isGatewayRunning,\n  gogCmd,\n  getBaseUrl,\n  readGoogleCredentials,\n  getApiEnableUrl,\n  constants,\n}) => {\n  const {\n    GOG_CONFIG_DIR,\n    GOG_STATE_PATH,\n    API_TEST_COMMANDS,\n    BASE_SCOPES,\n    SCOPE_MAP,\n    REVERSE_SCOPE_MAP,\n    kMaxGoogleAccounts,\n    gogClientCredentialsPath,\n  } = constants;\n\n  const readState = () => readGoogleState({ fs, statePath: GOG_STATE_PATH });\n  const saveState = (state) => writeGoogleState({ fs, statePath: GOG_STATE_PATH, state });\n  const syncBootstrapTools = (req) => {\n    try {\n      syncBootstrapPromptFiles({\n        fs,\n        workspaceDir: constants.WORKSPACE_DIR,\n        baseUrl: getBaseUrl(req),\n      });\n    } catch {}\n    try {\n      installGogCliSkill({ fs, openclawDir: constants.OPENCLAW_DIR });\n    } catch {}\n  };\n\n  const listAuthenticatedAccounts = async (state) => {\n    const configuredClients = new Set([kDefaultGoogleClient]);\n    listGoogleAccounts(state).forEach((account) => {\n      const client = String(account.client || kDefaultGoogleClient).trim() || kDefaultGoogleClient;\n      configuredClients.add(client);\n    });\n    const combined = [];\n    for (const client of configuredClients) {\n      const command =\n        client === kDefaultGoogleClient\n          ? \"auth list --json --check\"\n          : `--client ${quoteShellArg(client)} auth list --json --check`;\n      const result = await gogCmd(command, { quiet: true });\n      if (!result.ok) continue;\n      const parsed = parseJsonSafe(result.stdout, { accounts: [] });\n      const accounts = Array.isArray(parsed?.accounts) ? parsed.accounts : [];\n      accounts.forEach((entry) => {\n        combined.push({\n          ...entry,\n          client: String(entry.client || client || kDefaultGoogleClient).trim() || kDefaultGoogleClient,\n        });\n      });\n    }\n    return combined;\n  };\n\n  const accountIsAuthenticated = ({ account, authenticatedAccounts }) =>\n    authenticatedAccounts.some(\n      (entry) =>\n        String(entry.email || \"\").trim().toLowerCase() === String(account.email || \"\").trim().toLowerCase() &&\n        String(entry.client || kDefaultGoogleClient).trim() === String(account.client || kDefaultGoogleClient).trim() &&\n        (entry.valid !== false),\n    );\n\n  const getSelectedAccount = ({ state, accountId, fallbackToFirst = true }) => {\n    if (accountId) {\n      return getGoogleAccountById(state, accountId);\n    }\n    return fallbackToFirst ? listGoogleAccounts(state)[0] || null : null;\n  };\n\n  const clearStoredGoogleAuthForEmail = async ({\n    email,\n    preferredClient = kDefaultGoogleClient,\n    extraClients = [],\n  }) => {\n    const normalizedEmail = String(email || \"\").trim();\n    if (!normalizedEmail) return;\n    const clientCandidates = new Set([\n      kDefaultGoogleClient,\n      preferredClient,\n      ...extraClients,\n    ]);\n    for (const clientName of clientCandidates) {\n      const safeClientName =\n        String(clientName || \"\").trim() || kDefaultGoogleClient;\n      const clientArg =\n        safeClientName === kDefaultGoogleClient\n          ? \"\"\n          : `--client ${quoteShellArg(safeClientName)} `;\n      await gogCmd(\n        `${clientArg}auth remove ${quoteShellArg(normalizedEmail)} --force`,\n        { quiet: true },\n      );\n    }\n  };\n\n  const ensureClientCredentials = ({ client, clientId, clientSecret, req }) => {\n    const credentialsPath = gogClientCredentialsPath(client);\n    fs.mkdirSync(GOG_CONFIG_DIR, { recursive: true });\n    const credentials = {\n      web: {\n        client_id: clientId,\n        client_secret: clientSecret,\n        auth_uri: \"https://accounts.google.com/o/oauth2/auth\",\n        token_uri: \"https://oauth2.googleapis.com/token\",\n        redirect_uris: [`${getBaseUrl(req)}/auth/google/callback`],\n      },\n    };\n    fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2));\n    return credentialsPath;\n  };\n\n  app.get(\"/api/google/accounts\", async (req, res) => {\n    const state = readState();\n    const authenticatedAccounts = await listAuthenticatedAccounts(state);\n    const accounts = listGoogleAccounts(state).map((account) => {\n      const activeScopes = account.services || [];\n      const services = uniqueServiceLabels(activeScopes).join(\", \");\n      const hasCredentials = fs.existsSync(gogClientCredentialsPath(account.client));\n      return {\n        ...account,\n        services,\n        activeScopes,\n        hasCredentials,\n        authenticated:\n          hasCredentials &&\n          (Boolean(account.authenticated) || accountIsAuthenticated({ account, authenticatedAccounts })),\n      };\n    });\n    res.json({\n      ok: true,\n      hasCompanyCredentials: fs.existsSync(gogClientCredentialsPath(kDefaultGoogleClient)),\n      hasPersonalCredentials: fs.existsSync(gogClientCredentialsPath(\"personal\")),\n      accounts,\n    });\n  });\n\n  app.get(\"/api/google/status\", async (req, res) => {\n    if (!(await isGatewayRunning())) {\n      return res.json({\n        hasCredentials: false,\n        authenticated: false,\n        email: \"\",\n        services: \"\",\n        activeScopes: [],\n      });\n    }\n    const state = readState();\n    const selected = getSelectedAccount({\n      state,\n      accountId: String(req.query.accountId || \"\"),\n      fallbackToFirst: true,\n    });\n    if (!selected) {\n      return res.json({\n        hasCredentials: false,\n        authenticated: false,\n        email: \"\",\n        services: \"\",\n        activeScopes: [],\n      });\n    }\n    const authenticatedAccounts = await listAuthenticatedAccounts(state);\n    const activeScopes = selected.services || [];\n    const services = uniqueServiceLabels(activeScopes).join(\", \");\n    const hasCredentials = fs.existsSync(gogClientCredentialsPath(selected.client));\n    res.json({\n      accountId: selected.id,\n      client: selected.client,\n      personal: selected.personal,\n      hasCredentials,\n      authenticated:\n        hasCredentials &&\n        (Boolean(selected.authenticated) ||\n          accountIsAuthenticated({ account: selected, authenticatedAccounts })),\n      email: selected.email,\n      services,\n      activeScopes,\n    });\n  });\n\n  app.get(\"/api/google/credentials\", (req, res) => {\n    const state = readState();\n    const accountId = String(req.query.accountId || \"\").trim();\n    const requestedClient = String(req.query.client || \"\").trim();\n    const account = accountId ? getGoogleAccountById(state, accountId) : null;\n    const client =\n      String(account?.client || requestedClient || kDefaultGoogleClient).trim()\n      || kDefaultGoogleClient;\n    const credentials = readGoogleCredentials(client);\n    const hasCredentials = Boolean(credentials.clientId && credentials.clientSecret);\n    res.json({\n      ok: true,\n      client,\n      hasCredentials,\n      clientId: credentials.clientId || \"\",\n      clientSecret: credentials.clientSecret || \"\",\n    });\n  });\n\n  app.post(\"/api/google/credentials\", async (req, res) => {\n    const body = req.body || {};\n    const clientId = String(body.clientId || \"\").trim();\n    const clientSecret = String(body.clientSecret || \"\").trim();\n    const email = String(body.email || \"\").trim();\n    const accountId = String(body.accountId || \"\").trim();\n    const personal = Boolean(body.personal);\n    const client = String(body.client || (personal ? \"personal\" : kDefaultGoogleClient)).trim()\n      || kDefaultGoogleClient;\n    if (!clientId || !clientSecret || !email) {\n      return res.json({ ok: false, error: \"Missing fields\" });\n    }\n\n    try {\n      const state = readState();\n      const existing = accountId ? getGoogleAccountById(state, accountId) : null;\n      const legacyClientsForEmail = listGoogleAccounts(state)\n        .filter(\n          (entry) =>\n            String(entry.email || \"\").trim().toLowerCase() ===\n            email.toLowerCase(),\n        )\n        .map((entry) => String(entry.client || kDefaultGoogleClient).trim());\n      await clearStoredGoogleAuthForEmail({\n        email,\n        preferredClient: client,\n        extraClients: [\n          ...legacyClientsForEmail,\n          String(existing?.client || \"\").trim(),\n        ],\n      });\n      const credentialsPath = ensureClientCredentials({\n        client,\n        clientId,\n        clientSecret,\n        req,\n      });\n      const command = client === kDefaultGoogleClient\n        ? `auth credentials set ${quoteShellArg(credentialsPath)}`\n        : `--client ${quoteShellArg(client)} auth credentials set ${quoteShellArg(credentialsPath)}`;\n      const result = await gogCmd(command, { quiet: true });\n      if (!result.ok) {\n        throw new Error(result.stderr || \"Failed to set Google client credentials\");\n      }\n\n      const { state: nextState, account } = upsertGoogleAccount({\n        state,\n        maxAccounts: kMaxGoogleAccounts,\n        account: {\n          id: existing?.id || accountId || createGoogleAccountId(),\n          email,\n          personal,\n          client,\n          services: body.services || existing?.services || kDefaultGoogleScopes,\n          authenticated: false,\n        },\n      });\n      saveState(nextState);\n      syncBootstrapTools(req);\n\n      res.json({ ok: true, accountId: account.id, account });\n    } catch (err) {\n      console.error(\"[alphaclaw] Failed to save Google credentials:\", err);\n      res.json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/google/accounts\", (req, res) => {\n    const body = req.body || {};\n    const email = String(body.email || \"\").trim();\n    const accountId = String(body.accountId || \"\").trim();\n    const personal = Boolean(body.personal);\n    const client = String(body.client || (personal ? \"personal\" : kDefaultGoogleClient)).trim()\n      || kDefaultGoogleClient;\n    if (!email) {\n      return res.json({ ok: false, error: \"Missing fields\" });\n    }\n    if (!fs.existsSync(gogClientCredentialsPath(client))) {\n      return res.json({\n        ok: false,\n        error: \"Credentials missing for selected client. Save credentials first.\",\n      });\n    }\n    try {\n      const state = readState();\n      const existing = accountId ? getGoogleAccountById(state, accountId) : null;\n      const { state: nextState, account } = upsertGoogleAccount({\n        state,\n        maxAccounts: kMaxGoogleAccounts,\n        account: {\n          id: existing?.id || accountId || createGoogleAccountId(),\n          email,\n          personal,\n          client,\n          services: body.services || existing?.services || kDefaultGoogleScopes,\n          authenticated: Boolean(existing?.authenticated),\n        },\n      });\n      saveState(nextState);\n      syncBootstrapTools(req);\n      res.json({ ok: true, accountId: account.id, account });\n    } catch (err) {\n      res.json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/google/check\", async (req, res) => {\n    const state = readState();\n    const account = getSelectedAccount({\n      state,\n      accountId: String(req.query.accountId || \"\"),\n      fallbackToFirst: true,\n    });\n    if (!account) return res.json({ error: \"No Google account configured\" });\n\n    const enabledServices = uniqueServiceLabels(account.services || []);\n    const results = {};\n    for (const svc of enabledServices) {\n      const cmd = API_TEST_COMMANDS[svc];\n      if (!cmd) continue;\n      const clientArg =\n        account.client === kDefaultGoogleClient\n          ? \"\"\n          : `--client ${quoteShellArg(account.client)} `;\n      const result = await gogCmd(\n        `${clientArg}${cmd} --account ${quoteShellArg(account.email)}`,\n        { quiet: true },\n      );\n      const stderr = result.stderr || \"\";\n      if (stderr.includes(\"has not been used\") || stderr.includes(\"is not enabled\")) {\n        const projectMatch = stderr.match(/project=(\\d+)/);\n        results[svc] = {\n          status: \"not_enabled\",\n          enableUrl: getApiEnableUrl(svc, projectMatch?.[1]),\n        };\n      } else if (result.ok || stderr.includes(\"not found\") || stderr.includes(\"Not Found\")) {\n        results[svc] = { status: \"ok\", enableUrl: getApiEnableUrl(svc) };\n      } else {\n        results[svc] = {\n          status: \"error\",\n          message: result.stderr?.slice(0, 200),\n          enableUrl: getApiEnableUrl(svc),\n        };\n      }\n    }\n    res.json({ accountId: account.id, email: account.email, results });\n  });\n\n  app.post(\"/api/google/disconnect\", async (req, res) => {\n    const accountId = String(req.body?.accountId || \"\").trim();\n    const state = readState();\n    const account = getSelectedAccount({ state, accountId, fallbackToFirst: true });\n    if (!account) return res.json({ ok: true });\n    try {\n      const revokeFile = `/tmp/gog-revoke-${Date.now()}.json`;\n      const clientArg =\n        account.client === kDefaultGoogleClient\n          ? \"\"\n          : `--client ${quoteShellArg(account.client)} `;\n      const exportResult = await gogCmd(\n        `${clientArg}auth tokens export ${quoteShellArg(account.email)} --out ${quoteShellArg(revokeFile)} --overwrite`,\n        { quiet: true },\n      );\n      if (exportResult.ok && fs.existsSync(revokeFile)) {\n        try {\n          const tokenData = parseJsonSafe(fs.readFileSync(revokeFile, \"utf8\"), {});\n          if (tokenData.refresh_token) {\n            await fetch(`https://oauth2.googleapis.com/revoke?token=${tokenData.refresh_token}`, {\n              method: \"POST\",\n            });\n          }\n        } catch {}\n      }\n      try {\n        fs.unlinkSync(revokeFile);\n      } catch {}\n      await gogCmd(\n        `${clientArg}auth remove ${quoteShellArg(account.email)} --force`,\n        { quiet: true },\n      );\n      const { state: nextState } = removeGoogleAccount({\n        state,\n        accountId: account.id,\n      });\n      saveState(nextState);\n      syncBootstrapTools(req);\n      res.json({ ok: true });\n    } catch (err) {\n      console.error(\"[alphaclaw] Google disconnect error:\", err);\n      res.json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/auth/google/start\", (req, res) => {\n    const state = readState();\n    const requestedAccountId = String(req.query.accountId || \"\").trim();\n    const requestedClient = String(req.query.client || \"\").trim();\n    let account = requestedAccountId\n      ? getGoogleAccountById(state, requestedAccountId)\n      : null;\n    if (!account && req.query.email) {\n      account = getGoogleAccountByEmailAndClient(\n        state,\n        String(req.query.email || \"\").trim(),\n        requestedClient || kDefaultGoogleClient,\n      );\n    }\n    const client = account?.client || requestedClient || kDefaultGoogleClient;\n    const email = account?.email || String(req.query.email || \"\").trim();\n    const services = (\n      req.query.services ||\n      (account?.services || kDefaultGoogleScopes).join(\",\")\n    )\n      .split(\",\")\n      .map((scope) => String(scope || \"\").trim())\n      .filter(Boolean);\n    try {\n      const { clientId } = readGoogleCredentials(client);\n      if (!clientId) throw new Error(\"No client_id found\");\n      const scopes = [\n        ...BASE_SCOPES,\n        ...services.map((scope) => SCOPE_MAP[scope]).filter(Boolean),\n      ].join(\" \");\n      const redirectUri = `${getBaseUrl(req)}/auth/google/callback`;\n      const encodedState = Buffer.from(\n        JSON.stringify({\n          accountId: account?.id || requestedAccountId || \"\",\n          client,\n          email,\n          services,\n        }),\n      ).toString(\"base64url\");\n      const authUrl = new URL(\"https://accounts.google.com/o/oauth2/auth\");\n      authUrl.searchParams.set(\"client_id\", clientId);\n      authUrl.searchParams.set(\"redirect_uri\", redirectUri);\n      authUrl.searchParams.set(\"response_type\", \"code\");\n      authUrl.searchParams.set(\"scope\", scopes);\n      authUrl.searchParams.set(\"access_type\", \"offline\");\n      authUrl.searchParams.set(\"prompt\", \"consent\");\n      authUrl.searchParams.set(\"state\", encodedState);\n      if (email) authUrl.searchParams.set(\"login_hint\", email);\n      res.redirect(authUrl.toString());\n    } catch (err) {\n      console.error(\"[alphaclaw] Failed to start Google auth:\", err);\n      res.redirect(`/setup?google=error&message=${encodeURIComponent(err.message)}`);\n    }\n  });\n\n  app.get(\"/auth/google/callback\", async (req, res) => {\n    const { code, error, state } = req.query;\n    if (error) return res.redirect(`/setup?google=error&message=${encodeURIComponent(error)}`);\n    if (!code) return res.redirect(\"/setup?google=error&message=no_code\");\n\n    try {\n      const decodedState = parseJsonSafe(\n        Buffer.from(String(state || \"\"), \"base64url\").toString(),\n        {},\n      );\n      const accountId = String(decodedState.accountId || \"\").trim();\n      const requestedClient = String(decodedState.client || \"\").trim();\n      const stateData = readState();\n      const existingAccount = accountId\n        ? getGoogleAccountById(stateData, accountId)\n        : getGoogleAccountByEmailAndClient(\n            stateData,\n            String(decodedState.email || \"\").trim(),\n            requestedClient || kDefaultGoogleClient,\n          );\n      const client = existingAccount?.client || requestedClient || kDefaultGoogleClient;\n      const { clientId, clientSecret } = readGoogleCredentials(client);\n      if (!clientId || !clientSecret) {\n        throw new Error(`Google credentials missing for client \"${client}\"`);\n      }\n      const redirectUri = `${getBaseUrl(req)}/auth/google/callback`;\n      const tokenRes = await fetch(\"https://oauth2.googleapis.com/token\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n        body: new URLSearchParams({\n          code,\n          client_id: clientId,\n          client_secret: clientSecret,\n          redirect_uri: redirectUri,\n          grant_type: \"authorization_code\",\n        }),\n      });\n      const tokens = await tokenRes.json();\n      if (!tokenRes.ok || tokens.error) {\n        throw new Error(`Google token error: ${tokens.error_description || tokens.error || \"exchange_failed\"}`);\n      }\n\n      if (!tokens.refresh_token && !existingAccount?.authenticated) {\n        throw new Error(\n          \"No refresh token received. Revoke app access at myaccount.google.com/permissions and retry.\",\n        );\n      }\n\n      let email = String(existingAccount?.email || decodedState.email || \"\").trim();\n      if (!email && tokens.access_token) {\n        try {\n          const infoRes = await fetch(\"https://www.googleapis.com/oauth2/v2/userinfo\", {\n            headers: { Authorization: `Bearer ${tokens.access_token}` },\n          });\n          const info = await infoRes.json();\n          email = String(info.email || \"\").trim();\n        } catch {}\n      }\n\n      if (tokens.refresh_token) {\n        const tokenFile = `/tmp/gog-token-${Date.now()}.json`;\n        const tokenData = {\n          email,\n          client,\n          created_at: new Date().toISOString(),\n          refresh_token: tokens.refresh_token,\n        };\n        fs.writeFileSync(tokenFile, JSON.stringify(tokenData, null, 2));\n        const importCmd =\n          client === kDefaultGoogleClient\n            ? `auth tokens import ${quoteShellArg(tokenFile)}`\n            : `--client ${quoteShellArg(client)} auth tokens import ${quoteShellArg(tokenFile)}`;\n        const result = await gogCmd(importCmd, { quiet: true });\n        try {\n          fs.unlinkSync(tokenFile);\n        } catch {}\n        if (!result.ok) {\n          throw new Error(result.stderr || \"Failed to import Google token\");\n        }\n      }\n\n      const requestedServices = Array.isArray(decodedState.services)\n        ? decodedState.services\n        : [];\n      const grantedServices = tokens.scope\n        ? tokens.scope\n            .split(\" \")\n            .map((scope) => REVERSE_SCOPE_MAP[scope])\n            .filter(Boolean)\n        : requestedServices;\n      const { state: nextState, account } = upsertGoogleAccount({\n        state: stateData,\n        maxAccounts: kMaxGoogleAccounts,\n        account: {\n          id: existingAccount?.id || accountId || createGoogleAccountId(),\n          email,\n          personal: Boolean(existingAccount?.personal),\n          client,\n          services: grantedServices.length ? grantedServices : requestedServices,\n          authenticated: true,\n        },\n      });\n      saveState(nextState);\n      syncBootstrapTools(req);\n\n      const safeEmail = String(email || \"\").replace(/'/g, \"\\\\'\");\n      const safeAccountId = String(account.id || \"\").replace(/'/g, \"\\\\'\");\n      res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ google: 'success', accountId: '${safeAccountId}', email: '${safeEmail}' }, '*');\n      window.close();\n    </script><p>Google connected! You can close this window.</p></body></html>`);\n    } catch (err) {\n      console.error(\"[alphaclaw] Google OAuth callback error:\", err);\n      const safeMessage = String(err.message || \"unknown_error\").replace(/'/g, \"\\\\'\");\n      res.send(`<!DOCTYPE html><html><body><script>\n      window.opener?.postMessage({ google: 'error', message: '${safeMessage}' }, '*');\n      window.close();\n    </script><p>Error: ${safeMessage}. You can close this window.</p></body></html>`);\n    }\n  });\n};\n\nmodule.exports = { registerGoogleRoutes };\n"
  },
  {
    "path": "lib/server/routes/models.js",
    "content": "const { kFallbackOnboardingModels } = require(\"../constants\");\nconst { createModelCatalogCache } = require(\"../model-catalog-cache\");\nconst { getCommandOutputCandidates } = require(\"../utils/command-output\");\n\nconst runModelsGitSync = async (shellCmd) => {\n  if (typeof shellCmd !== \"function\") return null;\n  try {\n    await shellCmd('alphaclaw git-sync -m \"models: update config\" -f \"openclaw.json\"', {\n      timeout: 30000,\n    });\n    return null;\n  } catch (err) {\n    return err?.message || \"alphaclaw git-sync failed\";\n  }\n};\n\nconst parseJsonFromShellError = ({\n  error,\n  parseJsonFromNoisyOutput = () => null,\n} = {}) => {\n  for (const rawOutput of getCommandOutputCandidates(error)) {\n    const parsed = parseJsonFromNoisyOutput(rawOutput);\n    if (parsed) return parsed;\n  }\n  return null;\n};\n\nconst registerModelRoutes = ({\n  app,\n  shellCmd,\n  gatewayEnv,\n  parseJsonFromNoisyOutput,\n  normalizeOnboardingModels,\n  readOpenclawVersion,\n  isOnboarded = () => true,\n  authProfiles,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  modelCatalogCache = createModelCatalogCache({\n    shellCmd,\n    gatewayEnv,\n    parseJsonFromNoisyOutput,\n    normalizeOnboardingModels,\n    readOpenclawVersion,\n    shouldStartDynamicRefresh: isOnboarded,\n    fallbackModels: kFallbackOnboardingModels,\n  }),\n}) => {\n  const upsertEnvVar = (items, key, value) => {\n    const next = Array.isArray(items) ? [...items] : [];\n    const existing = next.find((entry) => entry.key === key);\n    if (existing) {\n      existing.value = value;\n      return next;\n    }\n    next.push({ key, value });\n    return next;\n  };\n\n  const removeEnvVar = (items, key) => {\n    const next = Array.isArray(items) ? [...items] : [];\n    return next.filter((entry) => entry.key !== key);\n  };\n\n  const readEnvVarMap = () => {\n    if (typeof readEnvFile !== \"function\") return new Map();\n    return new Map(\n      (readEnvFile() || []).map((entry) => [\n        String(entry?.key || \"\").trim(),\n        String(entry?.value || \"\").trim(),\n      ]),\n    );\n  };\n\n  const buildEnvBackedProfiles = (agentId) => {\n    const envMap = readEnvVarMap();\n    const providers = authProfiles.listApiKeyProviders?.() || [];\n    return providers.flatMap((provider) => {\n      const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider);\n      const envValue = String(envMap.get(envKey) || \"\").trim();\n      if (!envKey || !envValue) return [];\n      const profileId =\n        authProfiles.getDefaultProfileIdForApiKeyProvider?.(provider) ||\n        `${provider}:default`;\n      return [\n        {\n          id: profileId,\n          type: \"api_key\",\n          provider,\n          key: envValue,\n        },\n      ];\n    });\n  };\n\n  const mergeProfilesWithEnvFallback = (profiles, agentId) => {\n    const mergedProfiles = Array.isArray(profiles) ? [...profiles] : [];\n    const profileIndexById = new Map(\n      mergedProfiles.map((profile, index) => [profile?.id, index]),\n    );\n    for (const envProfile of buildEnvBackedProfiles(agentId)) {\n      const existingIndex = profileIndexById.get(envProfile.id);\n      if (existingIndex === undefined) {\n        profileIndexById.set(envProfile.id, mergedProfiles.length);\n        mergedProfiles.push(envProfile);\n        continue;\n      }\n      const existingProfile = mergedProfiles[existingIndex] || {};\n      const existingValue = String(\n        existingProfile?.key || existingProfile?.token || existingProfile?.access || \"\",\n      ).trim();\n      if (existingValue) continue;\n      mergedProfiles[existingIndex] = {\n        ...existingProfile,\n        ...envProfile,\n      };\n    }\n    return mergedProfiles;\n  };\n\n  const syncEnvVarsForProfiles = (profiles) => {\n    if (\n      !Array.isArray(profiles) ||\n      typeof readEnvFile !== \"function\" ||\n      typeof writeEnvFile !== \"function\" ||\n      typeof reloadEnv !== \"function\"\n    ) {\n      return;\n    }\n    let nextEnvVars = readEnvFile();\n    let changed = false;\n    for (const profile of profiles) {\n      if (profile?.type !== \"api_key\") continue;\n      const envKey = authProfiles.getEnvVarForApiKeyProvider?.(profile.provider);\n      const envValue = String(profile?.key || \"\").trim();\n      if (!envKey) continue;\n      const prevValue = String(\n        nextEnvVars.find((entry) => entry.key === envKey)?.value || \"\",\n      );\n      if (!envValue) {\n        if (!prevValue) continue;\n        nextEnvVars = removeEnvVar(nextEnvVars, envKey);\n        changed = true;\n        continue;\n      }\n      if (prevValue === envValue) continue;\n      nextEnvVars = upsertEnvVar(nextEnvVars, envKey, envValue);\n      changed = true;\n    }\n    if (!changed) return;\n    writeEnvFile(nextEnvVars);\n    reloadEnv();\n  };\n\n  const syncProfilesFromEnvVars = (agentId) => {\n    if (\n      typeof readEnvFile !== \"function\" ||\n      typeof authProfiles.upsertApiKeyProfileForEnvVar !== \"function\" ||\n      typeof authProfiles.removeApiKeyProfileForEnvVar !== \"function\"\n    ) {\n      return;\n    }\n    const envMap = readEnvVarMap();\n    const providers = authProfiles.listApiKeyProviders?.() || [];\n    for (const provider of providers) {\n      const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider);\n      if (!envKey) continue;\n      const envValue = String(envMap.get(envKey) || \"\").trim();\n      if (!envValue) {\n        authProfiles.removeApiKeyProfileForEnvVar(provider, agentId);\n        continue;\n      }\n      authProfiles.upsertApiKeyProfileForEnvVar(provider, envValue, agentId);\n    }\n  };\n\n  // ── Existing CLI-backed catalog/status routes ──\n\n  app.get(\"/api/models\", async (req, res) => {\n    const response = await modelCatalogCache.getCatalogResponse();\n    return res.json(response);\n  });\n\n  app.get(\"/api/models/status\", async (req, res) => {\n    try {\n      const output = await shellCmd(\"openclaw models status --json\", {\n        env: gatewayEnv(),\n        timeout: 20000,\n      });\n      const parsed = parseJsonFromNoisyOutput(output) || {};\n      res.json({\n        ok: true,\n        modelKey: parsed.resolvedDefault || parsed.defaultModel || null,\n        fallbacks: parsed.fallbacks || [],\n        imageModel: parsed.imageModel || null,\n      });\n    } catch (err) {\n      const parsed = parseJsonFromShellError({\n        error: err,\n        parseJsonFromNoisyOutput,\n      });\n      if (parsed) {\n        return res.json({\n          ok: true,\n          modelKey: parsed.resolvedDefault || parsed.defaultModel || null,\n          fallbacks: parsed.fallbacks || [],\n          imageModel: parsed.imageModel || null,\n        });\n      }\n      res.json({\n        ok: false,\n        error: err.message || \"Failed to read model status\",\n      });\n    }\n  });\n\n  app.post(\"/api/models/set\", async (req, res) => {\n    const { modelKey } = req.body || {};\n    if (!modelKey || typeof modelKey !== \"string\" || !modelKey.includes(\"/\")) {\n      return res.status(400).json({ ok: false, error: \"Missing modelKey\" });\n    }\n    try {\n      await shellCmd(`openclaw models set \"${modelKey}\"`, {\n        env: gatewayEnv(),\n        timeout: 30000,\n      });\n      modelCatalogCache.markStale();\n      res.json({ ok: true });\n    } catch (err) {\n      res\n        .status(400)\n        .json({ ok: false, error: err.message || \"Failed to set model\" });\n    }\n  });\n\n  // ── Model config (direct JSON) ──\n\n  app.get(\"/api/models/config\", (req, res) => {\n    try {\n      const { primary, configuredModels } = authProfiles.getModelConfig();\n      const agentId = req.query.agentId || undefined;\n      const profiles = mergeProfilesWithEnvFallback(\n        authProfiles.listProfiles(agentId),\n        agentId,\n      );\n      const store = authProfiles.loadAuthStore(agentId);\n      res.json({\n        ok: true,\n        primary,\n        configuredModels,\n        authProfiles: profiles,\n        authOrder: store.order || {},\n      });\n    } catch (err) {\n      res\n        .status(500)\n        .json({ ok: false, error: err.message || \"Failed to read config\" });\n    }\n  });\n\n  app.put(\"/api/models/config\", async (req, res) => {\n    const { primary, configuredModels, profiles, authOrder } = req.body || {};\n    const agentId = req.query.agentId || undefined;\n    if (primary !== undefined && (typeof primary !== \"string\" || !primary.includes(\"/\"))) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"Invalid primary model key\" });\n    }\n    if (\n      configuredModels !== undefined &&\n      (typeof configuredModels !== \"object\" || configuredModels === null)\n    ) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"Invalid configuredModels\" });\n    }\n    try {\n      authProfiles.setModelConfig({ primary, configuredModels });\n\n      if (Array.isArray(profiles)) {\n        for (const { id: profileId, ...credential } of profiles) {\n          if (profileId && credential.type && credential.provider) {\n            authProfiles.upsertProfile(profileId, credential, agentId);\n          }\n        }\n        syncEnvVarsForProfiles(profiles);\n      }\n\n      syncProfilesFromEnvVars(agentId);\n\n      if (authOrder && typeof authOrder === \"object\") {\n        for (const [provider, order] of Object.entries(authOrder)) {\n          if (Array.isArray(order)) {\n            authProfiles.setAuthOrder(provider, order, agentId);\n          }\n        }\n      }\n\n      // `auth-profiles.json` is the durable source of truth. Re-sync\n      // `openclaw.json.auth.profiles` on save so model re-adds restore refs.\n      authProfiles.syncConfigAuthReferencesForAgent(agentId);\n\n      const syncWarning = await runModelsGitSync(shellCmd);\n      modelCatalogCache.markStale();\n      res.json({\n        ok: true,\n        ...(syncWarning ? { syncWarning } : {}),\n      });\n    } catch (err) {\n      res\n        .status(500)\n        .json({ ok: false, error: err.message || \"Failed to save config\" });\n    }\n  });\n\n  // ── Auth profiles (direct JSON) ──\n\n  app.get(\"/api/models/auth\", (req, res) => {\n    try {\n      const agentId = req.query.agentId || undefined;\n      const profiles = authProfiles.listProfiles(agentId);\n      const store = authProfiles.loadAuthStore(agentId);\n      res.json({ ok: true, profiles, order: store.order || {} });\n    } catch (err) {\n      res\n        .status(500)\n        .json({\n          ok: false,\n          error: err.message || \"Failed to read auth profiles\",\n        });\n    }\n  });\n\n  app.put(\"/api/models/auth/:profileId\", (req, res) => {\n    const { profileId } = req.params;\n    const credential = req.body;\n    if (\n      !profileId ||\n      !credential?.type ||\n      !credential?.provider\n    ) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"Missing profileId, type, or provider\" });\n    }\n    const validTypes = new Set([\"api_key\", \"token\", \"oauth\"]);\n    if (!validTypes.has(credential.type)) {\n      return res.status(400).json({\n        ok: false,\n        error: `Invalid credential type: ${credential.type}`,\n      });\n    }\n    try {\n      const agentId = req.query.agentId || undefined;\n      authProfiles.upsertProfile(profileId, credential, agentId);\n      syncEnvVarsForProfiles([{ id: profileId, ...credential }]);\n      modelCatalogCache.markStale();\n      res.json({ ok: true });\n    } catch (err) {\n      res\n        .status(500)\n        .json({\n          ok: false,\n          error: err.message || \"Failed to save auth profile\",\n        });\n    }\n  });\n\n  app.delete(\"/api/models/auth/:profileId\", (req, res) => {\n    const { profileId } = req.params;\n    if (!profileId) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"Missing profileId\" });\n    }\n    try {\n      const agentId = req.query.agentId || undefined;\n      const removed = authProfiles.removeProfile(profileId, agentId);\n      modelCatalogCache.markStale();\n      res.json({ ok: true, removed });\n    } catch (err) {\n      res\n        .status(500)\n        .json({\n          ok: false,\n          error: err.message || \"Failed to remove auth profile\",\n        });\n    }\n  });\n};\n\nmodule.exports = { registerModelRoutes };\n"
  },
  {
    "path": "lib/server/routes/nodes.js",
    "content": "const crypto = require(\"crypto\");\nconst { parseJsonObjectFromNoisyOutput } = require(\"../utils/json\");\nconst { quoteShellArg } = require(\"../utils/shell\");\nconst {\n  readExecApprovalsConfig,\n  writeExecApprovalsConfig,\n} = require(\"../exec-defaults-config\");\n\nconst kAllowedExecHosts = new Set([\"gateway\", \"node\"]);\nconst kAllowedExecSecurity = new Set([\"deny\", \"allowlist\", \"full\"]);\nconst kAllowedExecAsk = new Set([\"off\", \"on-miss\", \"always\"]);\nconst kSafeNodeIdPattern = /^[\\w\\-:.]+$/;\nconst kNodeBrowserInvokeTimeoutMs = 30000;\nconst kNodeBrowserCliTimeoutMs = 35000;\nconst kDefaultNodeRouteCliTimeoutMs = 12000;\nconst kDefaultNodesStatusCliTimeoutMs = 12000;\nconst kDefaultNodesPendingCliTimeoutMs = 12000;\n\nconst quoteCliArg = (value) => quoteShellArg(value, { strategy: \"single\" });\n\nconst resolveCliTimeoutMs = (envName, fallbackMs, env = process.env) => {\n  const parsed = Number(env[envName]);\n  if (!Number.isFinite(parsed) || parsed <= 0) return fallbackMs;\n  return Math.round(parsed);\n};\n\nconst resolveNodeCliTimeouts = (env = process.env) => ({\n  route: resolveCliTimeoutMs(\n    \"ALPHACLAW_NODE_ROUTE_TIMEOUT_MS\",\n    kDefaultNodeRouteCliTimeoutMs,\n    env,\n  ),\n  status: resolveCliTimeoutMs(\n    \"ALPHACLAW_NODES_STATUS_TIMEOUT_MS\",\n    kDefaultNodesStatusCliTimeoutMs,\n    env,\n  ),\n  pending: resolveCliTimeoutMs(\n    \"ALPHACLAW_NODES_PENDING_TIMEOUT_MS\",\n    kDefaultNodesPendingCliTimeoutMs,\n    env,\n  ),\n});\n\nconst isCliTimeoutResult = (result) =>\n  Boolean(result?.timedOut || (result?.killed && result?.signal));\n\nconst formatCliFailure = ({ result, fallback, timeoutLabel, timeoutMs }) => {\n  if (isCliTimeoutResult(result)) {\n    return `${timeoutLabel} CLI timed out after ${timeoutMs}ms`;\n  }\n  return String(result?.stderr || \"\").trim() || fallback;\n};\n\nconst normalizeExecAsk = (value) => {\n  const normalized = String(value || \"\").trim().toLowerCase();\n  if (normalized === \"on\") return \"on-miss\";\n  return normalized;\n};\n\nconst buildDefaultExecConfig = () => ({\n  host: \"gateway\",\n  security: \"allowlist\",\n  ask: \"on-miss\",\n  node: \"\",\n});\n\nconst parseNodesStatus = (stdout) => {\n  const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};\n  const nodes = Array.isArray(parsed.nodes) ? parsed.nodes : [];\n  const pending = Array.isArray(parsed.pending)\n    ? parsed.pending\n    : nodes.filter((entry) => entry && entry.paired === false);\n  return { nodes, pending };\n};\n\nconst parseNodesPending = (stdout) => {\n  const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};\n  const list = Array.isArray(parsed.pending)\n    ? parsed.pending\n    : Array.isArray(parsed.requests)\n      ? parsed.requests\n      : Array.isArray(parsed.nodes)\n        ? parsed.nodes\n        : [];\n  return list\n    .map((entry) => {\n      if (!entry || typeof entry !== \"object\") return null;\n      const requestId = String(entry.requestId || entry.id || \"\").trim();\n      const nodeId = String(entry.nodeId || requestId).trim();\n      if (!nodeId) return null;\n      return {\n        ...entry,\n        id: requestId || nodeId,\n        nodeId,\n        paired: false,\n      };\n    })\n    .filter(Boolean);\n};\n\nconst parseNodeBrowserStatus = (stdout) => {\n  const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};\n  const payload =\n    parsed.payload && typeof parsed.payload === \"object\" ? parsed.payload : {};\n  const payloadResult = payload.result;\n  let decodedResult = payloadResult;\n  if (typeof decodedResult === \"string\") {\n    const parsedResult = parseJsonObjectFromNoisyOutput(decodedResult);\n    decodedResult = parsedResult || decodedResult;\n  }\n  if (decodedResult && typeof decodedResult === \"object\" && decodedResult.result) {\n    const nestedResult = decodedResult.result;\n    if (nestedResult && typeof nestedResult === \"object\") {\n      decodedResult = nestedResult;\n    }\n  }\n  return decodedResult && typeof decodedResult === \"object\" ? decodedResult : null;\n};\n\nconst ensureWildcardAgent = (file) => {\n  const agents = file.agents && typeof file.agents === \"object\" ? file.agents : {};\n  const wildcard =\n    agents[\"*\"] && typeof agents[\"*\"] === \"object\" ? agents[\"*\"] : {};\n  const allowlist = Array.isArray(wildcard.allowlist) ? wildcard.allowlist : [];\n  agents[\"*\"] = { ...wildcard, allowlist };\n  return { ...file, version: 1, agents };\n};\n\nconst resolveSetupUiBaseUrl = (req) => {\n  const explicit = String(\n    process.env.ALPHACLAW_SETUP_URL ||\n      process.env.ALPHACLAW_BASE_URL ||\n      process.env.RENDER_EXTERNAL_URL ||\n      process.env.URL ||\n      \"\",\n  )\n    .trim()\n    .replace(/\\/+$/, \"\");\n  if (explicit) return explicit;\n\n  const railwayPublicDomain = String(process.env.RAILWAY_PUBLIC_DOMAIN || \"\").trim();\n  if (railwayPublicDomain) {\n    return `https://${railwayPublicDomain}`;\n  }\n\n  const railwayStaticUrl = String(process.env.RAILWAY_STATIC_URL || \"\")\n    .trim()\n    .replace(/\\/+$/, \"\");\n  if (railwayStaticUrl) return railwayStaticUrl;\n\n  const forwardedProto = String(req.headers[\"x-forwarded-proto\"] || \"\").trim();\n  const forwardedHost = String(req.headers[\"x-forwarded-host\"] || \"\").trim();\n  if (forwardedProto && forwardedHost) {\n    return `${forwardedProto}://${forwardedHost}`;\n  }\n\n  const reqProtocol = req.protocol || \"http\";\n  const reqHost = req.get(\"host\");\n  if (reqHost) {\n    return `${reqProtocol}://${reqHost}`;\n  }\n\n  return \"http://localhost:3000\";\n};\n\nconst parseBaseUrlParts = (baseUrl) => {\n  try {\n    const parsed = new URL(baseUrl);\n    const tls = parsed.protocol === \"https:\";\n    const port =\n      Number(parsed.port) || (tls ? 443 : 80);\n    return {\n      baseUrl: parsed.origin,\n      host: parsed.hostname,\n      port,\n      tls,\n    };\n  } catch {\n    return {\n      baseUrl: \"http://localhost:3000\",\n      host: \"localhost\",\n      port: 3000,\n      tls: false,\n    };\n  }\n};\n\nconst registerNodeRoutes = ({\n  app,\n  clawCmd,\n  openclawDir,\n  gatewayToken = \"\",\n  fsModule,\n}) => {\n  const cliTimeouts = resolveNodeCliTimeouts();\n\n  app.get(\"/api/nodes\", async (_req, res) => {\n    const statusResult = await clawCmd(\"nodes status --json\", {\n      quiet: true,\n      timeoutMs: cliTimeouts.status,\n    });\n    if (!statusResult.ok) {\n      return res.status(500).json({\n        ok: false,\n        error: formatCliFailure({\n          result: statusResult,\n          fallback: \"Could not load nodes status\",\n          timeoutLabel: \"nodes status\",\n          timeoutMs: cliTimeouts.status,\n        }),\n      });\n    }\n    const status = parseNodesStatus(statusResult.stdout);\n    const pendingResult = await clawCmd(\"nodes pending --json\", {\n      quiet: true,\n      timeoutMs: cliTimeouts.pending,\n    });\n    const pending = pendingResult.ok\n      ? parseNodesPending(pendingResult.stdout)\n      : status.pending;\n    const pendingById = new Map();\n    for (const entry of pending) {\n      const nodeId = String(entry?.nodeId || entry?.id || \"\").trim();\n      if (!nodeId || pendingById.has(nodeId)) continue;\n      pendingById.set(nodeId, entry);\n    }\n    return res.json({\n      ok: true,\n      nodes: status.nodes,\n      pending: Array.from(pendingById.values()),\n    });\n  });\n\n  app.post(\"/api/nodes/:id/approve\", async (req, res) => {\n    const nodeId = String(req.params.id || \"\").trim();\n    if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {\n      return res.status(400).json({ ok: false, error: \"Invalid node id\" });\n    }\n    const result = await clawCmd(`nodes approve ${quoteCliArg(nodeId)}`);\n    if (!result.ok) {\n      return res.status(500).json({\n        ok: false,\n        error: result.stderr || \"Could not approve node\",\n      });\n    }\n    return res.json({ ok: true });\n  });\n\n  app.post(\"/api/nodes/:id/route\", async (req, res) => {\n    const nodeId = String(req.params.id || \"\").trim();\n    if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {\n      return res.status(400).json({ ok: false, error: \"Invalid node id\" });\n    }\n    const commands = [\n      \"config set tools.exec.host 'node'\",\n      \"config set tools.exec.security 'allowlist'\",\n      \"config set tools.exec.ask 'on-miss'\",\n      `config set tools.exec.node ${quoteCliArg(nodeId)}`,\n    ];\n    for (const command of commands) {\n      const result = await clawCmd(command, {\n        quiet: true,\n        timeoutMs: cliTimeouts.route,\n      });\n      if (!result.ok) {\n        return res.status(500).json({\n          ok: false,\n          error: formatCliFailure({\n            result,\n            fallback: `Could not apply node routing (${command})`,\n            timeoutLabel: \"node routing\",\n            timeoutMs: cliTimeouts.route,\n          }),\n        });\n      }\n    }\n    return res.json({ ok: true, restartRequired: true, nodeId });\n  });\n\n  app.delete(\"/api/nodes/:id\", async (req, res) => {\n    const nodeId = String(req.params.id || \"\").trim();\n    if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {\n      return res.status(400).json({ ok: false, error: \"Invalid node id\" });\n    }\n    const result = await clawCmd(`devices remove ${quoteCliArg(nodeId)}`, {\n      quiet: true,\n    });\n    if (!result.ok) {\n      return res.status(500).json({\n        ok: false,\n        error: result.stderr || \"Could not remove node\",\n      });\n    }\n    return res.json({ ok: true, nodeId });\n  });\n\n  app.get(\"/api/nodes/connect-info\", async (req, res) => {\n    const baseUrl = resolveSetupUiBaseUrl(req);\n    const parsed = parseBaseUrlParts(baseUrl);\n    return res.json({\n      ok: true,\n      baseUrl: parsed.baseUrl,\n      gatewayHost: parsed.host,\n      gatewayPort: parsed.port,\n      gatewayToken: String(gatewayToken || \"\"),\n      tls: parsed.tls,\n    });\n  });\n\n  app.get(\"/api/nodes/:id/browser-status\", async (req, res) => {\n    const nodeId = String(req.params.id || \"\").trim();\n    if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {\n      return res.status(400).json({ ok: false, error: \"Invalid node id\" });\n    }\n    const profile = String(req.query?.profile || \"user\").trim() || \"user\";\n    const params = JSON.stringify({\n      method: \"GET\",\n      path: \"/\",\n      query: { profile },\n    });\n    const result = await clawCmd(\n      `nodes invoke --node ${quoteCliArg(nodeId)} --command browser.proxy --params ${quoteCliArg(params)} --invoke-timeout ${kNodeBrowserInvokeTimeoutMs} --json`,\n      { quiet: true, timeoutMs: kNodeBrowserCliTimeoutMs },\n    );\n    if (!result.ok) {\n      return res.status(500).json({\n        ok: false,\n        error: result.stderr || \"Could not probe node browser status\",\n      });\n    }\n    const status = parseNodeBrowserStatus(result.stdout);\n    if (!status) {\n      return res.status(500).json({\n        ok: false,\n        error: \"Could not parse node browser status\",\n      });\n    }\n    return res.json({ ok: true, status, profile });\n  });\n\n  app.get(\"/api/nodes/exec-config\", async (_req, res) => {\n    const result = await clawCmd(\"config get tools.exec --json\", { quiet: true });\n    if (!result.ok) {\n      return res.json({ ok: true, config: buildDefaultExecConfig() });\n    }\n    const parsed = parseJsonObjectFromNoisyOutput(result.stdout) || {};\n    const config = buildDefaultExecConfig();\n    const host = String(parsed.host || \"\").trim().toLowerCase();\n    const security = String(parsed.security || \"\").trim().toLowerCase();\n    const ask = normalizeExecAsk(parsed.ask);\n    const node = String(parsed.node || \"\").trim();\n    if (kAllowedExecHosts.has(host)) config.host = host;\n    if (kAllowedExecSecurity.has(security)) config.security = security;\n    if (kAllowedExecAsk.has(ask)) config.ask = ask;\n    if (node) config.node = node;\n    return res.json({ ok: true, config });\n  });\n\n  app.post(\"/api/nodes/exec-config\", async (req, res) => {\n    const body = req.body || {};\n    const host = String(body.host || \"\").trim().toLowerCase();\n    const security = String(body.security || \"\").trim().toLowerCase();\n    const ask = normalizeExecAsk(body.ask);\n    const node = String(body.node || \"\").trim();\n    if (!kAllowedExecHosts.has(host)) {\n      return res.status(400).json({ ok: false, error: \"Invalid exec host\" });\n    }\n    if (!kAllowedExecSecurity.has(security)) {\n      return res.status(400).json({ ok: false, error: \"Invalid exec security\" });\n    }\n    if (!kAllowedExecAsk.has(ask)) {\n      return res.status(400).json({ ok: false, error: \"Invalid exec ask mode\" });\n    }\n    if (host === \"node\" && !node) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"Node target is required when host is node\" });\n    }\n\n    const commands = [\n      `config set tools.exec.host ${quoteCliArg(host)}`,\n      `config set tools.exec.security ${quoteCliArg(security)}`,\n      `config set tools.exec.ask ${quoteCliArg(ask)}`,\n      host === \"node\"\n        ? `config set tools.exec.node ${quoteCliArg(node)}`\n        : \"config set tools.exec.node ''\",\n    ];\n\n    for (const command of commands) {\n      const result = await clawCmd(command);\n      if (!result.ok) {\n        return res.status(500).json({\n          ok: false,\n          error: result.stderr || `Could not apply exec config (${command})`,\n        });\n      }\n    }\n\n    return res.json({ ok: true, restartRequired: true });\n  });\n\n  app.get(\"/api/nodes/exec-approvals\", (_req, res) => {\n    const approvals = ensureWildcardAgent(\n      readExecApprovalsConfig({ fsModule, openclawDir }),\n    );\n    const allowlist = approvals?.agents?.[\"*\"]?.allowlist || [];\n    return res.json({\n      ok: true,\n      file: approvals,\n      allowlist,\n    });\n  });\n\n  app.post(\"/api/nodes/exec-approvals/allowlist\", (req, res) => {\n    const pattern = String(req.body?.pattern || \"\").trim();\n    if (!pattern) {\n      return res.status(400).json({ ok: false, error: \"pattern is required\" });\n    }\n    const approvals = ensureWildcardAgent(\n      readExecApprovalsConfig({ fsModule, openclawDir }),\n    );\n    const allowlist = approvals.agents[\"*\"].allowlist;\n    const existing = allowlist.find(\n      (entry) => String(entry?.pattern || \"\").trim() === pattern,\n    );\n    if (existing) {\n      return res.json({ ok: true, entry: existing, unchanged: true });\n    }\n    const entry = {\n      pattern,\n      id: crypto.randomUUID(),\n      lastUsedAt: Date.now(),\n    };\n    approvals.agents[\"*\"].allowlist = [...allowlist, entry];\n    writeExecApprovalsConfig({ fsModule, openclawDir, file: approvals });\n    return res.json({ ok: true, entry });\n  });\n\n  app.delete(\"/api/nodes/exec-approvals/allowlist/:id\", (req, res) => {\n    const id = String(req.params.id || \"\").trim();\n    if (!id) {\n      return res.status(400).json({ ok: false, error: \"id is required\" });\n    }\n    const approvals = ensureWildcardAgent(\n      readExecApprovalsConfig({ fsModule, openclawDir }),\n    );\n    const allowlist = approvals.agents[\"*\"].allowlist;\n    const nextAllowlist = allowlist.filter((entry) => String(entry?.id || \"\") !== id);\n    if (nextAllowlist.length === allowlist.length) {\n      return res.status(404).json({ ok: false, error: \"Allowlist entry not found\" });\n    }\n    approvals.agents[\"*\"].allowlist = nextAllowlist;\n    writeExecApprovalsConfig({ fsModule, openclawDir, file: approvals });\n    return res.json({ ok: true });\n  });\n};\n\nmodule.exports = {\n  registerNodeRoutes,\n};\n"
  },
  {
    "path": "lib/server/routes/onboarding.js",
    "content": "const {\n  createOnboardingService,\n  getImportedPlaceholderReview,\n} = require(\"../onboarding\");\nconst path = require(\"path\");\nconst { scanWorkspace } = require(\"../onboarding/import/import-scanner\");\nconst {\n  detectSecrets,\n  extractPreFillValues,\n} = require(\"../onboarding/import/secret-detector\");\nconst {\n  promoteCloneToTarget,\n  alignHookTransforms,\n  applySecretExtraction,\n  canonicalizeConfigEnvRefs,\n  isValidTempDir,\n} = require(\"../onboarding/import/import-applier\");\nconst { cleanupTempClone } = require(\"../onboarding/github\");\n\nconst sanitizeOnboardingError = (error) => {\n  const raw = [error?.stderr, error?.stdout, error?.message]\n    .filter((value) => typeof value === \"string\" && value.trim())\n    .join(\"\\n\");\n  const redacted = String(raw || \"Onboarding failed\")\n    .replace(/sk-[^\\s\"]+/g, \"***\")\n    .replace(/ghp_[^\\s\"]+/g, \"***\")\n    .replace(/github_pat_[^\\s\"]+/g, \"***\")\n    .replace(/(?:token|api[_-]?key)[\"'\\s:=]+[^\\s\"']+/gi, (match) =>\n      match.replace(/[^\\s\"':=]+$/g, \"***\"),\n    );\n  const lower = redacted.toLowerCase();\n  if (\n    lower.includes(\"heap out of memory\") ||\n    lower.includes(\"allocation failed\") ||\n    lower.includes(\"fatal error: ineffective mark-compacts\")\n  ) {\n    return \"Onboarding ran out of memory. Please retry, and if it persists increase instance memory.\";\n  }\n  if (\n    lower.includes(\"permission denied\") ||\n    lower.includes(\"denied to\") ||\n    lower.includes(\"permission to\") ||\n    lower.includes(\"insufficient\") ||\n    lower.includes(\"not accessible by integration\") ||\n    lower.includes(\"could not read from remote repository\") ||\n    lower.includes(\"repository not found\")\n  ) {\n    return \"GitHub access failed. Verify your token permissions and workspace repo, then try again.\";\n  }\n  if (\n    lower.includes(\"already exists\") &&\n    (lower.includes(\"repo\") || lower.includes(\"repository\"))\n  ) {\n    return \"Repository setup failed because the target repo already exists or is unavailable.\";\n  }\n  if (\n    lower.includes(\"invalid api key\") ||\n    lower.includes(\"invalid_api_key\") ||\n    lower.includes(\"unauthorized\") ||\n    lower.includes(\"authentication failed\") ||\n    lower.includes(\"invalid token\")\n  ) {\n    return \"Model provider authentication failed. Check your API key/token and try again.\";\n  }\n  if (\n    lower.includes(\"etimedout\") ||\n    lower.includes(\"econnreset\") ||\n    lower.includes(\"enotfound\") ||\n    lower.includes(\"network\") ||\n    lower.includes(\"timed out\")\n  ) {\n    return \"Network error during onboarding. Please retry in a minute.\";\n  }\n  if (lower.includes(\"command failed: openclaw onboard\")) {\n    return \"Onboarding command failed. Please verify credentials and try again.\";\n  }\n  return redacted.slice(0, 300);\n};\n\nconst registerOnboardingRoutes = ({\n  app,\n  fs,\n  constants,\n  shellCmd,\n  gatewayEnv,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  isOnboarded,\n  resolveGithubRepoUrl,\n  resolveModelProvider,\n  hasCodexOauthProfile,\n  authProfiles,\n  ensureGatewayProxyConfig,\n  getBaseUrl,\n  startGateway,\n}) => {\n  // Keep mutating onboarding routes marker-gated so in-progress imports\n  // can promote files before the final completion marker is written.\n  const hasExplicitOnboardingMarker = () =>\n    fs.existsSync(constants.kOnboardingMarkerPath);\n\n  const onboardingService = createOnboardingService({\n    fs,\n    constants,\n    shellCmd,\n    gatewayEnv,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    resolveGithubRepoUrl,\n    resolveModelProvider,\n    hasCodexOauthProfile,\n    authProfiles,\n    ensureGatewayProxyConfig,\n    getBaseUrl,\n    startGateway,\n  });\n\n  const kEnvVarNamePattern = /^[A-Z_][A-Z0-9_]*$/;\n  const validateApprovedSecrets = ({ approvedSecrets = [], scannedSecrets = [] }) => {\n    if (!Array.isArray(approvedSecrets)) return { ok: true, secrets: [] };\n    const scannedByFingerprint = new Map(\n      scannedSecrets.map((secret) => [\n        [\n          String(secret?.configPath || \"\"),\n          String(secret?.file || \"\"),\n          String(secret?.value || \"\"),\n        ].join(\"\\u0000\"),\n        secret,\n      ]),\n    );\n    const secrets = [];\n    for (const approvedSecret of approvedSecrets) {\n      const fingerprint = [\n        String(approvedSecret?.configPath || \"\"),\n        String(approvedSecret?.file || \"\"),\n        String(approvedSecret?.value || \"\"),\n      ].join(\"\\u0000\");\n      const scannedSecret = scannedByFingerprint.get(fingerprint);\n      const envVarName = String(approvedSecret?.suggestedEnvVar || \"\").trim();\n      if (!scannedSecret || !envVarName || !kEnvVarNamePattern.test(envVarName)) {\n        return {\n          ok: false,\n          error: \"Invalid approved secrets payload\",\n        };\n      }\n      secrets.push({\n        ...scannedSecret,\n        suggestedEnvVar: envVarName,\n      });\n    }\n    return { ok: true, secrets };\n  };\n\n  app.get(\"/api/onboard/status\", (req, res) => {\n    res.json({ onboarded: hasExplicitOnboardingMarker() });\n  });\n\n  app.post(\"/api/onboard\", async (req, res) => {\n    if (hasExplicitOnboardingMarker())\n      return res.json({ ok: false, error: \"Already onboarded\" });\n\n    try {\n      const { vars, modelKey, importMode } = req.body;\n      const result = await onboardingService.completeOnboarding({\n        req,\n        vars,\n        modelKey,\n        importMode: !!importMode,\n      });\n      res.status(result.status).json(result.body);\n    } catch (err) {\n      console.error(\"[onboard] Error:\", err);\n      res.status(500).json({ ok: false, error: sanitizeOnboardingError(err) });\n    }\n  });\n\n  app.post(\"/api/onboard/github/verify\", async (req, res) => {\n    if (hasExplicitOnboardingMarker()) {\n      return res.json({ ok: false, error: \"Already onboarded\" });\n    }\n\n    try {\n      const githubRepoInput = String(req.body?.repo || \"\").trim();\n      const githubToken = String(req.body?.token || \"\").trim();\n      const mode = String(req.body?.mode || \"new\").trim();\n      if (!githubRepoInput || !githubToken) {\n        return res.status(400).json({\n          ok: false,\n          error: \"GitHub token and workspace repo are required\",\n        });\n      }\n\n      const result = await onboardingService.verifyGithubSetup({\n        githubRepoInput,\n        githubToken,\n        mode,\n        resolveGithubRepoUrl,\n      });\n      if (!result.ok) {\n        return res\n          .status(result.status || 400)\n          .json({ ok: false, error: result.error });\n      }\n      return res.json({\n        ok: true,\n        repoExists: result.repoExists || false,\n        repoIsEmpty: result.repoIsEmpty || false,\n        tempDir: result.tempDir || null,\n      });\n    } catch (err) {\n      console.error(\"[onboard] GitHub verify error:\", err);\n      return res\n        .status(500)\n        .json({ ok: false, error: sanitizeOnboardingError(err) });\n    }\n  });\n  app.post(\"/api/onboard/import/scan\", async (req, res) => {\n    if (hasExplicitOnboardingMarker()) {\n      return res.json({ ok: false, error: \"Already onboarded\" });\n    }\n\n    try {\n      const tempDir = String(req.body?.tempDir || \"\").trim();\n      if (!tempDir || !isValidTempDir(tempDir)) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"Invalid temp directory\" });\n      }\n      if (!fs.existsSync(tempDir)) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"Temp directory not found\" });\n      }\n\n      const scan = scanWorkspace({ fs, baseDir: tempDir });\n      if (!scan.sourceLayout?.supported) {\n        cleanupTempClone(tempDir);\n        return res.status(400).json({\n          ok: false,\n          error: scan.sourceLayout?.error || \"Unsupported import source layout\",\n        });\n      }\n\n      const secrets = detectSecrets({\n        fs,\n        baseDir: tempDir,\n        configFiles: scan.gatewayConfig.files,\n        envFiles: scan.envFiles.files,\n      });\n      const preFill = extractPreFillValues({\n        fs,\n        baseDir: tempDir,\n        configFiles: scan.gatewayConfig.files,\n      });\n\n      return res.json({ ok: true, ...scan, secrets, preFill });\n    } catch (err) {\n      console.error(\"[onboard] Import scan error:\", err);\n      return res\n        .status(500)\n        .json({ ok: false, error: sanitizeOnboardingError(err) });\n    }\n  });\n\n  app.post(\"/api/onboard/import/apply\", async (req, res) => {\n    if (hasExplicitOnboardingMarker()) {\n      return res.json({ ok: false, error: \"Already onboarded\" });\n    }\n\n    try {\n      const tempDir = String(req.body?.tempDir || \"\").trim();\n      const approvedSecrets = Array.isArray(req.body?.approvedSecrets)\n        ? req.body.approvedSecrets\n        : [];\n      const skipSecretExtraction = !!req.body?.skipSecretExtraction;\n      const githubToken = String(req.body?.githubToken || \"\").trim();\n      const githubRepoInput = String(req.body?.githubRepo || \"\").trim();\n\n      if (!tempDir || !isValidTempDir(tempDir)) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"Invalid temp directory\" });\n      }\n      if (!fs.existsSync(tempDir)) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"Temp directory not found\" });\n      }\n\n      const scan = scanWorkspace({ fs, baseDir: tempDir });\n      if (!scan.sourceLayout?.supported) {\n        cleanupTempClone(tempDir);\n        return res.status(400).json({\n          ok: false,\n          error: scan.sourceLayout?.error || \"Unsupported import source layout\",\n        });\n      }\n\n      let envVars = [];\n      const scannedSecrets = detectSecrets({\n        fs,\n        baseDir: tempDir,\n        configFiles: scan.gatewayConfig.files,\n        envFiles: scan.envFiles.files,\n      });\n      const approvedSecretValidation = validateApprovedSecrets({\n        approvedSecrets,\n        scannedSecrets,\n      });\n      if (!approvedSecretValidation.ok) {\n        return res.status(400).json({\n          ok: false,\n          error: approvedSecretValidation.error,\n        });\n      }\n      if (!skipSecretExtraction && approvedSecrets.length > 0) {\n        const extraction = applySecretExtraction({\n          fs,\n          baseDir: tempDir,\n          approvedSecrets: approvedSecretValidation.secrets,\n        });\n        envVars = extraction.envVars;\n      }\n      const canonicalization = canonicalizeConfigEnvRefs({\n        fs,\n        baseDir: tempDir,\n        configFiles: scan.gatewayConfig.files,\n        envVars,\n      });\n      envVars = canonicalization.envVars;\n\n      const configFiles = Array.isArray(scan.gatewayConfig?.files)\n        ? scan.gatewayConfig.files\n        : [\"openclaw.json\"].filter((f) => fs.existsSync(path.join(tempDir, f)));\n      const transformAlignment = alignHookTransforms({\n        fs,\n        baseDir: tempDir,\n        configFiles,\n      });\n\n      const preFill = extractPreFillValues({\n        fs,\n        baseDir: tempDir,\n        configFiles,\n      });\n\n      const promoteTargetDir =\n        scan.sourceLayout.kind === \"workspace-only\"\n          ? constants.WORKSPACE_DIR\n          : constants.OPENCLAW_DIR;\n      const promoteResult = promoteCloneToTarget({\n        fs,\n        tempDir,\n        targetDir: promoteTargetDir,\n        sourceSubdir: scan.sourceLayout.promoteSourceSubdir || \"\",\n        cleanupBootstrap: scan.sourceLayout.kind === \"full-openclaw-root\",\n      });\n      if (!promoteResult.ok) {\n        return res.status(500).json({ ok: false, error: promoteResult.error });\n      }\n\n      const existing = typeof readEnvFile === \"function\" ? readEnvFile() : [];\n      const merged = [...existing];\n      if (githubToken) {\n        const tokenIdx = merged.findIndex((v) => v.key === \"GITHUB_TOKEN\");\n        if (tokenIdx >= 0) {\n          merged[tokenIdx] = { key: \"GITHUB_TOKEN\", value: githubToken };\n        } else {\n          merged.push({ key: \"GITHUB_TOKEN\", value: githubToken });\n        }\n      }\n      if (githubRepoInput) {\n        const normalizedRepo = resolveGithubRepoUrl(githubRepoInput);\n        const repoIdx = merged.findIndex(\n          (v) => v.key === \"GITHUB_WORKSPACE_REPO\",\n        );\n        if (repoIdx >= 0) {\n          merged[repoIdx] = {\n            key: \"GITHUB_WORKSPACE_REPO\",\n            value: normalizedRepo,\n          };\n        } else {\n          merged.push({\n            key: \"GITHUB_WORKSPACE_REPO\",\n            value: normalizedRepo,\n          });\n        }\n      }\n      for (const newVar of envVars) {\n        const idx = merged.findIndex((v) => v.key === newVar.key);\n        if (idx >= 0) {\n          merged[idx] = newVar;\n        } else {\n          merged.push(newVar);\n        }\n      }\n      if (githubToken || githubRepoInput || envVars.length > 0) {\n        writeEnvFile(merged);\n        reloadEnv();\n      }\n      const systemVars =\n        constants.kSystemVars instanceof Set ? constants.kSystemVars : new Set();\n      const placeholderReview = getImportedPlaceholderReview({\n        fs,\n        openclawDir: constants.OPENCLAW_DIR,\n        envVars: merged,\n        systemVars,\n        normalizeConfig: true,\n      });\n\n      return res.json({\n        ok: true,\n        preFill,\n        placeholderReview,\n        sourceLayout: scan.sourceLayout,\n        envVarsImported: envVars.length,\n        canonicalizedEnvRefs: canonicalization.rewrittenRefs,\n        transformsAligned: transformAlignment.alignedCount,\n      });\n    } catch (err) {\n      console.error(\"[onboard] Import apply error:\", err);\n      cleanupTempClone(req.body?.tempDir);\n      return res\n        .status(500)\n        .json({ ok: false, error: sanitizeOnboardingError(err) });\n    }\n  });\n};\n\nmodule.exports = { registerOnboardingRoutes };\n"
  },
  {
    "path": "lib/server/routes/pages.js",
    "content": "const path = require(\"path\");\n\nconst registerPageRoutes = ({ app, requireAuth, isGatewayRunning }) => {\n  app.get(\"/health\", async (req, res) => {\n    const running = await isGatewayRunning();\n    res.json({\n      status: running ? \"healthy\" : \"starting\",\n      gateway: running ? \"running\" : \"starting\",\n    });\n  });\n\n  app.get(\"/\", requireAuth, (req, res) => {\n    res.sendFile(path.join(__dirname, \"..\", \"..\", \"public\", \"setup.html\"));\n  });\n\n  app.get(\"/setup\", (req, res) => {\n    res.sendFile(path.join(__dirname, \"..\", \"..\", \"public\", \"setup.html\"));\n  });\n};\n\nmodule.exports = { registerPageRoutes };\n"
  },
  {
    "path": "lib/server/routes/pairings.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { OPENCLAW_DIR } = require(\"../constants\");\nconst { buildManagedPaths } = require(\"../internal-files-migration\");\nconst { readOpenclawConfig } = require(\"../openclaw-config\");\nconst { parseJsonObjectFromNoisyOutput } = require(\"../utils/json\");\nconst { quoteShellArg } = require(\"../utils/shell\");\n\nconst kAllowedPairingChannels = new Set([\"telegram\", \"discord\", \"slack\", \"whatsapp\"]);\nconst kSafePairingArgPattern = /^[\\w\\-:.]+$/;\nconst kDevicesListCliTimeoutMs = 5000;\nconst kPairingRequestTtlMs = 60 * 60 * 1000;\nconst kDeviceApprovalCallerScopes = [\n  \"operator.admin\",\n  \"operator.read\",\n  \"operator.write\",\n  \"operator.approvals\",\n  \"operator.pairing\",\n  \"operator.talk.secrets\",\n];\nconst quoteCliArg = (value) => quoteShellArg(value, { strategy: \"single\" });\n\nlet deviceBootstrapModulePromise = null;\n\nconst loadDeviceBootstrapModule = async () => {\n  deviceBootstrapModulePromise ||= import(\"openclaw/plugin-sdk/device-bootstrap\");\n  return deviceBootstrapModulePromise;\n};\n\nconst defaultApproveDevicePairingDirect = async (requestId, options, baseDir) => {\n  const mod = await loadDeviceBootstrapModule();\n  if (typeof mod.approveDevicePairing !== \"function\") {\n    throw new Error(\"OpenClaw device approval helper is unavailable\");\n  }\n  return mod.approveDevicePairing(requestId, options, baseDir);\n};\n\nconst formatDevicePairingForbiddenMessage = (result) => {\n  switch (result?.reason) {\n    case \"caller-scopes-required\":\n      return `missing scope: ${result.scope || \"callerScopes-required\"}`;\n    case \"caller-missing-scope\":\n      return `missing scope: ${result.scope || \"unknown\"}`;\n    case \"scope-outside-requested-roles\":\n      return `invalid scope for requested roles: ${result.scope || \"unknown\"}`;\n    case \"bootstrap-role-not-allowed\":\n      return `bootstrap profile does not allow role: ${result.role || \"unknown\"}`;\n    case \"bootstrap-scope-not-allowed\":\n      return `bootstrap profile does not allow scope: ${result.scope || \"unknown\"}`;\n    default:\n      return \"Device pairing approval forbidden\";\n  }\n};\n\nconst redactApprovedDevice = (device) => {\n  if (!device || typeof device !== \"object\") return null;\n  const safeDevice = { ...device };\n  delete safeDevice.publicKey;\n  delete safeDevice.tokens;\n  return safeDevice;\n};\n\nconst normalizeDeviceApprovalResult = (approval, requestId) => {\n  if (approval?.status === \"approved\") {\n    return {\n      ok: true,\n      requestId: approval.requestId || requestId,\n      device: redactApprovedDevice(approval.device),\n    };\n  }\n  if (approval?.status === \"forbidden\") {\n    return {\n      ok: false,\n      statusCode: 403,\n      error: formatDevicePairingForbiddenMessage(approval),\n    };\n  }\n  return {\n    ok: false,\n    statusCode: 404,\n    error: \"Device pairing request not found\",\n  };\n};\n\nconst toHttpDeviceApprovalPayload = (result) => {\n  const { statusCode, ...payload } = result || {};\n  return payload;\n};\n\nconst isValidDeviceRequestId = (value) => {\n  const requestId = String(value || \"\").trim();\n  return Boolean(requestId && kSafePairingArgPattern.test(requestId));\n};\n\nconst resolvePairingStorePath = ({ openclawDir, channel }) =>\n  path.join(openclawDir, \"credentials\", `${String(channel).trim().toLowerCase()}-pairing.json`);\n\nconst readPairingStore = ({ fsModule, filePath }) => {\n  try {\n    const raw = fsModule.readFileSync(filePath, \"utf8\");\n    const parsed = JSON.parse(raw);\n    return Array.isArray(parsed?.requests) ? parsed.requests : [];\n  } catch {\n    return [];\n  }\n};\n\nconst normalizePairingCode = (value) =>\n  String(value || \"\")\n    .trim()\n    .toUpperCase();\n\nconst normalizePairingAccountId = (value) => String(value || \"\").trim() || \"default\";\n\nconst parseTimestampMs = (value) => {\n  const parsed = Date.parse(String(value || \"\").trim());\n  return Number.isFinite(parsed) ? parsed : null;\n};\n\nconst mapPairingStoreEntry = ({ entry, channel, nowMs = Date.now() }) => {\n  const code = normalizePairingCode(entry?.code || entry?.pairingCode);\n  if (!code) return null;\n  const createdAt = String(entry?.createdAt || \"\").trim();\n  const createdAtMs = parseTimestampMs(createdAt);\n  if (!createdAtMs || nowMs - createdAtMs > kPairingRequestTtlMs) {\n    return null;\n  }\n  return {\n    id: code,\n    code,\n    channel: String(channel || \"\").trim(),\n    accountId: normalizePairingAccountId(entry?.meta?.accountId || entry?.accountId),\n    requesterId: String(entry?.id || entry?.requesterId || \"\").trim(),\n    createdAt,\n  };\n};\n\nconst readPendingPairingsFromStore = ({ fsModule, openclawDir, channel, nowMs = Date.now() }) => {\n  const filePath = resolvePairingStorePath({ openclawDir, channel });\n  return readPairingStore({ fsModule, filePath })\n    .map((entry) => mapPairingStoreEntry({ entry, channel, nowMs }))\n    .filter(Boolean);\n};\n\nconst mergePendingPairings = (...lists) => {\n  const merged = [];\n  const seen = new Map();\n  for (const list of lists) {\n    for (const entry of Array.isArray(list) ? list : []) {\n      const code = normalizePairingCode(entry?.code || entry?.id);\n      const channel = String(entry?.channel || \"\").trim();\n      if (!code || !channel) continue;\n      const accountId = normalizePairingAccountId(entry?.accountId);\n      const key = `${channel}\\u0000${accountId}\\u0000${code}`;\n      const current = seen.get(key);\n      if (!current) {\n        const nextEntry = {\n          ...entry,\n          id: code,\n          code,\n          channel,\n          accountId,\n        };\n        seen.set(key, nextEntry);\n        merged.push(nextEntry);\n        continue;\n      }\n      if (!current.requesterId && entry?.requesterId) {\n        current.requesterId = String(entry.requesterId).trim();\n      }\n      if (!current.createdAt && entry?.createdAt) {\n        current.createdAt = String(entry.createdAt).trim();\n      }\n    }\n  }\n  return merged;\n};\n\nconst writePairingStore = ({ fsModule, filePath, requests }) => {\n  fsModule.mkdirSync(path.dirname(filePath), { recursive: true });\n  fsModule.writeFileSync(filePath, JSON.stringify({ version: 1, requests }, null, 2));\n};\n\nconst removeRequestFromPairingStore = ({ fsModule, openclawDir, channel, code, accountId }) => {\n  const filePath = resolvePairingStorePath({ openclawDir, channel });\n  const requests = readPairingStore({ fsModule, filePath });\n  const normalizedCode = String(code || \"\").trim().toUpperCase();\n  const normalizedAccountId = String(accountId || \"\").trim().toLowerCase();\n  const nextRequests = requests.filter((entry) => {\n    const entryCode = String(entry?.code || \"\").trim().toUpperCase();\n    if (entryCode !== normalizedCode) return true;\n    if (normalizedAccountId) {\n      const entryAccountId = String(entry?.meta?.accountId || \"\").trim().toLowerCase();\n      return entryAccountId !== normalizedAccountId;\n    }\n    return false;\n  });\n  if (nextRequests.length !== requests.length) {\n    writePairingStore({ fsModule, filePath, requests: nextRequests });\n    return true;\n  }\n  return false;\n};\n\nconst removeAccountRequestsFromPairingStore = ({ fsModule, openclawDir, channel, accountId }) => {\n  const filePath = resolvePairingStorePath({ openclawDir, channel });\n  const requests = readPairingStore({ fsModule, filePath });\n  if (requests.length === 0) return;\n  const normalizedAccountId = String(accountId || \"\").trim().toLowerCase() || \"default\";\n  const nextRequests = requests.filter((entry) => {\n    const entryAccountId = String(entry?.meta?.accountId || \"\").trim().toLowerCase() || \"default\";\n    return entryAccountId !== normalizedAccountId;\n  });\n  if (nextRequests.length !== requests.length) {\n    writePairingStore({ fsModule, filePath, requests: nextRequests });\n  }\n};\n\nconst registerPairingRoutes = ({\n  app,\n  clawCmd,\n  isOnboarded,\n  fsModule = fs,\n  openclawDir = OPENCLAW_DIR,\n  approveDevicePairingDirect = defaultApproveDevicePairingDirect,\n}) => {\n  let pairingCache = { pending: [], ts: 0, ttlMs: 0 };\n  const kPairingCacheTtlMs = 10000;\n  const kEmptyPairingCacheTtlMs = 1000;\n  const {\n    cliDeviceAutoApprovedPath: kCliAutoApproveMarkerPath,\n    internalDir: kManagedFilesDir,\n  } = buildManagedPaths({\n    openclawDir,\n  });\n\n  const hasCliAutoApproveMarker = () => fsModule.existsSync(kCliAutoApproveMarkerPath);\n\n  const writeCliAutoApproveMarker = () => {\n    fsModule.mkdirSync(kManagedFilesDir, { recursive: true });\n    fsModule.writeFileSync(\n      kCliAutoApproveMarkerPath,\n      JSON.stringify({ approvedAt: new Date().toISOString() }, null, 2),\n    );\n  };\n\n  const approveDeviceRequestWithAdminScope = async (requestId) => {\n    try {\n      const approval = await approveDevicePairingDirect(\n        requestId,\n        { callerScopes: kDeviceApprovalCallerScopes },\n        openclawDir,\n      );\n      return normalizeDeviceApprovalResult(approval, requestId);\n    } catch (error) {\n      return {\n        ok: false,\n        statusCode: 500,\n        error: error?.message || \"Could not approve device pairing\",\n      };\n    }\n  };\n\n  const parsePendingPairings = (stdout, channel) => {\n    const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};\n    const requestLists = [\n      ...(Array.isArray(parsed?.requests) ? [parsed.requests] : []),\n      ...(Array.isArray(parsed?.pending) ? [parsed.pending] : []),\n    ];\n    return requestLists\n      .flat()\n      .map((entry) => {\n        const code = String(entry?.code || entry?.pairingCode || \"\").trim().toUpperCase();\n        if (!code) return null;\n        return {\n          id: code,\n          code,\n          channel: String(channel || \"\").trim(),\n          accountId:\n            String(entry?.meta?.accountId || entry?.accountId || \"\").trim() || \"default\",\n          requesterId: String(entry?.id || entry?.requesterId || \"\").trim(),\n        };\n      })\n      .filter(Boolean);\n  };\n\n  app.get(\"/api/pairings\", async (req, res) => {\n    if (Date.now() - pairingCache.ts < Number(pairingCache.ttlMs || 0)) {\n      return res.json({ pending: pairingCache.pending });\n    }\n\n    const pending = [];\n    const channels = [\"telegram\", \"discord\", \"slack\", \"whatsapp\"];\n    const config = readOpenclawConfig({\n      fsModule,\n      openclawDir,\n      fallback: {},\n    });\n\n    for (const ch of channels) {\n      const pendingFromStore = readPendingPairingsFromStore({\n        fsModule,\n        openclawDir,\n        channel: ch,\n      });\n      const isEnabledInConfig = config.channels?.[ch]?.enabled === true;\n      if (!isEnabledInConfig && pendingFromStore.length === 0) continue;\n\n      const result = await clawCmd(`pairing list --channel ${ch} --json`, { quiet: true });\n      const rawOutput = [result.stdout, result.stderr].filter(Boolean).join(\"\\n\");\n      if (rawOutput) {\n        try {\n          pending.push(\n            ...mergePendingPairings(\n              parsePendingPairings(rawOutput, ch),\n              pendingFromStore,\n            ),\n          );\n        } catch {\n          pending.push(...pendingFromStore);\n        }\n        continue;\n      }\n      pending.push(...pendingFromStore);\n    }\n\n    pairingCache = {\n      pending,\n      ts: Date.now(),\n      ttlMs: pending.length > 0 ? kPairingCacheTtlMs : kEmptyPairingCacheTtlMs,\n    };\n    res.json({ pending });\n  });\n\n  app.post(\"/api/pairings/:id/approve\", async (req, res) => {\n    const channel = String(req.body?.channel || \"telegram\")\n      .trim()\n      .toLowerCase();\n    const accountId = String(req.body?.accountId || \"\").trim();\n    const pairingId = String(req.params.id || \"\").trim();\n    if (!kAllowedPairingChannels.has(channel)) {\n      return res.status(400).json({\n        ok: false,\n        error: `Unsupported pairing channel \"${channel}\"`,\n      });\n    }\n    if (!pairingId || !kSafePairingArgPattern.test(pairingId)) {\n      return res.status(400).json({\n        ok: false,\n        error: \"Invalid pairing id\",\n      });\n    }\n    if (accountId && !kSafePairingArgPattern.test(accountId)) {\n      return res.status(400).json({\n        ok: false,\n        error: \"Invalid account id\",\n      });\n    }\n    const approveCmd = accountId\n      ? `pairing approve --channel ${quoteCliArg(channel)} --account ${quoteCliArg(accountId)} ${quoteCliArg(pairingId)}`\n      : `pairing approve ${quoteCliArg(channel)} ${quoteCliArg(pairingId)}`;\n    const result = await clawCmd(approveCmd);\n    pairingCache.ts = 0;\n    res.json(result);\n  });\n\n  app.post(\"/api/pairings/:id/reject\", (req, res) => {\n    const channel = String(req.body.channel || \"telegram\").trim();\n    const accountId = String(req.body?.accountId || \"\").trim();\n    try {\n      const removed = removeRequestFromPairingStore({\n        fsModule,\n        openclawDir,\n        channel,\n        code: req.params.id,\n        accountId,\n      });\n      pairingCache.ts = 0;\n      if (removed) {\n        console.log(`[alphaclaw] Rejected pairing request ${req.params.id} for ${channel}${accountId ? `/${accountId}` : \"\"}`);\n        return res.json({ ok: true, removed: true });\n      }\n      return res.status(404).json({\n        ok: false,\n        removed: false,\n        error: \"Pairing request not found\",\n      });\n    } catch (error) {\n      console.error(`[alphaclaw] Pairing reject error: ${error.message}`);\n      res.status(500).json({ ok: false, error: error.message });\n    }\n  });\n\n  let devicePairingCache = { pending: [], cliAutoApproveComplete: false, ts: 0 };\n  const kDevicePairingCacheTtl = 3000;\n\n  app.get(\"/api/devices\", async (req, res) => {\n    if (!isOnboarded()) {\n      return res.json({ pending: [], cliAutoApproveComplete: hasCliAutoApproveMarker() });\n    }\n    if (Date.now() - devicePairingCache.ts < kDevicePairingCacheTtl) {\n      return res.json({\n        pending: devicePairingCache.pending,\n        cliAutoApproveComplete: devicePairingCache.cliAutoApproveComplete,\n      });\n    }\n    const result = await clawCmd(\"devices list --json\", {\n      quiet: true,\n      timeoutMs: kDevicesListCliTimeoutMs,\n    });\n    if (!result.ok) {\n      return res.json({ pending: [], cliAutoApproveComplete: hasCliAutoApproveMarker() });\n    }\n    try {\n      const parsed = parseJsonObjectFromNoisyOutput(result.stdout);\n      const pendingList = Array.isArray(parsed?.pending) ? parsed.pending : [];\n      let autoApprovedRequestId = null;\n      if (!hasCliAutoApproveMarker()) {\n        const firstCliPending = pendingList.find((d) => {\n          const clientId = String(d.clientId || \"\").toLowerCase();\n          const clientMode = String(d.clientMode || \"\").toLowerCase();\n          return clientId === \"cli\" || clientMode === \"cli\";\n        });\n        const firstCliPendingId = firstCliPending?.requestId || firstCliPending?.id;\n        if (firstCliPendingId) {\n          console.log(`[alphaclaw] Auto-approving first CLI device request: ${firstCliPendingId}`);\n          const approveResult = await approveDeviceRequestWithAdminScope(firstCliPendingId);\n          if (approveResult.ok) {\n            writeCliAutoApproveMarker();\n            autoApprovedRequestId = String(firstCliPendingId);\n          } else {\n            console.log(\n              `[alphaclaw] CLI auto-approve failed: ${(approveResult.error || \"\").slice(0, 200)}`,\n            );\n          }\n        }\n      }\n      const pending = pendingList\n        .filter((d) => String(d.requestId || d.id || \"\") !== autoApprovedRequestId)\n        .map((d) => ({\n          id: d.requestId || d.id,\n          platform: d.platform || null,\n          clientId: d.clientId || null,\n          clientMode: d.clientMode || null,\n          role: d.role || null,\n          scopes: d.scopes || [],\n          ts: d.ts || null,\n        }));\n      const cliAutoApproveComplete = hasCliAutoApproveMarker();\n      devicePairingCache = { pending, cliAutoApproveComplete, ts: Date.now() };\n      res.json({ pending, cliAutoApproveComplete });\n    } catch {\n      res.json({ pending: [], cliAutoApproveComplete: hasCliAutoApproveMarker() });\n    }\n  });\n\n  app.post(\"/api/devices/:id/approve\", async (req, res) => {\n    const requestId = String(req.params.id || \"\").trim();\n    if (!isValidDeviceRequestId(requestId)) {\n      return res.status(400).json({ ok: false, error: \"Invalid device request id\" });\n    }\n    const result = await approveDeviceRequestWithAdminScope(requestId);\n    devicePairingCache.ts = 0;\n    res\n      .status(result.ok ? 200 : result.statusCode || 500)\n      .json(toHttpDeviceApprovalPayload(result));\n  });\n\n  app.post(\"/api/devices/:id/reject\", async (req, res) => {\n    const requestId = String(req.params.id || \"\").trim();\n    if (!isValidDeviceRequestId(requestId)) {\n      return res.status(400).json({ ok: false, error: \"Invalid device request id\" });\n    }\n    const result = await clawCmd(`devices reject ${quoteCliArg(requestId)}`);\n    devicePairingCache.ts = 0;\n    res.json(result);\n  });\n};\n\nmodule.exports = {\n  registerPairingRoutes,\n  removeAccountRequestsFromPairingStore,\n};\n"
  },
  {
    "path": "lib/server/routes/proxy.js",
    "content": "const registerProxyRoutes = ({\n  app,\n  proxy,\n  getGatewayUrl,\n  SETUP_API_PREFIXES,\n  requireAuth,\n  oauthCallbackMiddleware,\n  webhookMiddleware,\n}) => {\n  const kOpenClawPathPattern = /^\\/openclaw\\/.+/;\n  const kAssetsPathPattern = /^\\/assets\\/.+/;\n  const kHooksPathPattern = /^\\/hooks\\/.+/;\n  const kWebhookPathPattern = /^\\/webhook\\/.+/;\n  const kApiPathPattern = /^\\/api\\/.+/;\n\n  app.all(\"/openclaw\", requireAuth, (req, res) => {\n    req.url = \"/\";\n    proxy.web(req, res, { target: getGatewayUrl() });\n  });\n  app.all(kOpenClawPathPattern, requireAuth, (req, res) => {\n    req.url = req.url.replace(/^\\/openclaw/, \"\");\n    proxy.web(req, res, { target: getGatewayUrl() });\n  });\n  app.all(kAssetsPathPattern, requireAuth, (req, res) =>\n    proxy.web(req, res, { target: getGatewayUrl() }),\n  );\n\n  app.all(\"/oauth/:id\", oauthCallbackMiddleware);\n  app.all(kHooksPathPattern, webhookMiddleware);\n  app.all(kWebhookPathPattern, webhookMiddleware);\n\n  app.all(kApiPathPattern, (req, res, next) => {\n    if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return next();\n    proxy.web(req, res, { target: getGatewayUrl() });\n  });\n};\n\nmodule.exports = { registerProxyRoutes };\n"
  },
  {
    "path": "lib/server/routes/system.js",
    "content": "const { buildManagedPaths } = require(\"../internal-files-migration\");\nconst { readOpenclawConfig } = require(\"../openclaw-config\");\nconst https = require(\"https\");\n\nconst registerSystemRoutes = ({\n  app,\n  fs,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  kKnownVars,\n  kKnownKeys,\n  kSystemVars,\n  syncChannelConfig,\n  isGatewayRunning,\n  isOnboarded,\n  getChannelStatus,\n  openclawVersionService,\n  alphaclawVersionService,\n  kAlphaclawGithubReleasesBaseUrl,\n  clawCmd,\n  restartGateway,\n  OPENCLAW_DIR,\n  restartRequiredState,\n  topicRegistry,\n  authProfiles,\n  watchdog,\n  doctorService,\n}) => {\n  let envRestartPending = false;\n  let openclawSecretRuntimePromise = null;\n  const kManagedChannelTokenPattern =\n    /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/;\n  const kEnvVarsReservedForUserInput = new Set([\n    \"GITHUB_WORKSPACE_REPO\",\n    \"GOG_KEYRING_PASSWORD\",\n    \"ALPHACLAW_ROOT_DIR\",\n    \"OPENCLAW_HOME\",\n    \"OPENCLAW_CONFIG_PATH\",\n    \"XDG_CONFIG_HOME\",\n  ]);\n  const kReservedUserEnvVarKeys = Array.from(\n    new Set([...kSystemVars, ...kEnvVarsReservedForUserInput]),\n  );\n  const isManagedChannelTokenKey = (key) =>\n    kManagedChannelTokenPattern.test(String(key || \"\").trim().toUpperCase());\n  const isReservedUserEnvVar = (key) =>\n    kSystemVars.has(key) || kEnvVarsReservedForUserInput.has(key);\n  const kSystemCronPath = \"/etc/cron.d/openclaw-hourly-sync\";\n  const kSystemCronConfigPath = `${OPENCLAW_DIR}/cron/system-sync.json`;\n  const { hourlyGitSyncPath: kSystemCronScriptPath } = buildManagedPaths({\n    openclawDir: OPENCLAW_DIR,\n  });\n  const kDefaultSystemCronSchedule = \"0 * * * *\";\n  const isValidCronSchedule = (value) =>\n    typeof value === \"string\" && /^(\\S+\\s+){4}\\S+$/.test(value.trim());\n  const buildSystemCronContent = (schedule) =>\n    [\n      \"SHELL=/bin/bash\",\n      \"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\n      `${schedule} root bash \"${kSystemCronScriptPath}\" >> /var/log/openclaw-hourly-sync.log 2>&1`,\n      \"\",\n    ].join(\"\\n\");\n  const shellEscapeArg = (value) => {\n    const safeValue = String(value || \"\");\n    return `'${safeValue.replace(/'/g, `'\\\\''`)}'`;\n  };\n  const parseJsonFromStdout = (stdout) => {\n    const raw = String(stdout || \"\").trim();\n    if (!raw) return null;\n    try {\n      return JSON.parse(raw);\n    } catch {}\n    const lines = raw\n      .split(\"\\n\")\n      .map((line) => line.trim())\n      .filter(Boolean);\n    for (const line of lines) {\n      if (!(line.startsWith(\"{\") || line.startsWith(\"[\"))) continue;\n      try {\n        return JSON.parse(line);\n      } catch {}\n    }\n    const candidateStarts = [raw.indexOf(\"{\"), raw.indexOf(\"[\")].filter((idx) => idx >= 0);\n    for (const start of candidateStarts) {\n      for (let end = raw.length; end > start; end -= 1) {\n        const candidate = raw.slice(start, end).trim();\n        if (!(candidate.endsWith(\"}\") || candidate.endsWith(\"]\"))) continue;\n        try {\n          return JSON.parse(candidate);\n        } catch {}\n      }\n    }\n    return null;\n  };\n  const getEnvFileValue = (key) =>\n    (typeof readEnvFile === \"function\" ? readEnvFile() : []).find(\n      (entry) => entry?.key === key,\n    )?.value;\n  const normalizeSecretValue = (value) => {\n    if (typeof value !== \"string\") return \"\";\n    const trimmed = String(value || \"\").trim();\n    if (trimmed.length >= 2) {\n      const first = trimmed[0];\n      const last = trimmed[trimmed.length - 1];\n      if ((first === '\"' && last === '\"') || (first === \"'\" && last === \"'\")) {\n        return trimmed.slice(1, -1).trim();\n      }\n    }\n    return trimmed;\n  };\n  const getEnvObject = () => {\n    const env = { ...process.env };\n    for (const entry of typeof readEnvFile === \"function\" ? readEnvFile() : []) {\n      const key = String(entry?.key || \"\").trim();\n      if (!key) continue;\n      if (!normalizeSecretValue(env[key])) {\n        env[key] = normalizeSecretValue(entry?.value);\n      }\n    }\n    return env;\n  };\n  const loadOpenclawSecretRuntime = async () => {\n    openclawSecretRuntimePromise ||= Promise.all([\n      import(\"openclaw/plugin-sdk/secret-input\"),\n      import(\"openclaw/plugin-sdk/runtime-secret-resolution\"),\n    ]).then(([secretInput, runtimeSecretResolution]) => ({\n      coerceSecretRef: secretInput.coerceSecretRef,\n      resolveSecretRefValues: runtimeSecretResolution.resolveSecretRefValues,\n    }));\n    return openclawSecretRuntimePromise;\n  };\n  const resolveSecretRefToken = async ({ config, value, env }) => {\n    try {\n      const { coerceSecretRef, resolveSecretRefValues } =\n        await loadOpenclawSecretRuntime();\n      const ref = coerceSecretRef(value, config?.secrets?.defaults);\n      if (!ref) return \"\";\n      const resolved = await resolveSecretRefValues([ref], { config, env });\n      const refKey = `${ref.source}:${ref.provider}:${ref.id}`;\n      return normalizeSecretValue(resolved.get(refKey));\n    } catch {\n      return \"\";\n    }\n  };\n  const resolveEnvReference = (value) => {\n    const match = String(value || \"\").trim().match(/^\\$\\{([A-Z_][A-Z0-9_]*)\\}$/);\n    if (!match) return \"\";\n    const envKey = match[1];\n    const envValue = process.env[envKey] || getEnvFileValue(envKey);\n    return normalizeSecretValue(envValue);\n  };\n  const getDashboardTokenFromConfig = async () => {\n    const config = readOpenclawConfig({\n      fsModule: fs,\n      openclawDir: OPENCLAW_DIR,\n      fallback: {},\n    });\n    const env = getEnvObject();\n    const configuredToken = config?.gateway?.auth?.token;\n    const resolvedSecretRefToken = await resolveSecretRefToken({\n      config,\n      value: configuredToken,\n      env,\n    });\n    if (resolvedSecretRefToken) return resolvedSecretRefToken;\n    if (typeof configuredToken === \"string\" && configuredToken.trim()) {\n      const trimmedToken = normalizeSecretValue(configuredToken);\n      if (/^\\$\\{[A-Z_][A-Z0-9_]*\\}$/.test(trimmedToken)) {\n        return resolveEnvReference(trimmedToken);\n      }\n      return trimmedToken;\n    }\n    return normalizeSecretValue(env.OPENCLAW_GATEWAY_TOKEN);\n  };\n  const buildDashboardUrl = (token) =>\n    token ? `/openclaw/#token=${encodeURIComponent(token)}` : \"/openclaw\";\n  const extractDashboardTokenFromOutput = (stdout) => {\n    const tokenMatch = String(stdout || \"\").match(/[#?&]token=([^\\s&#]+)/);\n    if (!tokenMatch) return \"\";\n    try {\n      return decodeURIComponent(tokenMatch[1]);\n    } catch {\n      return tokenMatch[1];\n    }\n  };\n  const getRawSessionKey = (sessionRow = {}) =>\n    String(sessionRow?.key || sessionRow?.sessionKey || sessionRow?.id || \"\").trim();\n  const getRawSessionsFromPayload = (payload) => {\n    if (Array.isArray(payload)) return payload;\n    const candidates = [\n      payload?.sessions,\n      payload?.items,\n      payload?.data?.sessions,\n      payload?.data?.items,\n      payload?.result?.sessions,\n      payload?.result?.items,\n    ];\n    for (const candidate of candidates) {\n      if (Array.isArray(candidate)) return candidate;\n    }\n    return [];\n  };\n  const toTitleWords = (value) =>\n    String(value || \"\")\n      .trim()\n      .split(/[-_\\s]+/)\n      .filter(Boolean)\n      .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n      .join(\" \");\n  const getDefaultAgentLabel = (config = {}) => {\n    return \"Main Agent\";\n  };\n  const getFallbackAgentLabel = (agentId = \"\") => {\n    const normalizedAgentId = String(agentId || \"\").trim();\n    if (!normalizedAgentId) return \"Agent\";\n    const titledAgentId = toTitleWords(normalizedAgentId) || normalizedAgentId;\n    return `${titledAgentId} Agent`;\n  };\n  const getConfiguredAgentLabel = (config = {}, agentId = \"\") => {\n    const normalizedAgentId = String(agentId || \"\").trim();\n    if (!normalizedAgentId) return \"Agent\";\n    const configuredAgents = Array.isArray(config?.agents?.list)\n      ? config.agents.list\n      : [];\n    const configuredAgent = configuredAgents.find(\n      (entry) => String(entry?.id || \"\").trim() === normalizedAgentId,\n    );\n    const configuredName =\n      String(configuredAgent?.name || \"\").trim() ||\n      String(configuredAgent?.identity?.name || \"\").trim();\n    if (configuredName) return configuredName;\n    if (normalizedAgentId === \"main\") return getDefaultAgentLabel(config);\n    return getFallbackAgentLabel(normalizedAgentId);\n  };\n  const getAgentLabelFromSessionKey = (key = \"\", config = {}) => {\n    const match = String(key || \"\").match(/^agent:([^:]+):/);\n    const agentId = String(match?.[1] || \"\").trim();\n    if (!agentId) return \"Agent\";\n    return getConfiguredAgentLabel(config, agentId);\n  };\n  const parseChannelFromSessionKey = (key = \"\") => {\n    const k = String(key || \"\");\n    if (k.includes(\":telegram:\")) return \"telegram\";\n    if (k.includes(\":discord:\")) return \"discord\";\n    if (k.includes(\":slack:\")) return \"slack\";\n    return \"\";\n  };\n  const getSessionTopicContext = (sessionKey = \"\") => {\n    const key = String(sessionKey || \"\");\n    const topicMatch = key.match(/:telegram:group:([^:]+):topic:([^:]+)$/);\n    if (!topicMatch) {\n      return {\n        groupName: \"\",\n        topicName: \"\",\n      };\n    }\n    const [, groupId, topicId] = topicMatch;\n    let groupEntry = null;\n    try {\n      groupEntry = topicRegistry?.getGroup?.(groupId) || null;\n    } catch {}\n    return {\n      groupName: String(groupEntry?.name || \"\").trim(),\n      topicName: String(groupEntry?.topics?.[topicId]?.name || \"\").trim(),\n    };\n  };\n  const syncApiKeyAuthProfilesFromEnvVars = (nextEnvVars) => {\n    if (!authProfiles) return;\n    const envMap = new Map(\n      (nextEnvVars || []).map((entry) => [\n        String(entry?.key || \"\").trim(),\n        String(entry?.value || \"\"),\n      ]),\n    );\n    const providers = authProfiles.listApiKeyProviders?.() || [];\n    for (const provider of providers) {\n      const envKey = authProfiles.getEnvVarForApiKeyProvider?.(provider);\n      if (!envKey) continue;\n      const value = envMap.get(envKey) || \"\";\n      if (!value.trim()) {\n        authProfiles.removeApiKeyProfileForEnvVar?.(provider);\n        continue;\n      }\n      authProfiles.upsertApiKeyProfileForEnvVar(provider, value);\n    }\n  };\n  const getSessionReplyTarget = (sessionKey = \"\") => {\n    const key = String(sessionKey || \"\");\n    const telegramDirectMatch = key.match(/:telegram:direct:([^:]+)$/);\n    if (telegramDirectMatch) {\n      return {\n        replyChannel: \"telegram\",\n        replyTo: String(telegramDirectMatch[1] || \"\"),\n      };\n    }\n    const telegramTopicMatch = key.match(\n      /:telegram:group:([^:]+):topic:([^:]+)$/,\n    );\n    if (telegramTopicMatch) {\n      return {\n        replyChannel: \"telegram\",\n        replyTo: `${String(telegramTopicMatch[1] || \"\")}:${String(telegramTopicMatch[2] || \"\")}`,\n      };\n    }\n    return {\n      replyChannel: \"\",\n      replyTo: \"\",\n    };\n  };\n\n  const listSendableAgentSessions = async () => {\n    const result = await clawCmd(\"sessions --json --all-agents\", {\n      quiet: true,\n    });\n    if (!result.ok) {\n      throw new Error(result.stderr || \"Could not load agent sessions\");\n    }\n    const payload = parseJsonFromStdout(result.stdout);\n    const sessions = getRawSessionsFromPayload(payload);\n    const config = readOpenclawConfig({\n      fsModule: fs,\n      openclawDir: OPENCLAW_DIR,\n      fallback: {},\n    });\n    return sessions\n      .map((sessionRow) => {\n        const key = getRawSessionKey(sessionRow);\n        if (!key) return null;\n        const replyTarget = getSessionReplyTarget(key);\n        const agentKeyMatch = key.match(/^agent:([^:]+):/);\n        const agentId = String(agentKeyMatch?.[1] || \"\").trim();\n        const channel =\n          parseChannelFromSessionKey(key) || replyTarget.replyChannel || \"\";\n        const topicContext = getSessionTopicContext(key);\n        return {\n          key,\n          sessionId: String(sessionRow?.sessionId || sessionRow?.id || \"\"),\n          updatedAt:\n            Number(\n              sessionRow?.updatedAt ||\n                sessionRow?.lastActivityAt ||\n                sessionRow?.lastActiveAt,\n            ) || 0,\n          agentId,\n          agentLabel: getAgentLabelFromSessionKey(key, config),\n          channel,\n          groupName: topicContext.groupName,\n          topicName: topicContext.topicName,\n          replyChannel: replyTarget.replyChannel,\n          replyTo: replyTarget.replyTo,\n        };\n      })\n      .filter(Boolean)\n      .sort((a, b) => b.updatedAt - a.updatedAt);\n  };\n  const readSystemCronConfig = () => {\n    try {\n      const raw = fs.readFileSync(kSystemCronConfigPath, \"utf8\");\n      const parsed = JSON.parse(raw);\n      const enabled = parsed.enabled !== false;\n      const schedule = isValidCronSchedule(parsed.schedule)\n        ? parsed.schedule.trim()\n        : kDefaultSystemCronSchedule;\n      return { enabled, schedule };\n    } catch {\n      return { enabled: true, schedule: kDefaultSystemCronSchedule };\n    }\n  };\n  const getSystemCronStatus = () => {\n    const config = readSystemCronConfig();\n    return {\n      enabled: config.enabled,\n      schedule: config.schedule,\n      installed: fs.existsSync(kSystemCronPath),\n      scriptExists: fs.existsSync(kSystemCronScriptPath),\n    };\n  };\n  const applySystemCronConfig = (nextConfig) => {\n    fs.mkdirSync(`${OPENCLAW_DIR}/cron`, { recursive: true });\n    fs.writeFileSync(\n      kSystemCronConfigPath,\n      JSON.stringify(nextConfig, null, 2),\n    );\n    if (nextConfig.enabled) {\n      fs.writeFileSync(\n        kSystemCronPath,\n        buildSystemCronContent(nextConfig.schedule),\n        {\n          mode: 0o644,\n        },\n      );\n    } else {\n      fs.rmSync(kSystemCronPath, { force: true });\n    }\n    return getSystemCronStatus();\n  };\n  const isVisibleInEnvars = (def) => def?.visibleInEnvars !== false;\n  const kReleaseNotesCacheTtlMs = 5 * 60 * 1000;\n  let kReleaseNotesCache = {\n    key: \"\",\n    fetchedAt: 0,\n    payload: null,\n  };\n  const isValidReleaseTag = (value) =>\n    /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(value || \"\"));\n  const fetchGitHubRelease = (tag = \"\") =>\n    new Promise((resolve, reject) => {\n      const normalizedTag = String(tag || \"\").trim();\n      const endpointPath = normalizedTag\n        ? `/tags/${encodeURIComponent(normalizedTag)}`\n        : \"/latest\";\n      const requestUrl = `${kAlphaclawGithubReleasesBaseUrl}${endpointPath}`;\n      const token = String(process.env.GITHUB_TOKEN || \"\").trim();\n      const headers = {\n        Accept: \"application/vnd.github+json\",\n        \"User-Agent\": \"alphaclaw-release-notes\",\n      };\n      if (token) headers.Authorization = `Bearer ${token}`;\n      const request = https.get(\n        requestUrl,\n        { headers, timeout: 7000 },\n        (response) => {\n          let raw = \"\";\n          response.setEncoding(\"utf8\");\n          response.on(\"data\", (chunk) => {\n            raw += chunk;\n          });\n          response.on(\"end\", () => {\n            let parsed = null;\n            try {\n              parsed = raw ? JSON.parse(raw) : null;\n            } catch {\n              parsed = null;\n            }\n            const statusCode = Number(response.statusCode) || 500;\n            if (statusCode >= 400) {\n              const message =\n                parsed?.message ||\n                `GitHub release lookup failed with status ${statusCode}`;\n              return reject(\n                Object.assign(new Error(message), {\n                  statusCode,\n                }),\n              );\n            }\n            resolve({\n              tag: String(parsed?.tag_name || normalizedTag || \"\"),\n              name: String(parsed?.name || \"\").trim(),\n              body: String(parsed?.body || \"\"),\n              htmlUrl: String(parsed?.html_url || \"\").trim(),\n              publishedAt: String(parsed?.published_at || \"\").trim(),\n            });\n          });\n        },\n      );\n      request.on(\"timeout\", () => {\n        request.destroy(new Error(\"GitHub release request timed out\"));\n      });\n      request.on(\"error\", (error) => {\n        reject(error);\n      });\n    });\n\n  app.get(\"/api/env\", (req, res) => {\n    const fileVars = readEnvFile();\n    const merged = [];\n\n    for (const def of kKnownVars) {\n      if (isReservedUserEnvVar(def.key)) continue;\n      if (!isVisibleInEnvars(def)) continue;\n      const fileEntry = fileVars.find((v) => v.key === def.key);\n      const value = fileEntry?.value || \"\";\n      merged.push({\n        key: def.key,\n        value,\n        label: def.label,\n        group: def.group,\n        hint: def.hint,\n        features: def.features,\n        source: fileEntry?.value ? \"env_file\" : \"unset\",\n        editable: true,\n      });\n    }\n\n    for (const v of fileVars) {\n      if (\n        kKnownKeys.has(v.key) ||\n        isReservedUserEnvVar(v.key) ||\n        isManagedChannelTokenKey(v.key)\n      ) {\n        continue;\n      }\n      merged.push({\n        key: v.key,\n        value: v.value,\n        label: v.key,\n        group: \"custom\",\n        hint: \"\",\n        source: \"env_file\",\n        editable: true,\n      });\n    }\n\n    res.json({\n      vars: merged,\n      reservedKeys: kReservedUserEnvVarKeys,\n      restartRequired: envRestartPending && isOnboarded(),\n    });\n  });\n\n  app.put(\"/api/env\", (req, res) => {\n    const { vars } = req.body;\n    if (!Array.isArray(vars)) {\n      return res.status(400).json({ ok: false, error: \"Missing vars array\" });\n    }\n\n    const blockedKeys = Array.from(\n      new Set(\n        vars\n          .map((v) => String(v?.key || \"\").trim())\n          .filter((key) => key && isReservedUserEnvVar(key)),\n      ),\n    );\n    if (blockedKeys.length) {\n      return res.status(400).json({\n        ok: false,\n        error: `Reserved environment variables cannot be edited: ${blockedKeys.join(\", \")}`,\n      });\n    }\n\n    const filtered = vars.filter(\n      (v) => !isReservedUserEnvVar(v.key) && !isManagedChannelTokenKey(v.key),\n    );\n    const existingLockedVars = readEnvFile().filter((v) =>\n      isReservedUserEnvVar(v.key),\n    );\n    const existingManagedChannelVars = readEnvFile().filter((v) =>\n      isManagedChannelTokenKey(v.key),\n    );\n    const hiddenKnownVarKeys = new Set(\n      kKnownVars\n        .filter(\n          (def) => !isReservedUserEnvVar(def.key) && !isVisibleInEnvars(def),\n        )\n        .map((def) => def.key),\n    );\n    const existingHiddenKnownVars = readEnvFile().filter((v) =>\n      hiddenKnownVarKeys.has(v.key),\n    );\n    const nextEnvVars = [\n      ...filtered,\n      ...existingHiddenKnownVars,\n      ...existingManagedChannelVars,\n      ...existingLockedVars,\n    ];\n    syncChannelConfig(nextEnvVars, \"remove\");\n    writeEnvFile(nextEnvVars);\n    const changed = reloadEnv();\n    syncApiKeyAuthProfilesFromEnvVars(nextEnvVars);\n    if (changed && isOnboarded()) {\n      envRestartPending = true;\n    }\n    const restartRequired = envRestartPending && isOnboarded();\n    console.log(\n      `[alphaclaw] Env vars saved (${nextEnvVars.length} vars, changed=${changed})`,\n    );\n    syncChannelConfig(nextEnvVars, \"add\");\n\n    res.json({ ok: true, changed, restartRequired });\n  });\n\n  const buildStatusPayload = async () => {\n    const configExists = fs.existsSync(`${OPENCLAW_DIR}/openclaw.json`);\n    const running = await isGatewayRunning();\n    const repo = process.env.GITHUB_WORKSPACE_REPO || \"\";\n    const openclawVersion = openclawVersionService.readOpenclawVersion();\n    const alphaclawVersion =\n      typeof alphaclawVersionService?.readAlphaclawVersion === \"function\"\n        ? alphaclawVersionService.readAlphaclawVersion()\n        : null;\n    return {\n      gateway: running\n        ? \"running\"\n        : configExists\n          ? \"starting\"\n          : \"not_onboarded\",\n      configExists,\n      channels: getChannelStatus(),\n      repo,\n      openclawVersion,\n      alphaclawVersion,\n      syncCron: getSystemCronStatus(),\n    };\n  };\n\n  app.get(\"/api/status\", async (req, res) => {\n    const payload = await buildStatusPayload();\n    res.json(payload);\n  });\n\n  app.get(\"/api/events/status\", async (req, res) => {\n    res.setHeader(\"Content-Type\", \"text/event-stream\");\n    res.setHeader(\"Cache-Control\", \"no-cache, no-transform\");\n    res.setHeader(\"Connection\", \"keep-alive\");\n    res.setHeader(\"X-Accel-Buffering\", \"no\");\n    res.flushHeaders?.();\n\n    const writeStatusEvent = async () => {\n      try {\n        const status = await buildStatusPayload();\n        const watchdogStatus =\n          typeof watchdog?.getStatus === \"function\" ? watchdog.getStatus() : null;\n        const doctorStatus =\n          typeof doctorService?.buildStatus === \"function\"\n            ? doctorService.buildStatus()\n            : null;\n        res.write(\"event: status\\n\");\n        res.write(\n          `data: ${JSON.stringify({\n            status,\n            watchdogStatus,\n            doctorStatus,\n            timestamp: new Date().toISOString(),\n          })}\\n\\n`,\n        );\n      } catch {}\n    };\n\n    await writeStatusEvent();\n    const statusIntervalId = setInterval(writeStatusEvent, 2000);\n    const keepAliveIntervalId = setInterval(() => {\n      res.write(\": keepalive\\n\\n\");\n    }, 15000);\n\n    req.on(\"close\", () => {\n      clearInterval(statusIntervalId);\n      clearInterval(keepAliveIntervalId);\n      res.end();\n    });\n  });\n\n  app.get(\"/api/sync-cron\", (req, res) => {\n    res.json({ ok: true, ...getSystemCronStatus() });\n  });\n\n  app.put(\"/api/sync-cron\", (req, res) => {\n    const current = readSystemCronConfig();\n    const { enabled, schedule } = req.body || {};\n    if (enabled !== undefined && typeof enabled !== \"boolean\") {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"enabled must be a boolean\" });\n    }\n    if (schedule !== undefined && !isValidCronSchedule(schedule)) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"schedule must be a 5-field cron string\" });\n    }\n    const nextConfig = {\n      enabled: typeof enabled === \"boolean\" ? enabled : current.enabled,\n      schedule:\n        typeof schedule === \"string\" && schedule.trim()\n          ? schedule.trim()\n          : current.schedule,\n    };\n    const status = applySystemCronConfig(nextConfig);\n    res.json({ ok: true, syncCron: status });\n  });\n\n  app.get(\"/api/alphaclaw/version\", async (req, res) => {\n    const refresh = String(req.query.refresh || \"\") === \"1\";\n    const status = await alphaclawVersionService.getVersionStatus(refresh);\n    res.json(status);\n  });\n\n  app.get(\"/api/alphaclaw/release-notes\", async (req, res) => {\n    const requestedTag = String(req.query.tag || \"\").trim();\n    if (requestedTag && !isValidReleaseTag(requestedTag)) {\n      return res.status(400).json({ ok: false, error: \"Invalid release tag\" });\n    }\n    const cacheKey = requestedTag || \"latest\";\n    const now = Date.now();\n    if (\n      kReleaseNotesCache.payload &&\n      kReleaseNotesCache.key === cacheKey &&\n      now - kReleaseNotesCache.fetchedAt < kReleaseNotesCacheTtlMs\n    ) {\n      return res.json({ ok: true, ...kReleaseNotesCache.payload });\n    }\n    try {\n      const payload = await fetchGitHubRelease(requestedTag);\n      kReleaseNotesCache = {\n        key: cacheKey,\n        fetchedAt: Date.now(),\n        payload,\n      };\n      return res.json({ ok: true, ...payload });\n    } catch (err) {\n      const statusCode = Number(err?.statusCode) || 502;\n      return res.status(statusCode).json({\n        ok: false,\n        error: err?.message || \"Could not fetch release notes\",\n      });\n    }\n  });\n\n  app.post(\"/api/alphaclaw/update\", async (req, res) => {\n    console.log(\"[alphaclaw] /api/alphaclaw/update requested\");\n    const result = await alphaclawVersionService.updateAlphaclaw();\n    console.log(\n      `[alphaclaw] /api/alphaclaw/update result: status=${result.status} ok=${result.body?.ok === true}`,\n    );\n    if (result.status === 200 && result.body?.ok) {\n      res.json(result.body);\n      if (!result.body?.managedUpdate) {\n        setTimeout(() => alphaclawVersionService.restartProcess(), 1000);\n      }\n    } else {\n      res.status(result.status).json(result.body);\n    }\n  });\n\n  app.get(\"/api/gateway-status\", async (req, res) => {\n    const result = await clawCmd(\"status\");\n    res.json(result);\n  });\n\n  app.get(\"/api/agent/sessions\", async (req, res) => {\n    try {\n      const sessions = await listSendableAgentSessions();\n      return res.json({ ok: true, sessions });\n    } catch (err) {\n      return res.status(502).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/agent/message\", async (req, res) => {\n    const rawMessage = String(req.body?.message || \"\");\n    const message = rawMessage.trim();\n    const sessionKey = String(req.body?.sessionKey || \"\").trim();\n    if (!message) {\n      return res.status(400).json({ ok: false, error: \"message is required\" });\n    }\n    if (message.length > 4000) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"message must be 4000 characters or fewer\" });\n    }\n    let command = `agent --agent main --message ${shellEscapeArg(message)}`;\n    if (sessionKey) {\n      let selectedSession = null;\n      try {\n        const sessions = await listSendableAgentSessions();\n        selectedSession =\n          sessions.find((sessionRow) => sessionRow.key === sessionKey) || null;\n      } catch (err) {\n        return res.status(502).json({ ok: false, error: err.message });\n      }\n      if (!selectedSession) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"Selected session was not found\" });\n      }\n      if (selectedSession.replyChannel && selectedSession.replyTo) {\n        command +=\n          ` --deliver --reply-channel ${shellEscapeArg(selectedSession.replyChannel)}` +\n          ` --reply-to ${shellEscapeArg(selectedSession.replyTo)}`;\n      } else if (selectedSession.sessionId) {\n        command += ` --session-id ${shellEscapeArg(selectedSession.sessionId)}`;\n      }\n    }\n    const result = await clawCmd(command, { quiet: true });\n    if (!result.ok) {\n      return res\n        .status(502)\n        .json({\n          ok: false,\n          error: result.stderr || \"Could not send message to agent\",\n        });\n    }\n    return res.json({ ok: true, stdout: result.stdout || \"\" });\n  });\n\n  app.get(\"/api/gateway/dashboard\", async (req, res) => {\n    if (!isOnboarded()) return res.json({ ok: false, url: \"/openclaw\" });\n    const token = await getDashboardTokenFromConfig();\n    if (token) {\n      return res.json({\n        ok: true,\n        url: buildDashboardUrl(token),\n        source: \"config\",\n      });\n    }\n    const result = await clawCmd(\"dashboard --no-open\");\n    if (result.ok && result.stdout) {\n      const cliToken = extractDashboardTokenFromOutput(result.stdout);\n      if (cliToken) {\n        return res.json({ ok: true, url: buildDashboardUrl(cliToken) });\n      }\n    }\n    res.json({ ok: true, url: \"/openclaw\", needsAuth: true });\n  });\n\n  app.get(\"/api/restart-status\", async (req, res) => {\n    try {\n      const snapshot = await restartRequiredState.getSnapshot();\n      res.json({\n        ok: true,\n        restartRequired: snapshot.restartRequired || envRestartPending,\n        restartInProgress: snapshot.restartInProgress,\n        gatewayRunning: snapshot.gatewayRunning,\n      });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/restart-status/dismiss\", async (req, res) => {\n    try {\n      envRestartPending = false;\n      restartRequiredState.clearRequired();\n      const snapshot = await restartRequiredState.getSnapshot();\n      res.json({\n        ok: true,\n        restartRequired: snapshot.restartRequired || envRestartPending,\n        restartInProgress: snapshot.restartInProgress,\n        gatewayRunning: snapshot.gatewayRunning,\n      });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/gateway/restart\", async (req, res) => {\n    if (!isOnboarded()) {\n      return res.status(400).json({ ok: false, error: \"Not onboarded\" });\n    }\n    restartRequiredState.markRestartInProgress();\n    try {\n      restartGateway();\n      envRestartPending = false;\n      restartRequiredState.clearRequired();\n      restartRequiredState.markRestartComplete();\n      const snapshot = await restartRequiredState.getSnapshot();\n      res.json({ ok: true, restartRequired: snapshot.restartRequired });\n    } catch (err) {\n      restartRequiredState.markRestartComplete();\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n};\n\nmodule.exports = { registerSystemRoutes };\n"
  },
  {
    "path": "lib/server/routes/telegram.js",
    "content": "const fs = require(\"fs\");\nconst { OPENCLAW_DIR } = require(\"../constants\");\nconst { isDebugEnabled } = require(\"../helpers\");\nconst {\n  readOpenclawConfig,\n  writeOpenclawConfig,\n} = require(\"../openclaw-config\");\nconst { parseBooleanValue } = require(\"../utils/boolean\");\nconst {\n  hasScopedBindingFields,\n  normalizeAccountId,\n} = require(\"../utils/channels\");\nconst { quoteShellArg } = require(\"../utils/shell\");\nconst topicRegistry = require(\"../topic-registry\");\nconst { syncConfigForTelegram } = require(\"../telegram-workspace\");\nconst resolveGroupId = (req) => {\n  const body = req.body || {};\n  const rawGroupId = body.groupId ?? body.chatId;\n  return rawGroupId == null ? \"\" : String(rawGroupId).trim();\n};\nconst resolveAllowUserId = async ({\n  telegramApi,\n  groupId,\n  preferredUserId,\n}) => {\n  const normalizedPreferred = String(preferredUserId || \"\").trim();\n  if (normalizedPreferred) return normalizedPreferred;\n  const admins = await telegramApi.getChatAdministrators(groupId);\n  const humanAdmins = admins.filter((entry) => !entry?.user?.is_bot);\n  if (humanAdmins.length === 0) return \"\";\n  const creator = humanAdmins.find((entry) => entry.status === \"creator\");\n  const targetAdmin = creator || humanAdmins[0];\n  return String(targetAdmin?.user?.id || \"\").trim();\n};\nconst isMissingTopicError = (errorMessage) => {\n  const message = String(errorMessage || \"\").toLowerCase();\n  return [\n    \"topic_id_invalid\",\n    \"message_thread_id_invalid\",\n    \"message_thread_not_found\",\n    \"topic_not_found\",\n    \"message thread not found\",\n    \"topic not found\",\n    \"invalid thread id\",\n    \"invalid topic id\",\n  ].some((token) => message.includes(token));\n};\n\nconst normalizeGitSyncMessagePart = (value) =>\n  String(value || \"\")\n    .replace(/[\\r\\n\\t]+/g, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n\nconst buildTelegramGitSyncCommand = (action, target = \"\") => {\n  const safeAction = normalizeGitSyncMessagePart(action);\n  const safeTarget = normalizeGitSyncMessagePart(target);\n  const message = `telegram workspace: ${safeAction} ${safeTarget}`.trim();\n  return `alphaclaw git-sync -m ${quoteShellArg(message, { strategy: \"single\" })}`;\n};\n\nconst { createTelegramApi } = require(\"../telegram-api\");\n\nconst kTelegramEnvKeyBase = \"TELEGRAM_BOT_TOKEN\";\n\nconst deriveAccountEnvKey = (accountId) => {\n  const normalized = normalizeAccountId(accountId);\n  if (normalized === \"default\") return kTelegramEnvKeyBase;\n  return `${kTelegramEnvKeyBase}_${normalized.replace(/-/g, \"_\").toUpperCase()}`;\n};\n\nconst resolveAccountTelegramApi = (accountId, defaultApi) => {\n  const normalized = normalizeAccountId(accountId);\n  if (normalized === \"default\") return defaultApi;\n  const envKey = deriveAccountEnvKey(normalized);\n  const token = process.env[envKey];\n  if (!token) {\n    console.log(\n      `[alphaclaw] Telegram account \"${normalized}\": env var ${envKey} not found, falling back to default token`,\n    );\n    return defaultApi;\n  }\n  return createTelegramApi(() => process.env[envKey]);\n};\n\nconst resolveAccountId = (req) =>\n  normalizeAccountId(req.query?.accountId || req.body?.accountId || \"\");\n\nconst resolveTelegramConfigForAccount = ({ telegramConfig, accountId }) => {\n  const normalizedAccountId = normalizeAccountId(accountId);\n  const accounts =\n    telegramConfig?.accounts && typeof telegramConfig.accounts === \"object\"\n      ? telegramConfig.accounts\n      : null;\n  const hasAccounts = !!accounts && Object.keys(accounts).length > 0;\n  if (hasAccounts) {\n    const accountConfig =\n      accounts[normalizedAccountId] &&\n      typeof accounts[normalizedAccountId] === \"object\"\n        ? accounts[normalizedAccountId]\n        : {};\n    return { normalizedAccountId, hasAccounts, accountConfig };\n  }\n  return {\n    normalizedAccountId,\n    hasAccounts: false,\n    accountConfig: telegramConfig || {},\n  };\n};\n\nconst resolveBoundAgentIdForAccount = ({ cfg, accountId }) => {\n  const bindings = Array.isArray(cfg?.bindings) ? cfg.bindings : [];\n  const normalizedAccountId = normalizeAccountId(accountId);\n  for (const binding of bindings) {\n    const match = binding?.match || {};\n    if (hasScopedBindingFields(match)) continue;\n    if (String(match.channel || \"\").trim() !== \"telegram\") continue;\n    const bindingAccountId = normalizeAccountId(match.accountId);\n    if (bindingAccountId !== normalizedAccountId) continue;\n    const boundAgentId = String(binding?.agentId || \"\").trim();\n    if (boundAgentId) return boundAgentId;\n  }\n  return normalizedAccountId === \"default\" ? \"default\" : \"\";\n};\n\nconst registerTelegramRoutes = ({\n  app,\n  telegramApi,\n  syncPromptFiles,\n  shellCmd,\n}) => {\n  const repairGroupAllowFromIfMissing = async ({\n    cfg,\n    accountId = \"default\",\n    groupId,\n    requireMention = false,\n    tgApi = telegramApi,\n  }) => {\n    const telegramConfig = cfg?.channels?.telegram || {};\n    const { accountConfig } = resolveTelegramConfigForAccount({\n      telegramConfig,\n      accountId,\n    });\n    if (\n      Array.isArray(accountConfig.groupAllowFrom) &&\n      accountConfig.groupAllowFrom.length > 0\n    ) {\n      return { repaired: false, resolvedUserId: \"\", syncWarning: null };\n    }\n    const resolvedUserId = await resolveAllowUserId({\n      telegramApi: tgApi,\n      groupId,\n      preferredUserId: \"\",\n    });\n    syncConfigForTelegram({\n      fs,\n      openclawDir: OPENCLAW_DIR,\n      topicRegistry,\n      groupId,\n      accountId,\n      requireMention,\n      resolvedUserId,\n    });\n    const syncWarning = await runTelegramGitSync(\n      \"repair-group-allow-from\",\n      groupId,\n    );\n    return { repaired: true, resolvedUserId, syncWarning };\n  };\n\n  const runTelegramGitSync = async (action, target = \"\") => {\n    if (typeof shellCmd !== \"function\") return null;\n    try {\n      await shellCmd(buildTelegramGitSyncCommand(action, target), {\n        timeout: 30000,\n      });\n      return null;\n    } catch (err) {\n      return err?.message || \"alphaclaw git-sync failed\";\n    }\n  };\n\n  // Verify bot token\n  app.get(\"/api/telegram/bot\", async (req, res) => {\n    try {\n      const reqAccountId = resolveAccountId(req);\n      const tgApi = resolveAccountTelegramApi(reqAccountId, telegramApi);\n      const me = await tgApi.getMe();\n      res.json({ ok: true, bot: me, accountId: reqAccountId || \"default\" });\n    } catch (e) {\n      res.json({ ok: false, error: e.message });\n    }\n  });\n\n  // Verify group: checks bot membership, admin rights, topics enabled\n  app.post(\"/api/telegram/groups/verify\", async (req, res) => {\n    const groupId = resolveGroupId(req);\n    if (!groupId)\n      return res.status(400).json({ ok: false, error: \"groupId is required\" });\n\n    try {\n      const tgApi = resolveAccountTelegramApi(\n        resolveAccountId(req),\n        telegramApi,\n      );\n      const chat = await tgApi.getChat(groupId);\n      const me = await tgApi.getMe();\n      const member = await tgApi.getChatMember(groupId, me.id);\n      const suggestedUserId = await resolveAllowUserId({\n        telegramApi: tgApi,\n        groupId,\n        preferredUserId: \"\",\n      });\n\n      const isAdmin =\n        member.status === \"administrator\" || member.status === \"creator\";\n      const isForum = !!chat.is_forum;\n\n      res.json({\n        ok: true,\n        chat: {\n          id: chat.id,\n          title: chat.title,\n          type: chat.type,\n          isForum,\n        },\n        bot: {\n          status: member.status,\n          isAdmin,\n          canManageTopics: isAdmin && member.can_manage_topics !== false,\n        },\n        suggestedUserId: suggestedUserId || null,\n      });\n    } catch (e) {\n      res.json({ ok: false, error: e.message });\n    }\n  });\n\n  // List topics from registry\n  app.get(\"/api/telegram/groups/:groupId/topics\", (req, res) => {\n    const group = topicRegistry.getGroup(req.params.groupId);\n    res.json({ ok: true, topics: group?.topics || {} });\n  });\n\n  // Create a topic via Telegram API + add to registry\n  app.post(\"/api/telegram/groups/:groupId/topics\", async (req, res) => {\n    const { groupId } = req.params;\n    const body = req.body || {};\n    const name = String(body.name ?? \"\").trim();\n    const rawIconColor = body.iconColor;\n    const systemInstructions = String(\n      body.systemInstructions ?? body.systemPrompt ?? \"\",\n    ).trim();\n    const hasAgentId = Object.prototype.hasOwnProperty.call(body, \"agentId\");\n    const agentId = String(body.agentId ?? \"\").trim();\n    const iconColorValue =\n      rawIconColor == null ? null : Number.parseInt(String(rawIconColor), 10);\n    const iconColor = Number.isFinite(iconColorValue)\n      ? iconColorValue\n      : undefined;\n    if (!name)\n      return res.status(400).json({ ok: false, error: \"name is required\" });\n\n    try {\n      const tgApi = resolveAccountTelegramApi(\n        resolveAccountId(req),\n        telegramApi,\n      );\n      const result = await tgApi.createForumTopic(groupId, name, {\n        iconColor,\n      });\n      const threadId = result.message_thread_id;\n      topicRegistry.addTopic(groupId, threadId, {\n        name: result.name,\n        iconColor: result.icon_color,\n        ...(systemInstructions ? { systemInstructions } : {}),\n        ...(hasAgentId ? { agentId: agentId || undefined } : {}),\n      });\n      syncConfigForTelegram({\n        fs,\n        openclawDir: OPENCLAW_DIR,\n        topicRegistry,\n        groupId,\n        accountId: resolveAccountId(req),\n        requireMention: false,\n        resolvedUserId: \"\",\n      });\n      syncPromptFiles();\n      const syncWarning = await runTelegramGitSync(\"create-topic\", result.name);\n      res.json({\n        ok: true,\n        topic: {\n          threadId,\n          name: result.name,\n          iconColor: result.icon_color,\n          ...(hasAgentId ? { agentId } : {}),\n        },\n        syncWarning,\n      });\n    } catch (e) {\n      res.json({ ok: false, error: e.message });\n    }\n  });\n\n  // Bulk-create topics\n  app.post(\"/api/telegram/groups/:groupId/topics/bulk\", async (req, res) => {\n    const { groupId } = req.params;\n    const body = req.body || {};\n    const topics = Array.isArray(body.topics) ? body.topics : [];\n    if (!Array.isArray(topics) || topics.length === 0) {\n      return res\n        .status(400)\n        .json({ ok: false, error: \"topics array is required\" });\n    }\n\n    const tgApi = resolveAccountTelegramApi(resolveAccountId(req), telegramApi);\n    const results = [];\n    for (const t of topics) {\n      if (!t.name) {\n        results.push({ name: t.name, ok: false, error: \"name is required\" });\n        continue;\n      }\n      try {\n        const result = await tgApi.createForumTopic(groupId, t.name, {\n          iconColor: t.iconColor || undefined,\n        });\n        const threadId = result.message_thread_id;\n        const systemInstructions = String(\n          t.systemInstructions ?? t.systemPrompt ?? \"\",\n        ).trim();\n        const hasAgentId = Object.prototype.hasOwnProperty.call(t, \"agentId\");\n        const agentId = String(t.agentId ?? \"\").trim();\n        topicRegistry.addTopic(groupId, threadId, {\n          name: result.name,\n          iconColor: result.icon_color,\n          ...(systemInstructions ? { systemInstructions } : {}),\n          ...(hasAgentId ? { agentId: agentId || undefined } : {}),\n        });\n        results.push({ name: result.name, threadId, ok: true });\n      } catch (e) {\n        results.push({ name: t.name, ok: false, error: e.message });\n      }\n    }\n    syncConfigForTelegram({\n      fs,\n      openclawDir: OPENCLAW_DIR,\n      topicRegistry,\n      groupId,\n      accountId: resolveAccountId(req),\n      requireMention: false,\n      resolvedUserId: \"\",\n    });\n    syncPromptFiles();\n    const syncWarning = await runTelegramGitSync(\"bulk-create-topics\", groupId);\n    res.json({ ok: true, results, syncWarning });\n  });\n\n  // Delete a topic\n  app.delete(\n    \"/api/telegram/groups/:groupId/topics/:topicId\",\n    async (req, res) => {\n      const { groupId, topicId } = req.params;\n      try {\n        const tgApi = resolveAccountTelegramApi(\n          resolveAccountId(req),\n          telegramApi,\n        );\n        await tgApi.deleteForumTopic(groupId, parseInt(topicId, 10));\n        topicRegistry.removeTopic(groupId, topicId);\n        syncConfigForTelegram({\n          fs,\n          openclawDir: OPENCLAW_DIR,\n          topicRegistry,\n          groupId,\n          accountId: resolveAccountId(req),\n          requireMention: false,\n          resolvedUserId: \"\",\n        });\n        syncPromptFiles();\n        const syncWarning = await runTelegramGitSync(\"delete-topic\", topicId);\n        res.json({ ok: true, syncWarning });\n      } catch (e) {\n        if (!isMissingTopicError(e?.message)) {\n          return res.json({ ok: false, error: e.message });\n        }\n        topicRegistry.removeTopic(groupId, topicId);\n        syncConfigForTelegram({\n          fs,\n          openclawDir: OPENCLAW_DIR,\n          topicRegistry,\n          groupId,\n          accountId: resolveAccountId(req),\n          requireMention: false,\n          resolvedUserId: \"\",\n        });\n        syncPromptFiles();\n        const syncWarning = await runTelegramGitSync(\n          \"delete-stale-topic\",\n          topicId,\n        );\n        return res.json({\n          ok: true,\n          removedFromRegistryOnly: true,\n          warning:\n            \"Topic no longer exists in Telegram; removed stale registry entry.\",\n          syncWarning,\n        });\n      }\n    },\n  );\n\n  // Update a topic (rename, system instructions, agent routing)\n  app.put(\"/api/telegram/groups/:groupId/topics/:topicId\", async (req, res) => {\n    const { groupId, topicId } = req.params;\n    const body = req.body || {};\n    const name = String(body.name ?? \"\").trim();\n    const hasSystemInstructions =\n      Object.prototype.hasOwnProperty.call(body, \"systemInstructions\") ||\n      Object.prototype.hasOwnProperty.call(body, \"systemPrompt\");\n    const systemInstructions = String(\n      body.systemInstructions ?? body.systemPrompt ?? \"\",\n    ).trim();\n    const hasAgentId = Object.prototype.hasOwnProperty.call(body, \"agentId\");\n    const agentId = String(body.agentId ?? \"\").trim();\n    if (!name)\n      return res.status(400).json({ ok: false, error: \"name is required\" });\n    try {\n      const threadId = Number.parseInt(String(topicId), 10);\n      if (!Number.isFinite(threadId)) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"topicId must be numeric\" });\n      }\n      const tgApi = resolveAccountTelegramApi(\n        resolveAccountId(req),\n        telegramApi,\n      );\n      const existingTopic =\n        topicRegistry.getGroup(groupId)?.topics?.[String(threadId)] || {};\n      const existingName = String(existingTopic.name || \"\").trim();\n      const shouldRename = !existingName || existingName !== name;\n      if (shouldRename) {\n        try {\n          await tgApi.editForumTopic(groupId, threadId, { name });\n        } catch (e) {\n          // Telegram returns TOPIC_NOT_MODIFIED when the name is unchanged.\n          if (!String(e.message || \"\").includes(\"TOPIC_NOT_MODIFIED\")) {\n            throw e;\n          }\n        }\n      }\n      topicRegistry.updateTopic(groupId, threadId, {\n        ...existingTopic,\n        name,\n        ...(hasSystemInstructions ? { systemInstructions } : {}),\n        ...(hasAgentId ? { agentId: agentId || undefined } : {}),\n      });\n      syncConfigForTelegram({\n        fs,\n        openclawDir: OPENCLAW_DIR,\n        topicRegistry,\n        groupId,\n        accountId: resolveAccountId(req),\n        requireMention: false,\n        resolvedUserId: \"\",\n      });\n      syncPromptFiles();\n      const syncWarning = await runTelegramGitSync(\"update-topic\", name);\n      return res.json({\n        ok: true,\n        topic: {\n          threadId,\n          name,\n          ...(hasSystemInstructions ? { systemInstructions } : {}),\n          ...(hasAgentId ? { agentId } : {}),\n        },\n        syncWarning,\n      });\n    } catch (e) {\n      return res.json({ ok: false, error: e.message });\n    }\n  });\n\n  // Configure openclaw.json for a group\n  app.post(\"/api/telegram/groups/:groupId/configure\", async (req, res) => {\n    const { groupId } = req.params;\n    const body = req.body || {};\n    const userId = body.userId ?? \"\";\n    const groupName = body.groupName ?? \"\";\n    const accountId = resolveAccountId(req);\n    const requireMention = parseBooleanValue(body.requireMention, false);\n    try {\n      const tgApi = resolveAccountTelegramApi(\n        accountId,\n        telegramApi,\n      );\n      const resolvedUserId = await resolveAllowUserId({\n        telegramApi: tgApi,\n        groupId,\n        preferredUserId: userId,\n      });\n      syncConfigForTelegram({\n        fs,\n        openclawDir: OPENCLAW_DIR,\n        topicRegistry,\n        groupId,\n        accountId,\n        requireMention,\n        resolvedUserId,\n      });\n\n      // Save metadata in local topic registry only.\n      const cfg = readOpenclawConfig({\n        fsModule: fs,\n        openclawDir: OPENCLAW_DIR,\n        fallback: {},\n      });\n      const boundAgentId = resolveBoundAgentIdForAccount({ cfg, accountId });\n      topicRegistry.setGroup(groupId, {\n        ...(groupName ? { name: groupName } : {}),\n        accountId,\n        ...(boundAgentId ? { agentId: boundAgentId } : {}),\n      });\n      syncPromptFiles();\n      const syncWarning = await runTelegramGitSync(\"configure-group\", groupId);\n\n      res.json({ ok: true, userId: resolvedUserId || null, syncWarning });\n    } catch (e) {\n      res.json({ ok: false, error: e.message });\n    }\n  });\n\n  // Get full topic registry\n  app.get(\"/api/telegram/topic-registry\", (req, res) => {\n    res.json({ ok: true, registry: topicRegistry.readRegistry() });\n  });\n\n  // Workspace bootstrap info (lets UI jump straight to management)\n  app.get(\"/api/telegram/workspace\", async (req, res) => {\n    try {\n      const accountId = resolveAccountId(req);\n      const debugEnabled = isDebugEnabled();\n      let cfg = readOpenclawConfig({\n        fsModule: fs,\n        openclawDir: OPENCLAW_DIR,\n        fallback: {},\n      });\n      let telegramConfig = cfg.channels?.telegram || {};\n      const { accountConfig } = resolveTelegramConfigForAccount({\n        telegramConfig,\n        accountId,\n      });\n      const configuredGroups =\n        accountConfig?.groups && typeof accountConfig.groups === \"object\"\n          ? accountConfig.groups\n          : {};\n      const groupIds = Object.keys(configuredGroups);\n      const registryFallbackGroups = topicRegistry.getGroupsForAccount(accountId);\n      const registryFallbackGroupIds = Object.keys(registryFallbackGroups);\n      const useRegistryFallback =\n        groupIds.length === 0 && registryFallbackGroupIds.length > 0;\n      if (groupIds.length === 0 && !useRegistryFallback) {\n        return res.json({\n          ok: true,\n          configured: false,\n          groups: [],\n          debugEnabled,\n        });\n      }\n\n      const tgApi = resolveAccountTelegramApi(accountId, telegramApi);\n      let activeGroupIds = useRegistryFallback ? registryFallbackGroupIds : groupIds;\n      if (!useRegistryFallback) {\n        let anyRepaired = false;\n        for (const gId of groupIds) {\n          const gConfig = configuredGroups[gId] || {};\n          const repairResult = await repairGroupAllowFromIfMissing({\n            cfg,\n            accountId,\n            groupId: gId,\n            requireMention: !!gConfig.requireMention,\n            tgApi,\n          });\n          if (repairResult.repaired) {\n            anyRepaired = true;\n          }\n        }\n        if (anyRepaired) {\n          cfg = readOpenclawConfig({\n            fsModule: fs,\n            openclawDir: OPENCLAW_DIR,\n            fallback: {},\n          });\n          telegramConfig = cfg.channels?.telegram || {};\n        }\n        const refreshedAccountConfig = resolveTelegramConfigForAccount({\n          telegramConfig,\n          accountId,\n        }).accountConfig;\n        const refreshedGroups =\n          refreshedAccountConfig?.groups &&\n          typeof refreshedAccountConfig.groups === \"object\"\n            ? refreshedAccountConfig.groups\n            : {};\n        activeGroupIds = Object.keys(refreshedGroups);\n      }\n\n      const groups = [];\n      for (const gId of activeGroupIds) {\n        const registryGroup = topicRegistry.getGroup(gId);\n        let gName = registryGroup?.name || gId;\n        try {\n          const chat = await tgApi.getChat(gId);\n          if (chat?.title) gName = chat.title;\n        } catch {}\n        groups.push({\n          groupId: gId,\n          groupName: gName,\n          topics: registryGroup?.topics || {},\n        });\n      }\n\n      const first = groups[0] || {};\n      return res.json({\n        ok: true,\n        configured: true,\n        groups,\n        groupId: first.groupId,\n        groupName: first.groupName,\n        topics: first.topics,\n        debugEnabled,\n        concurrency: {\n          agentMaxConcurrent: cfg.agents?.defaults?.maxConcurrent ?? null,\n          subagentMaxConcurrent:\n            cfg.agents?.defaults?.subagents?.maxConcurrent ?? null,\n        },\n      });\n    } catch (e) {\n      return res.json({ ok: false, error: e.message });\n    }\n  });\n\n  // Reset Telegram workspace onboarding state\n  app.post(\"/api/telegram/workspace/reset\", async (req, res) => {\n    try {\n      const accountId = resolveAccountId(req);\n      const cfg = readOpenclawConfig({\n        fsModule: fs,\n        openclawDir: OPENCLAW_DIR,\n        fallback: {},\n      });\n      const telegramConfig = cfg.channels?.telegram;\n      if (!telegramConfig || typeof telegramConfig !== \"object\") {\n        return res.json({ ok: true, syncWarning: null });\n      }\n      const { normalizedAccountId, hasAccounts, accountConfig } =\n        resolveTelegramConfigForAccount({\n          telegramConfig,\n          accountId,\n        });\n      const telegramGroups = Object.keys(accountConfig?.groups || {});\n      const groupsToRemove =\n        telegramGroups.length > 0\n          ? telegramGroups\n          : Object.keys(topicRegistry.getGroupsForAccount(accountId));\n      if (hasAccounts) {\n        const accountEntry = telegramConfig.accounts?.[normalizedAccountId];\n        if (accountEntry && typeof accountEntry === \"object\") {\n          delete accountEntry.groups;\n          delete accountEntry.groupAllowFrom;\n        }\n      } else {\n        delete telegramConfig.groups;\n        delete telegramConfig.groupAllowFrom;\n      }\n      writeOpenclawConfig({\n        fsModule: fs,\n        openclawDir: OPENCLAW_DIR,\n        config: cfg,\n        spacing: 2,\n      });\n\n      // Remove corresponding groups from topic registry\n      const registry = topicRegistry.readRegistry();\n      if (registry && registry.groups) {\n        for (const groupId of groupsToRemove) {\n          delete registry.groups[groupId];\n        }\n        topicRegistry.writeRegistry(registry);\n      }\n\n      syncPromptFiles();\n      const syncWarning = await runTelegramGitSync(\n        \"reset-workspace\",\n        \"telegram\",\n      );\n      return res.json({ ok: true, syncWarning });\n    } catch (e) {\n      return res.json({ ok: false, error: e.message });\n    }\n  });\n};\n\nmodule.exports = { registerTelegramRoutes, buildTelegramGitSyncCommand };\n"
  },
  {
    "path": "lib/server/routes/usage.js",
    "content": "const topicRegistry = require(\"../topic-registry\");\nconst { parsePositiveInt } = require(\"../utils/number\");\n\nconst kSummaryCacheTtlMs = 60 * 1000;\nconst kClientTimeZoneHeader = \"x-client-timezone\";\n\nconst createSummaryCache = () => new Map();\nconst toTitleLabel = (value) => {\n  const raw = String(value || \"\").trim();\n  if (!raw) return \"\";\n  return raw.charAt(0).toUpperCase() + raw.slice(1);\n};\nconst isUuidLike = (value) =>\n  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(\n    String(value || \"\").trim(),\n  );\n\n// Parse \"agent:main:telegram:group:-123:topic:42\" into structured labels.\nconst parseSessionLabels = (sessionKey) => {\n  const raw = String(sessionKey || \"\").trim();\n  if (!raw) return null;\n  const parts = raw.split(\":\");\n  const labels = [];\n\n  if (parts[0] === \"agent\" && parts[1]) {\n    labels.push({\n      label: parts[1].charAt(0).toUpperCase() + parts[1].slice(1),\n      tone: \"cyan\",\n    });\n  }\n\n  const channelIndex = parts.indexOf(\"telegram\");\n  if (channelIndex !== -1 && parts[channelIndex + 1]) {\n    const channelType = parts[channelIndex + 1];\n    if (channelType === \"direct\") {\n      labels.push({ label: \"Telegram Direct\", tone: \"blue\" });\n    } else if (channelType === \"group\") {\n      const groupId = parts[channelIndex + 2] || \"\";\n      let groupName = null;\n      let groupEntry = null;\n      try {\n        groupEntry = topicRegistry.getGroup(groupId);\n        groupName = groupEntry?.name || null;\n      } catch {}\n      labels.push({\n        label: groupName || `Group ${groupId}`,\n        tone: \"purple\",\n      });\n      const topicIndex = parts.indexOf(\"topic\", channelIndex);\n      if (topicIndex !== -1 && parts[topicIndex + 1]) {\n        const topicId = parts[topicIndex + 1];\n        const topicName = groupEntry?.topics?.[topicId]?.name || null;\n        labels.push({\n          label: topicName || `Topic ${topicId}`,\n          tone: \"gray\",\n        });\n      }\n    } else {\n      labels.push({\n        label: `Telegram ${channelType.charAt(0).toUpperCase() + channelType.slice(1)}`,\n        tone: \"blue\",\n      });\n    }\n  }\n  const hookIndex = parts.indexOf(\"hook\");\n  if (hookIndex !== -1) {\n    labels.push({ label: \"Hook\", tone: \"purple\" });\n    const hookName = String(parts[hookIndex + 1] || \"\").trim();\n    if (hookName && !isUuidLike(hookName)) {\n      labels.push({\n        label: toTitleLabel(hookName),\n        tone: \"gray\",\n      });\n    }\n  }\n  if (parts.includes(\"cron\")) {\n    labels.push({ label: \"Cron\", tone: \"blue\" });\n  }\n\n  return labels.length > 0 ? labels : null;\n};\n\nconst enrichSessionLabels = (session) => ({\n  ...session,\n  labels: parseSessionLabels(session.sessionKey || session.sessionId),\n});\n\nconst registerUsageRoutes = ({\n  app,\n  requireAuth,\n  getDailySummary,\n  getSessionsList,\n  getSessionDetail,\n  getSessionTimeSeries,\n}) => {\n  const summaryCache = createSummaryCache();\n\n  app.get(\"/api/usage/summary\", requireAuth, (req, res) => {\n    try {\n      const days = parsePositiveInt(req.query.days, 30);\n      const timeZone = String(\n        req.get(kClientTimeZoneHeader) || req.query.timeZone || \"\",\n      ).trim();\n      const cacheKey = `${days}:${timeZone || \"UTC\"}`;\n      const cached = summaryCache.get(cacheKey);\n      const now = Date.now();\n      if (cached && now - cached.cachedAt <= kSummaryCacheTtlMs) {\n        res.json({ ok: true, ...cached.payload, cached: true });\n        return;\n      }\n      const summary = getDailySummary({ days, timeZone });\n      const payload = { summary };\n      summaryCache.set(cacheKey, { payload, cachedAt: now });\n      res.json({ ok: true, ...payload, cached: false });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/usage/sessions\", requireAuth, (req, res) => {\n    try {\n      const limit = parsePositiveInt(req.query.limit, 50);\n      const sessions = getSessionsList({ limit }).map(enrichSessionLabels);\n      res.json({ ok: true, sessions });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/usage/sessions/:id\", requireAuth, (req, res) => {\n    try {\n      const sessionId = String(req.params.id || \"\").trim();\n      const detail = getSessionDetail({ sessionId });\n      if (!detail) {\n        res.status(404).json({ ok: false, error: \"Session not found\" });\n        return;\n      }\n      res.json({ ok: true, detail: enrichSessionLabels(detail) });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/usage/sessions/:id/timeseries\", requireAuth, (req, res) => {\n    try {\n      const sessionId = String(req.params.id || \"\").trim();\n      const maxPoints = parsePositiveInt(req.query.maxPoints, 100);\n      const series = getSessionTimeSeries({ sessionId, maxPoints });\n      res.json({ ok: true, series });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n};\n\nmodule.exports = { registerUsageRoutes };\n"
  },
  {
    "path": "lib/server/routes/watchdog.js",
    "content": "const { getSystemResources } = require(\"../system-resources\");\n\nconst registerWatchdogRoutes = ({\n  app,\n  requireAuth,\n  watchdog,\n  watchdogNotifier,\n  getRecentEvents,\n  readLogTail,\n  watchdogTerminal,\n}) => {\n  app.get(\"/api/watchdog/status\", requireAuth, (req, res) => {\n    try {\n      const status = watchdog.getStatus();\n      res.json({ ok: true, status });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/watchdog/events\", requireAuth, (req, res) => {\n    try {\n      const limit = Number.parseInt(String(req.query.limit || \"20\"), 10) || 20;\n      const includeRoutine =\n        String(req.query.includeRoutine || \"\").trim() === \"1\" ||\n        String(req.query.includeRoutine || \"\").trim().toLowerCase() === \"true\";\n      const events = getRecentEvents({ limit, includeRoutine });\n      res.json({ ok: true, events });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/watchdog/logs\", requireAuth, (req, res) => {\n    try {\n      const tail = Number.parseInt(String(req.query.tail || \"65536\"), 10) || 65536;\n      const logs = readLogTail(tail);\n      res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n      res.status(200).send(logs);\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/watchdog/repair\", requireAuth, async (req, res) => {\n    try {\n      const result = await watchdog.triggerRepair();\n      res.json({ ok: !!result?.ok, result });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/watchdog/settings\", requireAuth, (req, res) => {\n    try {\n      res.json({ ok: true, settings: watchdog.getSettings() });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/watchdog/resources\", requireAuth, (req, res) => {\n    try {\n      const status = watchdog.getStatus();\n      res.json({ ok: true, resources: getSystemResources({ gatewayPid: status.gatewayPid }) });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.put(\"/api/watchdog/settings\", requireAuth, (req, res) => {\n    try {\n      const settings = watchdog.updateSettings(req.body || {});\n      res.json({ ok: true, settings });\n    } catch (err) {\n      res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/watchdog/test-notification\", requireAuth, async (req, res) => {\n    try {\n      if (!watchdogNotifier?.notify) {\n        return res.status(503).json({ ok: false, error: \"Notifier not available\" });\n      }\n      const result = await watchdogNotifier.notify(\n        \"*AlphaClaw test notification* — your watchdog alerts are working.\",\n      );\n      res.json({ ok: true, result });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/watchdog/terminal/session\", requireAuth, (req, res) => {\n    try {\n      const terminalSession = watchdogTerminal.createOrReuseSession();\n      res.json({ ok: true, session: terminalSession });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/watchdog/terminal/output\", requireAuth, (req, res) => {\n    try {\n      const sessionId = String(req.query.sessionId || \"\");\n      if (!sessionId) {\n        res.status(400).json({ ok: false, error: \"Missing sessionId\" });\n        return;\n      }\n      const cursor = Number.parseInt(String(req.query.cursor || \"0\"), 10) || 0;\n      const output = watchdogTerminal.readOutput({ sessionId, cursor });\n      if (!output.found) {\n        res.status(404).json({ ok: false, error: \"Terminal session not found\" });\n        return;\n      }\n      res.json({ ok: true, ...output });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/watchdog/terminal/input\", requireAuth, (req, res) => {\n    try {\n      const sessionId = String(req.body?.sessionId || \"\");\n      const input = String(req.body?.input || \"\");\n      if (!sessionId) {\n        res.status(400).json({ ok: false, error: \"Missing sessionId\" });\n        return;\n      }\n      const result = watchdogTerminal.writeInput({ sessionId, input });\n      if (!result.ok) {\n        res.status(400).json({ ok: false, error: result.error || \"Write failed\" });\n        return;\n      }\n      res.json({ ok: true });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/watchdog/terminal/close\", requireAuth, (req, res) => {\n    try {\n      const sessionId = String(req.body?.sessionId || \"\");\n      if (!sessionId) {\n        res.status(400).json({ ok: false, error: \"Missing sessionId\" });\n        return;\n      }\n      watchdogTerminal.closeSession({ sessionId });\n      res.json({ ok: true });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n};\n\nmodule.exports = { registerWatchdogRoutes };\n"
  },
  {
    "path": "lib/server/routes/webhooks.js",
    "content": "const {\n  listWebhooks,\n  getWebhookDetail,\n  createWebhook,\n  updateWebhookDestination,\n  deleteWebhook,\n  validateWebhookName,\n} = require(\"../webhooks\");\nconst { isTruthyFlag } = require(\"../utils/boolean\");\n\nconst isFiniteInteger = (value) =>\n  Number.isFinite(value) && Number.isInteger(value);\nconst parseBooleanFlag = (value) => isTruthyFlag(value);\n\nconst buildHealth = ({ totalCount, errorCount }) => {\n  if (!totalCount || totalCount <= 0) return \"green\";\n  if (!errorCount || errorCount <= 0) return \"green\";\n  if (errorCount >= totalCount) return \"red\";\n  return \"yellow\";\n};\n\nconst mapSummaryByHook = (summaries) => {\n  const byHook = new Map();\n  for (const summary of summaries || []) byHook.set(summary.hookName, summary);\n  return byHook;\n};\n\nconst mergeWebhookAndSummary = ({ webhook, summary }) => {\n  const totalCount = Number(summary?.totalCount || 0);\n  const errorCount = Number(summary?.errorCount || 0);\n  const successCount = Number(summary?.successCount || 0);\n  const recentTotalCount = Number(summary?.recentTotalCount || 0);\n  const recentErrorCount = Number(summary?.recentErrorCount || 0);\n  const recentSuccessCount = Number(summary?.recentSuccessCount || 0);\n  const healthWindowSize = Number(summary?.healthWindowSize || 0);\n  return {\n    ...webhook,\n    lastReceived: summary?.lastReceived || null,\n    totalCount,\n    successCount,\n    errorCount,\n    recentTotalCount,\n    recentSuccessCount,\n    recentErrorCount,\n    healthWindowSize,\n    health: buildHealth({\n      totalCount: recentTotalCount || totalCount,\n      errorCount: recentTotalCount > 0 ? recentErrorCount : errorCount,\n    }),\n  };\n};\n\nconst normalizeStatusFilter = (rawStatus) => {\n  const status = String(rawStatus || \"all\")\n    .trim()\n    .toLowerCase();\n  if ([\"all\", \"success\", \"error\"].includes(status)) return status;\n  return \"all\";\n};\n\nconst buildWebhookUrls = ({ baseUrl, name, oauthCallback = null }) => {\n  const fullUrl = `${baseUrl}/hooks/${name}`;\n  const token = String(process.env.WEBHOOK_TOKEN || \"\").trim();\n  const queryStringUrl = token\n    ? `${fullUrl}?token=${encodeURIComponent(token)}`\n    : `${fullUrl}?token=<WEBHOOK_TOKEN>`;\n  const authHeaderValue = token\n    ? `Authorization: Bearer ${token}`\n    : \"Authorization: Bearer <WEBHOOK_TOKEN>\";\n  const callbackId = String(oauthCallback?.callbackId || \"\").trim();\n  return {\n    fullUrl,\n    queryStringUrl,\n    authHeaderValue,\n    hasRuntimeToken: !!token,\n    oauthCallbackId: callbackId || \"\",\n    oauthCallbackUrl: callbackId ? `${baseUrl}/oauth/${callbackId}` : \"\",\n    oauthCallbackCreatedAt: oauthCallback?.createdAt || null,\n    oauthCallbackRotatedAt: oauthCallback?.rotatedAt || null,\n    oauthCallbackLastUsedAt: oauthCallback?.lastUsedAt || null,\n  };\n};\n\nconst buildOauthTransformSource = (name) => {\n  return [\n    \"export default async function transform(payload, context) {\",\n    \"  const data = payload.payload || payload || {};\",\n    \"  const message = String(data.message || \\\"\\\").trim();\",\n    \"  const code = String(data.code || \\\"\\\").trim();\",\n    \"  const state = String(data.state || \\\"\\\").trim();\",\n    \"  const error = String(data.error || \\\"\\\").trim();\",\n    \"  const fallbackMessage = error\",\n    \"    ? `OAuth callback error: ${error}`\",\n    \"    : code\",\n    \"      ? \\\"OAuth callback received (authorization code present)\\\"\",\n    \"      : state\",\n    \"        ? \\\"OAuth callback received (state present)\\\"\",\n    \"        : \\\"OAuth callback received\\\";\",\n    \"  return {\",\n    \"    message: message || fallbackMessage,\",\n    `    name: data.name || \\\"${name}\\\",`,\n    \"    wakeMode: data.wakeMode || \\\"now\\\",\",\n    \"    oauth: {\",\n    \"      code,\",\n    \"      state,\",\n    \"      error,\",\n    \"    },\",\n    \"  };\",\n    \"}\",\n    \"\",\n  ].join(\"\\n\");\n};\n\nconst registerWebhookRoutes = ({\n  app,\n  fs,\n  constants,\n  getBaseUrl,\n  webhooksDb,\n  shellCmd,\n  restartRequiredState,\n}) => {\n  const {\n    getRequests = () => [],\n    getRequestById = () => null,\n    getHookSummaries = () => [],\n    deleteRequestsByHook = () => 0,\n    createOauthCallback: createOauthCallbackEntry = () => null,\n    getOauthCallbackByHook: getOauthCallbackByHookEntry = () => null,\n    rotateOauthCallback: rotateOauthCallbackEntry = () => null,\n    deleteOauthCallback: deleteOauthCallbackEntry = () => 0,\n  } = webhooksDb || {};\n  const fallbackRestartState = {\n    markRequired: () => {},\n    getSnapshot: async () => ({ restartRequired: false }),\n  };\n  const resolvedRestartState = restartRequiredState || fallbackRestartState;\n  const { markRequired: markRestartRequired, getSnapshot: getRestartSnapshot } =\n    resolvedRestartState;\n  const runWebhookGitSync = async (action, name) => {\n    if (typeof shellCmd !== \"function\") return null;\n    const safeName = String(name || \"\").trim();\n    const message = `webhooks: ${action} ${safeName}`.replace(/\"/g, \"\");\n    try {\n      await shellCmd(`alphaclaw git-sync -m \"${message}\"`, {\n        timeout: 30000,\n      });\n      return null;\n    } catch (err) {\n      return err?.message || \"alphaclaw git-sync failed\";\n    }\n  };\n\n  app.get(\"/api/webhooks\", (req, res) => {\n    try {\n      const hooks = listWebhooks({ fs, constants });\n      const summaries = getHookSummaries();\n      const summaryByHook = mapSummaryByHook(summaries);\n      const webhooks = hooks.map((webhook) => {\n        const oauthCallback = getOauthCallbackByHookEntry(webhook.name);\n        return {\n          ...mergeWebhookAndSummary({\n            webhook,\n            summary: summaryByHook.get(webhook.name),\n          }),\n          oauthCallbackEnabled: !!String(oauthCallback?.callbackId || \"\").trim(),\n        };\n      });\n      res.json({ ok: true, webhooks });\n    } catch (err) {\n      res.status(500).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/webhooks/:name\", (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const detail = getWebhookDetail({ fs, constants, name });\n      if (!detail)\n        return res.status(404).json({ ok: false, error: \"Webhook not found\" });\n      const summary = getHookSummaries().find((item) => item.hookName === name);\n      const oauthCallback = getOauthCallbackByHookEntry(name);\n      const merged = mergeWebhookAndSummary({ webhook: detail, summary });\n      const baseUrl = getBaseUrl(req);\n      const urls = buildWebhookUrls({ baseUrl, name, oauthCallback });\n      return res.json({\n        ok: true,\n        webhook: {\n          ...merged,\n          fullUrl: urls.fullUrl,\n          queryStringUrl: urls.queryStringUrl,\n          authHeaderValue: urls.authHeaderValue,\n          hasRuntimeToken: urls.hasRuntimeToken,\n          oauthCallbackId: urls.oauthCallbackId,\n          oauthCallbackUrl: urls.oauthCallbackUrl,\n          oauthCallbackCreatedAt: urls.oauthCallbackCreatedAt,\n          oauthCallbackRotatedAt: urls.oauthCallbackRotatedAt,\n          oauthCallbackLastUsedAt: urls.oauthCallbackLastUsedAt,\n          authNote:\n            \"All hooks use WEBHOOK_TOKEN. Use Authorization: Bearer <token> or x-openclaw-token header.\",\n        },\n      });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/webhooks\", async (req, res) => {\n    try {\n      const {\n        name: rawName,\n        destination = null,\n        oauthCallback = false,\n      } = req.body || {};\n      const name = validateWebhookName(rawName);\n      const transformSource = oauthCallback ? buildOauthTransformSource(name) : \"\";\n      const webhook = createWebhook({\n        fs,\n        constants,\n        name,\n        destination,\n        transformSource,\n      });\n      const oauthCallbackRecord = oauthCallback\n        ? createOauthCallbackEntry({ hookName: name })\n        : null;\n      const baseUrl = getBaseUrl(req);\n      const urls = buildWebhookUrls({\n        baseUrl,\n        name,\n        oauthCallback: oauthCallbackRecord,\n      });\n      const syncWarning = await runWebhookGitSync(\"create\", name);\n      markRestartRequired(\"webhooks\");\n      const snapshot = await getRestartSnapshot();\n      return res.status(201).json({\n        ok: true,\n        webhook: {\n          ...webhook,\n          fullUrl: urls.fullUrl,\n          queryStringUrl: urls.queryStringUrl,\n          authHeaderValue: urls.authHeaderValue,\n          hasRuntimeToken: urls.hasRuntimeToken,\n          oauthCallbackId: urls.oauthCallbackId,\n          oauthCallbackUrl: urls.oauthCallbackUrl,\n          oauthCallbackCreatedAt: urls.oauthCallbackCreatedAt,\n          oauthCallbackRotatedAt: urls.oauthCallbackRotatedAt,\n          oauthCallbackLastUsedAt: urls.oauthCallbackLastUsedAt,\n        },\n        restartRequired: snapshot.restartRequired,\n        syncWarning,\n      });\n    } catch (err) {\n      const status = String(err.message || \"\").includes(\"already exists\")\n        ? 409\n        : 400;\n      return res.status(status).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.put(\"/api/webhooks/:name/destination\", async (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const detail = updateWebhookDestination({\n        fs,\n        constants,\n        name,\n        destination: req?.body?.destination ?? null,\n      });\n      const summary = getHookSummaries().find((item) => item.hookName === name);\n      const oauthCallback = getOauthCallbackByHookEntry(name);\n      const merged = mergeWebhookAndSummary({ webhook: detail, summary });\n      const baseUrl = getBaseUrl(req);\n      const urls = buildWebhookUrls({ baseUrl, name, oauthCallback });\n      const syncWarning = await runWebhookGitSync(\"update destination\", name);\n      markRestartRequired(\"webhooks\");\n      const snapshot = await getRestartSnapshot();\n      return res.json({\n        ok: true,\n        webhook: {\n          ...merged,\n          fullUrl: urls.fullUrl,\n          queryStringUrl: urls.queryStringUrl,\n          authHeaderValue: urls.authHeaderValue,\n          hasRuntimeToken: urls.hasRuntimeToken,\n          oauthCallbackId: urls.oauthCallbackId,\n          oauthCallbackUrl: urls.oauthCallbackUrl,\n          oauthCallbackCreatedAt: urls.oauthCallbackCreatedAt,\n          oauthCallbackRotatedAt: urls.oauthCallbackRotatedAt,\n          oauthCallbackLastUsedAt: urls.oauthCallbackLastUsedAt,\n          authNote:\n            \"All hooks use WEBHOOK_TOKEN. Use Authorization: Bearer <token> or x-openclaw-token header.\",\n        },\n        restartRequired: snapshot.restartRequired,\n        syncWarning,\n      });\n    } catch (err) {\n      const status = String(err.message || \"\").includes(\"not found\") ? 404 : 400;\n      return res.status(status).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/webhooks/:name/oauth-callback\", (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const detail = getWebhookDetail({ fs, constants, name });\n      if (!detail)\n        return res.status(404).json({ ok: false, error: \"Webhook not found\" });\n      const existing = getOauthCallbackByHookEntry(name);\n      if (existing?.callbackId) {\n        return res.status(409).json({\n          ok: false,\n          error: \"OAuth callback alias already exists\",\n        });\n      }\n      const oauthCallback = createOauthCallbackEntry({ hookName: name });\n      const baseUrl = getBaseUrl(req);\n      const urls = buildWebhookUrls({ baseUrl, name, oauthCallback });\n      return res.status(201).json({\n        ok: true,\n        oauthCallbackId: urls.oauthCallbackId,\n        oauthCallbackUrl: urls.oauthCallbackUrl,\n        oauthCallbackCreatedAt: urls.oauthCallbackCreatedAt,\n        oauthCallbackRotatedAt: urls.oauthCallbackRotatedAt,\n        oauthCallbackLastUsedAt: urls.oauthCallbackLastUsedAt,\n      });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.post(\"/api/webhooks/:name/oauth-callback/rotate\", (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const detail = getWebhookDetail({ fs, constants, name });\n      if (!detail)\n        return res.status(404).json({ ok: false, error: \"Webhook not found\" });\n      const oauthCallback = rotateOauthCallbackEntry(name);\n      const baseUrl = getBaseUrl(req);\n      const urls = buildWebhookUrls({ baseUrl, name, oauthCallback });\n      return res.json({\n        ok: true,\n        oauthCallbackId: urls.oauthCallbackId,\n        oauthCallbackUrl: urls.oauthCallbackUrl,\n        oauthCallbackCreatedAt: urls.oauthCallbackCreatedAt,\n        oauthCallbackRotatedAt: urls.oauthCallbackRotatedAt,\n        oauthCallbackLastUsedAt: urls.oauthCallbackLastUsedAt,\n      });\n    } catch (err) {\n      const status = String(err?.message || \"\").includes(\"not found\") ? 404 : 400;\n      return res.status(status).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.delete(\"/api/webhooks/:name/oauth-callback\", (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const deletedCount = deleteOauthCallbackEntry(name);\n      return res.json({ ok: true, deleted: deletedCount > 0 });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.delete(\"/api/webhooks/:name\", async (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const deleteTransformDir = parseBooleanFlag(\n        req?.body?.deleteTransformDir,\n      );\n      const deletion = deleteWebhook({\n        fs,\n        constants,\n        name,\n        deleteTransformDir,\n      });\n      if (deletion?.managed) {\n        return res.status(409).json({\n          ok: false,\n          error: `Webhook \"${name}\" is managed by system setup and cannot be deleted`,\n        });\n      }\n      if (!deletion?.removed)\n        return res.status(404).json({ ok: false, error: \"Webhook not found\" });\n      deleteOauthCallbackEntry(name);\n      const deletedRequestCount = deleteRequestsByHook(name);\n      const syncWarning = await runWebhookGitSync(\"delete\", name);\n      markRestartRequired(\"webhooks\");\n      const snapshot = await getRestartSnapshot();\n      return res.json({\n        ok: true,\n        restartRequired: snapshot.restartRequired,\n        syncWarning,\n        deletedRequestCount,\n        deletedTransformDir: !!deletion.deletedTransformDir,\n      });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/webhooks/:name/requests\", (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const limit = Number.parseInt(String(req.query.limit || 50), 10);\n      const offset = Number.parseInt(String(req.query.offset || 0), 10);\n      const status = normalizeStatusFilter(req.query.status);\n      const hasBadPaging =\n        !isFiniteInteger(limit) ||\n        limit <= 0 ||\n        !isFiniteInteger(offset) ||\n        offset < 0;\n      if (hasBadPaging) {\n        return res\n          .status(400)\n          .json({ ok: false, error: \"Invalid limit/offset\" });\n      }\n      const requests = getRequests(name, { limit, offset, status });\n      return res.json({ ok: true, requests });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n\n  app.get(\"/api/webhooks/:name/requests/:id\", (req, res) => {\n    try {\n      const name = validateWebhookName(req.params.name);\n      const requestId = Number.parseInt(String(req.params.id || 0), 10);\n      if (!isFiniteInteger(requestId) || requestId <= 0) {\n        return res.status(400).json({ ok: false, error: \"Invalid request id\" });\n      }\n      const request = getRequestById(name, requestId);\n      if (!request)\n        return res.status(404).json({ ok: false, error: \"Request not found\" });\n      return res.json({ ok: true, request });\n    } catch (err) {\n      return res.status(400).json({ ok: false, error: err.message });\n    }\n  });\n};\n\nmodule.exports = { registerWebhookRoutes };\n"
  },
  {
    "path": "lib/server/slack-api.js",
    "content": "const kSlackApiBase = \"https://slack.com/api\";\nconst fs = require(\"fs\");\nconst { Readable } = require(\"stream\");\nconst { Blob } = require(\"buffer\");\n\n/**\n * Create Slack API client with enhanced features:\n * - Threading support\n * - Reactions\n * - File uploads\n * - Backward compatible with existing code\n */\nconst createSlackApi = (getToken) => {\n  const call = async (method, body = {}) => {\n    const token = typeof getToken === \"function\" ? getToken() : getToken;\n    if (!token) throw new Error(\"SLACK_BOT_TOKEN is not set\");\n    const res = await fetch(`${kSlackApiBase}/${method}`, {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${token}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(body),\n    });\n    if (!res.ok) {\n      throw new Error(`Slack API ${method}: HTTP ${res.status}`);\n    }\n    const data = await res.json();\n    if (!data.ok) {\n      const err = new Error(data.error || `Slack API error: ${method}`);\n      err.slackError = data.error;\n      throw err;\n    }\n    return data;\n  };\n\n  /**\n   * Convert various file input types to Buffer\n   */\n  const toBuffer = async (content) => {\n    if (Buffer.isBuffer(content)) {\n      return content;\n    } else if (content instanceof Readable) {\n      const chunks = [];\n      for await (const chunk of content) {\n        chunks.push(chunk);\n      }\n      return Buffer.concat(chunks);\n    } else if (typeof content === \"string\" && fs.existsSync(content)) {\n      return fs.readFileSync(content);\n    } else {\n      throw new Error(\"Invalid file content: must be Buffer, Stream, or file path\");\n    }\n  };\n\n  /**\n   * Verify Slack credentials\n   */\n  const authTest = () => call(\"auth.test\");\n\n  /**\n   * Send a message to a channel or DM\n   * @param {string} channel - Channel ID or user ID\n   * @param {string} text - Message text\n   * @param {object} opts - Options\n   * @param {string} opts.thread_ts - Thread timestamp (for threaded replies)\n   * @param {boolean} opts.reply_broadcast - Also send to channel (when in thread)\n   * @param {boolean} opts.mrkdwn - Enable Slack markdown formatting (default: true)\n   * @returns {Promise<object>} Response with ts (message timestamp)\n   */\n  const postMessage = (channel, text, opts = {}) => {\n    const payload = {\n      channel,\n      text: String(text || \"\"),\n    };\n\n    // Threading support\n    if (opts.thread_ts) {\n      payload.thread_ts = opts.thread_ts;\n    }\n    if (opts.reply_broadcast) {\n      payload.reply_broadcast = true;\n    }\n\n    // Formatting\n    if (opts.mrkdwn !== false) {\n      payload.mrkdwn = true;\n    }\n\n    return call(\"chat.postMessage\", payload);\n  };\n\n  /**\n   * Post a message in a thread (convenience wrapper)\n   * @param {string} channel - Channel ID\n   * @param {string} threadTs - Thread timestamp\n   * @param {string} text - Message text\n   * @param {object} opts - Additional options (reply_broadcast, etc.)\n   */\n  const postMessageInThread = (channel, threadTs, text, opts = {}) => {\n    return postMessage(channel, text, { ...opts, thread_ts: threadTs });\n  };\n\n  /**\n   * Add a reaction emoji to a message\n   * @param {string} channel - Channel ID\n   * @param {string} timestamp - Message timestamp\n   * @param {string} emoji - Emoji name (without colons, e.g., \"white_check_mark\")\n   */\n  const addReaction = (channel, timestamp, emoji) => {\n    // Remove colons if user included them\n    const cleanEmoji = String(emoji || \"\").replace(/^:|:$/g, \"\");\n    return call(\"reactions.add\", {\n      channel,\n      timestamp,\n      name: cleanEmoji,\n    });\n  };\n\n  /**\n   * Remove a reaction emoji from a message\n   * @param {string} channel - Channel ID\n   * @param {string} timestamp - Message timestamp\n   * @param {string} emoji - Emoji name (without colons)\n   */\n  const removeReaction = (channel, timestamp, emoji) => {\n    const cleanEmoji = String(emoji || \"\").replace(/^:|:$/g, \"\");\n    return call(\"reactions.remove\", {\n      channel,\n      timestamp,\n      name: cleanEmoji,\n    });\n  };\n\n  /**\n   * Upload a file to Slack using the 3-step external upload flow\n   * @param {string|string[]} channels - Channel ID(s) to share file in\n   * @param {Buffer|Stream|string} fileContent - File content (Buffer, Stream, or file path)\n   * @param {object} opts - Options\n   * @param {string} opts.filename - Filename\n   * @param {string} opts.title - File title\n   * @param {string} opts.initial_comment - Comment to add with file\n   * @param {string} opts.thread_ts - Thread timestamp (upload to thread)\n   * @param {string} opts.contentType - MIME type\n   * @returns {Promise<object>} Upload response with file info\n   */\n  const uploadFile = async (channels, fileContent, opts = {}) => {\n    const filename = opts.filename || \"file\";\n    const buffer = await toBuffer(fileContent);\n    const filesize = buffer.length;\n\n    // Step 1: Get upload URL\n    const uploadInfo = await call(\"files.getUploadURLExternal\", {\n      filename,\n      length: filesize,\n    });\n\n    const { upload_url, file_id } = uploadInfo;\n\n    // Step 2: Upload file to the external URL (raw POST, no auth)\n    const uploadRes = await fetch(upload_url, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": opts.contentType || \"application/octet-stream\",\n      },\n      body: buffer,\n    });\n\n    if (!uploadRes.ok) {\n      throw new Error(`File upload to external URL failed: HTTP ${uploadRes.status}`);\n    }\n\n    // Step 3: Complete the upload and share to channel(s)\n    const completePayload = {\n      files: [\n        {\n          id: file_id,\n          title: opts.title || filename,\n        },\n      ],\n    };\n\n    // Handle single channel vs multiple channels\n    if (channels) {\n      if (Array.isArray(channels)) {\n        completePayload.channel_id = channels[0]; // Primary channel\n        if (channels.length > 1) {\n          throw new Error(\"Multi-channel upload not supported with external upload flow. Use channel_id for one channel.\");\n        }\n      } else {\n        completePayload.channel_id = channels;\n      }\n    }\n\n    if (opts.initial_comment) {\n      completePayload.initial_comment = opts.initial_comment;\n    }\n\n    if (opts.thread_ts) {\n      completePayload.thread_ts = opts.thread_ts;\n    }\n\n    return call(\"files.completeUploadExternal\", completePayload);\n  };\n\n  /**\n   * Upload text as a code snippet with syntax highlighting\n   * @param {string|string[]} channels - Channel ID(s)\n   * @param {string} content - Text content\n   * @param {object} opts - Options\n   * @param {string} opts.filename - Filename (affects syntax highlighting, e.g., \"code.js\")\n   * @param {string} opts.title - Snippet title\n   * @param {string} opts.filetype - File type for syntax highlighting (e.g., \"javascript\")\n   * @param {string} opts.initial_comment - Comment\n   * @param {string} opts.thread_ts - Thread timestamp\n   */\n  const uploadTextSnippet = (channels, content, opts = {}) => {\n    const buffer = Buffer.from(String(content || \"\"), \"utf8\");\n    \n    // Detect language from filename if provided\n    let filename = opts.filename || \"snippet.txt\";\n    if (opts.filetype) {\n      const ext = opts.filetype.replace(/^\\./, \"\");\n      if (!filename.includes(\".\")) {\n        filename = `snippet.${ext}`;\n      }\n    }\n\n    return uploadFile(channels, buffer, {\n      ...opts,\n      filename,\n      contentType: \"text/plain\",\n    });\n  };\n\n  /**\n   * Update an existing message\n   * @param {string} channel - Channel ID\n   * @param {string} timestamp - Message timestamp\n   * @param {string} text - New message text\n   * @returns {Promise<object>} Update response\n   * @requires chat:write OAuth scope\n   */\n  const updateMessage = (channel, timestamp, text) => {\n    return call(\"chat.update\", {\n      channel,\n      ts: timestamp,\n      text: String(text || \"\"),\n    });\n  };\n\n  /**\n   * Delete a message\n   * @param {string} channel - Channel ID\n   * @param {string} timestamp - Message timestamp\n   * @requires chat:write OAuth scope\n   */\n  const deleteMessage = (channel, timestamp) => {\n    return call(\"chat.delete\", { channel, ts: timestamp });\n  };\n\n  /**\n   * Pin a message to a channel\n   * @param {string} channel - Channel ID\n   * @param {string} timestamp - Message timestamp\n   * @requires pins:write OAuth scope\n   */\n  const pinMessage = (channel, timestamp) => {\n    return call(\"pins.add\", { channel, timestamp });\n  };\n\n  /**\n   * Unpin a message from a channel\n   * @param {string} channel - Channel ID\n   * @param {string} timestamp - Message timestamp\n   * @requires pins:write OAuth scope\n   */\n  const unpinMessage = (channel, timestamp) => {\n    return call(\"pins.remove\", { channel, timestamp });\n  };\n\n  /**\n   * Get user information\n   * @param {string} userId - User ID\n   * @returns {Promise<object>} User info (name, real_name, email, etc.)\n   * @requires users:read OAuth scope\n   */\n  const getUserInfo = (userId) => {\n    return call(\"users.info\", { user: userId });\n  };\n\n  /**\n   * Get channel information\n   * @param {string} channelId - Channel ID\n   * @returns {Promise<object>} Channel info (name, topic, purpose, etc.)\n   * @requires channels:read or groups:read OAuth scope (depending on channel type)\n   */\n  const getChannelInfo = (channelId) => {\n    return call(\"conversations.info\", { channel: channelId });\n  };\n\n  return {\n    authTest,\n    postMessage,\n    postMessageInThread,\n    addReaction,\n    removeReaction,\n    uploadFile,\n    uploadTextSnippet,\n    updateMessage,\n    deleteMessage,\n    pinMessage,\n    unpinMessage,\n    getUserInfo,\n    getChannelInfo,\n  };\n};\n\nmodule.exports = { createSlackApi };\n"
  },
  {
    "path": "lib/server/startup.js",
    "content": "const runOnboardedBootSequence = ({\n  ensureManagedExecDefaults,\n  ensureUsageTrackerPluginConfig,\n  doSyncPromptFiles,\n  reloadEnv,\n  syncChannelConfig,\n  readEnvFile,\n  ensureGatewayProxyConfig,\n  resolveSetupUrl,\n  startGateway,\n  watchdog,\n  gmailWatchService,\n}) => {\n  try {\n    ensureManagedExecDefaults();\n  } catch (error) {\n    console.error(\n      `[alphaclaw] Failed to ensure managed exec defaults on boot: ${error.message}`,\n    );\n  }\n  try {\n    ensureUsageTrackerPluginConfig();\n  } catch (error) {\n    console.error(\n      `[alphaclaw] Failed to ensure usage-tracker plugin config on boot: ${error.message}`,\n    );\n  }\n  doSyncPromptFiles();\n  reloadEnv();\n  syncChannelConfig(readEnvFile());\n  ensureGatewayProxyConfig(resolveSetupUrl());\n  startGateway();\n  watchdog.start();\n  gmailWatchService.start();\n};\n\nmodule.exports = {\n  runOnboardedBootSequence,\n};\n"
  },
  {
    "path": "lib/server/system-resources.js",
    "content": "const os = require(\"os\");\nconst fs = require(\"fs\");\nconst { execSync } = require(\"child_process\");\nconst { kRootDir } = require(\"./constants\");\n\nconst readCgroupFile = (filePath) => {\n  try {\n    return fs.readFileSync(filePath, \"utf8\").trim();\n  } catch {\n    return null;\n  }\n};\n\nconst readFirstCgroupFile = (paths) => {\n  for (const filePath of paths) {\n    const value = readCgroupFile(filePath);\n    if (value != null) return value;\n  }\n  return null;\n};\n\nconst countCpuSet = (cpuSet) => {\n  if (!cpuSet) return null;\n  let count = 0;\n  const parts = String(cpuSet)\n    .split(\",\")\n    .map((part) => part.trim())\n    .filter(Boolean);\n  for (const part of parts) {\n    const [startRaw, endRaw] = part.split(\"-\");\n    const start = Number.parseInt(startRaw, 10);\n    const end = endRaw == null ? start : Number.parseInt(endRaw, 10);\n    if (Number.isNaN(start) || Number.isNaN(end)) continue;\n    count += Math.max(0, end - start + 1);\n  }\n  return count > 0 ? count : null;\n};\n\nconst parseCgroupMemory = () => {\n  const current = readFirstCgroupFile([\n    \"/sys/fs/cgroup/memory.current\",\n    \"/sys/fs/cgroup/memory/memory.usage_in_bytes\",\n  ]);\n  const max = readFirstCgroupFile([\n    \"/sys/fs/cgroup/memory.max\",\n    \"/sys/fs/cgroup/memory/memory.limit_in_bytes\",\n  ]);\n  if (!current) return null;\n  const usedBytes = Number.parseInt(current, 10);\n  if (Number.isNaN(usedBytes)) return null;\n  const parsedLimit =\n    max && max !== \"max\" ? Number.parseInt(max, 10) : null;\n  const limitBytes = Number.isNaN(parsedLimit) ? null : parsedLimit;\n  // Cgroup v1 uses huge sentinel values to mean \"no limit\".\n  const unlimited =\n    limitBytes == null ||\n    limitBytes <= 0 ||\n    limitBytes >= 9_000_000_000_000_000_000;\n  return {\n    usedBytes,\n    totalBytes: unlimited ? null : limitBytes,\n  };\n};\n\nconst parseCgroupCpu = () => {\n  const stat = readCgroupFile(\"/sys/fs/cgroup/cpu.stat\");\n  if (!stat) return null;\n  const lines = stat.split(\"\\n\");\n  const map = {};\n  for (const line of lines) {\n    const [key, val] = line.split(/\\s+/);\n    if (key && val) map[key] = Number.parseInt(val, 10);\n  }\n  return {\n    usageUsec: map.usage_usec ?? null,\n    userUsec: map.user_usec ?? null,\n    systemUsec: map.system_usec ?? null,\n  };\n};\n\nconst parseCgroupCpuV1 = () => {\n  const usageNs = readFirstCgroupFile([\n    \"/sys/fs/cgroup/cpuacct/cpuacct.usage\",\n    \"/sys/fs/cgroup/cpu/cpuacct.usage\",\n  ]);\n  if (!usageNs) return null;\n  const usageNsParsed = Number.parseInt(usageNs, 10);\n  if (Number.isNaN(usageNsParsed)) return null;\n  return {\n    usageUsec: Math.floor(usageNsParsed / 1000),\n    userUsec: null,\n    systemUsec: null,\n  };\n};\n\nconst getAllocatedCpuCores = () => {\n  const cpuMax = readCgroupFile(\"/sys/fs/cgroup/cpu.max\");\n  if (cpuMax) {\n    const [quotaRaw, periodRaw] = cpuMax.split(/\\s+/);\n    const quota = Number.parseInt(quotaRaw, 10);\n    const period = Number.parseInt(periodRaw, 10);\n    if (quotaRaw !== \"max\" && !Number.isNaN(quota) && !Number.isNaN(period) && period > 0) {\n      return quota / period;\n    }\n  }\n\n  const quotaV1 = readFirstCgroupFile([\n    \"/sys/fs/cgroup/cpu/cpu.cfs_quota_us\",\n    \"/sys/fs/cgroup/cpuacct/cpu.cfs_quota_us\",\n  ]);\n  const periodV1 = readFirstCgroupFile([\n    \"/sys/fs/cgroup/cpu/cpu.cfs_period_us\",\n    \"/sys/fs/cgroup/cpuacct/cpu.cfs_period_us\",\n  ]);\n  if (quotaV1 && periodV1) {\n    const quota = Number.parseInt(quotaV1, 10);\n    const period = Number.parseInt(periodV1, 10);\n    if (!Number.isNaN(quota) && !Number.isNaN(period) && quota > 0 && period > 0) {\n      return quota / period;\n    }\n  }\n\n  const cpuSet =\n    readCgroupFile(\"/sys/fs/cgroup/cpuset.cpus.effective\") ||\n    readCgroupFile(\"/sys/fs/cgroup/cpuset.cpus\");\n  return countCpuSet(cpuSet);\n};\n\nconst readProcStatus = (pid) => {\n  try {\n    const status = fs.readFileSync(`/proc/${pid}/status`, \"utf8\");\n    const vmRss = status.match(/VmRSS:\\s+(\\d+)\\s+kB/);\n    return { rssBytes: vmRss ? Number.parseInt(vmRss[1], 10) * 1024 : null };\n  } catch {\n    return null;\n  }\n};\n\nconst readPsStats = (pid) => {\n  try {\n    const out = execSync(`ps -o rss=,pcpu= -p ${pid}`, {\n      encoding: \"utf8\",\n      timeout: 2000,\n      stdio: [\"ignore\", \"pipe\", \"ignore\"],\n    }).trim();\n    const [rss, pcpu] = out.split(/\\s+/);\n    return {\n      rssBytes: rss ? Number.parseInt(rss, 10) * 1024 : null,\n      cpuPercent: pcpu ? Number.parseFloat(pcpu) : null,\n    };\n  } catch {\n    return null;\n  }\n};\n\nconst getProcessUsage = (pid) => {\n  if (!pid) return null;\n  const proc = readProcStatus(pid);\n  if (proc) return { rssBytes: proc.rssBytes };\n  const ps = readPsStats(pid);\n  if (ps) return { rssBytes: ps.rssBytes };\n  return null;\n};\n\nconst readDiskUsage = () => {\n  const paths = [kRootDir, \"/data\", \"/\"];\n  for (const diskPath of paths) {\n    try {\n      const stat = fs.statfsSync(diskPath);\n      return {\n        usedBytes: stat.bsize * (stat.blocks - stat.bfree),\n        totalBytes: stat.bsize * stat.blocks,\n        path: diskPath,\n      };\n    } catch {\n      // Try next path.\n    }\n  }\n  return { usedBytes: null, totalBytes: null, path: null };\n};\n\nlet prevCpuSnapshot = null;\nlet prevCpuSnapshotAt = 0;\n\nconst getSystemResources = ({ gatewayPid = null } = {}) => {\n  const hostCores = os.cpus().length || 1;\n  const allocatedCores = getAllocatedCpuCores() || hostCores;\n  const cgroupMem = parseCgroupMemory();\n  const mem = {\n    usedBytes: cgroupMem?.usedBytes ?? process.memoryUsage().rss,\n    totalBytes: cgroupMem?.totalBytes ?? os.totalmem(),\n  };\n\n  const diskUsage = readDiskUsage();\n\n  const cgroupCpu = parseCgroupCpu() || parseCgroupCpuV1();\n  let cpuPercent = null;\n  if (cgroupCpu?.usageUsec != null) {\n    const now = Date.now();\n    if (prevCpuSnapshot && prevCpuSnapshotAt) {\n      const elapsedMs = now - prevCpuSnapshotAt;\n      if (elapsedMs > 0) {\n        const usageDeltaUs = cgroupCpu.usageUsec - prevCpuSnapshot.usageUsec;\n        const elapsedUs = elapsedMs * 1000;\n        const rawPercent = (usageDeltaUs / elapsedUs) * 100;\n        cpuPercent = Math.min(100, Math.max(0, rawPercent / allocatedCores));\n      }\n    }\n    prevCpuSnapshot = cgroupCpu;\n    prevCpuSnapshotAt = now;\n  } else {\n    const load = os.loadavg();\n    cpuPercent = Math.min(100, Math.max(0, (load[0] / allocatedCores) * 100));\n  }\n\n  const alphaclawRss = process.memoryUsage().rss;\n  const gatewayUsage = getProcessUsage(gatewayPid);\n  const gatewayRss = gatewayUsage?.rssBytes ?? null;\n\n  return {\n    memory: {\n      usedBytes: mem.usedBytes,\n      totalBytes: mem.totalBytes,\n      percent: mem.totalBytes\n        ? Math.round((mem.usedBytes / mem.totalBytes) * 1000) / 10\n        : null,\n    },\n    disk: {\n      usedBytes: diskUsage.usedBytes,\n      totalBytes: diskUsage.totalBytes,\n      path: diskUsage.path,\n      percent: diskUsage.totalBytes\n        ? Math.round((diskUsage.usedBytes / diskUsage.totalBytes) * 1000) / 10\n        : null,\n    },\n    cpu: {\n      percent: cpuPercent != null ? Math.round(cpuPercent * 10) / 10 : null,\n      cores: Math.round(allocatedCores * 10) / 10,\n      hostCores,\n    },\n    processes: {\n      alphaclaw: { rssBytes: alphaclawRss },\n      gateway: { rssBytes: gatewayRss, pid: gatewayPid },\n    },\n  };\n};\n\nmodule.exports = { getSystemResources };\n"
  },
  {
    "path": "lib/server/telegram-api.js",
    "content": "const kTelegramApiBase = \"https://api.telegram.org\";\n\nconst createTelegramApi = (getToken) => {\n  const call = async (method, params = {}) => {\n    const token = typeof getToken === \"function\" ? getToken() : getToken;\n    if (!token) throw new Error(\"TELEGRAM_BOT_TOKEN is not set\");\n    const url = `${kTelegramApiBase}/bot${token}/${method}`;\n    const res = await fetch(url, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify(params),\n    });\n    const data = await res.json();\n    if (!data.ok) {\n      const err = new Error(data.description || `Telegram API error: ${method}`);\n      err.telegramErrorCode = data.error_code;\n      throw err;\n    }\n    return data.result;\n  };\n\n  const getMe = () => call(\"getMe\");\n\n  const getChat = (chatId) => call(\"getChat\", { chat_id: chatId });\n\n  const getChatMember = (chatId, userId) =>\n    call(\"getChatMember\", { chat_id: chatId, user_id: userId });\n\n  const getChatAdministrators = (chatId) =>\n    call(\"getChatAdministrators\", { chat_id: chatId });\n\n  const createForumTopic = (chatId, name, opts = {}) =>\n    call(\"createForumTopic\", {\n      chat_id: chatId,\n      name,\n      ...(opts.iconColor != null && { icon_color: opts.iconColor }),\n      ...(opts.iconCustomEmojiId && { icon_custom_emoji_id: opts.iconCustomEmojiId }),\n    });\n\n  const deleteForumTopic = (chatId, messageThreadId) =>\n    call(\"deleteForumTopic\", {\n      chat_id: chatId,\n      message_thread_id: messageThreadId,\n    });\n\n  const editForumTopic = (chatId, messageThreadId, opts = {}) =>\n    call(\"editForumTopic\", {\n      chat_id: chatId,\n      message_thread_id: messageThreadId,\n      ...(opts.name && { name: opts.name }),\n      ...(opts.iconCustomEmojiId && { icon_custom_emoji_id: opts.iconCustomEmojiId }),\n    });\n\n  const sendMessage = (chatId, text, opts = {}) =>\n    call(\"sendMessage\", {\n      chat_id: chatId,\n      text: String(text || \"\"),\n      ...(opts.parseMode && { parse_mode: opts.parseMode }),\n      ...(opts.disableWebPagePreview && {\n        disable_web_page_preview: !!opts.disableWebPagePreview,\n      }),\n    });\n\n  return {\n    getMe,\n    getChat,\n    getChatMember,\n    getChatAdministrators,\n    createForumTopic,\n    deleteForumTopic,\n    editForumTopic,\n    sendMessage,\n  };\n};\n\nmodule.exports = { createTelegramApi };\n"
  },
  {
    "path": "lib/server/telegram-workspace.js",
    "content": "const kTelegramTopicConcurrencyMultiplier = 3;\nconst kAgentConcurrencyFloor = 8;\nconst kSubagentConcurrencyFloor = 4;\nconst { normalizeAccountId } = require(\"./utils/channels\");\nconst {\n  readOpenclawConfig,\n  writeOpenclawConfig,\n} = require(\"./openclaw-config\");\n\nconst resolveTelegramAccountConfig = ({ telegramConfig, accountId }) => {\n  const normalizedAccountId = normalizeAccountId(accountId);\n  const accounts =\n    telegramConfig?.accounts && typeof telegramConfig.accounts === \"object\"\n      ? telegramConfig.accounts\n      : null;\n  const hasAccounts = !!accounts && Object.keys(accounts).length > 0;\n  if (hasAccounts) {\n    const nextAccountConfig =\n      accounts[normalizedAccountId] && typeof accounts[normalizedAccountId] === \"object\"\n        ? accounts[normalizedAccountId]\n        : {};\n    return {\n      normalizedAccountId,\n      hasAccounts,\n      accountConfig: nextAccountConfig,\n    };\n  }\n  return {\n    normalizedAccountId,\n    hasAccounts: false,\n    accountConfig: telegramConfig,\n  };\n};\n\nconst syncConfigForTelegram = ({\n  fs,\n  openclawDir,\n  topicRegistry,\n  groupId,\n  accountId = \"default\",\n  requireMention = false,\n  resolvedUserId = \"\",\n}) => {\n  const cfg = readOpenclawConfig({\n    fsModule: fs,\n    openclawDir,\n    fallback: {},\n  });\n\n  // Remove legacy root keys from older setup flow.\n  delete cfg.sessions;\n  delete cfg.groups;\n  delete cfg.groupAllowFrom;\n\n  if (!cfg.channels) cfg.channels = {};\n  if (!cfg.channels.telegram) cfg.channels.telegram = {};\n  const telegramConfig = cfg.channels.telegram;\n  const { normalizedAccountId, hasAccounts, accountConfig } =\n    resolveTelegramAccountConfig({\n      telegramConfig,\n      accountId,\n    });\n  if (hasAccounts) {\n    if (!telegramConfig.accounts || typeof telegramConfig.accounts !== \"object\") {\n      telegramConfig.accounts = {};\n    }\n    if (\n      !telegramConfig.accounts[normalizedAccountId]\n      || typeof telegramConfig.accounts[normalizedAccountId] !== \"object\"\n    ) {\n      telegramConfig.accounts[normalizedAccountId] = {};\n    }\n  }\n  const targetConfig = hasAccounts\n    ? telegramConfig.accounts[normalizedAccountId]\n    : telegramConfig;\n\n  if (!targetConfig.groups || typeof targetConfig.groups !== \"object\") {\n    targetConfig.groups = {};\n  }\n  const existingGroupConfig = targetConfig.groups[groupId] || {};\n  targetConfig.groups[groupId] = {\n    ...existingGroupConfig,\n    requireMention,\n  };\n\n  const registryTopics = topicRegistry.getGroup(groupId)?.topics || {};\n  const promptTopics = {};\n  for (const [threadId, topic] of Object.entries(registryTopics)) {\n    const systemPrompt = String(topic?.systemInstructions || \"\").trim();\n    const topicAgentId = String(topic?.agentId || \"\").trim();\n    if (!systemPrompt && !topicAgentId) continue;\n    promptTopics[threadId] = {\n      ...(systemPrompt ? { systemPrompt } : {}),\n      ...(topicAgentId ? { agentId: topicAgentId } : {}),\n    };\n  }\n  if (Object.keys(promptTopics).length > 0) {\n    targetConfig.groups[groupId].topics = promptTopics;\n  } else {\n    delete targetConfig.groups[groupId].topics;\n  }\n\n  targetConfig.groupPolicy = \"allowlist\";\n  if (!Array.isArray(targetConfig.groupAllowFrom)) {\n    targetConfig.groupAllowFrom = [];\n  }\n  if (\n    resolvedUserId\n    && !targetConfig.groupAllowFrom.includes(String(resolvedUserId))\n  ) {\n    targetConfig.groupAllowFrom.push(String(resolvedUserId));\n  }\n\n  // Persist thread sessions and keep concurrency in schema-valid agent defaults.\n  if (!cfg.session) cfg.session = {};\n  if (!cfg.session.resetByType) cfg.session.resetByType = {};\n  cfg.session.resetByType.thread = { mode: \"idle\", idleMinutes: 525600 };\n\n  const totalTopics = topicRegistry.getTotalTopicCount();\n  const maxConcurrent = Math.max(\n    totalTopics * kTelegramTopicConcurrencyMultiplier,\n    kAgentConcurrencyFloor,\n  );\n  if (!cfg.agents) cfg.agents = {};\n  if (!cfg.agents.defaults) cfg.agents.defaults = {};\n  cfg.agents.defaults.maxConcurrent = maxConcurrent;\n  if (!cfg.agents.defaults.subagents) cfg.agents.defaults.subagents = {};\n  cfg.agents.defaults.subagents.maxConcurrent = Math.max(\n    maxConcurrent - 2,\n    kSubagentConcurrencyFloor,\n  );\n\n  writeOpenclawConfig({\n    fsModule: fs,\n    openclawDir,\n    config: cfg,\n    spacing: 2,\n  });\n\n  return {\n    totalTopics,\n    maxConcurrent: cfg.agents.defaults.maxConcurrent,\n    subagentMaxConcurrent: cfg.agents.defaults.subagents.maxConcurrent,\n  };\n};\n\nmodule.exports = { syncConfigForTelegram };\n"
  },
  {
    "path": "lib/server/topic-registry.js",
    "content": "const fs = require(\"fs\");\nconst { WORKSPACE_DIR } = require(\"./constants\");\nconst { normalizeAccountId } = require(\"./utils/channels\");\n\nconst kRegistryPath = `${WORKSPACE_DIR}/topic-registry.json`;\nconst kDefaultAgentId = \"default\";\n\nconst normalizeGroupAgentId = (value) =>\n  String(value || \"\").trim() || kDefaultAgentId;\n\nconst readRegistry = () => {\n  try {\n    return JSON.parse(fs.readFileSync(kRegistryPath, \"utf8\"));\n  } catch {\n    return { groups: {} };\n  }\n};\n\nconst writeRegistry = (registry) => {\n  fs.mkdirSync(WORKSPACE_DIR, { recursive: true });\n  fs.writeFileSync(kRegistryPath, JSON.stringify(registry, null, 2));\n};\n\nconst getGroup = (groupId) => {\n  const registry = readRegistry();\n  return registry.groups[groupId] || null;\n};\n\nconst setGroup = (groupId, groupData) => {\n  const registry = readRegistry();\n  const existingGroup = registry.groups[groupId] || {\n    name: groupId,\n    topics: {},\n  };\n  registry.groups[groupId] = {\n    ...existingGroup,\n    ...groupData,\n    topics: existingGroup.topics || {},\n  };\n  writeRegistry(registry);\n  return registry;\n};\n\nconst getGroupsForAccount = (accountId) => {\n  const registry = readRegistry();\n  const normalizedAccountId = normalizeAccountId(accountId);\n  const groups = registry.groups && typeof registry.groups === \"object\"\n    ? registry.groups\n    : {};\n  return Object.fromEntries(\n    Object.entries(groups).filter(([, group]) => {\n      const groupAccountId = normalizeAccountId(group?.accountId);\n      return groupAccountId === normalizedAccountId;\n    }),\n  );\n};\n\nconst addTopic = (groupId, threadId, topicData) => {\n  const registry = readRegistry();\n  if (!registry.groups[groupId]) {\n    registry.groups[groupId] = { name: groupId, topics: {} };\n  }\n  if (\n    !registry.groups[groupId].topics ||\n    typeof registry.groups[groupId].topics !== \"object\"\n  ) {\n    registry.groups[groupId].topics = {};\n  }\n  registry.groups[groupId].topics[String(threadId)] = topicData;\n  writeRegistry(registry);\n  return registry;\n};\n\nconst updateTopic = (groupId, threadId, topicData) => {\n  const registry = readRegistry();\n  if (!registry.groups[groupId]) {\n    registry.groups[groupId] = { name: groupId, topics: {} };\n  }\n  if (\n    !registry.groups[groupId].topics ||\n    typeof registry.groups[groupId].topics !== \"object\"\n  ) {\n    registry.groups[groupId].topics = {};\n  }\n  const existing = registry.groups[groupId].topics[String(threadId)] || {};\n  registry.groups[groupId].topics[String(threadId)] = {\n    ...existing,\n    ...topicData,\n  };\n  writeRegistry(registry);\n  return registry;\n};\n\nconst removeTopic = (groupId, threadId) => {\n  const registry = readRegistry();\n  if (registry.groups[groupId]?.topics) {\n    delete registry.groups[groupId].topics[String(threadId)];\n  }\n  writeRegistry(registry);\n  return registry;\n};\n\nconst getTotalTopicCount = () => {\n  const registry = readRegistry();\n  let count = 0;\n  for (const group of Object.values(registry.groups)) {\n    count += Object.keys(group.topics || {}).length;\n  }\n  return count;\n};\n\nconst getTopicsForAgent = (agentId) => {\n  const registry = readRegistry();\n  const groups = registry.groups && typeof registry.groups === \"object\"\n    ? registry.groups\n    : {};\n  const normalizedAgentId = normalizeGroupAgentId(agentId);\n  const rows = [];\n  for (const [groupId, group] of Object.entries(groups)) {\n    const groupAgentId = normalizeGroupAgentId(group?.agentId);\n    const groupName = String(group?.name || \"\").trim() || groupId;\n    const topics = group?.topics && typeof group.topics === \"object\"\n      ? group.topics\n      : {};\n    const isGroupOwner = groupAgentId === normalizedAgentId;\n    for (const [threadId, topic] of Object.entries(topics)) {\n      const topicAgentId = String(topic?.agentId || \"\").trim();\n      if (!isGroupOwner && topicAgentId !== normalizedAgentId) continue;\n      rows.push({\n        groupName,\n        groupId,\n        topicName: topic?.name,\n        threadId,\n        groupAgentId,\n        topicAgentId,\n      });\n    }\n  }\n  return rows;\n};\n\n// Render the topic registry as a markdown section for TOOLS.md\nconst renderTopicRegistryMarkdown = ({\n  includeSyncGuidance = false,\n  agentId = \"\",\n} = {}) => {\n  const registry = readRegistry();\n  const groups = registry.groups && typeof registry.groups === \"object\"\n    ? registry.groups\n    : {};\n  const normalizedAgentId = String(agentId || \"\").trim();\n  const rows = normalizedAgentId\n    ? getTopicsForAgent(normalizedAgentId)\n    : Object.entries(groups).flatMap(([groupId, group]) =>\n      Object.entries(group.topics || {}).map(([threadId, topic]) => ({\n        groupName: group.name || groupId,\n        groupId,\n        topicName: topic.name,\n        threadId,\n      })));\n  if (rows.length === 0) return \"\";\n\n  const lines = [\n    \"\",\n    \"## Topic Registry\",\n    \"\",\n    \"When sending messages to group topics, use these thread IDs:\",\n    \"\",\n    \"| Group | Topic | Thread ID |\",\n    \"| ----- | ----- | --------- |\",\n  ];\n  for (const r of rows) {\n    lines.push(\n      `| ${r.groupName} (${r.groupId}) | ${r.topicName} | ${r.threadId} |`,\n    );\n  }\n  if (includeSyncGuidance) {\n    lines.push(\n      \"\",\n      \"### Sync Rules\",\n      \"\",\n      \"When Telegram workspace is enabled, keep topic mappings in sync with real Telegram activity:\",\n      \"\",\n      \"- If a message arrives in an unregistered Telegram topic, ask the user to name it for addition to the registry.\",\n      '- When adding a topic (new or missing) run `alphaclaw telegram topic add --thread <threadId> --name \"<topicName>\"` immediately, no confirmation needed.',\n      \"- Never edit `hooks/bootstrap/TOOLS.md` directly for topic changes\",\n      \"\",\n    );\n  } else {\n    lines.push(\"\");\n  }\n  return lines.join(\"\\n\");\n};\n\nmodule.exports = {\n  kRegistryPath,\n  readRegistry,\n  writeRegistry,\n  getGroup,\n  setGroup,\n  getGroupsForAccount,\n  addTopic,\n  updateTopic,\n  removeTopic,\n  getTotalTopicCount,\n  getTopicsForAgent,\n  renderTopicRegistryMarkdown,\n};\n"
  },
  {
    "path": "lib/server/usage-tracker-config.js",
    "content": "const path = require(\"path\");\nconst { readOpenclawConfig, writeOpenclawConfig } = require(\"./openclaw-config\");\n\nconst kUsageTrackerPluginPath = path.resolve(\n  __dirname,\n  \"..\",\n  \"plugin\",\n  \"usage-tracker\",\n);\nconst kConversationAccessHookPolicyKey = \"allowConversationAccess\";\n\nconst ensurePluginsShell = (cfg = {}) => {\n  if (!cfg.plugins || typeof cfg.plugins !== \"object\") cfg.plugins = {};\n  if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = [];\n  if (!cfg.plugins.load || typeof cfg.plugins.load !== \"object\") {\n    cfg.plugins.load = {};\n  }\n  if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = [];\n  if (!cfg.plugins.entries || typeof cfg.plugins.entries !== \"object\") {\n    cfg.plugins.entries = {};\n  }\n};\n\nconst ensurePluginAllowed = ({ cfg = {}, pluginKey = \"\" }) => {\n  const normalizedPluginKey = String(pluginKey || \"\").trim();\n  if (!normalizedPluginKey) return;\n  ensurePluginsShell(cfg);\n  if (!cfg.plugins.allow.includes(normalizedPluginKey)) {\n    cfg.plugins.allow.push(normalizedPluginKey);\n  }\n};\n\nconst buildUsageTrackerHookPolicy = ({ existingHooks = {} } = {}) => {\n  const hooks = {};\n  if (typeof existingHooks.allowPromptInjection === \"boolean\") {\n    hooks.allowPromptInjection = existingHooks.allowPromptInjection;\n  }\n  hooks[kConversationAccessHookPolicyKey] = true;\n  return hooks;\n};\n\nconst ensureUsageTrackerPluginEntry = (cfg = {}) => {\n  const before = JSON.stringify(cfg);\n  ensurePluginAllowed({ cfg, pluginKey: \"usage-tracker\" });\n  if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) {\n    cfg.plugins.load.paths.push(kUsageTrackerPluginPath);\n  }\n  const existingEntry =\n    cfg.plugins.entries[\"usage-tracker\"] &&\n    typeof cfg.plugins.entries[\"usage-tracker\"] === \"object\"\n      ? cfg.plugins.entries[\"usage-tracker\"]\n      : {};\n  const existingHooks =\n    existingEntry.hooks && typeof existingEntry.hooks === \"object\"\n      ? existingEntry.hooks\n      : {};\n  const hooks = buildUsageTrackerHookPolicy({\n    existingHooks,\n  });\n  const nextEntry = {\n    ...existingEntry,\n    enabled: true,\n  };\n  if (Object.keys(hooks).length > 0) {\n    nextEntry.hooks = hooks;\n  } else {\n    delete nextEntry.hooks;\n  }\n  cfg.plugins.entries[\"usage-tracker\"] = nextEntry;\n  return JSON.stringify(cfg) !== before;\n};\n\nconst ensureUsageTrackerPluginConfig = ({ fsModule, openclawDir }) => {\n  const cfg = readOpenclawConfig({\n    fsModule,\n    openclawDir,\n    fallback: {},\n  });\n  const changed = ensureUsageTrackerPluginEntry(cfg);\n  if (!changed) return false;\n  writeOpenclawConfig({\n    fsModule,\n    openclawDir,\n    config: cfg,\n    spacing: 2,\n  });\n  return true;\n};\n\nmodule.exports = {\n  kUsageTrackerPluginPath,\n  ensurePluginsShell,\n  ensurePluginAllowed,\n  ensureUsageTrackerPluginEntry,\n  ensureUsageTrackerPluginConfig,\n};\n"
  },
  {
    "path": "lib/server/utils/boolean.js",
    "content": "const isTruthyFlag = (value) =>\n  [\"1\", \"true\", \"yes\", \"on\"].includes(\n    String(value ?? \"\")\n      .trim()\n      .toLowerCase(),\n  );\n\nconst parseBooleanValue = (value, fallbackValue = false) => {\n  if (typeof value === \"boolean\") return value;\n  if (typeof value === \"number\") return value !== 0;\n  if (typeof value === \"string\") {\n    const normalized = value.trim().toLowerCase();\n    if ([\"true\", \"1\", \"yes\", \"on\"].includes(normalized)) return true;\n    if ([\"false\", \"0\", \"no\", \"off\", \"\"].includes(normalized)) return false;\n  }\n  return fallbackValue;\n};\n\nmodule.exports = {\n  isTruthyFlag,\n  parseBooleanValue,\n};\n"
  },
  {
    "path": "lib/server/utils/channels.js",
    "content": "const normalizeAccountId = (value) => String(value || \"\").trim() || \"default\";\n\nconst hasScopedBindingFields = (match = {}) =>\n  !!match.peer ||\n  !!match.parentPeer ||\n  !!String(match.guildId || \"\").trim() ||\n  !!String(match.teamId || \"\").trim() ||\n  (Array.isArray(match.roles) && match.roles.length > 0);\n\nmodule.exports = {\n  normalizeAccountId,\n  hasScopedBindingFields,\n};\n"
  },
  {
    "path": "lib/server/utils/command-output.js",
    "content": "const getCommandOutputCandidates = (error) => {\n  const stdout = String(error?.stdout || \"\").trim();\n  const stderr = String(error?.stderr || \"\").trim();\n  const combined = [stdout, stderr].filter(Boolean).join(\"\\n\").trim();\n\n  return [...new Set([combined, stdout, stderr].filter(Boolean))];\n};\n\nmodule.exports = {\n  getCommandOutputCandidates,\n};\n"
  },
  {
    "path": "lib/server/utils/json.js",
    "content": "const parseJsonSafe = (rawValue, fallbackValue = null, options = {}) => {\n  const shouldTrim = options?.trim === true;\n  const text = shouldTrim\n    ? String(rawValue ?? \"\").trim()\n    : String(rawValue ?? \"\");\n  if (!text) return fallbackValue;\n  try {\n    return JSON.parse(text);\n  } catch {\n    return fallbackValue;\n  }\n};\n\nconst parseJsonValueFromNoisyOutput = (rawValue) => {\n  const text = String(rawValue ?? \"\");\n  const openingChars = new Set([\"{\", \"[\"]);\n  const closingCharByOpeningChar = {\n    \"{\": \"}\",\n    \"[\": \"]\",\n  };\n  for (let startIndex = 0; startIndex < text.length; startIndex += 1) {\n    const openingChar = text[startIndex];\n    if (!openingChars.has(openingChar)) continue;\n    const expectedClosingChar = closingCharByOpeningChar[openingChar];\n    const stack = [expectedClosingChar];\n    let inString = false;\n    let escapeNextChar = false;\n    for (let currentIndex = startIndex + 1; currentIndex < text.length; currentIndex += 1) {\n      const currentChar = text[currentIndex];\n      if (inString) {\n        if (escapeNextChar) {\n          escapeNextChar = false;\n          continue;\n        }\n        if (currentChar === \"\\\\\") {\n          escapeNextChar = true;\n          continue;\n        }\n        if (currentChar === \"\\\"\") {\n          inString = false;\n        }\n        continue;\n      }\n      if (currentChar === \"\\\"\") {\n        inString = true;\n        continue;\n      }\n      if (openingChars.has(currentChar)) {\n        stack.push(closingCharByOpeningChar[currentChar]);\n        continue;\n      }\n      if (currentChar !== stack[stack.length - 1]) continue;\n      stack.pop();\n      if (stack.length > 0) continue;\n      const candidate = text.slice(startIndex, currentIndex + 1);\n      try {\n        return JSON.parse(candidate);\n      } catch {\n        break;\n      }\n    }\n  }\n  return null;\n};\n\nconst parseJsonObjectFromNoisyOutput = (rawValue) => {\n  const parsedValue = parseJsonValueFromNoisyOutput(rawValue);\n  return parsedValue && typeof parsedValue === \"object\" && !Array.isArray(parsedValue)\n    ? parsedValue\n    : null;\n};\n\nmodule.exports = {\n  parseJsonSafe,\n  parseJsonValueFromNoisyOutput,\n  parseJsonObjectFromNoisyOutput,\n};\n"
  },
  {
    "path": "lib/server/utils/network.js",
    "content": "const normalizeIp = (ip) => String(ip || \"\").replace(/^::ffff:/, \"\");\n\nmodule.exports = {\n  normalizeIp,\n};\n"
  },
  {
    "path": "lib/server/utils/number.js",
    "content": "const parsePositiveInt = (value, fallbackValue) => {\n  const parsed = Number.parseInt(String(value ?? \"\"), 10);\n  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackValue;\n};\n\nmodule.exports = {\n  parsePositiveInt,\n};\n"
  },
  {
    "path": "lib/server/utils/shell.js",
    "content": "const quoteShellArg = (value, options = {}) => {\n  const strategy = String(options?.strategy || \"double\").trim().toLowerCase();\n  const normalizedValue = String(value || \"\");\n\n  if (strategy === \"single\") {\n    return `'${normalizedValue.replace(/'/g, `'\\\"'\\\"'`)}'`;\n  }\n  if (strategy === \"double\") {\n    return `\"${normalizedValue.replace(/([\"\\\\$`])/g, \"\\\\$1\")}\"`;\n  }\n  throw new Error(`Unsupported shell quote strategy: ${strategy}`);\n};\n\nmodule.exports = {\n  quoteShellArg,\n};\n"
  },
  {
    "path": "lib/server/watchdog-notify.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst { OPENCLAW_DIR } = require(\"./constants\");\nconst { createSlackApi } = require(\"./slack-api\");\nconst { quoteShellArg } = require(\"./utils/shell\");\n\nconst kSlackBotEnvKey = \"SLACK_BOT_TOKEN\";\nconst kWhatsAppOwnerNumberEnvKey = \"WHATSAPP_OWNER_NUMBER\";\n\nconst normalizeAccountId = (value) =>\n  String(value || \"\").trim().toLowerCase() || \"default\";\n\nconst resolveCredentialPairingAccountId = ({ channel, fileName }) => {\n  const prefix = `${String(channel || \"\").trim().toLowerCase()}-`;\n  const suffix = \"-allowFrom.json\";\n  const rawFileName = String(fileName || \"\").trim();\n  if (!rawFileName.startsWith(prefix) || !rawFileName.endsWith(suffix)) {\n    return \"\";\n  }\n  return normalizeAccountId(rawFileName.slice(prefix.length, -suffix.length));\n};\n\nconst deriveSlackBotEnvKey = (accountId = \"default\") => {\n  const normalizedAccountId = normalizeAccountId(accountId);\n  if (normalizedAccountId === \"default\") return kSlackBotEnvKey;\n  return `${kSlackBotEnvKey}_${normalizedAccountId.replace(/-/g, \"_\").toUpperCase()}`;\n};\n\nconst getPairedTargetsByAccount = ({\n  channel,\n  fsImpl = fs,\n  openclawDir = OPENCLAW_DIR,\n}) => {\n  const safeChannel = String(channel || \"\").trim().toLowerCase();\n  if (!safeChannel) return new Map();\n  const credentialsDir = path.join(openclawDir, \"credentials\");\n  if (!fsImpl.existsSync(credentialsDir)) return new Map();\n  const idsByAccount = new Map();\n  try {\n    const files = fsImpl\n      .readdirSync(credentialsDir)\n      .filter(\n        (fileName) =>\n          fileName.startsWith(`${safeChannel}-`) && fileName.endsWith(\"-allowFrom.json\"),\n      );\n    for (const fileName of files) {\n      const accountId = resolveCredentialPairingAccountId({\n        channel: safeChannel,\n        fileName,\n      });\n      if (!accountId) continue;\n      const filePath = path.join(credentialsDir, fileName);\n      const raw = fsImpl.readFileSync(filePath, \"utf8\");\n      const parsed = JSON.parse(raw);\n      const allowFrom = Array.isArray(parsed?.allowFrom) ? parsed.allowFrom : [];\n      const ids =\n        idsByAccount.get(accountId) instanceof Set\n          ? idsByAccount.get(accountId)\n          : new Set();\n      for (const id of allowFrom) {\n        if (id == null) continue;\n        const value = String(id).trim();\n        if (!value) continue;\n        ids.add(value);\n      }\n      idsByAccount.set(accountId, ids);\n    }\n  } catch (err) {\n    console.error(`[watchdog] could not resolve ${safeChannel} allowFrom IDs: ${err.message}`);\n  }\n  return new Map(\n    Array.from(idsByAccount.entries()).map(([accountId, ids]) => [\n      accountId,\n      Array.from(ids),\n    ]),\n  );\n};\n\nconst getPairedIds = ({\n  channel,\n  fsImpl = fs,\n  openclawDir = OPENCLAW_DIR,\n}) => {\n  const ids = new Set();\n  const idsByAccount = getPairedTargetsByAccount({\n    channel,\n    fsImpl,\n    openclawDir,\n  });\n  for (const accountIds of idsByAccount.values()) {\n    for (const id of accountIds) {\n      ids.add(id);\n    }\n  }\n  return Array.from(ids);\n};\n\nconst formatDiscordMessage = (message) =>\n  String(message || \"\").replace(/(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)/g, \"**$1**\");\n\n/**\n * Track thread state for Slack notifications\n * Key: accountId:userId, Value: { threadTs, lastEvent }\n */\nconst slackThreads = new Map();\n\nconst createWatchdogNotifier = ({\n  telegramApi,\n  discordApi,\n  slackApi,\n  clawCmd = null,\n  readEnvFile = () => [],\n  createSlackApi: createSlackApiFactory = createSlackApi,\n  fsImpl = fs,\n  openclawDir = OPENCLAW_DIR,\n}) => {\n  const notify = async (message, opts = {}) => {\n    const summary = {\n      telegram: { sent: 0, failed: 0, skipped: false, targets: 0 },\n      discord: { sent: 0, failed: 0, skipped: false, targets: 0 },\n      slack: { sent: 0, failed: 0, skipped: false, targets: 0 },\n      whatsapp: { sent: 0, failed: 0, skipped: false, targets: 0 },\n    };\n    const envVars = typeof readEnvFile === \"function\" ? readEnvFile() : [];\n    const envMap = new Map(\n      (Array.isArray(envVars) ? envVars : [])\n        .map((entry) => [\n          String(entry?.key || \"\").trim(),\n          String(entry?.value || \"\").trim(),\n        ])\n        .filter(([key]) => key),\n    );\n    const telegramTargets = getPairedIds({\n      channel: \"telegram\",\n      fsImpl,\n      openclawDir,\n    });\n    summary.telegram.targets = telegramTargets.length;\n    if (!telegramApi?.sendMessage || !process.env.TELEGRAM_BOT_TOKEN || telegramTargets.length === 0) {\n      summary.telegram.skipped = true;\n    } else {\n      for (const chatId of telegramTargets) {\n        try {\n          await telegramApi.sendMessage(chatId, String(message || \"\"), {\n            parseMode: \"Markdown\",\n          });\n          summary.telegram.sent += 1;\n        } catch (err) {\n          summary.telegram.failed += 1;\n          console.error(`[watchdog] telegram notification failed for ${chatId}: ${err.message}`);\n        }\n      }\n    }\n\n    const discordTargets = getPairedIds({\n      channel: \"discord\",\n      fsImpl,\n      openclawDir,\n    });\n    summary.discord.targets = discordTargets.length;\n    if (!discordApi?.sendDirectMessage || !process.env.DISCORD_BOT_TOKEN || discordTargets.length === 0) {\n      summary.discord.skipped = true;\n    } else {\n      const discordMessage = formatDiscordMessage(message);\n      for (const userId of discordTargets) {\n        try {\n          await discordApi.sendDirectMessage(userId, discordMessage);\n          summary.discord.sent += 1;\n        } catch (err) {\n          summary.discord.failed += 1;\n          console.error(`[watchdog] discord notification failed for ${userId}: ${err.message}`);\n        }\n      }\n    }\n\n    // Enhanced Slack notifications with threading and reactions\n    const slackTargetsByAccount = getPairedTargetsByAccount({\n      channel: \"slack\",\n      fsImpl,\n      openclawDir,\n    });\n    summary.slack.targets = Array.from(slackTargetsByAccount.values()).reduce(\n      (total, targets) => total + targets.length,\n      0,\n    );\n    if (summary.slack.targets === 0) {\n      summary.slack.skipped = true;\n    } else {\n      const eventType = opts.eventType || \"info\"; // crash, recovery, health, info\n      for (const [accountId, slackTargets] of slackTargetsByAccount.entries()) {\n        if (!slackTargets.length) continue;\n        const envKey = deriveSlackBotEnvKey(accountId);\n        const botToken = String(envMap.get(envKey) || process.env[envKey] || \"\").trim();\n        if (!botToken) {\n          summary.slack.failed += slackTargets.length;\n          for (const userId of slackTargets) {\n            console.error(\n              `[watchdog] slack notification failed for ${accountId}/${userId}: missing ${envKey}`,\n            );\n          }\n          continue;\n        }\n\n        const accountSlackApi =\n          accountId === \"default\" &&\n          slackApi?.postMessage &&\n          botToken === String(process.env.SLACK_BOT_TOKEN || \"\").trim()\n            ? slackApi\n            : createSlackApiFactory(() => botToken);\n\n        for (const userId of slackTargets) {\n          try {\n            let threadTs = null;\n            let shouldCreateNewThread = true;\n            const threadKey = `${accountId}:${userId}`;\n\n            const existingThread = slackThreads.get(threadKey);\n            if (existingThread && existingThread.lastEvent === \"crash\" && eventType === \"recovery\") {\n              threadTs = existingThread.threadTs;\n              shouldCreateNewThread = false;\n            }\n\n            const result = await accountSlackApi.postMessage(userId, String(message || \"\"), {\n              thread_ts: threadTs,\n              mrkdwn: true,\n            });\n\n            if (shouldCreateNewThread && result.ts) {\n              slackThreads.set(threadKey, {\n                threadTs: result.ts,\n                lastEvent: eventType,\n              });\n            }\n\n            if (result.ts && result.channel && accountSlackApi.addReaction) {\n              try {\n                if (eventType === \"crash\") {\n                  await accountSlackApi.addReaction(result.channel, result.ts, \"x\");\n                } else if (eventType === \"recovery\") {\n                  await accountSlackApi.addReaction(\n                    result.channel,\n                    result.ts,\n                    \"white_check_mark\",\n                  );\n                } else if (eventType === \"health\") {\n                  await accountSlackApi.addReaction(result.channel, result.ts, \"heart\");\n                }\n              } catch (reactionErr) {\n                console.error(\n                  `[watchdog] slack reaction failed for ${accountId}/${userId}: ${reactionErr.message}`,\n                );\n              }\n            }\n\n            summary.slack.sent += 1;\n          } catch (err) {\n            summary.slack.failed += 1;\n            console.error(\n              `[watchdog] slack notification failed for ${accountId}/${userId}: ${err.message}`,\n            );\n          }\n        }\n      }\n    }\n\n    const whatsAppOwnerNumber = String(\n      envMap.get(kWhatsAppOwnerNumberEnvKey) ||\n        process.env[kWhatsAppOwnerNumberEnvKey] ||\n        \"\",\n    ).trim();\n    const whatsappTargets = whatsAppOwnerNumber ? [whatsAppOwnerNumber] : [];\n    summary.whatsapp.targets = whatsappTargets.length;\n    if (!clawCmd || whatsappTargets.length === 0) {\n      summary.whatsapp.skipped = true;\n    } else {\n      for (const target of whatsappTargets) {\n        try {\n          const result = await clawCmd(\n            `message send --channel whatsapp --target ${quoteShellArg(\n              String(target || \"\").trim(),\n            )} --message ${quoteShellArg(String(message || \"\"))}`,\n            { quiet: true, timeoutMs: 30000 },\n          );\n          if (!result?.ok) {\n            throw new Error(\n              String(result?.stderr || result?.stdout || \"WhatsApp send failed\"),\n            );\n          }\n          summary.whatsapp.sent += 1;\n        } catch (err) {\n          summary.whatsapp.failed += 1;\n          console.error(`[watchdog] whatsapp notification failed for ${target}: ${err.message}`);\n        }\n      }\n    }\n\n    const sent =\n      summary.telegram.sent +\n      summary.discord.sent +\n      summary.slack.sent +\n      summary.whatsapp.sent;\n    const failed =\n      summary.telegram.failed +\n      summary.discord.failed +\n      summary.slack.failed +\n      summary.whatsapp.failed;\n    return {\n      ok: sent > 0,\n      sent,\n      failed,\n      channels: summary,\n      ...(sent === 0 ? { reason: \"no_channels_delivered\" } : {}),\n    };\n  };\n\n  return { notify };\n};\n\nmodule.exports = { createWatchdogNotifier };\n"
  },
  {
    "path": "lib/server/watchdog-terminal-ws.js",
    "content": "const { WebSocketServer } = require(\"ws\");\n\nconst kWatchdogTerminalWsPath = \"/api/watchdog/terminal/ws\";\n\nconst createWatchdogTerminalWsBridge = ({\n  server,\n  proxy,\n  getGatewayUrl,\n  isAuthorizedRequest,\n  watchdogTerminal,\n  chatWsService = null,\n}) => {\n  const watchdogTerminalWss = new WebSocketServer({ noServer: true });\n\n  watchdogTerminalWss.on(\"connection\", (socket) => {\n    let closed = false;\n    const terminalSession = watchdogTerminal.createOrReuseSession();\n    const sessionId = String(terminalSession?.id || \"\");\n    if (!sessionId) {\n      socket.close(1011, \"No terminal session\");\n      return;\n    }\n\n    const send = (payload = {}) => {\n      if (closed || socket.readyState !== 1) return;\n      socket.send(JSON.stringify(payload));\n    };\n\n    send({\n      type: \"session\",\n      session: terminalSession,\n    });\n\n    const subscription = watchdogTerminal.subscribe({\n      sessionId,\n      replayBuffer: false,\n      tailLines: 1,\n      onEvent: (event) => {\n        if (event?.type === \"output\") {\n          send({ type: \"output\", data: String(event.data || \"\") });\n          return;\n        }\n        if (event?.type === \"exit\") {\n          send({\n            type: \"exit\",\n            code: event.code ?? null,\n            signal: event.signal ?? null,\n          });\n        }\n      },\n    });\n    if (!subscription.ok) {\n      socket.close(1011, \"Terminal subscribe failed\");\n      return;\n    }\n\n    socket.on(\"message\", (rawData) => {\n      let payload = null;\n      try {\n        payload = JSON.parse(String(rawData || \"\"));\n      } catch {\n        return;\n      }\n      const messageType = String(payload?.type || \"\");\n      if (messageType !== \"input\") return;\n      const data = String(payload?.data || \"\");\n      if (!data) return;\n      watchdogTerminal.writeInput({ sessionId, input: data });\n    });\n\n    socket.on(\"close\", () => {\n      closed = true;\n      subscription.unsubscribe();\n    });\n    socket.on(\"error\", () => {\n      closed = true;\n      subscription.unsubscribe();\n    });\n  });\n\n  server.on(\"upgrade\", (req, socket, head) => {\n    const requestUrl = new URL(\n      req.url || \"/\",\n      `http://${req.headers.host || \"localhost\"}`,\n    );\n    if (\n      requestUrl.pathname.startsWith(\"/openclaw\") ||\n      requestUrl.pathname === kWatchdogTerminalWsPath ||\n      requestUrl.pathname === \"/api/ws/chat\"\n    ) {\n      const upgradeReq = {\n        headers: req.headers,\n        path: requestUrl.pathname,\n        query: Object.fromEntries(requestUrl.searchParams.entries()),\n      };\n      if (!isAuthorizedRequest(upgradeReq)) {\n        socket.write(\n          \"HTTP/1.1 401 Unauthorized\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\nUnauthorized\",\n        );\n        socket.destroy();\n        return;\n      }\n    }\n    if (requestUrl.pathname === kWatchdogTerminalWsPath) {\n      watchdogTerminalWss.handleUpgrade(req, socket, head, (ws) => {\n        watchdogTerminalWss.emit(\"connection\", ws, req);\n      });\n      return;\n    }\n    if (requestUrl.pathname === \"/api/ws/chat\") {\n      if (!chatWsService || typeof chatWsService.handleUpgrade !== \"function\") {\n        socket.write(\n          \"HTTP/1.1 503 Service Unavailable\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\nChat websocket unavailable\",\n        );\n        socket.destroy();\n        return;\n      }\n      chatWsService.handleUpgrade(req, socket, head);\n      return;\n    }\n    proxy.ws(req, socket, head, { target: getGatewayUrl() });\n  });\n};\n\nmodule.exports = {\n  createWatchdogTerminalWsBridge,\n};\n"
  },
  {
    "path": "lib/server/watchdog-terminal.js",
    "content": "const crypto = require(\"crypto\");\nconst { spawn, spawnSync } = require(\"child_process\");\n\nconst kSessionIdleTtlMs = 15 * 60 * 1000;\nconst kCleanupIntervalMs = 30 * 1000;\nconst kMaxBufferedOutputChars = 200000;\n\nconst hasScriptCommand = () => {\n  try {\n    const result = spawnSync(\"sh\", [\"-lc\", \"command -v script >/dev/null 2>&1\"], {\n      stdio: \"ignore\",\n    });\n    return result.status === 0;\n  } catch {\n    return false;\n  }\n};\n\nconst createShellProcess = ({\n  shell = \"/bin/bash\",\n  cwd = process.cwd(),\n  env = {},\n  preferPty = false,\n} = {}) => {\n  if (preferPty && process.platform === \"darwin\") {\n    return spawn(\"script\", [\"-q\", \"/dev/null\", shell, \"-i\"], {\n      cwd,\n      env: { ...env, TERM: env.TERM || \"xterm-256color\" },\n      stdio: \"pipe\",\n    });\n  }\n  if (preferPty) {\n    return spawn(\"script\", [\"-q\", \"-f\", \"-c\", `${shell} -i`, \"/dev/null\"], {\n      cwd,\n      env: { ...env, TERM: env.TERM || \"xterm-256color\" },\n      stdio: \"pipe\",\n    });\n  }\n  return spawn(shell, [\"-i\"], {\n    cwd,\n    env: { ...env, TERM: env.TERM || \"xterm-256color\" },\n    stdio: \"pipe\",\n  });\n};\n\nconst createWatchdogTerminalService = ({\n  cwd = process.cwd(),\n  shell = process.env.SHELL || \"/bin/bash\",\n  env = process.env,\n} = {}) => {\n  let session = null;\n  const preferPty = hasScriptCommand();\n\n  const notifySubscribers = (event) => {\n    if (!session?.subscribers?.size) return;\n    session.subscribers.forEach((subscriber) => {\n      try {\n        subscriber(event);\n      } catch {}\n    });\n  };\n\n  const appendOutput = (chunk = \"\") => {\n    if (!session || !chunk) return;\n    const chunkText = String(chunk);\n    session.output += chunkText;\n    session.endCursor += chunkText.length;\n    if (session.output.length > kMaxBufferedOutputChars) {\n      const trimCount = session.output.length - kMaxBufferedOutputChars;\n      session.output = session.output.slice(trimCount);\n      session.startCursor += trimCount;\n    }\n    notifySubscribers({ type: \"output\", data: chunkText });\n  };\n\n  const markActive = () => {\n    if (!session) return;\n    session.lastActiveAtMs = Date.now();\n  };\n\n  const createOrReuseSession = () => {\n    if (session && !session.ended) {\n      markActive();\n      return {\n        id: session.id,\n        shell,\n        cwd,\n        ended: false,\n      };\n    }\n    if (session && session.ended) session = null;\n\n    const proc = createShellProcess({ shell, cwd, env, preferPty });\n    const sessionId = crypto.randomUUID();\n    session = {\n      id: sessionId,\n      proc,\n      output: \"\",\n      startCursor: 0,\n      endCursor: 0,\n      ended: false,\n      exitCode: null,\n      signal: null,\n      lastActiveAtMs: Date.now(),\n      subscribers: new Set(),\n    };\n\n    proc.stdout.setEncoding(\"utf8\");\n    proc.stderr.setEncoding(\"utf8\");\n    proc.stdout.on(\"data\", (chunk) => appendOutput(chunk));\n    proc.stderr.on(\"data\", (chunk) => appendOutput(chunk));\n    proc.on(\"close\", (code, signal) => {\n      if (!session || session.id !== sessionId) return;\n      session.ended = true;\n      session.exitCode = code;\n      session.signal = signal;\n      const endLine = `\\r\\n[terminal exited${code != null ? ` with code ${code}` : \"\"}${signal ? ` (${signal})` : \"\"}]\\r\\n`;\n      appendOutput(endLine);\n      notifySubscribers({\n        type: \"exit\",\n        code,\n        signal,\n      });\n    });\n\n    return {\n      id: session.id,\n      shell,\n      cwd,\n      ended: false,\n    };\n  };\n\n  const subscribe = ({\n    sessionId = \"\",\n    onEvent = () => {},\n    replayBuffer = true,\n    tailLines = 0,\n  } = {}) => {\n    if (!session || String(session.id) !== String(sessionId || \"\")) {\n      return {\n        ok: false,\n        error: \"Terminal session not found\",\n        unsubscribe: () => {},\n      };\n    }\n    markActive();\n    const subscriber = (event) => onEvent(event);\n    session.subscribers.add(subscriber);\n    if (replayBuffer && session.output) {\n      onEvent({ type: \"output\", data: session.output });\n    } else if (!replayBuffer && Number(tailLines || 0) > 0 && !session.ended) {\n      const lines = String(session.output || \"\").split(\"\\n\");\n      const count = Math.max(1, Math.floor(Number(tailLines || 0)));\n      const tail = lines.slice(-count).join(\"\\n\");\n      if (tail.trim()) onEvent({ type: \"output\", data: tail });\n    }\n    if (session.ended) {\n      onEvent({\n        type: \"exit\",\n        code: session.exitCode,\n        signal: session.signal,\n      });\n    }\n    return {\n      ok: true,\n      unsubscribe: () => {\n        if (!session) return;\n        session.subscribers.delete(subscriber);\n      },\n    };\n  };\n\n  const readOutput = ({ sessionId = \"\", cursor = 0 } = {}) => {\n    if (!session || String(session.id) !== String(sessionId || \"\")) {\n      return {\n        found: false,\n        output: \"\",\n        cursor: 0,\n        startCursor: 0,\n        endCursor: 0,\n        ended: true,\n      };\n    }\n    markActive();\n    const requestedCursor = Number(cursor);\n    const safeCursor = Number.isFinite(requestedCursor)\n      ? Math.max(0, Math.floor(requestedCursor))\n      : 0;\n    const effectiveCursor =\n      safeCursor < session.startCursor || safeCursor > session.endCursor\n        ? session.startCursor\n        : safeCursor;\n    const sliceIndex = Math.max(0, effectiveCursor - session.startCursor);\n    return {\n      found: true,\n      output: session.output.slice(sliceIndex),\n      cursor: session.endCursor,\n      startCursor: session.startCursor,\n      endCursor: session.endCursor,\n      ended: !!session.ended,\n      exitCode: session.exitCode,\n      signal: session.signal,\n    };\n  };\n\n  const writeInput = ({ sessionId = \"\", input = \"\" } = {}) => {\n    if (!session || String(session.id) !== String(sessionId || \"\")) {\n      return { ok: false, error: \"Terminal session not found\" };\n    }\n    if (session.ended || !session.proc.stdin.writable) {\n      return { ok: false, error: \"Terminal session has ended\" };\n    }\n    markActive();\n    session.proc.stdin.write(String(input || \"\"));\n    return { ok: true };\n  };\n\n  const closeSession = ({ sessionId = \"\" } = {}) => {\n    if (!session || String(session.id) !== String(sessionId || \"\")) {\n      return { ok: true };\n    }\n    const targetProc = session.proc;\n    session = null;\n    try {\n      targetProc.kill(\"SIGTERM\");\n    } catch {}\n    return { ok: true };\n  };\n\n  const disposeSession = () => {\n    if (!session) return;\n    const targetProc = session.proc;\n    session = null;\n    try {\n      targetProc.kill(\"SIGTERM\");\n    } catch {}\n  };\n\n  const cleanupTimer = setInterval(() => {\n    if (!session || session.ended) return;\n    const idleForMs = Date.now() - Number(session.lastActiveAtMs || 0);\n    if (idleForMs < kSessionIdleTtlMs) return;\n    try {\n      session.proc.kill(\"SIGTERM\");\n    } catch {}\n  }, kCleanupIntervalMs);\n  cleanupTimer.unref?.();\n\n  return {\n    createOrReuseSession,\n    subscribe,\n    readOutput,\n    writeInput,\n    closeSession,\n    disposeSession,\n  };\n};\n\nmodule.exports = {\n  createWatchdogTerminalService,\n};\n"
  },
  {
    "path": "lib/server/watchdog.js",
    "content": "const {\n  kWatchdogCheckIntervalMs,\n  kWatchdogDegradedCheckIntervalMs,\n  kWatchdogStartupFailureThreshold,\n  kWatchdogMaxRepairAttempts,\n  kWatchdogCrashLoopWindowMs,\n  kWatchdogCrashLoopThreshold,\n} = require(\"./constants\");\n\nconst kHealthStartupGraceMs = 30 * 1000;\nconst kBootstrapHealthCheckMs = 5 * 1000;\nconst kExpectedRestartWindowMs = 15 * 1000;\nconst kGatewayHealthTimeoutMs = 5 * 1000;\n\nconst isTruthy = (value) =>\n  [\"1\", \"true\", \"yes\", \"on\"].includes(\n    String(value || \"\")\n      .trim()\n      .toLowerCase(),\n  );\n\nconst isDuplicateGatewayLaunchExit = ({ code, stderrTail = [] } = {}) => {\n  if (code !== 1) return false;\n  const stderrText = (Array.isArray(stderrTail) ? stderrTail : [])\n    .map((entry) => String(entry || \"\"))\n    .join(\"\\n\")\n    .toLowerCase();\n  if (!stderrText) return false;\n  return (\n    stderrText.includes(\"another gateway instance is already listening\") ||\n    (stderrText.includes(\"port\") && stderrText.includes(\"already in use\"))\n  );\n};\n\nconst createWatchdog = ({\n  clawCmd,\n  launchGatewayProcess,\n  insertWatchdogEvent,\n  notifier,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  resolveSetupUrl,\n  resolveGatewayHealthUrl = () => \"\",\n}) => {\n  const state = {\n    lifecycle: \"stopped\",\n    health: \"unknown\",\n    uptimeStartedAt: null,\n    lastHealthCheckAt: null,\n    repairAttempts: 0,\n    crashTimestamps: [],\n    autoRepair: isTruthy(process.env.WATCHDOG_AUTO_REPAIR),\n    notificationsDisabled: isTruthy(\n      process.env.WATCHDOG_NOTIFICATIONS_DISABLED,\n    ),\n    operationInProgress: false,\n    gatewayStartedAt: null,\n    gatewayPid: null,\n    crashRecoveryActive: false,\n    expectedRestartInProgress: false,\n    expectedRestartUntilMs: 0,\n    pendingRecoveryNoticeSource: \"\",\n    awaitingAutoRepairRecovery: false,\n    startupConsecutiveHealthFailures: 0,\n  };\n  let healthTimer = null;\n  let bootstrapHealthTimer = null;\n  let degradedHealthTimer = null;\n  let activeIncidentKey = \"\";\n  let sentIncidentNotifications = new Set();\n\n  const openIncident = (incidentKey = \"gateway\") => {\n    const normalizedKey = String(incidentKey || \"gateway\");\n    if (activeIncidentKey === normalizedKey) return;\n    activeIncidentKey = normalizedKey;\n    sentIncidentNotifications = new Set();\n  };\n\n  const closeIncident = () => {\n    activeIncidentKey = \"\";\n    sentIncidentNotifications = new Set();\n  };\n\n  const clearDegradedHealthCheckTimer = () => {\n    if (!degradedHealthTimer) return;\n    clearTimeout(degradedHealthTimer);\n    degradedHealthTimer = null;\n  };\n\n  const scheduleDegradedHealthCheck = () => {\n    if (degradedHealthTimer) return;\n    if (state.health !== \"degraded\" || state.lifecycle !== \"running\") return;\n    degradedHealthTimer = setTimeout(async () => {\n      degradedHealthTimer = null;\n      if (state.health !== \"degraded\" || state.lifecycle !== \"running\") return;\n      await runHealthCheck({\n        source: \"degraded_retry\",\n        allowAutoRepair: false,\n      });\n      if (state.health === \"degraded\" && state.lifecycle === \"running\") {\n        scheduleDegradedHealthCheck();\n      }\n    }, kWatchdogDegradedCheckIntervalMs);\n    if (typeof degradedHealthTimer.unref === \"function\")\n      degradedHealthTimer.unref();\n  };\n\n  const clearExpectedRestartWindow = () => {\n    state.expectedRestartInProgress = false;\n    state.expectedRestartUntilMs = 0;\n  };\n\n  const markExpectedRestartWindow = (durationMs = kExpectedRestartWindowMs) => {\n    const safeDuration = Math.max(\n      5000,\n      Number(durationMs) || kExpectedRestartWindowMs,\n    );\n    state.expectedRestartInProgress = true;\n    state.expectedRestartUntilMs = Date.now() + safeDuration;\n  };\n\n  const startRegularHealthChecks = () => {\n    if (healthTimer) return;\n    healthTimer = setInterval(() => {\n      void runHealthCheck();\n    }, kWatchdogCheckIntervalMs);\n    if (typeof healthTimer.unref === \"function\") healthTimer.unref();\n  };\n\n  const startBootstrapHealthChecks = () => {\n    if (bootstrapHealthTimer) return;\n    const runBootstrapCheck = async () => {\n      const healthy = await runHealthCheck();\n      // Bootstrap checks are only for the \"initializing\" phase. As soon as we\n      // either become healthy or transition into any non-unknown state\n      // (degraded/unhealthy/etc.), stop 5s polling and fall back to normal\n      // interval checks to avoid noisy health-check spam.\n      if (healthy || state.health !== \"unknown\") {\n        if (bootstrapHealthTimer) {\n          clearTimeout(bootstrapHealthTimer);\n          bootstrapHealthTimer = null;\n        }\n        startRegularHealthChecks();\n        return;\n      }\n      bootstrapHealthTimer = setTimeout(() => {\n        void runBootstrapCheck();\n      }, kBootstrapHealthCheckMs);\n      if (typeof bootstrapHealthTimer.unref === \"function\") {\n        bootstrapHealthTimer.unref();\n      }\n    };\n    void runBootstrapCheck();\n  };\n\n  const trimCrashWindow = () => {\n    const threshold = Date.now() - kWatchdogCrashLoopWindowMs;\n    state.crashTimestamps = state.crashTimestamps.filter(\n      (ts) => ts >= threshold,\n    );\n  };\n\n  const createCorrelationId = () =>\n    `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;\n\n  const logEvent = (\n    eventType,\n    source,\n    status,\n    details = null,\n    correlationId = \"\",\n  ) => {\n    try {\n      insertWatchdogEvent({\n        eventType,\n        source,\n        status,\n        details,\n        correlationId,\n      });\n    } catch (err) {\n      console.error(`[watchdog] failed to log event: ${err.message}`);\n    }\n  };\n\n  const notify = async (message, correlationId = \"\", eventType = \"info\") => {\n    if (state.notificationsDisabled) {\n      return { ok: false, skipped: true, reason: \"notifications_disabled\" };\n    }\n    if (!notifier?.notify) return { ok: false, reason: \"notifier_unavailable\" };\n    const result = await notifier.notify(message, { eventType });\n    logEvent(\n      \"notification\",\n      \"watchdog\",\n      result.ok ? \"ok\" : \"failed\",\n      result,\n      correlationId,\n    );\n    return result;\n  };\n\n  const notifyOncePerIncident = async (\n    notificationKey,\n    message,\n    correlationId = \"\",\n    eventType = \"info\",\n  ) => {\n    const key = String(notificationKey || \"\").trim();\n    if (!key) return notify(message, correlationId, eventType);\n    if (sentIncidentNotifications.has(key)) {\n      return {\n        ok: false,\n        skipped: true,\n        reason: \"incident_notification_already_sent\",\n      };\n    }\n    const result = await notify(message, correlationId, eventType);\n    if (result?.ok || result?.skipped) {\n      sentIncidentNotifications.add(key);\n    }\n    return result;\n  };\n\n  const getWatchdogSetupUrl = () => {\n    try {\n      const base =\n        typeof resolveSetupUrl === \"function\"\n          ? String(resolveSetupUrl() || \"\")\n          : \"\";\n      if (base) return `${base.replace(/\\/+$/, \"\")}/#/watchdog`;\n      const fallbackPort =\n        Number.parseInt(String(process.env.PORT || \"3000\"), 10) || 3000;\n      return `http://localhost:${fallbackPort}/#/watchdog`;\n    } catch {\n      return \"\";\n    }\n  };\n\n  const withViewLogsSuffix = (line) => {\n    const setupUrl = getWatchdogSetupUrl();\n    if (!setupUrl) return line;\n    return `${line} - [View logs](${setupUrl})`;\n  };\n\n  const asInlineCode = (value) =>\n    `\\`${String(value || \"\").replace(/`/g, \"\")}\\``;\n\n  const notifyAutoRepairOutcome = async ({\n    source,\n    correlationId,\n    ok,\n    verifiedHealthy = null,\n    attempts = 0,\n  }) => {\n    if (source === \"manual\") return;\n    openIncident(\"gateway_recovery\");\n    const title = ok\n      ? verifiedHealthy\n        ? \"🟢 Auto-repair complete, gateway healthy\"\n        : \"🟡 Auto-repair started, awaiting health check\"\n      : \"🔴 Auto-repair failed\";\n    const notificationKey = ok\n      ? verifiedHealthy\n        ? \"auto_repair_complete\"\n        : \"auto_repair_awaiting_health\"\n      : \"auto_repair_failed\";\n    await notifyOncePerIncident(\n      notificationKey,\n      [\n        \"🐺 *AlphaClaw Watchdog*\",\n        withViewLogsSuffix(title),\n        `Trigger: ${asInlineCode(source)}`,\n        ...(attempts > 0 ? [`Attempt count: ${attempts}`] : []),\n      ].join(\"\\n\"),\n      correlationId,\n      ok && verifiedHealthy ? \"recovery\" : \"crash\",\n    );\n  };\n\n  const getSettings = () => ({\n    autoRepair: state.autoRepair,\n    notificationsEnabled: !state.notificationsDisabled,\n  });\n\n  const probeGatewayHealth = async () => {\n    const healthUrl = String(resolveGatewayHealthUrl() || \"\").trim();\n    if (!healthUrl) {\n      return {\n        ok: false,\n        reason: \"gateway health URL unavailable\",\n      };\n    }\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), kGatewayHealthTimeoutMs);\n    try {\n      const response = await fetch(healthUrl, {\n        method: \"GET\",\n        headers: { Accept: \"application/json\" },\n        signal: controller.signal,\n      });\n      const rawBody = await response.text();\n      let parsedBody = null;\n      try {\n        parsedBody = rawBody ? JSON.parse(rawBody) : null;\n      } catch {}\n      if (!response.ok) {\n        return {\n          ok: false,\n          reason:\n            parsedBody?.error ||\n            `gateway health returned HTTP ${response.status}`,\n        };\n      }\n      if (parsedBody?.ok === false) {\n        return {\n          ok: false,\n          reason: parsedBody?.error || \"gateway unhealthy\",\n        };\n      }\n      return {\n        ok: true,\n        details: parsedBody,\n      };\n    } catch (error) {\n      const message =\n        error?.name === \"AbortError\"\n          ? `gateway health timed out after ${kGatewayHealthTimeoutMs}ms`\n          : error?.message || \"gateway health request failed\";\n      return {\n        ok: false,\n        reason: message,\n      };\n    } finally {\n      clearTimeout(timeoutId);\n    }\n  };\n\n  const updateSettings = ({ autoRepair, notificationsEnabled } = {}) => {\n    const hasAutoRepair = typeof autoRepair === \"boolean\";\n    const hasNotificationsEnabled = typeof notificationsEnabled === \"boolean\";\n    if (!hasAutoRepair && !hasNotificationsEnabled) {\n      throw new Error(\n        \"Expected autoRepair and/or notificationsEnabled boolean\",\n      );\n    }\n    const envVars = readEnvFile();\n    if (hasAutoRepair) {\n      const existingIdx = envVars.findIndex(\n        (item) => item.key === \"WATCHDOG_AUTO_REPAIR\",\n      );\n      const nextValue = autoRepair ? \"true\" : \"false\";\n      if (existingIdx >= 0) {\n        envVars[existingIdx] = { ...envVars[existingIdx], value: nextValue };\n      } else {\n        envVars.push({ key: \"WATCHDOG_AUTO_REPAIR\", value: nextValue });\n      }\n    }\n    if (hasNotificationsEnabled) {\n      const existingIdx = envVars.findIndex(\n        (item) => item.key === \"WATCHDOG_NOTIFICATIONS_DISABLED\",\n      );\n      const nextValue = notificationsEnabled ? \"false\" : \"true\";\n      if (existingIdx >= 0) {\n        envVars[existingIdx] = { ...envVars[existingIdx], value: nextValue };\n      } else {\n        envVars.push({\n          key: \"WATCHDOG_NOTIFICATIONS_DISABLED\",\n          value: nextValue,\n        });\n      }\n    }\n    writeEnvFile(envVars);\n    reloadEnv();\n    state.autoRepair = isTruthy(process.env.WATCHDOG_AUTO_REPAIR);\n    state.notificationsDisabled = isTruthy(\n      process.env.WATCHDOG_NOTIFICATIONS_DISABLED,\n    );\n    return getSettings();\n  };\n\n  const runRepair = async ({ source, correlationId, force = false }) => {\n    if (!force && !state.autoRepair) {\n      return { ok: false, skipped: true, reason: \"auto_repair_disabled\" };\n    }\n    if (!force && state.awaitingAutoRepairRecovery) {\n      return { ok: false, skipped: true, reason: \"awaiting_health_recovery\" };\n    }\n    if (state.operationInProgress) {\n      return { ok: false, skipped: true, reason: \"operation_in_progress\" };\n    }\n\n    state.operationInProgress = true;\n    try {\n      const result = await clawCmd(\"doctor --fix --yes\", { quiet: true });\n      const ok = !!result?.ok;\n      logEvent(\"repair\", source, ok ? \"ok\" : \"failed\", result, correlationId);\n      if (ok) {\n        let launchedGateway = false;\n        try {\n          const child = launchGatewayProcess();\n          launchedGateway = !!child;\n          if (launchedGateway) {\n            logEvent(\n              \"restart\",\n              \"repair\",\n              \"ok\",\n              { pid: child.pid },\n              correlationId,\n            );\n          } else {\n            logEvent(\n              \"restart\",\n              \"repair\",\n              \"failed\",\n              { reason: \"launchGatewayProcess returned no child\" },\n              correlationId,\n            );\n          }\n        } catch (err) {\n          logEvent(\n            \"restart\",\n            \"repair\",\n            \"failed\",\n            { error: err.message },\n            correlationId,\n          );\n        }\n        state.health = \"unknown\";\n        state.lifecycle = \"running\";\n        state.repairAttempts = 0;\n        state.crashTimestamps = [];\n        state.awaitingAutoRepairRecovery = false;\n        const verifiedHealthy = await runHealthCheck({\n          allowDuringOperation: true,\n          source: \"repair_verify\",\n          allowAutoRepair: false,\n        });\n        await notifyAutoRepairOutcome({\n          source,\n          correlationId,\n          ok: true,\n          verifiedHealthy,\n          attempts: state.repairAttempts,\n        });\n        if (!verifiedHealthy && source !== \"manual\") {\n          state.pendingRecoveryNoticeSource = source;\n          state.awaitingAutoRepairRecovery = true;\n        } else {\n          state.pendingRecoveryNoticeSource = \"\";\n          state.awaitingAutoRepairRecovery = false;\n        }\n        return { ok: true, verifiedHealthy, launchedGateway, result };\n      }\n\n      state.repairAttempts += 1;\n      state.health = \"unhealthy\";\n      await notifyAutoRepairOutcome({\n        source,\n        correlationId,\n        ok: false,\n        attempts: state.repairAttempts,\n      });\n      if (state.repairAttempts >= kWatchdogMaxRepairAttempts) {\n        await notify(\n          [\n            \"🐺 *AlphaClaw Watchdog*\",\n            \"🔴 Auto-repair failed repeatedly\",\n            `Attempts: ${state.repairAttempts}`,\n            withViewLogsSuffix(\"Auto-repair paused until manual action.\"),\n          ].join(\"\\n\"),\n          correlationId,\n          \"crash\",\n        );\n      }\n      return { ok: false, result };\n    } finally {\n      state.operationInProgress = false;\n    }\n  };\n\n  const runHealthCheck = async ({\n    allowDuringOperation = false,\n    source = \"health_timer\",\n    allowAutoRepair = true,\n  } = {}) => {\n    if (\n      state.expectedRestartInProgress &&\n      Date.now() >= state.expectedRestartUntilMs\n    ) {\n      clearExpectedRestartWindow();\n    }\n    if (state.operationInProgress && !allowDuringOperation) return false;\n    const gatewayStartedAtAtStart = state.gatewayStartedAt;\n    const correlationId = createCorrelationId();\n    state.lastHealthCheckAt = new Date().toISOString();\n    const parsed = await probeGatewayHealth();\n    const staleAfterRestart =\n      gatewayStartedAtAtStart != null &&\n      state.gatewayStartedAt != null &&\n      state.gatewayStartedAt !== gatewayStartedAtAtStart;\n    const restartWindowActive =\n      state.expectedRestartInProgress &&\n      Date.now() < state.expectedRestartUntilMs;\n    if (staleAfterRestart) {\n      return false;\n    }\n    if (parsed.ok) {\n      const wasUnhealthy = state.health !== \"healthy\";\n      const recoveredFromCrashLoop = state.lifecycle === \"crash_loop\";\n      const shouldNotifyRecovery =\n        !!activeIncidentKey ||\n        recoveredFromCrashLoop ||\n        !!state.pendingRecoveryNoticeSource ||\n        state.awaitingAutoRepairRecovery;\n      state.startupConsecutiveHealthFailures = 0;\n      clearDegradedHealthCheckTimer();\n      clearExpectedRestartWindow();\n      state.health = \"healthy\";\n      state.lifecycle = \"running\";\n      if (!state.uptimeStartedAt || wasUnhealthy)\n        state.uptimeStartedAt = Date.now();\n      state.repairAttempts = 0;\n      state.crashRecoveryActive = false;\n      state.awaitingAutoRepairRecovery = false;\n      if (shouldNotifyRecovery) {\n        logEvent(\n          \"recovery\",\n          source,\n          \"ok\",\n          [\n            {\n              previousLifecycle: recoveredFromCrashLoop\n                ? \"crash_loop\"\n                : null,\n              previousRecoverySource: state.pendingRecoveryNoticeSource || null,\n              health: \"healthy\",\n            },\n          ][0],\n          correlationId,\n        );\n        await notifyOncePerIncident(\n          \"gateway_healthy_again\",\n          [\n            \"🐺 *AlphaClaw Watchdog*\",\n            withViewLogsSuffix(\"🟢 Gateway healthy again\"),\n          ].join(\"\\n\"),\n          correlationId,\n          \"recovery\",\n        );\n      }\n      state.pendingRecoveryNoticeSource = \"\";\n      closeIncident();\n      logEvent(\n        \"health_check\",\n        source,\n        \"ok\",\n        parsed.details || { ok: true },\n        correlationId,\n      );\n      return true;\n    }\n    if (restartWindowActive) {\n      state.startupConsecutiveHealthFailures = 0;\n      clearDegradedHealthCheckTimer();\n      logEvent(\n        \"health_check\",\n        source,\n        \"ok\",\n        {\n          reason: parsed.reason,\n          details: parsed.details || null,\n          skipped: true,\n          expectedRestartActive: true,\n          expectedRestartUntilMs: state.expectedRestartUntilMs,\n        },\n        correlationId,\n      );\n      return false;\n    }\n\n    const withinStartupGrace =\n      !!state.gatewayStartedAt &&\n      Date.now() - state.gatewayStartedAt < kHealthStartupGraceMs &&\n      state.lifecycle === \"running\" &&\n      !state.crashRecoveryActive;\n    if (withinStartupGrace) {\n      state.startupConsecutiveHealthFailures = 0;\n      clearDegradedHealthCheckTimer();\n      logEvent(\n        \"health_check\",\n        source,\n        \"ok\",\n        {\n          reason: parsed.reason,\n          details: parsed.details || null,\n          skipped: true,\n          startupGraceActive: true,\n          startupGraceMs: kHealthStartupGraceMs,\n        },\n        correlationId,\n      );\n      return false;\n    }\n\n    if (state.health === \"unknown\" && state.lifecycle === \"running\") {\n      state.startupConsecutiveHealthFailures += 1;\n      if (\n        state.startupConsecutiveHealthFailures <\n        kWatchdogStartupFailureThreshold\n      ) {\n        logEvent(\n          \"health_check\",\n          source,\n          \"ok\",\n          {\n            reason: parsed.reason,\n            details: parsed.details || null,\n            skipped: true,\n            startupFailureRetryActive: true,\n            startupConsecutiveFailures: state.startupConsecutiveHealthFailures,\n            startupFailureThreshold: kWatchdogStartupFailureThreshold,\n          },\n          correlationId,\n        );\n        return false;\n      }\n    } else {\n      state.startupConsecutiveHealthFailures = 0;\n    }\n\n    state.health = \"degraded\";\n    scheduleDegradedHealthCheck();\n    logEvent(\n      \"health_check\",\n      source,\n      \"failed\",\n      { reason: parsed.reason, details: parsed.details || null },\n      correlationId,\n    );\n    if (!state.autoRepair || !allowAutoRepair) return false;\n    if (state.awaitingAutoRepairRecovery) return false;\n    await runRepair({ source, correlationId });\n    return false;\n  };\n\n  const restartAfterCrash = async (correlationId) => {\n    if (state.operationInProgress) return;\n    state.operationInProgress = true;\n    try {\n      const child = launchGatewayProcess();\n      if (child) {\n        logEvent(\n          \"restart\",\n          \"exit_event\",\n          \"ok\",\n          { pid: child.pid },\n          correlationId,\n        );\n      } else {\n        logEvent(\n          \"restart\",\n          \"exit_event\",\n          \"failed\",\n          { reason: \"launchGatewayProcess returned no child\" },\n          correlationId,\n        );\n      }\n    } catch (err) {\n      logEvent(\n        \"restart\",\n        \"exit_event\",\n        \"failed\",\n        { error: err.message },\n        correlationId,\n      );\n    } finally {\n      state.operationInProgress = false;\n    }\n  };\n\n  const onGatewayExit = ({\n    code,\n    signal,\n    expectedExit = false,\n    stderrTail = [],\n  } = {}) => {\n    const correlationId = createCorrelationId();\n    clearDegradedHealthCheckTimer();\n    if (expectedExit && (code == null || code === 0)) {\n      state.lifecycle = \"restarting\";\n      state.health = \"unknown\";\n      state.uptimeStartedAt = null;\n      state.crashRecoveryActive = false;\n      markExpectedRestartWindow();\n      startBootstrapHealthChecks();\n      logEvent(\n        \"restart\",\n        \"exit_event\",\n        \"ok\",\n        { expectedExit: true, code: code ?? null, signal: signal ?? null },\n        correlationId,\n      );\n      return;\n    }\n    if (isDuplicateGatewayLaunchExit({ code, stderrTail })) {\n      state.lifecycle = \"running\";\n      state.health = \"unknown\";\n      state.crashRecoveryActive = false;\n      state.startupConsecutiveHealthFailures = 0;\n      if (!state.uptimeStartedAt) {\n        state.uptimeStartedAt = Date.now();\n      }\n      startBootstrapHealthChecks();\n      logEvent(\n        \"restart\",\n        \"exit_event\",\n        \"ok\",\n        {\n          duplicateLaunch: true,\n          code: code ?? null,\n          signal: signal ?? null,\n          stderrTail,\n        },\n        correlationId,\n      );\n      return;\n    }\n\n    state.lifecycle = \"crashed\";\n    state.health = \"unhealthy\";\n    state.uptimeStartedAt = null;\n    state.crashRecoveryActive = true;\n    state.crashTimestamps.push(Date.now());\n    trimCrashWindow();\n    logEvent(\n      \"crash\",\n      \"exit_event\",\n      \"failed\",\n      { code: code ?? null, signal: signal ?? null, stderrTail },\n      correlationId,\n    );\n\n    if (state.crashTimestamps.length >= kWatchdogCrashLoopThreshold) {\n      state.lifecycle = \"crash_loop\";\n      openIncident(\"gateway_recovery\");\n      logEvent(\n        \"crash_loop\",\n        \"exit_event\",\n        \"failed\",\n        {\n          crashesInWindow: state.crashTimestamps.length,\n          windowMs: kWatchdogCrashLoopWindowMs,\n        },\n        correlationId,\n      );\n      void notifyOncePerIncident(\n        \"crash_loop_detected\",\n        [\n          \"🐺 *AlphaClaw Watchdog*\",\n          withViewLogsSuffix(\n            state.autoRepair\n              ? \"🔴 Crash loop detected, auto-repairing...\"\n              : \"🔴 Crash loop detected\",\n          ),\n          `Crashes: ${state.crashTimestamps.length} in the last ${Math.floor(kWatchdogCrashLoopWindowMs / 1000)}s`,\n          `Last exit code: ${code ?? \"unknown\"}`,\n          ...(state.autoRepair\n            ? []\n            : [\"Auto-restart paused; manual action required.\"]),\n        ].join(\"\\n\"),\n        correlationId,\n        \"crash\",\n      );\n      if (state.autoRepair) {\n        void runRepair({\n          source: \"crash_loop\",\n          correlationId,\n        });\n        return;\n      }\n      return;\n    }\n\n    void restartAfterCrash(correlationId);\n  };\n\n  const onGatewayLaunch = ({ startedAt = Date.now(), pid = null } = {}) => {\n    clearDegradedHealthCheckTimer();\n    state.lifecycle = \"running\";\n    state.health = \"unknown\";\n    state.startupConsecutiveHealthFailures = 0;\n    state.crashRecoveryActive = false;\n    clearExpectedRestartWindow();\n    state.uptimeStartedAt = startedAt;\n    state.gatewayStartedAt = startedAt;\n    state.gatewayPid = pid;\n    startBootstrapHealthChecks();\n  };\n\n  const onExpectedRestart = () => {\n    clearDegradedHealthCheckTimer();\n    state.lifecycle = \"restarting\";\n    state.health = \"unknown\";\n    state.uptimeStartedAt = null;\n    state.startupConsecutiveHealthFailures = 0;\n    state.crashRecoveryActive = false;\n    markExpectedRestartWindow();\n    startBootstrapHealthChecks();\n  };\n\n  const triggerRepair = async () => {\n    const correlationId = createCorrelationId();\n    return runRepair({\n      source: \"manual\",\n      correlationId,\n      force: true,\n    });\n  };\n\n  const start = () => {\n    if (healthTimer || bootstrapHealthTimer) return;\n    clearDegradedHealthCheckTimer();\n    state.lifecycle = \"running\";\n    state.health = \"unknown\";\n    state.startupConsecutiveHealthFailures = 0;\n    state.gatewayStartedAt = Date.now();\n    startBootstrapHealthChecks();\n  };\n\n  const stop = () => {\n    clearDegradedHealthCheckTimer();\n    if (bootstrapHealthTimer) {\n      clearTimeout(bootstrapHealthTimer);\n      bootstrapHealthTimer = null;\n    }\n    if (healthTimer) {\n      clearInterval(healthTimer);\n      healthTimer = null;\n    }\n    state.lifecycle = \"stopped\";\n    state.uptimeStartedAt = null;\n    state.startupConsecutiveHealthFailures = 0;\n    state.awaitingAutoRepairRecovery = false;\n    state.pendingRecoveryNoticeSource = \"\";\n    closeIncident();\n  };\n\n  const getStatus = () => {\n    trimCrashWindow();\n    return {\n      lifecycle: state.lifecycle,\n      health: state.health,\n      uptimeMs: state.uptimeStartedAt ? Date.now() - state.uptimeStartedAt : 0,\n      uptimeStartedAt: state.uptimeStartedAt\n        ? new Date(state.uptimeStartedAt).toISOString()\n        : null,\n      lastHealthCheckAt: state.lastHealthCheckAt,\n      repairAttempts: state.repairAttempts,\n      autoRepair: state.autoRepair,\n      crashCountInWindow: state.crashTimestamps.length,\n      crashLoopThreshold: kWatchdogCrashLoopThreshold,\n      crashLoopWindowMs: kWatchdogCrashLoopWindowMs,\n      operationInProgress: state.operationInProgress,\n      gatewayPid: state.gatewayPid,\n    };\n  };\n\n  return {\n    getStatus,\n    getSettings,\n    updateSettings,\n    triggerRepair,\n    onExpectedRestart,\n    onGatewayExit,\n    onGatewayLaunch,\n    start,\n    stop,\n  };\n};\n\nmodule.exports = { createWatchdog };\n"
  },
  {
    "path": "lib/server/webhook-middleware.js",
    "content": "const http = require(\"http\");\nconst https = require(\"https\");\nconst { URL } = require(\"url\");\nconst { normalizeIp } = require(\"./utils/network\");\n\nconst kRedactedHeaderKeys = new Set([\"authorization\", \"cookie\", \"x-webhook-token\"]);\nconst kRedactedPayloadKeys = new Set([\n  \"authorization\",\n  \"code\",\n  \"token\",\n  \"access_token\",\n  \"refresh_token\",\n  \"id_token\",\n  \"client_secret\",\n]);\nconst kGmailDedupeTtlMs = 24 * 60 * 60 * 1000;\nconst kGmailDedupeCleanupIntervalMs = 60 * 1000;\n\nconst sanitizeHeaders = (headers) => {\n  const sanitized = {};\n  for (const [key, value] of Object.entries(headers || {})) {\n    const normalizedKey = String(key || \"\").toLowerCase();\n    if (!normalizedKey) continue;\n    if (kRedactedHeaderKeys.has(normalizedKey)) {\n      sanitized[normalizedKey] = \"[REDACTED]\";\n      continue;\n    }\n    sanitized[normalizedKey] = Array.isArray(value) ? value.join(\", \") : String(value || \"\");\n  }\n  return sanitized;\n};\n\nconst extractBodyBuffer = (req) => {\n  if (Buffer.isBuffer(req.body)) return req.body;\n  if (typeof req.body === \"string\") return Buffer.from(req.body, \"utf8\");\n  if (req.body && typeof req.body === \"object\") {\n    return Buffer.from(JSON.stringify(req.body), \"utf8\");\n  }\n  return Buffer.alloc(0);\n};\n\nconst truncateText = (text, maxBytes) => {\n  const buffer = Buffer.isBuffer(text) ? text : Buffer.from(String(text || \"\"), \"utf8\");\n  if (buffer.length <= maxBytes) {\n    return { text: buffer.toString(\"utf8\"), truncated: false };\n  }\n  return {\n    text: buffer.subarray(0, maxBytes).toString(\"utf8\"),\n    truncated: true,\n  };\n};\n\nconst redactPayloadData = (value, key = \"\") => {\n  const normalizedKey = String(key || \"\").toLowerCase();\n  if (normalizedKey && kRedactedPayloadKeys.has(normalizedKey)) {\n    return \"[REDACTED]\";\n  }\n\n  if (Array.isArray(value)) {\n    return value.map((item) => redactPayloadData(item));\n  }\n\n  if (value && typeof value === \"object\") {\n    const redacted = {};\n    for (const [childKey, childValue] of Object.entries(value)) {\n      redacted[childKey] = redactPayloadData(childValue, childKey);\n    }\n    return redacted;\n  }\n\n  return value;\n};\n\nconst sanitizePayloadForLogging = (bodyBuffer) => {\n  if (!Buffer.isBuffer(bodyBuffer) || bodyBuffer.length === 0) return bodyBuffer;\n  const parsedBody = parseJsonSafe(bodyBuffer.toString(\"utf8\"));\n  if (!parsedBody || typeof parsedBody !== \"object\") {\n    return bodyBuffer;\n  }\n  return Buffer.from(JSON.stringify(redactPayloadData(parsedBody)), \"utf8\");\n};\n\nconst toGatewayRequestHeaders = ({ reqHeaders, contentLength, authorization }) => {\n  const headers = { ...reqHeaders };\n  delete headers.host;\n  delete headers[\"content-length\"];\n  delete headers[\"transfer-encoding\"];\n  headers[\"content-length\"] = String(contentLength);\n  if (authorization) headers.authorization = authorization;\n  return headers;\n};\n\nconst resolveHookName = (req) => {\n  const paramPath =\n    req?.params?.path ??\n    req?.params?.[0] ??\n    req?.params?.[\"*\"] ??\n    \"\";\n  const fromParams = String(paramPath).split(\"/\").filter(Boolean)[0] || \"\";\n  if (fromParams) return decodeURIComponent(fromParams);\n\n  const pathname = String(req?.path || req?.originalUrl || \"\").split(\"?\")[0];\n  const segments = pathname.split(\"/\").filter(Boolean);\n  if (segments.length >= 2 && (segments[0] === \"hooks\" || segments[0] === \"webhook\")) {\n    return decodeURIComponent(segments[1] || \"\");\n  }\n  return \"\";\n};\n\nconst resolveGatewayPath = ({ pathname, search }) => {\n  if (pathname.startsWith(\"/webhook/\")) {\n    return `/hooks/${pathname.slice(\"/webhook/\".length)}${search || \"\"}`;\n  }\n  return `${pathname}${search || \"\"}`;\n};\n\nconst resolveForwardMethod = (method) => {\n  if (String(method || \"\").toUpperCase() === \"GET\") return \"POST\";\n  return method;\n};\n\nconst parseJsonSafe = (rawValue) => {\n  try {\n    return JSON.parse(String(rawValue || \"\").trim() || \"{}\");\n  } catch {\n    return null;\n  }\n};\n\nconst queryParamsToObject = (searchParams) => {\n  const params = {};\n  for (const [key, value] of searchParams.entries()) {\n    if (Object.prototype.hasOwnProperty.call(params, key)) {\n      const currentValue = params[key];\n      if (Array.isArray(currentValue)) {\n        currentValue.push(value);\n      } else {\n        params[key] = [currentValue, value];\n      }\n      continue;\n    }\n    params[key] = value;\n  }\n  return params;\n};\n\nconst buildBodyFromQueryParams = ({ bodyBuffer, queryParams }) => {\n  if (!queryParams || Object.keys(queryParams).length === 0) {\n    return null;\n  }\n\n  if (bodyBuffer.length === 0) {\n    return Buffer.from(JSON.stringify(queryParams), \"utf8\");\n  }\n\n  const parsedBody = parseJsonSafe(bodyBuffer.toString(\"utf8\"));\n  if (!parsedBody || typeof parsedBody !== \"object\" || Array.isArray(parsedBody)) {\n    return null;\n  }\n\n  // Keep explicit body values authoritative when both are provided.\n  const mergedBody = { ...queryParams, ...parsedBody };\n  return Buffer.from(JSON.stringify(mergedBody), \"utf8\");\n};\n\nconst getGmailPayloadData = (parsedBody) => {\n  if (!parsedBody || typeof parsedBody !== \"object\") return null;\n  if (parsedBody.payload && typeof parsedBody.payload === \"object\") {\n    return parsedBody.payload;\n  }\n  return parsedBody;\n};\n\nconst getGmailMessageId = (message = {}) => {\n  const preferredId = String(message?.id || \"\").trim();\n  if (preferredId) return preferredId;\n  const fallbackId = String(message?.messageId || \"\").trim();\n  return fallbackId;\n};\n\nconst buildGmailDedupedBodyBuffer = ({ parsedBody, filteredMessages }) => {\n  if (parsedBody?.payload && typeof parsedBody.payload === \"object\") {\n    return Buffer.from(\n      JSON.stringify({\n        ...parsedBody,\n        payload: {\n          ...parsedBody.payload,\n          messages: filteredMessages,\n        },\n      }),\n      \"utf8\",\n    );\n  }\n  return Buffer.from(\n    JSON.stringify({\n      ...(parsedBody || {}),\n      messages: filteredMessages,\n    }),\n    \"utf8\",\n  );\n};\n\nconst createWebhookMiddleware = ({\n  gatewayUrl,\n  getGatewayUrl,\n  insertRequest,\n  maxPayloadBytes = 50 * 1024,\n}) => {\n  const gmailSeenMessageIds = new Map();\n  let lastGmailDedupeCleanupAt = 0;\n\n  const pruneGmailSeenMessageIds = (nowMs) => {\n    if (nowMs - lastGmailDedupeCleanupAt < kGmailDedupeCleanupIntervalMs) return;\n    for (const [messageKey, seenAt] of gmailSeenMessageIds.entries()) {\n      if (nowMs - seenAt > kGmailDedupeTtlMs) {\n        gmailSeenMessageIds.delete(messageKey);\n      }\n    }\n    lastGmailDedupeCleanupAt = nowMs;\n  };\n\n  return (req, res) => {\n    const resolvedGatewayUrl =\n      typeof getGatewayUrl === \"function\" ? getGatewayUrl() : gatewayUrl;\n    const gateway = new URL(resolvedGatewayUrl);\n    const protocolClient = gateway.protocol === \"https:\" ? https : http;\n    const inboundUrl = new URL(req.url, `http://${req.headers.host || \"localhost\"}`);\n    let tokenFromQuery = \"\";\n    if (inboundUrl.searchParams.has(\"token\")) {\n      const tokenValue = String(inboundUrl.searchParams.get(\"token\") || \"\");\n      if (!req.headers.authorization) {\n        tokenFromQuery = tokenValue;\n      }\n      inboundUrl.searchParams.delete(\"token\");\n    }\n\n    let bodyBuffer = extractBodyBuffer(req);\n    const queryBody = queryParamsToObject(inboundUrl.searchParams);\n    const bodyWithQueryParams = buildBodyFromQueryParams({\n      bodyBuffer,\n      queryParams: queryBody,\n    });\n    if (bodyWithQueryParams) {\n      bodyBuffer = bodyWithQueryParams;\n    }\n    const hookName = resolveHookName(req);\n\n    if (hookName === \"gmail\" && bodyBuffer.length > 0) {\n      const parsedBody = parseJsonSafe(bodyBuffer.toString(\"utf8\"));\n      const payloadData = getGmailPayloadData(parsedBody);\n      const accountKey = String(\n        payloadData?.account || payloadData?.email || payloadData?.inbox || \"unknown\",\n      )\n        .trim()\n        .toLowerCase();\n      const messages = Array.isArray(payloadData?.messages) ? payloadData.messages : [];\n      if (messages.length > 0) {\n        const nowMs = Date.now();\n        pruneGmailSeenMessageIds(nowMs);\n        const unseenMessages = [];\n        for (const message of messages) {\n          const messageId = getGmailMessageId(message);\n          if (!messageId) {\n            unseenMessages.push(message);\n            continue;\n          }\n          const dedupeKey = `${accountKey}:${messageId}`;\n          if (gmailSeenMessageIds.has(dedupeKey)) {\n            continue;\n          }\n          gmailSeenMessageIds.set(dedupeKey, nowMs);\n          unseenMessages.push(message);\n        }\n        if (unseenMessages.length === 0) {\n          return res.status(200).json({ ok: true, deduped: true });\n        }\n        if (unseenMessages.length < messages.length && parsedBody) {\n          bodyBuffer = buildGmailDedupedBodyBuffer({\n            parsedBody,\n            filteredMessages: unseenMessages,\n          });\n        }\n      }\n    }\n\n    const sourceIp = normalizeIp(\n      req.ip || req.headers[\"x-forwarded-for\"] || req.socket?.remoteAddress || \"\",\n    );\n    const sanitizedHeaders = sanitizeHeaders(req.headers);\n    const payload = truncateText(sanitizePayloadForLogging(bodyBuffer), maxPayloadBytes);\n\n    const gatewayHeaders = toGatewayRequestHeaders({\n      reqHeaders: req.headers,\n      contentLength: bodyBuffer.length,\n      authorization: tokenFromQuery ? `Bearer ${tokenFromQuery}` : req.headers.authorization,\n    });\n    if (bodyWithQueryParams && !gatewayHeaders[\"content-type\"]) {\n      gatewayHeaders[\"content-type\"] = \"application/json\";\n    }\n\n    const requestOptions = {\n      protocol: gateway.protocol,\n      hostname: gateway.hostname,\n      port: gateway.port,\n      method: resolveForwardMethod(req.method),\n      path: resolveGatewayPath({\n        pathname: inboundUrl.pathname,\n        search: inboundUrl.search,\n      }),\n      headers: gatewayHeaders,\n    };\n\n    const proxyReq = protocolClient.request(requestOptions, (proxyRes) => {\n      const responseChunks = [];\n      let responseSize = 0;\n      let responseTruncated = false;\n\n      proxyRes.on(\"data\", (chunk) => {\n        if (!Buffer.isBuffer(chunk)) return;\n        if (responseSize >= maxPayloadBytes) {\n          responseTruncated = true;\n          return;\n        }\n        const remaining = maxPayloadBytes - responseSize;\n        if (chunk.length > remaining) {\n          responseChunks.push(chunk.subarray(0, remaining));\n          responseSize += remaining;\n          responseTruncated = true;\n          return;\n        }\n        responseChunks.push(chunk);\n        responseSize += chunk.length;\n      });\n\n      proxyRes.on(\"end\", () => {\n        const responseText = Buffer.concat(responseChunks).toString(\"utf8\");\n        const gatewayBody = responseTruncated ? `${responseText}\\n[TRUNCATED]` : responseText;\n        try {\n          insertRequest({\n            hookName,\n            method: req.method,\n            headers: sanitizedHeaders,\n            payload: payload.text,\n            payloadTruncated: payload.truncated,\n            payloadSize: bodyBuffer.length,\n            sourceIp,\n            gatewayStatus: proxyRes.statusCode || null,\n            gatewayBody,\n          });\n        } catch (err) {\n          console.error(\"[webhook] failed to write request log:\", err.message);\n        }\n      });\n\n      res.statusCode = proxyRes.statusCode || 502;\n      for (const [key, value] of Object.entries(proxyRes.headers || {})) {\n        if (value == null) continue;\n        res.setHeader(key, value);\n      }\n      proxyRes.pipe(res);\n    });\n\n    proxyReq.on(\"error\", (err) => {\n      try {\n        insertRequest({\n          hookName,\n          method: req.method,\n          headers: sanitizedHeaders,\n          payload: payload.text,\n          payloadTruncated: payload.truncated,\n          payloadSize: bodyBuffer.length,\n          sourceIp,\n          gatewayStatus: 502,\n          gatewayBody: err.message || \"Gateway unavailable\",\n        });\n      } catch {}\n      if (!res.headersSent) {\n        res.status(502).json({ error: \"Gateway unavailable\" });\n      }\n    });\n\n    if (bodyBuffer.length > 0) {\n      proxyReq.write(bodyBuffer);\n    }\n    proxyReq.end();\n  };\n};\n\nmodule.exports = { createWebhookMiddleware };\n"
  },
  {
    "path": "lib/server/webhooks.js",
    "content": "const path = require(\"path\");\n\nconst kNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nconst kTransformsDir = \"hooks/transforms\";\nconst kManagedWebhookConfigs = [\n  {\n    name: \"gmail\",\n    preset: \"gmail\",\n    description:\n      \"Managed by AlphaClaw Gmail Watch setup. Required for internal Gmail watch delivery.\",\n  },\n];\n\nconst getConfigPath = ({ OPENCLAW_DIR }) =>\n  path.join(OPENCLAW_DIR, \"openclaw.json\");\n\nconst readConfig = ({ fs, constants }) => {\n  const configPath = getConfigPath(constants);\n  const cfg = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n  return { cfg, configPath };\n};\n\nconst writeConfig = ({ fs, configPath, cfg }) => {\n  fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));\n};\n\nconst getTransformRelativePath = (name) =>\n  `${kTransformsDir}/${name}/${name}-transform.mjs`;\nconst getTransformModulePath = (name) => `${name}/${name}-transform.mjs`;\nconst getTransformAbsolutePath = ({ OPENCLAW_DIR }, name) =>\n  path.join(OPENCLAW_DIR, getTransformRelativePath(name));\nconst getTransformDirectoryRelativePath = (name) => `${kTransformsDir}/${name}`;\nconst getTransformDirectoryAbsolutePath = ({ OPENCLAW_DIR }, name) =>\n  path.join(OPENCLAW_DIR, getTransformDirectoryRelativePath(name));\nconst normalizeTransformModulePath = ({ modulePath, name }) => {\n  const rawModulePath = String(modulePath || \"\")\n    .trim()\n    .replace(/^\\/+/, \"\");\n  const fallbackModulePath = getTransformModulePath(name);\n  const nextModulePath = rawModulePath || fallbackModulePath;\n  if (nextModulePath.startsWith(`${kTransformsDir}/`)) {\n    return nextModulePath.slice(kTransformsDir.length + 1);\n  }\n  return nextModulePath;\n};\n\nconst ensureHooksRoot = (cfg) => {\n  if (!cfg.hooks) cfg.hooks = {};\n  if (!Array.isArray(cfg.hooks.mappings)) {\n    cfg.hooks.mappings = [];\n  }\n  if (typeof cfg.hooks.enabled !== \"boolean\") cfg.hooks.enabled = true;\n  if (typeof cfg.hooks.path !== \"string\" || !cfg.hooks.path.trim())\n    cfg.hooks.path = \"/hooks\";\n  if (typeof cfg.hooks.token !== \"string\" || !cfg.hooks.token.trim()) {\n    cfg.hooks.token = \"${WEBHOOK_TOKEN}\";\n  }\n  if (\n    typeof cfg.hooks.defaultSessionKey !== \"string\" ||\n    !cfg.hooks.defaultSessionKey.trim()\n  ) {\n    cfg.hooks.defaultSessionKey = \"hook:ingress\";\n  }\n  if (typeof cfg.hooks.allowRequestSessionKey !== \"boolean\") {\n    cfg.hooks.allowRequestSessionKey = false;\n  }\n  if (!Array.isArray(cfg.hooks.allowedSessionKeyPrefixes)) {\n    cfg.hooks.allowedSessionKeyPrefixes = [\"hook:\"];\n  }\n  if (!cfg.hooks.allowedSessionKeyPrefixes.includes(\"hook:\")) {\n    cfg.hooks.allowedSessionKeyPrefixes = [\n      ...cfg.hooks.allowedSessionKeyPrefixes,\n      \"hook:\",\n    ];\n  }\n  return cfg.hooks.mappings;\n};\n\nconst getMappingHookName = (mapping) =>\n  String(mapping?.match?.path || \"\").trim();\nconst isWebhookMapping = (mapping) => !!getMappingHookName(mapping);\nconst findMappingIndexByName = (mappings, name) =>\n  mappings.findIndex((mapping) => getMappingHookName(mapping) === name);\n\nconst validateWebhookName = (name) => {\n  const normalized = String(name || \"\")\n    .trim()\n    .toLowerCase();\n  if (!normalized) throw new Error(\"Webhook name is required\");\n  if (!kNamePattern.test(normalized)) {\n    throw new Error(\n      \"Webhook name must be lowercase letters, numbers, and hyphens\",\n    );\n  }\n  return normalized;\n};\n\nconst normalizeDestination = (destination = null) => {\n  if (!destination || typeof destination !== \"object\") return null;\n  const channel = String(destination?.channel || \"\").trim();\n  const to = String(destination?.to || \"\").trim();\n  const agentId = String(destination?.agentId || \"\").trim();\n  if (!channel && !to) return null;\n  if (!channel || !to) {\n    throw new Error(\"destination.channel and destination.to are required\");\n  }\n  return {\n    channel,\n    to,\n    ...(agentId ? { agentId } : {}),\n  };\n};\n\nconst resolveTransformPathFromMapping = (name, mapping) => {\n  const modulePath = normalizeTransformModulePath({\n    modulePath: mapping?.transform?.module,\n    name,\n  });\n  return `${kTransformsDir}/${modulePath}`;\n};\n\nconst normalizeMappingTransformModules = (mappings) => {\n  let changed = false;\n  for (const mapping of mappings || []) {\n    const name = getMappingHookName(mapping);\n    if (!name) continue;\n    const normalizedModulePath = normalizeTransformModulePath({\n      modulePath: mapping?.transform?.module,\n      name,\n    });\n    if (\n      !mapping.transform ||\n      mapping.transform.module !== normalizedModulePath\n    ) {\n      mapping.transform = {\n        ...(mapping.transform || {}),\n        module: normalizedModulePath,\n      };\n      changed = true;\n    }\n  }\n  return changed;\n};\n\nconst buildDefaultTransformSource = (name) => {\n  return [\n    \"export default async function transform(payload, context) {\",\n    \"  const data = payload.payload || payload;\",\n    \"  return {\",\n    \"    message: data.message,\",\n    `    name: data.name || \"${name}\",`,\n    '    wakeMode: data.wakeMode || \"now\",',\n    \"  };\",\n    \"}\",\n    \"\",\n  ].join(\"\\n\");\n};\n\nconst ensureWebhookTransform = ({\n  fs,\n  constants,\n  name,\n  source = \"\",\n  destination = null,\n  forceWrite = false,\n}) => {\n  const webhookName = validateWebhookName(name);\n  const transformAbsolutePath = getTransformAbsolutePath(\n    constants,\n    webhookName,\n  );\n  fs.mkdirSync(path.dirname(transformAbsolutePath), { recursive: true });\n  if (fs.existsSync(transformAbsolutePath) && !forceWrite) {\n    return { changed: false, path: transformAbsolutePath };\n  }\n  fs.writeFileSync(\n    transformAbsolutePath,\n    String(source || \"\").trim()\n      ? `${String(source).replace(/\\s+$/, \"\")}\\n`\n      : buildDefaultTransformSource(webhookName),\n  );\n  return { changed: true, path: transformAbsolutePath };\n};\n\nconst ensureWebhookMapping = ({ cfg, name, mapping = {} }) => {\n  const webhookName = validateWebhookName(name);\n  const mappings = ensureHooksRoot(cfg);\n  const normalizedModulesChanged = normalizeMappingTransformModules(mappings);\n  const index = findMappingIndexByName(mappings, webhookName);\n  const defaults = {\n    match: { path: webhookName },\n    action: \"agent\",\n    name: webhookName,\n    wakeMode: \"now\",\n    transform: { module: getTransformModulePath(webhookName) },\n  };\n  if (index === -1) {\n    mappings.push({\n      ...defaults,\n      ...mapping,\n      match: { ...defaults.match, ...(mapping.match || {}) },\n      transform: { ...defaults.transform, ...(mapping.transform || {}) },\n    });\n    return { changed: true, created: true, normalizedModulesChanged };\n  }\n  const current = mappings[index] || {};\n  const next = {\n    ...current,\n    ...mapping,\n    match: {\n      ...(current.match || {}),\n      ...(mapping.match || {}),\n      path: webhookName,\n    },\n    action: mapping.action || current.action || defaults.action,\n    wakeMode: mapping.wakeMode || current.wakeMode || defaults.wakeMode,\n    transform: {\n      ...(current.transform || {}),\n      ...(mapping.transform || {}),\n      module:\n        String(mapping?.transform?.module || \"\").trim() ||\n        String(current?.transform?.module || \"\").trim() ||\n        defaults.transform.module,\n    },\n  };\n  if (JSON.stringify(current) !== JSON.stringify(next)) {\n    mappings[index] = next;\n    return { changed: true, created: false, normalizedModulesChanged };\n  }\n  return {\n    changed: normalizedModulesChanged,\n    created: false,\n    normalizedModulesChanged,\n  };\n};\n\nconst resolveDefaultAgentId = (cfg) => {\n  const agents = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];\n  const explicitDefault = agents.find((entry) => !!entry?.default);\n  const defaultId = String(explicitDefault?.id || \"\").trim();\n  if (defaultId) return defaultId;\n  const firstId = String(agents[0]?.id || \"\").trim();\n  return firstId || \"main\";\n};\n\nconst resolveWebhookAgentId = ({ cfg, requestedAgentId = \"\" }) => {\n  const normalizedRequested = String(requestedAgentId || \"\").trim();\n  const agents = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];\n  if (\n    normalizedRequested &&\n    agents.some((entry) => String(entry?.id || \"\").trim() === normalizedRequested)\n  ) {\n    return normalizedRequested;\n  }\n  return resolveDefaultAgentId(cfg);\n};\n\nconst listManagedWebhooksFromConfig = ({ cfg }) => {\n  const presets = Array.isArray(cfg?.hooks?.presets) ? cfg.hooks.presets : [];\n  return kManagedWebhookConfigs\n    .filter((managed) => presets.includes(managed.preset))\n    .map((managed) => ({\n      name: managed.name,\n      enabled: true,\n      createdAt: null,\n      path: `/hooks/${managed.name}`,\n      transformPath: null,\n      transformExists: true,\n      managed: true,\n      managedReason: managed.description,\n    }));\n};\n\nconst isManagedWebhook = ({ cfg, name }) => {\n  const normalized = String(name || \"\")\n    .trim()\n    .toLowerCase();\n  if (!normalized) return false;\n  return listManagedWebhooksFromConfig({ cfg }).some(\n    (webhook) => webhook.name === normalized,\n  );\n};\n\nconst listWebhooks = ({ fs, constants }) => {\n  const { cfg } = readConfig({ fs, constants });\n  const mappings = ensureHooksRoot(cfg);\n  const managedWebhooks = listManagedWebhooksFromConfig({ cfg });\n  const managedByName = new Map(\n    managedWebhooks.map((item) => [item.name, item]),\n  );\n  const mappingWebhooks = mappings.filter(isWebhookMapping).map((mapping) => {\n    const name = getMappingHookName(mapping);\n    const managed = managedByName.get(name);\n    const transformPath = resolveTransformPathFromMapping(name, mapping);\n    const transformAbsolutePath = path.join(\n      constants.OPENCLAW_DIR,\n      transformPath,\n    );\n    let createdAt = null;\n    try {\n      const stat = fs.statSync(transformAbsolutePath);\n      createdAt =\n        stat.birthtime?.toISOString?.() || stat.ctime?.toISOString?.() || null;\n    } catch {}\n    return {\n      name,\n      enabled: true,\n      createdAt,\n      path: `/hooks/${name}`,\n      transformPath,\n      transformExists: fs.existsSync(transformAbsolutePath),\n      deliver: Boolean(mapping?.deliver),\n      channel: String(mapping?.channel || \"\").trim(),\n      to: String(mapping?.to || \"\").trim(),\n      agentId: String(mapping?.agentId || \"\").trim(),\n      managed: Boolean(managed),\n      managedReason: managed?.managedReason || \"\",\n    };\n  });\n  const mappingNames = new Set(mappingWebhooks.map((item) => item.name));\n  const syntheticManagedWebhooks = managedWebhooks.filter(\n    (item) => !mappingNames.has(item.name),\n  );\n  return [...mappingWebhooks, ...syntheticManagedWebhooks].sort((a, b) =>\n    a.name.localeCompare(b.name),\n  );\n};\n\nconst getWebhookDetail = ({ fs, constants, name }) => {\n  const webhookName = validateWebhookName(name);\n  const hooks = listWebhooks({ fs, constants });\n  const detail = hooks.find((item) => item.name === webhookName);\n  if (!detail) return null;\n  if (detail.managed || !detail.transformPath) {\n    return {\n      ...detail,\n      transformExists: true,\n    };\n  }\n  const transformAbsolutePath = path.join(\n    constants.OPENCLAW_DIR,\n    detail.transformPath,\n  );\n  return {\n    ...detail,\n    transformExists: fs.existsSync(transformAbsolutePath),\n  };\n};\n\nconst createWebhook = ({\n  fs,\n  constants,\n  name,\n  upsert = false,\n  allowManagedName = false,\n  mapping = {},\n  transformSource = \"\",\n  destination = null,\n  overwriteTransform = false,\n}) => {\n  const webhookName = validateWebhookName(name);\n  const normalizedDestination = normalizeDestination(destination);\n  const { cfg, configPath } = readConfig({ fs, constants });\n  if (!allowManagedName && isManagedWebhook({ cfg, name: webhookName })) {\n    throw new Error(\n      `Webhook \"${webhookName}\" is managed and cannot be created manually`,\n    );\n  }\n  const existingMappings = ensureHooksRoot(cfg);\n  const exists = findMappingIndexByName(existingMappings, webhookName) !== -1;\n  if (exists && !upsert) {\n    throw new Error(`Webhook \"${webhookName}\" already exists`);\n  }\n  const agentId = resolveWebhookAgentId({\n    cfg,\n    requestedAgentId:\n      String(mapping?.agentId || \"\").trim() ||\n      String(normalizedDestination?.agentId || \"\").trim(),\n  });\n  const resolvedMapping = {\n    ...mapping,\n    deliver: true,\n    channel:\n      String(mapping?.channel || \"\").trim() ||\n      String(normalizedDestination?.channel || \"\").trim() ||\n      \"last\",\n    ...(String(mapping?.to || \"\").trim() || String(normalizedDestination?.to || \"\").trim()\n      ? {\n          to:\n            String(mapping?.to || \"\").trim() ||\n            String(normalizedDestination?.to || \"\").trim(),\n        }\n      : {}),\n    agentId,\n  };\n  const ensuredMapping = ensureWebhookMapping({\n    cfg,\n    name: webhookName,\n    mapping: resolvedMapping,\n  });\n  const ensuredTransform = ensureWebhookTransform({\n    fs,\n    constants,\n    name: webhookName,\n    source: transformSource,\n    destination: normalizedDestination,\n    forceWrite: overwriteTransform,\n  });\n  if (ensuredMapping.changed || ensuredTransform.changed || !exists) {\n    writeConfig({ fs, configPath, cfg });\n  }\n  return getWebhookDetail({ fs, constants, name: webhookName });\n};\n\nconst updateWebhookDestination = ({ fs, constants, name, destination = null }) => {\n  const webhookName = validateWebhookName(name);\n  const normalizedDestination = normalizeDestination(destination);\n  const { cfg, configPath } = readConfig({ fs, constants });\n  if (isManagedWebhook({ cfg, name: webhookName })) {\n    throw new Error(\n      `Webhook \"${webhookName}\" is managed and cannot be updated manually`,\n    );\n  }\n  const mappings = ensureHooksRoot(cfg);\n  const normalizedModulesChanged = normalizeMappingTransformModules(mappings);\n  const index = findMappingIndexByName(mappings, webhookName);\n  if (index === -1) {\n    throw new Error(\"Webhook not found\");\n  }\n  const current = mappings[index] || {};\n  const agentId = resolveWebhookAgentId({\n    cfg,\n    requestedAgentId:\n      String(normalizedDestination?.agentId || \"\").trim() ||\n      String(current?.agentId || \"\").trim(),\n  });\n  const next = {\n    ...current,\n    deliver: true,\n    channel:\n      String(normalizedDestination?.channel || \"\").trim() ||\n      \"last\",\n    agentId,\n  };\n  if (String(normalizedDestination?.to || \"\").trim()) {\n    next.to = String(normalizedDestination.to).trim();\n  } else {\n    delete next.to;\n  }\n  const changed = JSON.stringify(current) !== JSON.stringify(next);\n  if (changed) {\n    mappings[index] = next;\n  }\n  if (changed || normalizedModulesChanged) {\n    writeConfig({ fs, configPath, cfg });\n  }\n  return getWebhookDetail({ fs, constants, name: webhookName });\n};\n\nconst deleteWebhook = ({ fs, constants, name, deleteTransformDir = false }) => {\n  const webhookName = validateWebhookName(name);\n  const { cfg, configPath } = readConfig({ fs, constants });\n  if (isManagedWebhook({ cfg, name: webhookName })) {\n    return {\n      removed: false,\n      managed: true,\n      deletedTransformDir: false,\n    };\n  }\n  const mappings = ensureHooksRoot(cfg);\n  const normalizedModules = normalizeMappingTransformModules(mappings);\n  const index = findMappingIndexByName(mappings, webhookName);\n  if (index === -1) {\n    if (normalizedModules) writeConfig({ fs, configPath, cfg });\n    return false;\n  }\n  mappings.splice(index, 1);\n  writeConfig({ fs, configPath, cfg });\n  let deletedTransformDir = false;\n  if (deleteTransformDir) {\n    const transformDirAbsolutePath = getTransformDirectoryAbsolutePath(\n      constants,\n      webhookName,\n    );\n    if (fs.existsSync(transformDirAbsolutePath)) {\n      fs.rmSync(transformDirAbsolutePath, { recursive: true, force: true });\n      deletedTransformDir = !fs.existsSync(transformDirAbsolutePath);\n      if (!deletedTransformDir) {\n        throw new Error(\n          `Failed to delete transform directory: ${getTransformDirectoryRelativePath(webhookName)}`,\n        );\n      }\n    }\n  }\n  return {\n    removed: true,\n    deletedTransformDir,\n  };\n};\n\nmodule.exports = {\n  listWebhooks,\n  getWebhookDetail,\n  createWebhook,\n  updateWebhookDestination,\n  deleteWebhook,\n  validateWebhookName,\n  getTransformRelativePath,\n};\n"
  },
  {
    "path": "lib/server.js",
    "content": "const express = require(\"express\");\nconst http = require(\"http\");\nconst httpProxy = require(\"http-proxy\");\nconst path = require(\"path\");\nconst fs = require(\"fs\");\n\nconst constants = require(\"./server/constants\");\nconst { initLogWriter, readLogTail } = require(\"./server/log-writer\");\ninitLogWriter({\n  rootDir: constants.kRootDir,\n  maxBytes: constants.kLogMaxBytes,\n});\nconst {\n  parseJsonFromNoisyOutput,\n  normalizeOnboardingModels,\n  resolveModelProvider,\n  resolveGithubRepoUrl,\n  createPkcePair,\n  parseCodexAuthorizationInput,\n  getCodexAccountId,\n  getBaseUrl,\n  getApiEnableUrl,\n  readGoogleCredentials,\n  getClientKey,\n} = require(\"./server/helpers\");\nconst {\n  initWebhooksDb,\n  insertRequest,\n  getRequests,\n  getRequestById,\n  getHookSummaries,\n  deleteRequestsByHook,\n  createOauthCallback,\n  getOauthCallbackByHook,\n  getOauthCallbackById,\n  rotateOauthCallback,\n  deleteOauthCallback,\n  markOauthCallbackUsed,\n} = require(\"./server/db/webhooks\");\nconst {\n  initWatchdogDb,\n  insertWatchdogEvent,\n  getRecentEvents,\n} = require(\"./server/db/watchdog\");\nconst {\n  initUsageDb,\n  getDailySummary,\n  getSessionsList,\n  getSessionDetail,\n  getSessionTimeSeries,\n  getSessionUsageByKeyPattern,\n} = require(\"./server/db/usage\");\nconst topicRegistry = require(\"./server/topic-registry\");\nconst {\n  initDoctorDb,\n  listDoctorRuns,\n  listDoctorCards,\n  getInitialWorkspaceBaseline,\n  setInitialWorkspaceBaseline,\n  createDoctorRun,\n  completeDoctorRun,\n  insertDoctorCards,\n  getDoctorRun,\n  getDoctorCardsByRunId,\n  getDoctorCard,\n  updateDoctorCardStatus,\n} = require(\"./server/db/doctor\");\nconst { createWebhookMiddleware } = require(\"./server/webhook-middleware\");\nconst {\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  startEnvWatcher,\n} = require(\"./server/env\");\nconst {\n  gatewayEnv,\n  getGatewayPort,\n  getGatewayUrl,\n  isOnboarded,\n  isGatewayRunning,\n  startGateway,\n  restartGateway: restartGatewayWithReload,\n  restartGatewayLight: restartGatewayLightWithReload,\n  attachGatewaySignalHandlers,\n  ensureGatewayProxyConfig,\n  syncChannelConfig,\n  getChannelStatus,\n  launchGatewayProcess,\n  setGatewayExitHandler,\n  setGatewayLaunchHandler,\n} = require(\"./server/gateway\");\nconst { createCommands } = require(\"./server/commands\");\nconst { createAuthProfiles } = require(\"./server/auth-profiles\");\nconst { createLoginThrottle } = require(\"./server/login-throttle\");\nconst { createOpenclawVersionService } = require(\"./server/openclaw-version\");\nconst { createAlphaclawVersionService } = require(\"./server/alphaclaw-version\");\nconst {\n  createRestartRequiredState,\n} = require(\"./server/restart-required-state\");\nconst {\n  ensureOpenclawRuntimeArtifacts,\n  resolveSetupUiUrl,\n  syncBootstrapPromptFiles,\n} = require(\"./server/onboarding/workspace\");\nconst {\n  cleanupStaleImportTempDirs,\n} = require(\"./server/onboarding/import/import-temp\");\nconst {\n  migrateManagedInternalFiles,\n} = require(\"./server/internal-files-migration\");\nconst { installGogCliSkill } = require(\"./server/gog-skill\");\nconst { createTelegramApi } = require(\"./server/telegram-api\");\nconst { createDiscordApi } = require(\"./server/discord-api\");\nconst { createSlackApi } = require(\"./server/slack-api\");\nconst { createWatchdogNotifier } = require(\"./server/watchdog-notify\");\nconst { createWatchdog } = require(\"./server/watchdog\");\nconst { createWatchdogTerminalService } = require(\"./server/watchdog-terminal\");\nconst {\n  createWatchdogTerminalWsBridge,\n} = require(\"./server/watchdog-terminal-ws\");\nconst { createDoctorService } = require(\"./server/doctor/service\");\nconst { createAgentsService } = require(\"./server/agents/service\");\nconst { createOperationEventsService } = require(\"./server/operation-events\");\nconst { createChatWsService } = require(\"./server/chat-ws\");\nconst { runOnboardedBootSequence } = require(\"./server/startup\");\nconst { createCronService } = require(\"./server/cron-service\");\nconst {\n  initializeServerRuntime,\n  initializeServerDatabases,\n} = require(\"./server/init/runtime-init\");\nconst {\n  registerServerRoutes,\n} = require(\"./server/init/register-server-routes\");\nconst {\n  startServerLifecycle,\n  registerServerShutdown,\n} = require(\"./server/init/server-lifecycle\");\nconst {\n  ensureUsageTrackerPluginConfig,\n} = require(\"./server/usage-tracker-config\");\nconst {\n  ensureManagedExecDefaults,\n} = require(\"./server/exec-defaults-config\");\nconst {\n  ensureOpenclawStartupEnv,\n} = require(\"./server/openclaw-runtime-env\");\n\nconst { PORT, kTrustProxyHops, SETUP_API_PREFIXES } = constants;\n\ninitializeServerRuntime({\n  fs,\n  constants,\n  ensureOpenclawStartupEnv,\n  startEnvWatcher,\n  attachGatewaySignalHandlers,\n  cleanupStaleImportTempDirs,\n  migrateManagedInternalFiles,\n});\n\nconst app = express();\napp.set(\"trust proxy\", kTrustProxyHops);\napp.use([\"/webhook\", \"/hooks\"], express.raw({ type: \"*/*\", limit: \"5mb\" }));\napp.use(\"/gmail-pubsub\", express.raw({ type: \"*/*\", limit: \"5mb\" }));\napp.use(express.json({ limit: \"5mb\" }));\n\nconst proxy = httpProxy.createProxyServer({\n  target: getGatewayUrl(),\n  ws: true,\n  changeOrigin: true,\n});\nproxy.on(\"error\", (err, req, res) => {\n  if (res && res.writeHead) {\n    res.writeHead(502, { \"Content-Type\": \"application/json\" });\n    res.end(JSON.stringify({ error: \"Gateway unavailable\" }));\n  }\n});\n\nconst authProfiles = createAuthProfiles();\nconst { shellCmd, clawCmd, gogCmd } = createCommands({ gatewayEnv });\nconst agentsService = createAgentsService({\n  fs,\n  OPENCLAW_DIR: constants.OPENCLAW_DIR,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  restartGateway: () => restartGatewayWithReload(reloadEnv),\n  clawCmd,\n});\nconst loginThrottle = { ...createLoginThrottle(), getClientKey };\nconst resolveSetupUrl = () =>\n  resolveSetupUiUrl(\n    process.env.ALPHACLAW_SETUP_URL ||\n      process.env.ALPHACLAW_BASE_URL ||\n      process.env.RENDER_EXTERNAL_URL ||\n      process.env.URL,\n  );\nconst restartGateway = () => restartGatewayWithReload(reloadEnv);\nconst openclawVersionService = createOpenclawVersionService({\n  gatewayEnv,\n  restartGateway,\n  isOnboarded,\n});\nconst alphaclawVersionService = createAlphaclawVersionService({\n  readOpenclawVersion: () => openclawVersionService.readOpenclawVersion(),\n});\nconst restartRequiredState = createRestartRequiredState({ isGatewayRunning });\nconst operationEvents = createOperationEventsService();\nconst chatWsService = createChatWsService({\n  fs,\n  openclawDir: constants.OPENCLAW_DIR,\n  getGatewayPort,\n});\nconst cronService = createCronService({\n  clawCmd,\n  OPENCLAW_DIR: constants.OPENCLAW_DIR,\n  getSessionUsageByKeyPattern,\n});\n\napp.use(express.static(path.join(__dirname, \"public\")));\ninitializeServerDatabases({\n  constants,\n  initWebhooksDb,\n  initWatchdogDb,\n  initUsageDb,\n  initDoctorDb,\n});\nconst webhookMiddleware = createWebhookMiddleware({\n  getGatewayUrl,\n  insertRequest,\n  maxPayloadBytes: constants.kMaxPayloadBytes,\n});\nconst telegramApi = createTelegramApi(() => process.env.TELEGRAM_BOT_TOKEN);\nconst discordApi = createDiscordApi(() => process.env.DISCORD_BOT_TOKEN);\nconst slackApi = createSlackApi(() => process.env.SLACK_BOT_TOKEN);\nconst watchdogNotifier = createWatchdogNotifier({\n  telegramApi,\n  discordApi,\n  slackApi,\n  clawCmd,\n  readEnvFile,\n});\nconst watchdog = createWatchdog({\n  clawCmd,\n  launchGatewayProcess,\n  insertWatchdogEvent,\n  notifier: watchdogNotifier,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  resolveSetupUrl,\n  resolveGatewayHealthUrl: () => `${getGatewayUrl()}/health`,\n});\nconst watchdogTerminal = createWatchdogTerminalService({\n  cwd: constants.OPENCLAW_DIR,\n});\nconst doctorService = createDoctorService({\n  clawCmd,\n  listDoctorRuns,\n  listDoctorCards,\n  getInitialWorkspaceBaseline,\n  setInitialWorkspaceBaseline,\n  createDoctorRun,\n  completeDoctorRun,\n  insertDoctorCards,\n  getDoctorRun,\n  getDoctorCardsByRunId,\n  getDoctorCard,\n  updateDoctorCardStatus,\n  workspaceRoot: constants.WORKSPACE_DIR,\n  managedRoot: constants.OPENCLAW_DIR,\n  protectedPaths: Array.from(constants.kProtectedBrowsePaths || []),\n  lockedPaths: Array.from(constants.kLockedBrowsePaths || []),\n});\nsetGatewayExitHandler((payload) => watchdog.onGatewayExit(payload));\nsetGatewayLaunchHandler((payload) => watchdog.onGatewayLaunch(payload));\nconst doSyncPromptFiles = () => {\n  const setupUiUrl = resolveSetupUrl();\n  ensureOpenclawRuntimeArtifacts({\n    fs,\n    openclawDir: constants.OPENCLAW_DIR,\n  });\n  syncBootstrapPromptFiles({\n    fs,\n    workspaceDir: constants.WORKSPACE_DIR,\n    baseUrl: setupUiUrl,\n  });\n  installGogCliSkill({ fs, openclawDir: constants.OPENCLAW_DIR });\n};\nconst { isAuthorizedRequest, gmailWatchService } = registerServerRoutes({\n  app,\n  fs,\n  constants,\n  loginThrottle,\n  shellCmd,\n  clawCmd,\n  gogCmd,\n  gatewayEnv,\n  parseJsonFromNoisyOutput,\n  normalizeOnboardingModels,\n  authProfiles,\n  readEnvFile,\n  writeEnvFile,\n  reloadEnv,\n  isOnboarded,\n  isGatewayRunning,\n  resolveGithubRepoUrl,\n  resolveModelProvider,\n  ensureGatewayProxyConfig,\n  getBaseUrl,\n  startGateway,\n  syncChannelConfig,\n  getChannelStatus,\n  openclawVersionService,\n  alphaclawVersionService,\n  restartGateway,\n  restartRequiredState,\n  topicRegistry,\n  createPkcePair,\n  parseCodexAuthorizationInput,\n  getCodexAccountId,\n  readGoogleCredentials,\n  getApiEnableUrl,\n  telegramApi,\n  doSyncPromptFiles,\n  getRequests,\n  getRequestById,\n  getHookSummaries,\n  deleteRequestsByHook,\n  createOauthCallback,\n  getOauthCallbackByHook,\n  getOauthCallbackById,\n  rotateOauthCallback,\n  deleteOauthCallback,\n  markOauthCallbackUsed,\n  watchdog,\n  watchdogNotifier,\n  getRecentEvents,\n  readLogTail,\n  watchdogTerminal,\n  getDailySummary,\n  getSessionsList,\n  getSessionDetail,\n  getSessionTimeSeries,\n  cronService,\n  doctorService,\n  agentsService,\n  operationEvents,\n  proxy,\n  getGatewayUrl,\n  SETUP_API_PREFIXES,\n  webhookMiddleware,\n});\napp.get(\"/api/chat/history\", async (req, res) => {\n  const upgradeReq = {\n    headers: req.headers,\n    path: req.path,\n    query: req.query || {},\n  };\n  if (!isAuthorizedRequest(upgradeReq)) {\n    return res.status(401).json({ ok: false, error: \"Unauthorized\" });\n  }\n  const sessionKey = String(req.query?.sessionKey || \"\").trim();\n  if (!sessionKey) {\n    return res.status(400).json({ ok: false, error: \"sessionKey is required\" });\n  }\n  try {\n    const { messages, rawHistory } = await chatWsService.fetchHistory(sessionKey);\n    return res.json({\n      ok: true,\n      sessionKey,\n      messages,\n      rawHistory,\n    });\n  } catch (err) {\n    return res.status(502).json({\n      ok: false,\n      error: err?.message || \"Could not load chat history\",\n    });\n  }\n});\n\nconst server = http.createServer(app);\ncreateWatchdogTerminalWsBridge({\n  server,\n  proxy,\n  getGatewayUrl,\n  isAuthorizedRequest,\n  watchdogTerminal,\n  chatWsService,\n});\n\nstartServerLifecycle({\n  server,\n  PORT,\n  isOnboarded,\n  runOnboardedBootSequence,\n  ensureManagedExecDefaults: () =>\n    ensureManagedExecDefaults({\n      fsModule: fs,\n      openclawDir: constants.OPENCLAW_DIR,\n    }),\n  ensureUsageTrackerPluginConfig: () =>\n    ensureUsageTrackerPluginConfig({\n      fsModule: fs,\n      openclawDir: constants.OPENCLAW_DIR,\n    }),\n  doSyncPromptFiles,\n  reloadEnv,\n  syncChannelConfig,\n  readEnvFile,\n  ensureGatewayProxyConfig,\n  resolveSetupUrl,\n  startGateway,\n  watchdog,\n  gmailWatchService,\n});\nregisterServerShutdown({\n  gmailWatchService,\n  watchdogTerminal,\n});\n"
  },
  {
    "path": "lib/setup/core-prompts/AGENTS.md",
    "content": "### ⚠️ No YOLO System Changes!\n\n**NEVER** make risky system changes (OpenClaw config, network settings, package installations/updates, source code modifications, etc.) without the user's explicit approval FIRST.\n\nAlways explain:\n\n1. **What** you want to change\n2. **Why** you want to change it\n3. **What could go wrong**\n\nThen WAIT for the user's approval.\n\n### Plan Before You Build\n\nBefore diving into implementation, share your plan when the work is **significant**. Significance isn't about line count — a single high-impact change can be just as significant as a multi-step refactor. Ask yourself:\n\n- Could this break existing behavior or introduce subtle bugs?\n- Does it touch critical paths, shared state, or external integrations?\n- Are there multiple valid approaches worth weighing?\n- Would reverting this be painful?\n\nIf any of these apply, outline your approach first — what you intend to do, in what order, and any trade-offs you see — then **wait for the user's sign-off** before proceeding. For straightforward, low-risk tasks, just get it done.\n\n### Save and Show Your Work (IMPORTANT)\n\nYour `.openclaw` directory is version-controlled and this is how work survives container restarts.\n\n### Persistent Storage Rules\n\nThis deployment runs in an ephemeral container. `/tmp`, other temp directories, and files outside `/data` can disappear on restart or redeploy.\n\nAnything that must survive redeploys must live under `/data/.openclaw`.\n\nFor plugins and other durable artifacts:\n\n- Prefer normal `openclaw plugins install <spec>` flows for persistent installs.\n- If you must stage or unpack a local plugin first, stage it under `/data/.openclaw/...`, not `/tmp/...`.\n- Never persist `plugins.load.paths` entries that point at temp directories.\n\nAnytime you add, edit, or remove workspace files, openclaw.json, cron.json, skills, or external resources (third-party pages, databases, integrations), **commit and push your changes to git**. Never force push; always pull first if there might be remote changes.\n\nWhenever you do this, end your message with a **Changes committed** summary. Use workspace-relative paths for local files.\n\n```\nChanges committed ([abc1234](commit url)): <-- linked abbreviated hash, no backticks\n• path/or/resource (new|edit|delete) — brief description\n```\n"
  },
  {
    "path": "lib/setup/core-prompts/TOOLS.md",
    "content": "## AlphaClaw Harness\n\nAlphaClaw is the setup and management harness that runs alongside OpenClaw. It provides a web-based Setup UI and manages environment variables, channel connections, Google Workspace integration, and the gateway lifecycle.\n\nAlphaClaw UI: `{{SETUP_UI_URL}}`\n\nDo not deflect actionable requests to the Setup UI. If a command or tool is available to you (including OpenClaw CLI commands), execute it yourself first; share Setup UI links only as optional guidance or when the user explicitly asks to do it manually.\n\n### Tabs\n\n| Tab       | URL                          | What it helps with                                                                                                                                                                         |\n| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| 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 |\n| Watchdog  | `{{SETUP_UI_URL}}#watchdog`  | Gateway watchdog lifecycle, crash-loop visibility, restart diagnostics, and auto-repair feature                                                                                            |\n| Providers | `{{SETUP_UI_URL}}#providers` | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram), feature capabilities, Codex OAuth                                                                    |\n| Envars    | `{{SETUP_UI_URL}}#envars`    | View/edit/add environment variables (saved to `/data/.env`), gateway restart to apply changes                                                                                              |\n| Webhooks  | `{{SETUP_UI_URL}}#webhooks`  | Webhook endpoint visibility, create flow, request history, and gateway delivery debugging                                                                                                  |\n| Browse    | `{{SETUP_UI_URL}}#browse`    | File browser and editor rooted at `.openclaw`, markdown preview/edit flow, and git-aware save workflow                                                                                     |\n\n### Environment variables\n\nChanges to env vars are made through the **Envars** tab (`{{SETUP_UI_URL}}#envars`). After saving, a gateway restart may be required to pick up the changes — the UI prompts for this automatically. Do not edit `/data/.env` directly; use the Setup UI so changes are validated and the gateway restart is handled.\n\n### Persistent storage\n\nThis deployment runs in an ephemeral container. `/tmp` and other temp locations do not survive redeploys.\n\nAnything persistent must live under `/data/.openclaw`.\n\nFor plugins and local tooling:\n\n- Prefer normal `openclaw plugins install <spec>` flows for durable installs.\n- If you need to stage a local plugin or helper files first, put them under `/data/.openclaw/...`, not `/tmp/...`.\n- Do not leave durable `plugins.load.paths` entries pointing at temp directories.\n\n### Google Workspace\n\nGoogle Workspace is connected via the **General** tab (`{{SETUP_UI_URL}}#general`). The user provides OAuth client credentials from Google Cloud Console, then authorizes access to the services they need (Gmail, Calendar, Drive, Sheets, Docs, Tasks, Contacts, Meet). Connected accounts and `gog` CLI usage are covered by the gog-cli skill.\n\n## Telegram Formatting\n\n- **Links:** Use markdown syntax `[text](URL)` — HTML `<a href>` does NOT render\n\n## Webhooks\n\nYou can create webhooks yourself or the user can create them through the AlphaClaw UI.\n\nWebhook transform files must follow this convention:\n\n- Path: hooks/transforms/{hook-name}/{hook-name}-transform.mjs\n- Signature: export default async function transform(payload, context)\n- Webhook data is at payload.payload (nested)\n- Never create transform files outside of hooks/transforms/\n- When modifying a transform, read the existing file first\n"
  },
  {
    "path": "lib/setup/env.template",
    "content": "# OpenClaw Environment Variables\n# Edit via the Setup UI or directly in this file\n\n# --- AI Provider (at least one required) ---\nANTHROPIC_API_KEY=\nANTHROPIC_TOKEN=\nOPENAI_API_KEY=\nGEMINI_API_KEY=\nELEVENLABS_API_KEY=\n\n# --- GitHub (required) ---\nGITHUB_TOKEN=\nGITHUB_WORKSPACE_REPO=\n\n# --- Setup UI auth (required) ---\nSETUP_PASSWORD=\n\n# --- Channels (at least one required) ---\nTELEGRAM_BOT_TOKEN=\nDISCORD_BOT_TOKEN=\n\n# --- Tools (optional) ---\nBRAVE_API_KEY=\n"
  },
  {
    "path": "lib/setup/gitignore",
    "content": "# Ignore everything by default.\n*\n\n# Whitelist specific files/dirs.\n!workspace/\n!workspace/**\nworkspace/.openclaw/\nworkspace/.openclaw/**\n!gogcli/\ngogcli/*\n!gogcli/state.json\ndb/\ndb/**\n!skills/\n!skills/**\n!hooks/\n!hooks/transforms/\n!hooks/transforms/**\n!cron/\n!cron/jobs.json\n# OpenClaw 2026.4.20+: runtime execution state (job definitions stay in cron/jobs.json).\ncron/jobs-state.json\n!openclaw.json\n!.gitignore\n"
  },
  {
    "path": "lib/setup/hourly-git-sync.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nREPO=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\ncd \"$REPO\"\n\n# Load persisted env vars when running under cron's minimal environment.\nif [[ -f \"$REPO/.env\" ]]; then\n  set -a\n  # shellcheck disable=SC1091\n  source \"$REPO/.env\"\n  set +a\nfi\n\n# Drop cron scheduler runtime-only churn when it is metadata/timestamp-only.\nmaybe_restore_if_runtime_only() {\n  local file=\"$1\"\n  [[ -f \"$file\" ]] || return 0\n\n  # Only inspect when the file differs from HEAD.\n  if git diff --quiet -- \"$file\"; then\n    return 0\n  fi\n\n  if node - \"$file\" <<'NODE'\nconst fs = require('fs');\nconst cp = require('child_process');\nconst file = process.argv[2];\n\nconst sanitize = (value) => {\n  if (Array.isArray(value)) return value.map(sanitize);\n  if (value && typeof value === 'object') {\n    const out = {};\n    for (const [k, v] of Object.entries(value)) {\n      if (/^(lastRun|nextRun|updatedAt|createdAt|lastStarted|lastFinished|lastSuccess|lastFailure|lastError|lastExitCode|lastDurationMs|runCount|runs|timestamp|time|ts|ms)$/i.test(k)) {\n        continue;\n      }\n      out[k] = sanitize(v);\n    }\n    return out;\n  }\n  return value;\n};\n\nconst parseJson = (str) => {\n  try {\n    return JSON.parse(str);\n  } catch {\n    return null;\n  }\n};\n\nlet headRaw = '';\ntry {\n  headRaw = cp.execSync(`git show HEAD:${file}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });\n} catch {\n  process.exit(2); // no HEAD version to compare\n}\n\nlet workRaw = '';\ntry {\n  workRaw = fs.readFileSync(file, 'utf8');\n} catch {\n  process.exit(3);\n}\n\nconst headJson = parseJson(headRaw);\nconst workJson = parseJson(workRaw);\nif (!headJson || !workJson) process.exit(4);\n\nconst a = JSON.stringify(sanitize(headJson));\nconst b = JSON.stringify(sanitize(workJson));\nprocess.exit(a === b ? 0 : 1);\nNODE\n  then\n    # Runtime metadata only; restore cleanly so it doesn't create noise commits.\n    git restore --worktree --staged -- \"$file\" || git checkout -- \"$file\"\n  fi\n}\n\nmaybe_restore_if_runtime_only \"cron/jobs.json\"\nmaybe_restore_if_runtime_only \"crons.json\"\n\nresolve_alphaclaw_cmd() {\n  if command -v alphaclaw >/dev/null 2>&1; then\n    command -v alphaclaw\n    return 0\n  fi\n\n  local candidate_paths=(\n    \"/app/node_modules/.bin/alphaclaw\"\n    \"$REPO/node_modules/.bin/alphaclaw\"\n    \"$REPO/../node_modules/.bin/alphaclaw\"\n  )\n  local candidate\n  for candidate in \"${candidate_paths[@]}\"; do\n    if [[ -x \"$candidate\" ]]; then\n      echo \"$candidate\"\n      return 0\n    fi\n  done\n\n  return 1\n}\n\nmsg=\"Auto-commit hourly sync $(date -u +'%Y-%m-%dT%H:%M:%SZ')\"\nalphaclaw_cmd=\"$(resolve_alphaclaw_cmd || true)\"\nif [[ -z \"${alphaclaw_cmd:-}\" ]]; then\n  echo \"hourly-git-sync: alphaclaw CLI not found in PATH or known install paths\" >&2\n  exit 127\nfi\n\"$alphaclaw_cmd\" git-sync -m \"$msg\"\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/calendar.md",
    "content": "## Calendar\n\n```bash\n# List calendars\ngog calendar calendars\n\n# Today's events\ngog calendar events <calendarId> --today\ngog calendar events <calendarId> --tomorrow\ngog calendar events <calendarId> --week\ngog calendar events <calendarId> --days 3\n\n# Events from all calendars\ngog calendar events --all --today\n\n# Date range\ngog calendar events <calendarId> --from 2025-01-15T00:00:00Z --to 2025-01-22T00:00:00Z\n\n# Search events\ngog calendar search \"meeting\" --today\ngog calendar search \"standup\" --days 30\n\n# Get single event\ngog calendar event <calendarId> <eventId>\n\n# Create event\ngog calendar create <calendarId> --summary \"Meeting\" --from 2025-01-15T10:00:00Z --to 2025-01-15T11:00:00Z\ngog calendar create <calendarId> --summary \"Team Sync\" --from 2025-01-15T14:00:00Z --to 2025-01-15T15:00:00Z --attendees \"alice@example.com,bob@example.com\" --location \"Zoom\"\n\n# Recurrence + reminders\ngog calendar create <calendarId> --summary \"Weekly\" --from 2025-01-15T09:00:00Z --to 2025-01-15T09:30:00Z --rrule \"RRULE:FREQ=WEEKLY\" --reminder \"popup:15m\"\n\n# Update event\ngog calendar update <calendarId> <eventId> --summary \"Updated\" --from 2025-01-15T11:00:00Z --to 2025-01-15T12:00:00Z\n\n# Add attendees without replacing existing\ngog calendar update <calendarId> <eventId> --add-attendee \"alice@example.com\"\n\n# Send notifications\ngog calendar create <calendarId> --summary \"Sync\" --from ... --to ... --send-updates all\ngog calendar update <calendarId> <eventId> --send-updates externalOnly\n\n# Delete event\ngog calendar delete <calendarId> <eventId>\n\n# RSVP to invitation\ngog calendar respond <calendarId> <eventId> --status accepted\ngog calendar respond <calendarId> <eventId> --status declined\ngog calendar respond <calendarId> <eventId> --status tentative\n\n# Free/busy check\ngog calendar freebusy --calendars \"primary,work@example.com\" --from 2025-01-15T00:00:00Z --to 2025-01-16T00:00:00Z\n\n# Conflict detection\ngog calendar conflicts --calendars \"primary\" --today\n\n# Special event types\ngog calendar create primary --event-type focus-time --from ... --to ...\ngog calendar create primary --event-type out-of-office --from ... --to ... --all-day\n```\n\nJSON output includes `startDayOfWeek`, `endDayOfWeek`, `timezone`, and `startLocal`/`endLocal` fields.\nUse `primary` as calendarId for the user's default calendar.\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/contacts.md",
    "content": "## Contacts\n\n```bash\n# List personal contacts\ngog contacts list --max 50\n\n# Search contacts\ngog contacts search \"Ada\" --max 50\n\n# Get contact by resource name or email\ngog contacts get people/<resourceName>\ngog contacts get user@example.com\n\n# Create contact\ngog contacts create --given \"John\" --family \"Doe\" --email \"john@example.com\" --phone \"+1234567890\"\n\n# Update contact\ngog contacts update people/<resourceName> --given \"Jane\" --email \"jane@example.com\" --notes \"Updated\"\n\n# Delete contact\ngog contacts delete people/<resourceName>\n\n# Other contacts (people you've interacted with)\ngog contacts other list --max 50\ngog contacts other search \"John\" --max 50\n\n# Workspace directory (Google Workspace only)\ngog contacts directory list --max 50\ngog contacts directory search \"Jane\" --max 50\n```\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/docs.md",
    "content": "## Docs\n\n```bash\n# Document info\ngog docs info <docId>\n\n# Read document text\ngog docs cat <docId>\ngog docs cat <docId> --max-bytes 10000\ngog docs cat <docId> --tab \"Notes\"\ngog docs cat <docId> --all-tabs\n\n# List tabs\ngog docs list-tabs <docId>\n\n# Create document\ngog docs create \"My Doc\"\ngog docs create \"My Doc\" --file ./doc.md\n\n# Copy document\ngog docs copy <docId> \"My Doc Copy\"\n\n# Export\ngog docs export <docId> --format pdf --out ./doc.pdf\ngog docs export <docId> --format docx --out ./doc.docx\ngog docs export <docId> --format txt --out ./doc.txt\n\n# Update document content (markdown)\ngog docs update <docId> --format markdown --content-file ./doc.md\ngog docs write <docId> --replace --markdown --file ./doc.md\n\n# Find and replace\ngog docs find-replace <docId> \"old text\" \"new text\"\n\n# Sed-style editing (sedmat) with markdown formatting\ngog docs sed <docId> 's/hello/**hello**/'          # bold\ngog docs sed <docId> 's/hello/*hello*/'             # italic\ngog docs sed <docId> 's/hello/`hello`/'             # monospace\ngog docs sed <docId> 's/hello/__hello__/'           # underline\ngog docs sed <docId> 's/Google/[Google](https://google.com)/'  # link\ngog docs sed <docId> 's/{{LOGO}}/![](https://example.com/logo.png)/'  # image\n\n# Tables via sedmat\ngog docs sed <docId> 's/{{TABLE}}/|3x4|/'           # create 3-row, 4-col table\ngog docs sed <docId> 's/|1|[A1]/**Name**/'          # set cell A1\n```\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/drive.md",
    "content": "## Drive\n\n```bash\n# List files (default: all accessible files including shared drives)\ngog drive ls --max 20\ngog drive ls --parent <folderId> --max 20\ngog drive ls --no-all-drives\n\n# Search files\ngog drive search \"invoice\" --max 20\ngog drive search \"mimeType = 'application/pdf'\" --raw-query\n\n# Get file metadata\ngog drive get <fileId>\ngog drive url <fileId>\n\n# Download file\ngog drive download <fileId> --out ./downloaded.bin\n\n# Export Google Workspace files\ngog drive download <fileId> --format pdf --out ./exported.pdf\ngog drive download <fileId> --format docx --out ./doc.docx\n\n# Upload file\ngog drive upload ./path/to/file --parent <folderId>\ngog drive upload ./file.docx --convert\ngog drive upload ./file --replace <fileId>\n\n# Copy file\ngog drive copy <fileId> \"Copy Name\"\n\n# Organize\ngog drive mkdir \"New Folder\"\ngog drive mkdir \"New Folder\" --parent <parentFolderId>\ngog drive rename <fileId> \"New Name\"\ngog drive move <fileId> --parent <destinationFolderId>\ngog drive delete <fileId>\ngog drive delete <fileId> --permanent\n\n# Permissions\ngog drive permissions <fileId>\ngog drive share <fileId> --to user --email user@example.com --role reader\ngog drive share <fileId> --to user --email user@example.com --role writer\ngog drive unshare <fileId> --permission-id <permissionId>\n\n# Shared drives\ngog drive drives --max 100\n```\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/gmail.md",
    "content": "## Gmail\n\n```bash\n# Search threads (returns thread IDs + snippets)\ngog gmail search 'newer_than:7d' --max 10\ngog gmail search 'from:alice@example.com subject:invoice' --max 20\n\n# Search individual messages (add --include-body to fetch bodies)\ngog gmail messages search 'newer_than:7d' --max 10 --include-body\n\n# Read a thread and optionally download attachments\ngog gmail thread get <threadId>\ngog gmail thread get <threadId> --download --out-dir ./attachments\n\n# Read a single message\ngog gmail get <messageId>\n\n# Modify labels on a thread\ngog gmail thread modify <threadId> --add STARRED --remove INBOX\n\n# Send email (plain text)\ngog gmail send --to recipient@example.com --subject \"Subject\" --body \"Body text\"\n\n# Send email (HTML)\ngog gmail send --to recipient@example.com --subject \"Subject\" --body \"Plain fallback\" --body-html \"<p>Hello</p>\"\n\n# Reply to a message (with quoted original)\ngog gmail send --reply-to-message-id <messageId> --quote --to recipient@example.com --subject \"Re: Subject\" --body \"Reply text\"\n\n# Send with body from file or stdin\ngog gmail send --to recipient@example.com --subject \"Subject\" --body-file ./message.txt\ngog gmail send --to recipient@example.com --subject \"Subject\" --body-file -\n\n# Labels\ngog gmail labels list\ngog gmail labels get INBOX --json\ngog gmail labels create \"My Label\"\ngog gmail labels delete <labelIdOrName>\n\n# Drafts\ngog gmail drafts list\ngog gmail drafts create --to recipient@example.com --subject \"Draft\" --body \"Body\"\ngog gmail drafts update <draftId> --subject \"Updated\" --body \"New body\"\ngog gmail drafts send <draftId>\n\n# Batch operations\ngog gmail batch modify <messageId1> <messageId2> --add STARRED --remove INBOX\ngog gmail batch delete <messageId1> <messageId2>\n\n# Filters\ngog gmail filters list\ngog gmail filters create --from 'noreply@example.com' --add-label 'Notifications'\ngog gmail filters delete <filterId>\n\n# Vacation / auto-reply\ngog gmail vacation get\ngog gmail vacation enable --subject \"Out of office\" --message \"I'm away\"\ngog gmail vacation disable\n\n# History (for incremental sync)\ngog gmail history --since <historyId>\n```\n\nOutput: use `--json` for structured output, `--plain` for TSV.\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/meet.md",
    "content": "## Meet\n\n```bash\n# List meeting spaces\ngog meet spaces list\n```\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/sheets.md",
    "content": "## Sheets\n\n```bash\n# Spreadsheet metadata (sheets list, properties)\ngog sheets metadata <spreadsheetId>\n\n# Read cell range\ngog sheets get <spreadsheetId> 'Sheet1!A1:B10'\n\n# Write cells (pipe-delimited rows, comma-separated columns)\ngog sheets update <spreadsheetId> 'A1' 'val1|val2,val3|val4'\ngog sheets update <spreadsheetId> 'A1' --values-json '[[\"a\",\"b\"],[\"c\",\"d\"]]'\n\n# Append rows\ngog sheets append <spreadsheetId> 'Sheet1!A:C' 'new|row|data'\n\n# Clear range\ngog sheets clear <spreadsheetId> 'Sheet1!A1:B10'\n\n# Create spreadsheet\ngog sheets create \"My Spreadsheet\" --sheets \"Sheet1,Sheet2\"\n\n# Copy spreadsheet\ngog sheets copy <spreadsheetId> \"Copy Name\"\n\n# Export\ngog sheets export <spreadsheetId> --format pdf --out ./sheet.pdf\ngog sheets export <spreadsheetId> --format xlsx --out ./sheet.xlsx\n\n# Format cells\ngog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{\"textFormat\":{\"bold\":true}}' --format-fields 'userEnteredFormat.textFormat.bold'\n\n# Insert rows/columns\ngog sheets insert <spreadsheetId> \"Sheet1\" rows 2 --count 3\ngog sheets insert <spreadsheetId> \"Sheet1\" cols 3 --after\n\n# Cell notes and hyperlinks\ngog sheets notes <spreadsheetId> 'Sheet1!A1:B10'\ngog sheets links <spreadsheetId> 'Sheet1!A1:B10'\n```\n\nWrite format: rows separated by `,` and columns by `|`. Use `--values-json` for complex data.\n`--copy-validation-from` copies data validation from a reference range when updating/appending.\n"
  },
  {
    "path": "lib/setup/skills/gog-cli/tasks.md",
    "content": "## Tasks\n\n```bash\n# List task lists\ngog tasks lists\n\n# List tasks in a list\ngog tasks list <tasklistId> --max 50\n\n# Get single task\ngog tasks get <tasklistId> <taskId>\n\n# Create task list\ngog tasks lists create \"My List\"\n\n# Add task\ngog tasks add <tasklistId> --title \"Task title\"\ngog tasks add <tasklistId> --title \"Weekly sync\" --due 2025-02-01 --repeat weekly --repeat-count 4\n\n# Update task\ngog tasks update <tasklistId> <taskId> --title \"Updated title\"\n\n# Complete / uncomplete task\ngog tasks done <tasklistId> <taskId>\ngog tasks undo <tasklistId> <taskId>\n\n# Delete task / clear completed\ngog tasks delete <tasklistId> <taskId>\ngog tasks clear <tasklistId>\n```\n\nDue dates are date-only; time components may be ignored by Google Tasks.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@chrysb/alphaclaw\",\n  \"version\": \"0.9.15\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"description\": \"Setup UI, gateway manager, and onboarding wrapper for OpenClaw\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/chrysb/alphaclaw.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/chrysb/alphaclaw/issues\"\n  },\n  \"homepage\": \"https://github.com/chrysb/alphaclaw#readme\",\n  \"license\": \"MIT\",\n  \"bin\": {\n    \"alphaclaw\": \"bin/alphaclaw.js\"\n  },\n  \"files\": [\n    \"bin/\",\n    \"lib/\"\n  ],\n  \"scripts\": {\n    \"start\": \"node bin/alphaclaw.js start\",\n    \"build:ui\": \"node scripts/build-ui.mjs\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\",\n    \"test:watchdog\": \"vitest run tests/server/watchdog.test.js tests/server/watchdog-db.test.js tests/server/routes-watchdog.test.js\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"prepack\": \"npm run build:ui\"\n  },\n  \"dependencies\": {\n    \"express\": \"^4.21.0\",\n    \"http-proxy\": \"^1.18.1\",\n    \"openclaw\": \"2026.5.6\",\n    \"ws\": \"^8.19.0\"\n  },\n  \"devDependencies\": {\n    \"@vitest/coverage-v8\": \"^4.0.18\",\n    \"@xterm/addon-fit\": \"^0.10.0\",\n    \"@xterm/xterm\": \"^5.5.0\",\n    \"chart.js\": \"^4.5.1\",\n    \"esbuild\": \"^0.25.9\",\n    \"htm\": \"^3.1.1\",\n    \"marked\": \"^16.4.1\",\n    \"preact\": \"^10.27.2\",\n    \"supertest\": \"^7.2.2\",\n    \"tailwindcss\": \"^3.4.17\",\n    \"vitest\": \"^4.0.18\",\n    \"wouter-preact\": \"^3.7.1\"\n  },\n  \"engines\": {\n    \"node\": \">=22.14.0\"\n  }\n}\n"
  },
  {
    "path": "scripts/build-ui.mjs",
    "content": "import { mkdirSync, rmSync, copyFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport esbuild from \"esbuild\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\nconst projectRoot = path.resolve(__dirname, \"..\");\n\nconst publicRoot = path.join(projectRoot, \"lib\", \"public\");\nconst distDir = path.join(publicRoot, \"dist\");\nconst entryPoint = path.join(publicRoot, \"js\", \"app.js\");\nconst tailwindConfigPath = path.join(projectRoot, \"tailwind.config.cjs\");\nconst tailwindInputPath = path.join(publicRoot, \"css\", \"tailwind.input.css\");\nconst tailwindOutputPath = path.join(publicRoot, \"css\", \"tailwind.generated.css\");\nconst xtermCssSrc = path.join(\n  projectRoot,\n  \"node_modules\",\n  \"@xterm\",\n  \"xterm\",\n  \"css\",\n  \"xterm.css\",\n);\nconst xtermCssDestDir = path.join(publicRoot, \"css\", \"vendor\");\nconst xtermCssDest = path.join(xtermCssDestDir, \"xterm.css\");\nconst execFileAsync = promisify(execFile);\n\nrmSync(distDir, { recursive: true, force: true });\nmkdirSync(distDir, { recursive: true });\nmkdirSync(xtermCssDestDir, { recursive: true });\ncopyFileSync(xtermCssSrc, xtermCssDest);\nawait execFileAsync(\n  \"npm\",\n  [\n    \"exec\",\n    \"tailwindcss\",\n    \"--\",\n    \"-c\",\n    tailwindConfigPath,\n    \"-i\",\n    tailwindInputPath,\n    \"-o\",\n    tailwindOutputPath,\n    \"--minify\",\n  ],\n  { cwd: projectRoot },\n);\n\nawait esbuild.build({\n  entryPoints: [entryPoint],\n  outdir: distDir,\n  bundle: true,\n  format: \"esm\",\n  splitting: true,\n  minify: true,\n  sourcemap: false,\n  target: [\"es2022\"],\n  entryNames: \"[name].bundle\",\n  chunkNames: \"chunks/[name]-[hash]\",\n  logLevel: \"info\",\n});\n"
  },
  {
    "path": "scripts/dev/crash-watchdog-config.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nCONTAINER_NAME=\"${1:-openclaw-railway-template-openclaw-1}\"\nCONFIG_PATH=\"${2:-/data/.openclaw/openclaw.json}\"\nINVALID_DIR=\"${3:-/tmp/does-not-exist}\"\n\nif ! command -v docker >/dev/null 2>&1; then\n  echo \"docker is required but not found in PATH\" >&2\n  exit 1\nfi\n\nif ! docker ps --format '{{.Names}}' | awk -v name=\"$CONTAINER_NAME\" '$0 == name { found=1 } END { exit !found }'; then\n  echo \"Container not running: $CONTAINER_NAME\" >&2\n  echo \"Tip: pass a container name as first arg.\" >&2\n  exit 1\nfi\n\ndocker exec \"$CONTAINER_NAME\" node -e \"\nconst fs = require('fs');\nconst path = '$CONFIG_PATH';\nconst invalidDir = '$INVALID_DIR';\nconst raw = fs.readFileSync(path, 'utf8');\nconst cfg = JSON.parse(raw);\ncfg.hooks = cfg.hooks || {};\ncfg.hooks.transformDir = invalidDir;\nfs.writeFileSync(path, JSON.stringify(cfg, null, 2));\nconsole.log('Injected invalid hooks.transformDir for watchdog test');\nconsole.log('config:', path);\nconsole.log('hooks.transformDir:', cfg.hooks.transformDir);\n\"\n\n"
  },
  {
    "path": "tailwind.config.cjs",
    "content": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\n    \"./lib/public/setup.html\",\n    \"./lib/public/login.html\",\n    \"./lib/public/js/**/*.js\",\n  ],\n  theme: {\n    extend: {\n      colors: {\n        surface: \"var(--bg-sidebar)\",\n        border: \"var(--border)\",\n        body: \"var(--text)\",\n        bright: \"var(--text-bright)\",\n        \"fg-muted\": \"var(--text-muted)\",\n        \"fg-dim\": \"var(--text-dim)\",\n        field: \"var(--field-bg-contrast)\",\n        overlay: \"var(--overlay)\",\n        status: {\n          error: \"var(--status-error)\",\n          \"error-muted\": \"var(--status-error-muted)\",\n          \"error-bg\": \"var(--status-error-bg)\",\n          \"error-border\": \"var(--status-error-border)\",\n          warning: \"var(--status-warning)\",\n          \"warning-muted\": \"var(--status-warning-muted)\",\n          \"warning-bg\": \"var(--status-warning-bg)\",\n          \"warning-border\": \"var(--status-warning-border)\",\n          success: \"var(--status-success)\",\n          \"success-muted\": \"var(--status-success-muted)\",\n          \"success-bg\": \"var(--status-success-bg)\",\n          \"success-border\": \"var(--status-success-border)\",\n          info: \"var(--status-info)\",\n          \"info-muted\": \"var(--status-info-muted)\",\n          \"info-bg\": \"var(--status-info-bg)\",\n          \"info-border\": \"var(--status-info-border)\",\n        },\n      },\n      fontFamily: {\n        mono: [\"'JetBrains Mono'\", \"monospace\"],\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "tests/bin/alphaclaw.test.js",
    "content": "const { execSync } = require(\"child_process\");\nconst path = require(\"path\");\nconst fs = require(\"fs\");\nconst os = require(\"os\");\n\ndescribe(\"bin/alphaclaw port check\", () => {\n  let tmpDir;\n  let tmpHome;\n\n  beforeEach(() => {\n    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-test-\"));\n    tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-home-\"));\n  });\n\n  afterEach(() => {\n    try {\n      if (fs.existsSync(tmpDir)) {\n        fs.rmSync(tmpDir, { recursive: true, force: true });\n      }\n    } catch {}\n    try {\n      if (fs.existsSync(tmpHome)) {\n        fs.rmSync(tmpHome, { recursive: true, force: true });\n      }\n    } catch {}\n  });\n\n  const binPath = path.resolve(__dirname, \"../../bin/alphaclaw.js\");\n\n  it(\"exits with error if PORT env var is 18789\", () => {\n    let output = \"\";\n    let status = 0;\n    try {\n      execSync(`ALPHACLAW_ROOT_DIR=\"${tmpDir}\" node \"${binPath}\" start`, {\n        stdio: \"pipe\",\n        encoding: \"utf8\",\n        env: { ...process.env, PORT: \"18789\", ALPHACLAW_ROOT_DIR: tmpDir }\n      });\n    } catch (e) {\n      status = e.status;\n      output = e.stdout + e.stderr;\n    }\n\n    expect(status).toBe(1);\n    expect(output).toContain(\"AlphaClaw cannot be started on port 18789\");\n    expect(output).toContain(\"reserved for the OpenClaw gateway\");\n  });\n\n  it(\"exits with error if --port flag is 18789\", () => {\n    let output = \"\";\n    let status = 0;\n    try {\n      execSync(`ALPHACLAW_ROOT_DIR=\"${tmpDir}\" node \"${binPath}\" start --port 18789`, {\n        stdio: \"pipe\",\n        encoding: \"utf8\",\n        env: { ...process.env, PORT: \"3000\", ALPHACLAW_ROOT_DIR: tmpDir }\n      });\n    } catch (e) {\n      status = e.status;\n      output = e.stdout + e.stderr;\n    }\n\n    expect(status).toBe(1);\n    expect(output).toContain(\"AlphaClaw cannot be started on port 18789\");\n    expect(output).toContain(\"reserved for the OpenClaw gateway\");\n  });\n\n  it(\"does not exit if PORT is not 18789 (fails on SETUP_PASSWORD)\", () => {\n    let output = \"\";\n    let status = 0;\n    try {\n      // We expect it to fail on SETUP_PASSWORD missing, which is AFTER the port check\n      execSync(`ALPHACLAW_ROOT_DIR=\"${tmpDir}\" node \"${binPath}\" start`, {\n        stdio: \"pipe\",\n        encoding: \"utf8\",\n        env: { ...process.env, PORT: \"3001\", ALPHACLAW_ROOT_DIR: tmpDir, SETUP_PASSWORD: \"\" }\n      });\n    } catch (e) {\n      status = e.status;\n      output = e.stdout + e.stderr;\n    }\n\n    expect(status).toBe(1);\n    expect(output).not.toContain(\"AlphaClaw cannot be started on port 18789\");\n    expect(output).toContain(\"SETUP_PASSWORD is missing or empty\");\n  });\n\n  it(\"exports OPENCLAW_STATE_DIR during managed startup\", () => {\n    const preloadPath = path.join(tmpDir, \"capture-openclaw-env.js\");\n    const capturePath = path.join(tmpDir, \"captured-openclaw-env.json\");\n    fs.writeFileSync(\n      preloadPath,\n      `\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst Module = require(\"module\");\nconst childProcess = require(\"child_process\");\n\nconst realLoad = Module._load;\nconst realCopyFileSync = fs.copyFileSync;\nconst realWriteFileSync = fs.writeFileSync;\nconst realUnlinkSync = fs.unlinkSync;\nconst realChmodSync = fs.chmodSync;\n\nconst capturePath = process.env.ALPHACLAW_CAPTURE_ENV_PATH;\nconst testHome = process.env.ALPHACLAW_TEST_HOME;\nif (testHome) {\n  os.homedir = () => testHome;\n}\n\nchildProcess.execSync = (command, options = {}) => {\n  const cmd = String(command || \"\");\n  if (\n    cmd.startsWith(\"command -v \") ||\n    cmd === \"pgrep -x cron\" ||\n    cmd === \"cron\"\n  ) {\n    return \"\";\n  }\n  if (cmd.startsWith(\"git \")) {\n    return \"\";\n  }\n  return \"\";\n};\n\nfs.copyFileSync = (src, dest, ...rest) => {\n  const target = String(dest || \"\");\n  if (\n    target.startsWith(\"/usr/local/bin/\") ||\n    target.startsWith(\"/etc/cron.d/\")\n  ) {\n    return;\n  }\n  return realCopyFileSync(src, dest, ...rest);\n};\n\nfs.writeFileSync = (targetPath, data, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (\n    target.startsWith(\"/usr/local/bin/\") ||\n    target.startsWith(\"/etc/cron.d/\")\n  ) {\n    return;\n  }\n  return realWriteFileSync(targetPath, data, ...rest);\n};\n\nfs.unlinkSync = (targetPath, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (target.startsWith(\"/etc/cron.d/\")) return;\n  return realUnlinkSync(targetPath, ...rest);\n};\n\nfs.chmodSync = (targetPath, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (target.startsWith(\"/usr/local/bin/\")) return;\n  return realChmodSync(targetPath, ...rest);\n};\n\nModule._load = function patchedLoad(request, parent, isMain) {\n  const parentFile = String(parent && parent.filename ? parent.filename : \"\");\n  if (\n    (request === \"../lib/server.js\" || String(request || \"\").endsWith(\"/lib/server.js\")) &&\n    parentFile.endsWith(path.join(\"bin\", \"alphaclaw.js\"))\n  ) {\n    fs.writeFileSync(\n      capturePath,\n      JSON.stringify({\n        HOME: process.env.HOME,\n        OPENCLAW_HOME: process.env.OPENCLAW_HOME,\n        OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH,\n        OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,\n        XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,\n      }),\n    );\n    return {};\n  }\n  return realLoad.apply(this, arguments);\n};\n      `.trim(),\n    );\n\n    execSync(`node \"${binPath}\" start`, {\n      stdio: \"pipe\",\n      encoding: \"utf8\",\n      env: {\n        ...process.env,\n        SETUP_PASSWORD: \"test-password\",\n        ALPHACLAW_ROOT_DIR: tmpDir,\n        ALPHACLAW_TEST_HOME: tmpHome,\n        ALPHACLAW_CAPTURE_ENV_PATH: capturePath,\n        NODE_OPTIONS: `--require=${preloadPath}`,\n      },\n    });\n\n    const reportedEnv = JSON.parse(fs.readFileSync(capturePath, \"utf8\"));\n    expect(reportedEnv).toEqual({\n      HOME: tmpDir,\n      OPENCLAW_HOME: tmpDir,\n      OPENCLAW_CONFIG_PATH: path.join(tmpDir, \".openclaw\", \"openclaw.json\"),\n      OPENCLAW_STATE_DIR: path.join(tmpDir, \".openclaw\"),\n      XDG_CONFIG_HOME: path.join(tmpDir, \".openclaw\"),\n    });\n\n  });\n\n  it(\"creates a gogcli compatibility symlink under the managed home\", () => {\n    const preloadPath = path.join(tmpDir, \"capture-openclaw-env.js\");\n    fs.writeFileSync(\n      preloadPath,\n      `\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst Module = require(\"module\");\nconst childProcess = require(\"child_process\");\n\nconst realLoad = Module._load;\nconst realCopyFileSync = fs.copyFileSync;\nconst realWriteFileSync = fs.writeFileSync;\nconst realUnlinkSync = fs.unlinkSync;\nconst realChmodSync = fs.chmodSync;\n\nconst testHome = process.env.ALPHACLAW_TEST_HOME;\nif (testHome) {\n  os.homedir = () => testHome;\n}\n\nchildProcess.execSync = (command, options = {}) => {\n  const cmd = String(command || \"\");\n  if (\n    cmd.startsWith(\"command -v \") ||\n    cmd === \"pgrep -x cron\" ||\n    cmd === \"cron\"\n  ) {\n    return \"\";\n  }\n  if (cmd.startsWith(\"git \")) {\n    return \"\";\n  }\n  return \"\";\n};\n\nfs.copyFileSync = (src, dest, ...rest) => {\n  const target = String(dest || \"\");\n  if (\n    target.startsWith(\"/usr/local/bin/\") ||\n    target.startsWith(\"/etc/cron.d/\")\n  ) {\n    return;\n  }\n  return realCopyFileSync(src, dest, ...rest);\n};\n\nfs.writeFileSync = (targetPath, data, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (\n    target.startsWith(\"/usr/local/bin/\") ||\n    target.startsWith(\"/etc/cron.d/\")\n  ) {\n    return;\n  }\n  return realWriteFileSync(targetPath, data, ...rest);\n};\n\nfs.unlinkSync = (targetPath, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (target.startsWith(\"/etc/cron.d/\")) return;\n  return realUnlinkSync(targetPath, ...rest);\n};\n\nfs.chmodSync = (targetPath, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (target.startsWith(\"/usr/local/bin/\")) return;\n  return realChmodSync(targetPath, ...rest);\n};\n\nModule._load = function patchedLoad(request, parent, isMain) {\n  const parentFile = String(parent && parent.filename ? parent.filename : \"\");\n  if (\n    (request === \"../lib/server.js\" || String(request || \"\").endsWith(\"/lib/server.js\")) &&\n    parentFile.endsWith(path.join(\"bin\", \"alphaclaw.js\"))\n  ) {\n    return {};\n  }\n  return realLoad.apply(this, arguments);\n};\n      `.trim(),\n    );\n\n    execSync(`node \"${binPath}\" start`, {\n      stdio: \"pipe\",\n      encoding: \"utf8\",\n      env: {\n        ...process.env,\n        SETUP_PASSWORD: \"test-password\",\n        ALPHACLAW_ROOT_DIR: tmpDir,\n        ALPHACLAW_TEST_HOME: tmpHome,\n        NODE_OPTIONS: `--require=${preloadPath}`,\n      },\n    });\n\n    const compatPath = path.join(tmpDir, \".config\", \"gogcli\");\n    const managedPath = path.join(tmpDir, \".openclaw\", \"gogcli\");\n    expect(fs.lstatSync(compatPath).isSymbolicLink()).toBe(true);\n    expect(path.resolve(path.dirname(compatPath), fs.readlinkSync(compatPath))).toBe(\n      managedPath,\n    );\n  });\n\n  it(\"does not replace an existing gogcli config directory\", () => {\n    const preloadPath = path.join(tmpDir, \"capture-openclaw-env.js\");\n    fs.writeFileSync(\n      preloadPath,\n      `\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst Module = require(\"module\");\nconst childProcess = require(\"child_process\");\n\nconst realLoad = Module._load;\nconst realCopyFileSync = fs.copyFileSync;\nconst realWriteFileSync = fs.writeFileSync;\nconst realUnlinkSync = fs.unlinkSync;\nconst realChmodSync = fs.chmodSync;\n\nconst testHome = process.env.ALPHACLAW_TEST_HOME;\nif (testHome) {\n  os.homedir = () => testHome;\n}\n\nchildProcess.execSync = (command, options = {}) => {\n  const cmd = String(command || \"\");\n  if (\n    cmd.startsWith(\"command -v \") ||\n    cmd === \"pgrep -x cron\" ||\n    cmd === \"cron\"\n  ) {\n    return \"\";\n  }\n  if (cmd.startsWith(\"git \")) {\n    return \"\";\n  }\n  return \"\";\n};\n\nfs.copyFileSync = (src, dest, ...rest) => {\n  const target = String(dest || \"\");\n  if (\n    target.startsWith(\"/usr/local/bin/\") ||\n    target.startsWith(\"/etc/cron.d/\")\n  ) {\n    return;\n  }\n  return realCopyFileSync(src, dest, ...rest);\n};\n\nfs.writeFileSync = (targetPath, data, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (\n    target.startsWith(\"/usr/local/bin/\") ||\n    target.startsWith(\"/etc/cron.d/\")\n  ) {\n    return;\n  }\n  return realWriteFileSync(targetPath, data, ...rest);\n};\n\nfs.unlinkSync = (targetPath, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (target.startsWith(\"/etc/cron.d/\")) return;\n  return realUnlinkSync(targetPath, ...rest);\n};\n\nfs.chmodSync = (targetPath, ...rest) => {\n  const target = String(targetPath || \"\");\n  if (target.startsWith(\"/usr/local/bin/\")) return;\n  return realChmodSync(targetPath, ...rest);\n};\n\nModule._load = function patchedLoad(request, parent, isMain) {\n  const parentFile = String(parent && parent.filename ? parent.filename : \"\");\n  if (\n    (request === \"../lib/server.js\" || String(request || \"\").endsWith(\"/lib/server.js\")) &&\n    parentFile.endsWith(path.join(\"bin\", \"alphaclaw.js\"))\n  ) {\n    return {};\n  }\n  return realLoad.apply(this, arguments);\n};\n      `.trim(),\n    );\n\n    const compatPath = path.join(tmpDir, \".config\", \"gogcli\");\n    fs.mkdirSync(compatPath, { recursive: true });\n    fs.writeFileSync(path.join(compatPath, \"config.json\"), \"{}\");\n\n    execSync(`node \"${binPath}\" start`, {\n      stdio: \"pipe\",\n      encoding: \"utf8\",\n      env: {\n        ...process.env,\n        SETUP_PASSWORD: \"test-password\",\n        ALPHACLAW_ROOT_DIR: tmpDir,\n        ALPHACLAW_TEST_HOME: tmpHome,\n        NODE_OPTIONS: `--require=${preloadPath}`,\n      },\n    });\n\n    expect(fs.lstatSync(compatPath).isDirectory()).toBe(true);\n    expect(fs.existsSync(path.join(compatPath, \"config.json\"))).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/bin/openclaw-config-restore.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { execSync } = require(\"child_process\");\nconst {\n  restoreMissingOpenclawConfigFromRemote,\n} = require(\"../../lib/cli/openclaw-config-restore\");\n\nconst runGit = (cwd, command) =>\n  execSync(`git ${command}`, {\n    cwd,\n    stdio: \"pipe\",\n    encoding: \"utf8\",\n    env: {\n      ...process.env,\n      GIT_AUTHOR_NAME: \"AlphaClaw Test\",\n      GIT_AUTHOR_EMAIL: \"alphaclaw@example.test\",\n      GIT_COMMITTER_NAME: \"AlphaClaw Test\",\n      GIT_COMMITTER_EMAIL: \"alphaclaw@example.test\",\n    },\n  });\n\nconst writeConfig = (dir, value) => {\n  fs.writeFileSync(\n    path.join(dir, \"openclaw.json\"),\n    `${JSON.stringify({ source: value }, null, 2)}\\n`,\n    \"utf8\",\n  );\n};\n\nconst readConfig = (dir) =>\n  JSON.parse(fs.readFileSync(path.join(dir, \"openclaw.json\"), \"utf8\"));\n\nconst createConfigRepo = () => {\n  const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-restore-test-\"));\n  const sourceDir = path.join(rootDir, \"source\");\n  const remoteDir = path.join(rootDir, \"remote.git\");\n  const openclawDir = path.join(rootDir, \"openclaw\");\n\n  fs.mkdirSync(sourceDir, { recursive: true });\n  runGit(sourceDir, \"init -b main\");\n  runGit(sourceDir, \"config commit.gpgsign false\");\n  writeConfig(sourceDir, \"remote-v1\");\n  runGit(sourceDir, \"add openclaw.json\");\n  runGit(sourceDir, \"commit -m initial\");\n\n  runGit(rootDir, `init --bare ${JSON.stringify(remoteDir)}`);\n  runGit(sourceDir, `remote add origin ${JSON.stringify(remoteDir)}`);\n  runGit(sourceDir, \"push -u origin main\");\n  runGit(rootDir, `clone ${JSON.stringify(remoteDir)} ${JSON.stringify(openclawDir)}`);\n\n  return { rootDir, sourceDir, openclawDir };\n};\n\nconst pushRemoteConfig = (sourceDir, value) => {\n  writeConfig(sourceDir, value);\n  runGit(sourceDir, \"add openclaw.json\");\n  runGit(sourceDir, `commit -m ${JSON.stringify(`config ${value}`)}`);\n  runGit(sourceDir, \"push origin main\");\n};\n\ndescribe(\"restoreMissingOpenclawConfigFromRemote\", () => {\n  let repos;\n  let logs;\n\n  beforeEach(() => {\n    repos = createConfigRepo();\n    logs = [];\n  });\n\n  afterEach(() => {\n    if (repos?.rootDir) {\n      fs.rmSync(repos.rootDir, { recursive: true, force: true });\n    }\n  });\n\n  const restore = () =>\n    restoreMissingOpenclawConfigFromRemote({\n      openclawDir: repos.openclawDir,\n      env: {},\n      logger: { log: (message) => logs.push(message) },\n    });\n\n  it(\"does not overwrite an existing clean openclaw.json\", () => {\n    pushRemoteConfig(repos.sourceDir, \"remote-v2\");\n\n    const result = restore();\n\n    expect(result).toEqual({ restored: false, skipped: true, reason: \"exists\" });\n    expect(readConfig(repos.openclawDir)).toEqual({ source: \"remote-v1\" });\n    expect(logs).toContain(\n      \"[alphaclaw] Remote config restore skipped: local openclaw.json already exists\",\n    );\n  });\n\n  it(\"does not overwrite local openclaw.json edits\", () => {\n    pushRemoteConfig(repos.sourceDir, \"remote-v2\");\n    writeConfig(repos.openclawDir, \"local-draft\");\n\n    const result = restore();\n\n    expect(result).toEqual({\n      restored: false,\n      skipped: true,\n      reason: \"exists\",\n    });\n    expect(readConfig(repos.openclawDir)).toEqual({ source: \"local-draft\" });\n    expect(logs).toContain(\n      \"[alphaclaw] Remote config restore skipped: local openclaw.json already exists\",\n    );\n  });\n\n  it(\"restores openclaw.json from remote when it is missing\", () => {\n    pushRemoteConfig(repos.sourceDir, \"remote-v2\");\n    fs.rmSync(path.join(repos.openclawDir, \"openclaw.json\"), { force: true });\n\n    const result = restore();\n\n    expect(result).toMatchObject({\n      restored: true,\n      skipped: false,\n      reason: \"missing\",\n      branch: \"main\",\n    });\n    expect(readConfig(repos.openclawDir)).toEqual({ source: \"remote-v2\" });\n    expect(logs).toContain(\n      \"[alphaclaw] Restored missing openclaw.json from origin/main\",\n    );\n  });\n});\n"
  },
  {
    "path": "tests/frontend/agent-identity.test.js",
    "content": "import { sanitizeAgentEmoji } from \"../../lib/public/js/lib/agent-identity.js\";\n\ndescribe(\"frontend/agent-identity\", () => {\n  describe(\"sanitizeAgentEmoji\", () => {\n    it(\"returns the trimmed glyph for a single emoji\", () => {\n      expect(sanitizeAgentEmoji(\"✨\")).toBe(\"✨\");\n      expect(sanitizeAgentEmoji(\"  🌙  \")).toBe(\"🌙\");\n    });\n\n    it(\"accepts ZWJ sequences\", () => {\n      expect(sanitizeAgentEmoji(\"👨‍👩‍👧‍👦\")).toBe(\"👨‍👩‍👧‍👦\");\n    });\n\n    it(\"rejects shortcode strings like :sparkles:\", () => {\n      expect(sanitizeAgentEmoji(\":sparkles:\")).toBe(\"\");\n      expect(sanitizeAgentEmoji(\":crescent_moon:\")).toBe(\"\");\n    });\n\n    it(\"rejects plain ASCII text\", () => {\n      expect(sanitizeAgentEmoji(\"abc\")).toBe(\"\");\n      expect(sanitizeAgentEmoji(\"Vee\")).toBe(\"\");\n    });\n\n    it(\"returns an empty string for nullish or empty input\", () => {\n      expect(sanitizeAgentEmoji(null)).toBe(\"\");\n      expect(sanitizeAgentEmoji(undefined)).toBe(\"\");\n      expect(sanitizeAgentEmoji(\"\")).toBe(\"\");\n      expect(sanitizeAgentEmoji(\"   \")).toBe(\"\");\n    });\n  });\n});\n"
  },
  {
    "path": "tests/frontend/api.test.js",
    "content": "const loadApiModule = async () => import(\"../../lib/public/js/lib/api.js\");\n\nconst mockJsonResponse = (status, payload) => ({\n  status,\n  ok: status >= 200 && status < 300,\n  text: async () => JSON.stringify(payload),\n  json: async () => payload,\n});\n\ndescribe(\"frontend/api\", () => {\n  const expectLastFetchHeaders = (expectedContentType = \"\") => {\n    const callArgs = global.fetch.mock.calls[global.fetch.mock.calls.length - 1] || [];\n    const options = callArgs[1] || {};\n    const headers = options.headers;\n    expect(headers).toBeInstanceOf(Headers);\n    if (expectedContentType) {\n      expect(headers.get(\"Content-Type\")).toBe(expectedContentType);\n    }\n    return { callArgs, options, headers };\n  };\n\n  beforeEach(() => {\n    global.fetch = vi.fn();\n    global.window = { location: { href: \"http://localhost/\" } };\n  });\n\n  it(\"fetchStatus returns parsed JSON on success\", async () => {\n    const payload = { gateway: \"running\" };\n    global.fetch.mockResolvedValue(mockJsonResponse(200, payload));\n    const api = await loadApiModule();\n\n    const result = await api.fetchStatus();\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/status\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual(payload);\n    expect(window.location.href).toBe(\"http://localhost/\");\n  });\n\n  it(\"redirects to /setup and throws on 401\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(401, { error: \"Unauthorized\" }));\n    const api = await loadApiModule();\n\n    await expect(api.fetchStatus()).rejects.toThrow(\"Unauthorized\");\n    expect(window.location.href).toBe(\"/setup\");\n  });\n\n  it(\"runOnboard sends vars and modelKey payload\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true }));\n    const api = await loadApiModule();\n    const vars = [{ key: \"OPENAI_API_KEY\", value: \"sk-123\" }];\n    const modelKey = \"openai/gpt-5.1-codex\";\n\n    const result = await api.runOnboard(vars, modelKey);\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/onboard\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({ vars, modelKey, importMode: false }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true });\n  });\n\n  it(\"verifyGithubOnboardingRepo posts repo, token, and mode\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, repoExists: true }));\n    const api = await loadApiModule();\n\n    const result = await api.verifyGithubOnboardingRepo(\n      \"my-org/source-repo\",\n      \"ghp_123\",\n      \"existing\",\n    );\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/onboard/github/verify\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          repo: \"my-org/source-repo\",\n          token: \"ghp_123\",\n          mode: \"existing\",\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, repoExists: true });\n  });\n\n  it(\"scanImportRepo posts the temp dir payload\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, hasOpenclawSetup: true }));\n    const api = await loadApiModule();\n\n    const result = await api.scanImportRepo(\"/tmp/alphaclaw-import-1234\");\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/onboard/import/scan\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({ tempDir: \"/tmp/alphaclaw-import-1234\" }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, hasOpenclawSetup: true });\n  });\n\n  it(\"applyImport posts import approval payload\", async () => {\n    global.fetch.mockResolvedValue(\n      mockJsonResponse(200, {\n        ok: true,\n        envVarsImported: 2,\n        placeholderReview: {\n          found: true,\n          count: 1,\n          vars: [{ key: \"SLACK_BOT_TOKEN\", status: \"missing\" }],\n        },\n      }),\n    );\n    const api = await loadApiModule();\n\n    const result = await api.applyImport({\n      tempDir: \"/tmp/alphaclaw-import-1234\",\n      approvedSecrets: [{ suggestedEnvVar: \"OPENAI_API_KEY\", value: \"sk-123\" }],\n      skipSecretExtraction: false,\n      githubRepo: \"owner/target-repo\",\n      githubToken: \"ghp_123\",\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/onboard/import/apply\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          tempDir: \"/tmp/alphaclaw-import-1234\",\n          approvedSecrets: [{ suggestedEnvVar: \"OPENAI_API_KEY\", value: \"sk-123\" }],\n          skipSecretExtraction: false,\n          githubRepo: \"owner/target-repo\",\n          githubToken: \"ghp_123\",\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({\n      ok: true,\n      envVarsImported: 2,\n      placeholderReview: {\n        found: true,\n        count: 1,\n        vars: [{ key: \"SLACK_BOT_TOKEN\", status: \"missing\" }],\n      },\n    });\n  });\n\n  it(\"saveEnvVars uses PUT with expected request body\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, changed: true }));\n    const api = await loadApiModule();\n    const vars = [{ key: \"GITHUB_TOKEN\", value: \"ghp_123\" }];\n\n    const result = await api.saveEnvVars(vars);\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/env\",\n      expect.objectContaining({\n        method: \"PUT\",\n        body: JSON.stringify({ vars }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, changed: true });\n  });\n\n  it(\"saveEnvVars throws server error on non-OK response\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(400, { error: \"Reserved env var\" }));\n    const api = await loadApiModule();\n\n    await expect(api.saveEnvVars([{ key: \"PORT\", value: \"3000\" }])).rejects.toThrow(\n      \"Reserved env var\",\n    );\n  });\n\n  it(\"approveDevice encodes ids and throws API errors\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(403, { ok: false, error: \"missing scope\" }));\n    const api = await loadApiModule();\n\n    await expect(api.approveDevice(\"req/admin 1\")).rejects.toThrow(\"missing scope\");\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/devices/req%2Fadmin%201/approve\",\n      expect.objectContaining({\n        method: \"POST\",\n        headers: expect.any(Headers),\n      }),\n    );\n  });\n\n  it(\"fetchUsageSummary calls usage summary endpoint\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, summary: { daily: [] } }));\n    const api = await loadApiModule();\n\n    const result = await api.fetchUsageSummary(90);\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/usage/summary?days=90\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({ ok: true, summary: { daily: [] } });\n  });\n\n  it(\"fetchUsageSessions calls usage sessions endpoint\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, sessions: [] }));\n    const api = await loadApiModule();\n\n    const result = await api.fetchUsageSessions(100);\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/usage/sessions?limit=100\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({ ok: true, sessions: [] });\n  });\n\n  it(\"fetchDoctorStatus calls Doctor status endpoint\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, status: { stale: true } }));\n    const api = await loadApiModule();\n\n    const result = await api.fetchDoctorStatus();\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/doctor/status\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({ ok: true, status: { stale: true } });\n  });\n\n  it(\"fetchDoctorCards calls aggregated Doctor cards endpoint\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, cards: [] }));\n    const api = await loadApiModule();\n\n    const result = await api.fetchDoctorCards({ runId: \"all\" });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/doctor/cards?runId=all\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({ ok: true, cards: [] });\n  });\n\n  it(\"startDoctorRun posts to the Doctor run endpoint\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(202, { ok: true, runId: 42 }));\n    const api = await loadApiModule();\n\n    const result = await api.startDoctorRun();\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/doctor/run\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({}),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, runId: 42 });\n  });\n\n  it(\"importDoctorResult posts raw Doctor output\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(201, { ok: true, runId: 43 }));\n    const api = await loadApiModule();\n\n    const result = await api.importDoctorResult('{\"summary\":\"Imported\",\"cards\":[]}');\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/doctor/import\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({ rawOutput: '{\"summary\":\"Imported\",\"cards\":[]}' }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, runId: 43 });\n  });\n\n  it(\"fetchUsageSessionDetail encodes session id in path\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, detail: { sessionId: \"x\" } }));\n    const api = await loadApiModule();\n\n    const result = await api.fetchUsageSessionDetail(\"agent:main:telegram:group:-1:topic:2\");\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/usage/sessions/agent%3Amain%3Atelegram%3Agroup%3A-1%3Atopic%3A2\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({ ok: true, detail: { sessionId: \"x\" } });\n  });\n\n  it(\"sendDoctorCardFix posts delivery fields\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, stdout: \"sent\" }));\n    const api = await loadApiModule();\n\n    const result = await api.sendDoctorCardFix({\n      cardId: 7,\n      sessionId: \"session-123\",\n      replyChannel: \"telegram\",\n      replyTo: \"1050\",\n      prompt: \"Use a more focused fix request\",\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/doctor/findings/7/fix\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          sessionId: \"session-123\",\n          replyChannel: \"telegram\",\n          replyTo: \"1050\",\n          prompt: \"Use a more focused fix request\",\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, stdout: \"sent\" });\n  });\n\n  it(\"createWebhook posts optional destination fields\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(201, { ok: true, webhook: { name: \"gmail\" } }));\n    const api = await loadApiModule();\n\n    const result = await api.createWebhook(\"gmail-alerts\", {\n      destination: {\n        channel: \"telegram\",\n        to: \"-1003709908795:4011\",\n      },\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/webhooks\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          name: \"gmail-alerts\",\n          destination: {\n            channel: \"telegram\",\n            to: \"-1003709908795:4011\",\n          },\n          oauthCallback: false,\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, webhook: { name: \"gmail\" } });\n  });\n\n  it(\"updateWebhookDestination puts destination fields\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, webhook: { name: \"gmail-alerts\" } }));\n    const api = await loadApiModule();\n\n    const result = await api.updateWebhookDestination(\"gmail-alerts\", {\n      destination: {\n        channel: \"telegram\",\n        to: \"1050\",\n        agentId: \"main\",\n      },\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/webhooks/gmail-alerts/destination\",\n      expect.objectContaining({\n        method: \"PUT\",\n        body: JSON.stringify({\n          destination: {\n            channel: \"telegram\",\n            to: \"1050\",\n            agentId: \"main\",\n          },\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, webhook: { name: \"gmail-alerts\" } });\n  });\n\n  it(\"startGmailWatch posts optional destination fields\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, accountId: \"acct-1\" }));\n    const api = await loadApiModule();\n\n    const result = await api.startGmailWatch(\"acct-1\", {\n      destination: {\n        channel: \"telegram\",\n        to: \"1050\",\n      },\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/gmail/watch/start\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          accountId: \"acct-1\",\n          destination: {\n            channel: \"telegram\",\n            to: \"1050\",\n          },\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, accountId: \"acct-1\" });\n  });\n\n  it(\"syncBrowseChanges posts commit message\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, committed: true }));\n    const api = await loadApiModule();\n\n    const result = await api.syncBrowseChanges(\"sync changes\");\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/browse/git-sync\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({ message: \"sync changes\" }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true, committed: true });\n  });\n\n  it(\"fetchBrowseFileDiff calls git diff endpoint with encoded path\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true, content: \"diff --git\" }));\n    const api = await loadApiModule();\n\n    const result = await api.fetchBrowseFileDiff(\"workspace/hooks/bootstrap/AGENTS.md\");\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/browse/git-diff?path=workspace%2Fhooks%2Fbootstrap%2FAGENTS.md\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({ ok: true, content: \"diff --git\" });\n  });\n\n  it(\"downloadBrowseFile calls download endpoint and triggers browser download\", async () => {\n    const fileBlob = new Blob([\"test\"], { type: \"text/plain\" });\n    const createObjectURL = vi.fn(() => \"blob:test-url\");\n    const revokeObjectURL = vi.fn();\n    global.window.URL = { createObjectURL, revokeObjectURL };\n    const click = vi.fn();\n    const remove = vi.fn();\n    const appendChild = vi.fn();\n    global.document = {\n      createElement: vi.fn((tagName) =>\n        tagName === \"a\"\n          ? {\n              href: \"\",\n              download: \"\",\n              click,\n              remove,\n            }\n          : {},\n      ),\n      body: { appendChild },\n    };\n    global.fetch.mockResolvedValue({\n      status: 200,\n      ok: true,\n      blob: async () => fileBlob,\n      text: async () => \"\",\n    });\n    const api = await loadApiModule();\n\n    const result = await api.downloadBrowseFile(\"workspace/file.txt\");\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/browse/download?path=workspace%2Ffile.txt\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(createObjectURL).toHaveBeenCalledWith(fileBlob);\n    expect(appendChild).toHaveBeenCalledTimes(1);\n    expect(click).toHaveBeenCalledTimes(1);\n    expect(remove).toHaveBeenCalledTimes(1);\n    expect(revokeObjectURL).toHaveBeenCalledWith(\"blob:test-url\");\n    expect(result).toEqual({ ok: true });\n  });\n\n  it(\"createChannelAccount posts provider, token, and agent binding fields\", async () => {\n    global.fetch.mockResolvedValue(\n      mockJsonResponse(201, {\n        ok: true,\n        channel: \"telegram\",\n        account: { id: \"alerts\", envKey: \"TELEGRAM_BOT_TOKEN_ALERTS\" },\n      }),\n    );\n    const api = await loadApiModule();\n\n    const result = await api.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Alerts\",\n      accountId: \"alerts\",\n      token: \"123:abc\",\n      agentId: \"ops\",\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/channels/accounts\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          provider: \"telegram\",\n          name: \"Alerts\",\n          accountId: \"alerts\",\n          token: \"123:abc\",\n          agentId: \"ops\",\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({\n      ok: true,\n      channel: \"telegram\",\n      account: { id: \"alerts\", envKey: \"TELEGRAM_BOT_TOKEN_ALERTS\" },\n    });\n  });\n\n  it(\"updateChannelAccount posts editable channel fields\", async () => {\n    global.fetch.mockResolvedValue(\n      mockJsonResponse(200, {\n        ok: true,\n        channel: \"telegram\",\n        account: { id: \"alerts\", name: \"Alerts Bot\", boundAgentId: \"main\" },\n      }),\n    );\n    const api = await loadApiModule();\n\n    const result = await api.updateChannelAccount({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n      name: \"Alerts Bot\",\n      agentId: \"main\",\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/channels/accounts\",\n      expect.objectContaining({\n        method: \"PUT\",\n        body: JSON.stringify({\n          provider: \"telegram\",\n          accountId: \"alerts\",\n          name: \"Alerts Bot\",\n          agentId: \"main\",\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({\n      ok: true,\n      channel: \"telegram\",\n      account: { id: \"alerts\", name: \"Alerts Bot\", boundAgentId: \"main\" },\n    });\n  });\n\n  it(\"deleteChannelAccount sends provider and account id\", async () => {\n    global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true }));\n    const api = await loadApiModule();\n\n    const result = await api.deleteChannelAccount({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n    });\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/channels/accounts\",\n      expect.objectContaining({\n        method: \"DELETE\",\n        body: JSON.stringify({\n          provider: \"telegram\",\n          accountId: \"alerts\",\n        }),\n        headers: expect.any(Headers),\n      }),\n    );\n    expectLastFetchHeaders(\"application/json\");\n    expect(result).toEqual({ ok: true });\n  });\n});\n"
  },
  {
    "path": "tests/frontend/browse-draft-state.test.js",
    "content": "const loadDraftState = async () => import(\"../../lib/public/js/lib/browse-draft-state.js\");\n\nconst createStorage = () => {\n  const store = new Map();\n  return {\n    get length() {\n      return store.size;\n    },\n    key: (index) => Array.from(store.keys())[index] || null,\n    getItem: (key) => (store.has(key) ? store.get(key) : null),\n    setItem: (key, value) => {\n      store.set(String(key), String(value));\n    },\n    removeItem: (key) => {\n      store.delete(String(key));\n    },\n  };\n};\n\ndescribe(\"frontend/browse-draft-state\", () => {\n  it(\"writes, reads, and clears per-file drafts\", async () => {\n    const storage = createStorage();\n    const draftState = await loadDraftState();\n\n    draftState.writeStoredFileDraft(\"workspace/a.md\", \"draft body\", storage);\n    expect(draftState.readStoredFileDraft(\"workspace/a.md\", storage)).toBe(\"draft body\");\n\n    draftState.clearStoredFileDraft(\"workspace/a.md\", storage);\n    expect(draftState.readStoredFileDraft(\"workspace/a.md\", storage)).toBe(\"\");\n  });\n\n  it(\"updates draft index and dispatches changes\", async () => {\n    const storage = createStorage();\n    const dispatchEvent = vi.fn();\n    const draftState = await loadDraftState();\n\n    draftState.updateDraftIndex(\"workspace/a.md\", true, { storage, dispatchEvent });\n    draftState.updateDraftIndex(\"workspace/b.md\", true, { storage, dispatchEvent });\n    draftState.updateDraftIndex(\"workspace/a.md\", false, { storage, dispatchEvent });\n\n    const index = draftState.readDraftIndex(storage);\n    expect(Array.from(index)).toEqual([\"workspace/b.md\"]);\n    expect(dispatchEvent).toHaveBeenCalledTimes(3);\n  });\n\n  it(\"builds draft index from per-file keys when no index exists\", async () => {\n    const storage = createStorage();\n    const draftState = await loadDraftState();\n    storage.setItem(\"alphaclaw.browse.draft.docs/a.txt\", \"a\");\n    storage.setItem(\"alphaclaw.browse.draft.src/b.txt\", \"b\");\n\n    const draftPaths = draftState.readStoredDraftPaths(storage);\n\n    expect(Array.from(draftPaths).sort()).toEqual([\"docs/a.txt\", \"src/b.txt\"]);\n    const storedIndexRaw = storage.getItem(\"alphaclaw.browse.draftIndex\");\n    expect(storedIndexRaw).toBeTruthy();\n    expect(JSON.parse(storedIndexRaw)).toEqual([\"docs/a.txt\", \"src/b.txt\"]);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/channel-create-operation.test.js",
    "content": "const loadChannelCreateOperationModule = async () =>\n  import(\"../../lib/public/js/lib/channel-create-operation.js\");\n\ndescribe(\"frontend/channel-create-operation\", () => {\n  beforeEach(() => {\n    vi.resetModules();\n    vi.useRealTimers();\n    global.window = {\n      EventSource: function EventSource() {},\n    };\n  });\n\n  it(\"falls back to direct create when EventSource is unavailable\", async () => {\n    const createChannelAccount = vi.fn(async () => ({ ok: true, mode: \"direct\" }));\n    const createChannelAccountJob = vi.fn();\n    const subscribeOperationEvents = vi.fn();\n    global.window = {};\n    vi.doMock(\"../../lib/public/js/lib/api.js\", () => ({\n      createChannelAccount,\n      createChannelAccountJob,\n      subscribeOperationEvents,\n    }));\n    const { createChannelAccountWithProgress } =\n      await loadChannelCreateOperationModule();\n\n    const result = await createChannelAccountWithProgress({\n      payload: { provider: \"telegram\" },\n      onPhase: vi.fn(),\n    });\n\n    expect(result).toEqual({ ok: true, mode: \"direct\" });\n    expect(createChannelAccount).toHaveBeenCalledWith({\n      provider: \"telegram\",\n    });\n    expect(createChannelAccountJob).not.toHaveBeenCalled();\n    expect(subscribeOperationEvents).not.toHaveBeenCalled();\n  });\n\n  it(\"debounces phase transitions and applies deferred phase after minimum visibility\", async () => {\n    vi.useFakeTimers();\n    const createChannelAccount = vi.fn();\n    const createChannelAccountJob = vi.fn(async () => ({ operationId: \"op-1\" }));\n    let handlers = null;\n    const close = vi.fn();\n    const subscribeOperationEvents = vi.fn((nextHandlers) => {\n      handlers = nextHandlers;\n      return close;\n    });\n    vi.doMock(\"../../lib/public/js/lib/api.js\", () => ({\n      createChannelAccount,\n      createChannelAccountJob,\n      subscribeOperationEvents,\n    }));\n    const { createChannelAccountWithProgress } =\n      await loadChannelCreateOperationModule();\n    const onPhase = vi.fn();\n\n    const operationPromise = createChannelAccountWithProgress({\n      payload: { provider: \"telegram\" },\n      onPhase,\n    });\n    await Promise.resolve();\n\n    handlers.onMessage({\n      event: \"phase\",\n      data: { phase: \"restarting\", label: \"Restarting gateway...\" },\n    });\n    handlers.onMessage({\n      event: \"phase\",\n      data: { phase: \"finalizing\", label: \"Finalizing...\" },\n    });\n\n    expect(onPhase.mock.calls.map((call) => call[0])).toEqual([\n      \"Loading...\",\n      \"Restarting gateway...\",\n    ]);\n\n    vi.advanceTimersByTime(1000);\n    expect(onPhase.mock.calls.map((call) => call[0])).toEqual([\n      \"Loading...\",\n      \"Restarting gateway...\",\n    ]);\n\n    vi.advanceTimersByTime(200);\n    expect(onPhase.mock.calls.map((call) => call[0])).toEqual([\n      \"Loading...\",\n      \"Restarting gateway...\",\n      \"Finalizing...\",\n    ]);\n\n    handlers.onMessage({\n      event: \"done\",\n      data: { ok: true },\n    });\n    await expect(operationPromise).resolves.toEqual({ ok: true });\n    expect(close).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"settles only once even if stream emits more terminal events\", async () => {\n    const createChannelAccount = vi.fn();\n    const createChannelAccountJob = vi.fn(async () => ({ operationId: \"op-2\" }));\n    let handlers = null;\n    const close = vi.fn();\n    const subscribeOperationEvents = vi.fn((nextHandlers) => {\n      handlers = nextHandlers;\n      return close;\n    });\n    vi.doMock(\"../../lib/public/js/lib/api.js\", () => ({\n      createChannelAccount,\n      createChannelAccountJob,\n      subscribeOperationEvents,\n    }));\n    const { createChannelAccountWithProgress } =\n      await loadChannelCreateOperationModule();\n\n    const operationPromise = createChannelAccountWithProgress({\n      payload: { provider: \"discord\" },\n      onPhase: vi.fn(),\n    });\n    await Promise.resolve();\n\n    handlers.onMessage({\n      event: \"error\",\n      data: { error: \"first failure\" },\n    });\n    handlers.onMessage({\n      event: \"done\",\n      data: { ok: true },\n    });\n    handlers.onError();\n\n    await expect(operationPromise).rejects.toThrow(\"first failure\");\n    expect(close).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/channel-provider-availability.test.js",
    "content": "const loadChannelProviderAvailabilityModule = async () =>\n  import(\"../../lib/public/js/lib/channel-provider-availability.js\");\n\ndescribe(\"frontend/channel-provider-availability\", () => {\n  beforeEach(() => {\n    vi.resetModules();\n  });\n\n  it(\"allows multiple Slack accounts while keeping Discord single-account\", async () => {\n    const {\n      isSingleAccountChannelProvider,\n      isChannelProviderDisabledForAdd,\n    } = await loadChannelProviderAvailabilityModule();\n    const configuredChannelMap = new Map([\n      [\"slack\", { accounts: [{ id: \"default\" }] }],\n      [\"discord\", { accounts: [{ id: \"default\" }] }],\n    ]);\n\n    expect(isSingleAccountChannelProvider(\"slack\")).toBe(false);\n    expect(isSingleAccountChannelProvider(\"discord\")).toBe(true);\n    expect(\n      isChannelProviderDisabledForAdd({\n        configuredChannelMap,\n        provider: \"slack\",\n      }),\n    ).toBe(false);\n    expect(\n      isChannelProviderDisabledForAdd({\n        configuredChannelMap,\n        provider: \"discord\",\n      }),\n    ).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/clipboard.test.js",
    "content": "const loadClipboardModule = async () => import(\"../../lib/public/js/lib/clipboard.js\");\n\ndescribe(\"frontend/clipboard\", () => {\n  beforeEach(() => {\n    vi.unstubAllGlobals();\n    vi.stubGlobal(\"navigator\", {});\n    vi.stubGlobal(\"document\", undefined);\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  it(\"uses the async clipboard API when available\", async () => {\n    const writeText = vi.fn().mockResolvedValue(undefined);\n    vi.stubGlobal(\"navigator\", {\n      clipboard: { writeText },\n    });\n\n    const { copyTextToClipboard } = await loadClipboardModule();\n\n    await expect(copyTextToClipboard(\"workspace/docs/readme.md\")).resolves.toBe(true);\n    expect(writeText).toHaveBeenCalledWith(\"workspace/docs/readme.md\");\n  });\n\n  it(\"falls back to document.execCommand when clipboard API is unavailable\", async () => {\n    const appendChild = vi.fn();\n    const removeChild = vi.fn();\n    const select = vi.fn();\n    const setAttribute = vi.fn();\n    const fallbackElement = {\n      value: \"\",\n      style: {},\n      select,\n      setAttribute,\n    };\n\n    vi.stubGlobal(\"document\", {\n      createElement: vi.fn(() => fallbackElement),\n      execCommand: vi.fn(() => true),\n      body: {\n        appendChild,\n        removeChild,\n      },\n    });\n\n    const { copyTextToClipboard } = await loadClipboardModule();\n\n    await expect(copyTextToClipboard(\"workspace/config.json\")).resolves.toBe(true);\n    expect(global.document.createElement).toHaveBeenCalledWith(\"textarea\");\n    expect(setAttribute).toHaveBeenCalledWith(\"readonly\", \"\");\n    expect(select).toHaveBeenCalledTimes(1);\n    expect(global.document.execCommand).toHaveBeenCalledWith(\"copy\");\n    expect(appendChild).toHaveBeenCalledWith(fallbackElement);\n    expect(removeChild).toHaveBeenCalledWith(fallbackElement);\n  });\n\n  it(\"returns false when there is no text to copy\", async () => {\n    const { copyTextToClipboard } = await loadClipboardModule();\n\n    await expect(copyTextToClipboard(\"\")).resolves.toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/codex-oauth-window.test.js",
    "content": "const loadCodexOauthWindow = async () =>\n  import(\"../../lib/public/js/lib/codex-oauth-window.js\");\n\ndescribe(\"frontend/codex-oauth-window\", () => {\n  beforeEach(() => {\n    vi.resetModules();\n    global.window = {\n      open: vi.fn(),\n      location: { href: \"http://localhost/\" },\n    };\n  });\n\n  it(\"uses popup features when opening Codex auth\", async () => {\n    global.window.open.mockReturnValue({ closed: false });\n    const mod = await loadCodexOauthWindow();\n\n    const opened = mod.openCodexAuthWindow();\n\n    expect(global.window.open).toHaveBeenCalledWith(\n      \"/auth/codex/start\",\n      \"codex-auth\",\n      \"popup=yes,width=640,height=780\",\n    );\n    expect(opened).toBeTruthy();\n  });\n\n  it(\"falls back to navigating the current page when opening fails\", async () => {\n    global.window.open.mockReturnValue(null);\n    const mod = await loadCodexOauthWindow();\n\n    const opened = mod.openCodexAuthWindow();\n\n    expect(opened).toBeNull();\n    expect(global.window.location.href).toBe(\"/auth/codex/start\");\n  });\n\n  it(\"detects automatic localhost callback messages\", async () => {\n    const mod = await loadCodexOauthWindow();\n\n    expect(\n      mod.isCodexAuthCallbackMessage({\n        codex: \"callback-input\",\n        input: \"http://localhost:1455/auth/callback?code=abc&state=def\",\n      }),\n    ).toBe(true);\n    expect(mod.isCodexAuthCallbackMessage({ codex: \"success\" })).toBe(false);\n    expect(\n      mod.isCodexAuthCallbackMessage({\n        codex: \"callback-input\",\n        input: \"   \",\n      }),\n    ).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/cron-calendar-helpers.test.js",
    "content": "const loadCalendarHelpers = async () =>\n  import(\"../../lib/public/js/components/cron-tab/cron-calendar-helpers.js\");\n\ndescribe(\"frontend/cron-calendar-helpers\", () => {\n  it(\"classifies repeating jobs separately\", async () => {\n    const { classifyRepeatingJobs } = await loadCalendarHelpers();\n    const { repeatingJobs, scheduledJobs } = classifyRepeatingJobs([\n      { id: \"job-every\", schedule: { kind: \"every\", everyMs: 30 * 60 * 1000 } },\n      { id: \"job-cron-15m\", schedule: { kind: \"cron\", expr: \"*/15 * * * *\" } },\n      { id: \"job-cron-25m-window\", schedule: { kind: \"cron\", expr: \"*/25 6-13 * * 1-5\" } },\n      { id: \"job-cron\", schedule: { kind: \"cron\", expr: \"0 2 * * *\" } },\n    ]);\n    expect(repeatingJobs.map((job) => job.id)).toEqual([\n      \"job-every\",\n      \"job-cron-15m\",\n      \"job-cron-25m-window\",\n    ]);\n    expect(scheduledJobs.map((job) => job.id)).toEqual([\"job-cron\"]);\n  });\n\n  it(\"expands cron schedules into rolling slots\", async () => {\n    const { expandJobsToRollingSlots } = await loadCalendarHelpers();\n    const nowMs = Date.UTC(2026, 2, 11, 10, 0, 0);\n    const { range, slots } = expandJobsToRollingSlots({\n      jobs: [{ id: \"job-cron\", name: \"Daily 2am\", schedule: { kind: \"cron\", expr: \"0 2 * * *\" } }],\n      nowMs,\n      pastDays: 1,\n      futureDays: 1,\n    });\n    expect(range.dayCount).toBe(3);\n    expect(slots.length).toBe(3);\n    expect(slots.every((slot) => slot.hourOfDay === 2)).toBe(true);\n  });\n\n  it(\"maps explicit run statuses to past slots only\", async () => {\n    const { mapRunStatusesToSlots } = await loadCalendarHelpers();\n    const nowMs = Date.UTC(2026, 2, 11, 10, 0, 0);\n    const pastSlotMs = Date.UTC(2026, 2, 11, 8, 0, 0);\n    const futureSlotMs = Date.UTC(2026, 2, 11, 12, 0, 0);\n    const slots = [\n      { key: \"job-a:past\", jobId: \"job-a\", scheduledAtMs: pastSlotMs },\n      { key: \"job-a:future\", jobId: \"job-a\", scheduledAtMs: futureSlotMs },\n    ];\n    const statusBySlotKey = mapRunStatusesToSlots({\n      slots,\n      bulkRunsByJobId: {\n        \"job-a\": {\n          entries: [{ ts: pastSlotMs + 15 * 60 * 1000, status: \"ok\" }],\n        },\n      },\n      nowMs,\n    });\n    expect(statusBySlotKey[\"job-a:past\"]).toBe(\"ok\");\n    expect(statusBySlotKey[\"job-a:future\"]).toBeUndefined();\n  });\n\n  it(\"returns upcoming slots within 24h window\", async () => {\n    const { getUpcomingSlots, buildSlotKey } = await loadCalendarHelpers();\n    const nowMs = Date.UTC(2026, 2, 11, 10, 0, 0);\n    const slots = [\n      { key: buildSlotKey({ jobId: \"a\", scheduledAtMs: nowMs - 3600000 }), jobId: \"a\", scheduledAtMs: nowMs - 3600000 },\n      { key: buildSlotKey({ jobId: \"b\", scheduledAtMs: nowMs + 3600000 }), jobId: \"b\", scheduledAtMs: nowMs + 3600000 },\n      { key: buildSlotKey({ jobId: \"c\", scheduledAtMs: nowMs + 7200000 }), jobId: \"c\", scheduledAtMs: nowMs + 7200000 },\n      { key: buildSlotKey({ jobId: \"d\", scheduledAtMs: nowMs + 25 * 3600000 }), jobId: \"d\", scheduledAtMs: nowMs + 25 * 3600000 },\n    ];\n    const result = getUpcomingSlots({ slots, nowMs });\n    expect(result.length).toBe(2);\n    expect(result[0].jobId).toBe(\"b\");\n    expect(result[1].jobId).toBe(\"c\");\n  });\n\n  it(\"builds token tiers from usage averages\", async () => {\n    const { buildTokenTierByJobId } = await loadCalendarHelpers();\n    const tierByJobId = buildTokenTierByJobId({\n      jobs: [\n        { id: \"job-1\", enabled: true },\n        { id: \"job-2\", enabled: true },\n        { id: \"job-3\", enabled: true },\n        { id: \"job-4\", enabled: true },\n        { id: \"job-5\", enabled: false },\n        { id: \"job-6\", enabled: true },\n      ],\n      usageByJobId: {\n        \"job-1\": { avgTokensPerRun: 10 },\n        \"job-2\": { avgTokensPerRun: 200 },\n        \"job-3\": { avgTokensPerRun: 400 },\n        \"job-4\": { avgTokensPerRun: 900 },\n      },\n    });\n    expect(tierByJobId[\"job-1\"]).toBe(\"low\");\n    expect(tierByJobId[\"job-4\"]).toBe(\"very-high\");\n    expect(tierByJobId[\"job-5\"]).toBe(\"disabled\");\n    expect(tierByJobId[\"job-6\"]).toBe(\"unknown\");\n  });\n});\n"
  },
  {
    "path": "tests/frontend/cron-helpers.test.js",
    "content": "const loadCronHelpers = async () =>\n  import(\"../../lib/public/js/components/cron-tab/cron-helpers.js\");\n\ndescribe(\"frontend/cron-helpers\", () => {\n  it(\"formats schedule labels\", async () => {\n    const { formatCronScheduleLabel } = await loadCronHelpers();\n    expect(\n      formatCronScheduleLabel({\n        kind: \"every\",\n        everyMs: 30 * 60 * 1000,\n      }),\n    ).toContain(\"Every\");\n    expect(\n      formatCronScheduleLabel({\n        kind: \"cron\",\n        expr: \"0 8 * * 1-5\",\n        tz: \"America/Los_Angeles\",\n      }),\n    ).toContain(\"Weekdays at\");\n    expect(\n      formatCronScheduleLabel(\n        {\n          kind: \"cron\",\n          expr: \"0 8 * * 1-5\",\n          tz: \"UTC\",\n        },\n        {\n          includeTimeZoneWhenDifferent: true,\n          clientTimeZone: \"America/Los_Angeles\",\n        },\n      ),\n    ).toContain(\"(UTC)\");\n    expect(\n      formatCronScheduleLabel(\n        {\n          kind: \"cron\",\n          expr: \"0 8 * * 1-5\",\n          tz: \"America/Los_Angeles\",\n        },\n        {\n          includeTimeZoneWhenDifferent: true,\n          clientTimeZone: \"America/Los_Angeles\",\n        },\n      ),\n    ).not.toContain(\"(\");\n    expect(\n      formatCronScheduleLabel({\n        kind: \"cron\",\n        expr: \"*/25 6-13 * * 1-5\",\n      }),\n    ).toBe(\"Every 25m, 6am-1pm weekdays\");\n    expect(\n      formatCronScheduleLabel({\n        kind: \"cron\",\n        expr: \"0 4 1 * *\",\n      }),\n    ).toBe(\"Monthly on day 1 at 4:00am\");\n    expect(\n      formatCronScheduleLabel({\n        cron: \"0 10 * * 6\",\n      }),\n    ).toBe(\"Every Sat at 10:00am\");\n    expect(\n      formatCronScheduleLabel({\n        kind: \"at\",\n        at: \"2026-03-11T08:00:00.000Z\",\n      }),\n    ).toContain(\"At\");\n  });\n\n  it(\"builds optimization warnings for risky jobs\", async () => {\n    const { buildCronOptimizationWarnings } = await loadCronHelpers();\n    const warnings = buildCronOptimizationWarnings(\n      [\n        {\n          id: \"job-1\",\n          name: \"Delivery Mismatch\",\n          delivery: { mode: \"none\" },\n          payload: { kind: \"systemEvent\", text: \"Use message tool to send to telegram\" },\n          state: { consecutiveErrors: 0 },\n        },\n        {\n          id: \"job-2\",\n          name: \"Erroring Job\",\n          delivery: { mode: \"announce\" },\n          payload: { message: \"noop\" },\n          state: { consecutiveErrors: 3 },\n        },\n        {\n          id: \"job-3\",\n          name: \"Heartbeat Delivery\",\n          delivery: { mode: \"announce\" },\n          payload: { message: \"noop\" },\n          state: {\n            consecutiveErrors: 0,\n            lastDelivered: false,\n            lastDeliveryStatus: \"not-delivered\",\n          },\n        },\n        {\n          id: \"job-4\",\n          name: \"Needs Delivery\",\n          delivery: { mode: \"announce\" },\n          payload: { message: \"noop\" },\n          state: {\n            consecutiveErrors: 0,\n            lastDelivered: false,\n            lastDeliveryStatus: \"not-delivered\",\n            lastSummary: \"Work complete.\",\n          },\n        },\n        {\n          id: \"job-5\",\n          name: \"Ok But Not Delivered\",\n          delivery: { mode: \"announce\" },\n          payload: { message: \"noop\" },\n          state: {\n            lastDelivered: false,\n            lastDeliveryStatus: \"not-delivered\",\n            lastStatus: \"ok\",\n          },\n        },\n      ],\n      {\n        \"job-3\": {\n          entries: [\n            {\n              ts: Date.now(),\n              summary: \"HEARTBEAT_OK (Note: refresher check only)\",\n            },\n          ],\n        },\n      },\n    );\n    expect(warnings.length).toBeGreaterThan(0);\n    expect(warnings.some((warning) => warning.title.includes(\"Delivery Mismatch\"))).toBe(true);\n    expect(warnings.some((warning) => warning.title.includes(\"Erroring Job\"))).toBe(true);\n    expect(warnings.some((warning) => warning.title.includes(\"Heartbeat Delivery\"))).toBe(false);\n    expect(warnings.some((warning) => warning.title.includes(\"Needs Delivery\"))).toBe(true);\n    expect(warnings.some((warning) => warning.title.includes(\"Ok But Not Delivered\"))).toBe(false);\n  });\n\n  it(\"reads cron prompts from systemEvent text or agentTurn message payloads\", async () => {\n    const { readCronJobPrompt } = await loadCronHelpers();\n    expect(\n      readCronJobPrompt({\n        payload: { kind: \"systemEvent\", text: \"main prompt\" },\n      }),\n    ).toBe(\"main prompt\");\n    expect(\n      readCronJobPrompt({\n        payload: { kind: \"agentTurn\", message: \"isolated prompt\" },\n      }),\n    ).toBe(\"isolated prompt\");\n    expect(readCronJobPrompt({ payload: { text: \"missing kind\" } })).toBe(\"\");\n  });\n\n  it(\"formats next run as due/overdue when timestamp is in the past\", async () => {\n    const { formatNextRunRelativeMs } = await loadCronHelpers();\n    const nowMs = Date.now();\n    expect(formatNextRunRelativeMs(nowMs - 15 * 1000, nowMs)).toBe(\"due now\");\n    expect(formatNextRunRelativeMs(nowMs - 2 * 60 * 1000, nowMs)).toBe(\"overdue by 2m\");\n    expect(formatNextRunRelativeMs(nowMs + 2 * 60 * 1000, nowMs)).toBe(\"in 2m\");\n  });\n\n  it(\"formats compact relative values in short suffix style\", async () => {\n    const { formatRelativeCompact } = await loadCronHelpers();\n    const nowMs = Date.now();\n    expect(formatRelativeCompact(nowMs - 10 * 1000, nowMs)).toBe(\"10s\");\n    expect(formatRelativeCompact(nowMs - 10 * 60 * 1000, nowMs)).toBe(\"10m\");\n    expect(formatRelativeCompact(nowMs - 10 * 60 * 60 * 1000, nowMs)).toBe(\"10h\");\n    expect(formatRelativeCompact(nowMs - 10 * 24 * 60 * 60 * 1000, nowMs)).toBe(\"10d\");\n    expect(formatRelativeCompact(nowMs - 30 * 24 * 60 * 60 * 1000, nowMs)).toBe(\"1mo\");\n  });\n});\n"
  },
  {
    "path": "tests/frontend/doctor-helpers.test.js",
    "content": "const loadDoctorHelpers = async () =>\n  import(\"../../lib/public/js/components/doctor/helpers.js\");\n\ndescribe(\"frontend/doctor helpers\", () => {\n  it(\"groups cards by status and counts priorities\", async () => {\n    const helpers = await loadDoctorHelpers();\n    const cards = [\n      { id: 1, priority: \"P0\", status: \"open\" },\n      { id: 2, priority: \"P1\", status: \"dismissed\" },\n      { id: 3, priority: \"P2\", status: \"fixed\" },\n      { id: 4, priority: \"P2\", status: \"open\" },\n    ];\n\n    expect(helpers.buildDoctorPriorityCounts(cards)).toEqual({\n      P0: 1,\n      P1: 1,\n      P2: 2,\n    });\n    expect(helpers.groupDoctorCardsByStatus(cards)).toEqual({\n      open: [\n        { id: 1, priority: \"P0\", status: \"open\" },\n        { id: 4, priority: \"P2\", status: \"open\" },\n      ],\n      dismissed: [{ id: 2, priority: \"P1\", status: \"dismissed\" }],\n      fixed: [{ id: 3, priority: \"P2\", status: \"fixed\" }],\n    });\n  });\n\n  it(\"only shows the warning for stale Doctor states with meaningful changes\", async () => {\n    const helpers = await loadDoctorHelpers();\n\n    expect(\n      helpers.shouldShowDoctorWarning({\n        needsInitialRun: true,\n        stale: true,\n        changeSummary: { hasMeaningfulChanges: true },\n      }),\n    ).toBe(false);\n    expect(\n      helpers.shouldShowDoctorWarning({\n        needsInitialRun: false,\n        stale: false,\n        changeSummary: { hasMeaningfulChanges: true },\n      }),\n    ).toBe(false);\n    expect(\n      helpers.shouldShowDoctorWarning({\n        needsInitialRun: false,\n        stale: true,\n        changeSummary: { hasMeaningfulChanges: false },\n      }),\n    ).toBe(false);\n    expect(\n      helpers.shouldShowDoctorWarning(\n        {\n          needsInitialRun: false,\n          stale: true,\n          changeSummary: { hasMeaningfulChanges: true },\n        },\n        Date.now() + 1000,\n      ),\n    ).toBe(false);\n    expect(\n      helpers.shouldShowDoctorWarning({\n        needsInitialRun: false,\n        stale: true,\n        changeSummary: { hasMeaningfulChanges: true },\n      }),\n    ).toBe(true);\n    expect(\n      helpers.getDoctorWarningMessage({\n        needsInitialRun: false,\n        stale: true,\n        changeSummary: { changedFilesCount: 3 },\n      }),\n    ).toBe(\n      \"Drift Doctor has not been run in the last week and 3 files changed since the last review.\",\n    );\n  });\n\n  it(\"formats categories and run filter options\", async () => {\n    const helpers = await loadDoctorHelpers();\n\n    expect(helpers.formatDoctorCategory(\"token_efficiency\")).toBe(\n      \"Token Efficiency\",\n    );\n    expect(helpers.getDoctorCategoryTone(\"token_efficiency\")).toBe(\"info\");\n    expect(helpers.getDoctorCategoryTone(\"redundancy\")).toBe(\"accent\");\n    expect(helpers.getDoctorCategoryTone(\"workspace_state\")).toBe(\"secondary\");\n    expect(\n      helpers.buildDoctorRunMarkers({\n        status: \"completed\",\n        cardCount: 0,\n        priorityCounts: { P0: 0, P1: 0, P2: 0 },\n      }),\n    ).toEqual([{ tone: \"success\", count: 0, label: \"No findings\" }]);\n    expect(\n      helpers.buildDoctorRunMarkers({\n        status: \"completed\",\n        cardCount: 3,\n        priorityCounts: { P0: 2, P1: 1, P2: 0 },\n      }),\n    ).toEqual([\n      { tone: \"danger\", count: 0, label: \"P0\" },\n      { tone: \"warning\", count: 0, label: \"P1\" },\n    ]);\n    expect(\n      helpers.buildDoctorRunMarkers({\n        status: \"running\",\n      }),\n    ).toEqual([{ tone: \"cyan\", count: 0, label: \"Running\" }]);\n    expect(helpers.getDoctorRunPillDetail({ status: \"failed\" })).toBe(\"Failed\");\n    expect(\n      helpers.getDoctorRunPillDetail({ status: \"completed\", cardCount: 0 }),\n    ).toBe(\"No findings\");\n    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 0 })).toBe(\n      \"No changes since last run\",\n    );\n    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 2 })).toBe(\n      \"2 changes since last run\",\n    );\n    expect(helpers.getDoctorChangeLabel({ changedFilesCount: 1 })).toBe(\n      \"1 change since last run\",\n    );\n    expect(helpers.getDoctorStatusTone(\"fixed\")).toBe(\"success\");\n    expect(helpers.buildDoctorStatusFilterOptions()).toEqual([\n      { value: \"open\", label: \"Open\" },\n      { value: \"dismissed\", label: \"Dismissed\" },\n      { value: \"fixed\", label: \"Fixed\" },\n    ]);\n  });\n\n  it(\"formats persistent Project Context truncation warnings\", async () => {\n    const helpers = await loadDoctorHelpers();\n    const fileLimitStatus = {\n      bootstrapContext: {\n        hasActiveTruncation: true,\n        hasActiveNearLimitFiles: false,\n        hasActiveWarnings: true,\n        hasTotalLimitTruncation: false,\n        bootstrapMaxChars: 20000,\n        bootstrapTotalMaxChars: 150000,\n        truncationGuidance:\n          \"OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.\",\n        activeTruncatedFiles: [\n          { path: \"AGENTS.md\", rawChars: 24500, injectedChars: 20000 },\n        ],\n        activeNearLimitFiles: [],\n      },\n    };\n    const multiFileStatus = {\n      bootstrapContext: {\n        hasActiveTruncation: true,\n        hasActiveNearLimitFiles: true,\n        hasActiveWarnings: true,\n        hasTotalLimitTruncation: true,\n        bootstrapMaxChars: 20000,\n        bootstrapTotalMaxChars: 150000,\n        truncationGuidance:\n          \"OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.\",\n        activeTruncatedFiles: [\n          { path: \"AGENTS.md\", rawChars: 24500, injectedChars: 20000 },\n          {\n            path: \"hooks/bootstrap/AGENTS.md\",\n            rawChars: 1800,\n            injectedChars: 1000,\n          },\n        ],\n        activeNearLimitFiles: [\n          { path: \"TOOLS.md\", rawChars: 20000 },\n          {\n            path: \"hooks/bootstrap/TOOLS.md\",\n            rawChars: 19500,\n            injectedChars: 19500,\n          },\n        ],\n      },\n    };\n\n    const nearLimitStatus = {\n      bootstrapContext: {\n        hasActiveTruncation: false,\n        hasActiveNearLimitFiles: true,\n        hasActiveWarnings: true,\n        bootstrapMaxChars: 20000,\n        bootstrapTotalMaxChars: 150000,\n        activeTruncatedFiles: [],\n        activeNearLimitFiles: [\n          { path: \"TOOLS.md\", rawChars: 19000, injectedChars: 19000 },\n        ],\n      },\n    };\n\n    expect(helpers.hasDoctorBootstrapWarnings(fileLimitStatus)).toBe(true);\n    expect(helpers.getDoctorBootstrapWarningTitle(fileLimitStatus)).toBe(\n      \"One of your main files is being truncated:\",\n    );\n    expect(helpers.getDoctorBootstrapWarningTitle(multiFileStatus)).toBe(\n      \"Some of your main files are being truncated or nearing the limit:\",\n    );\n    expect(helpers.getDoctorBootstrapTruncationItems(multiFileStatus)).toEqual([\n      {\n        path: \"AGENTS.md\",\n        size: \"24,500 chars\",\n        statusText: \"-4,500 cut\",\n        statusTone: \"danger\",\n      },\n      {\n        path: \"TOOLS.md\",\n        size: \"20,000 chars\",\n        statusText: \"Near limit\",\n        statusTone: \"warning\",\n      },\n    ]);\n    expect(helpers.getDoctorBootstrapWarningTitle(nearLimitStatus)).toBe(\n      \"One of your main files is nearing the limit:\",\n    );\n    expect(helpers.getDoctorBootstrapTruncationItems(fileLimitStatus)).toEqual([\n      {\n        path: \"AGENTS.md\",\n        size: \"24,500 chars\",\n        statusText: \"-4,500 cut\",\n        statusTone: \"danger\",\n      },\n    ]);\n    expect(helpers.getDoctorBootstrapTruncationItems(nearLimitStatus)).toEqual([\n      {\n        path: \"TOOLS.md\",\n        size: \"19,000 chars\",\n        statusText: \"Near limit\",\n        statusTone: \"warning\",\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/file-tree-utils.test.js",
    "content": "const loadFileTreeUtils = async () => import(\"../../lib/public/js/lib/file-tree-utils.js\");\n\ndescribe(\"frontend/file-tree-utils\", () => {\n  it(\"collects ancestor folder paths for selected files\", async () => {\n    const { collectAncestorFolderPaths } = await loadFileTreeUtils();\n\n    expect(collectAncestorFolderPaths(\"devices/agents/config.json\")).toEqual([\n      \"devices\",\n      \"devices/agents\",\n    ]);\n  });\n\n  it(\"returns empty list for top-level files\", async () => {\n    const { collectAncestorFolderPaths } = await loadFileTreeUtils();\n\n    expect(collectAncestorFolderPaths(\"openclaw.json\")).toEqual([]);\n    expect(collectAncestorFolderPaths(\"\")).toEqual([]);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/file-viewer-utils.test.js",
    "content": "const loadFileViewerUtils = async () =>\n  import(\"../../lib/public/js/components/file-viewer/utils.js\");\n\ndescribe(\"frontend/file-viewer-utils\", () => {\n  it(\"counts lines without splitting huge strings\", async () => {\n    const { countTextLines } = await loadFileViewerUtils();\n\n    expect(countTextLines(\"\")).toBe(1);\n    expect(countTextLines(\"one line\")).toBe(1);\n    expect(countTextLines(\"a\\nb\\nc\")).toBe(3);\n  });\n\n  it(\"switches to simple editor mode for very large files\", async () => {\n    const { shouldUseSimpleEditorMode } = await loadFileViewerUtils();\n\n    expect(\n      shouldUseSimpleEditorMode({\n        contentLength: 300000,\n        lineCount: 50,\n        charThreshold: 250000,\n        lineThreshold: 5000,\n      }),\n    ).toBe(true);\n    expect(\n      shouldUseSimpleEditorMode({\n        contentLength: 1000,\n        lineCount: 8000,\n        charThreshold: 250000,\n        lineThreshold: 5000,\n      }),\n    ).toBe(true);\n    expect(\n      shouldUseSimpleEditorMode({\n        contentLength: 1000,\n        lineCount: 100,\n        charThreshold: 250000,\n        lineThreshold: 5000,\n      }),\n    ).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/model-catalog.test.js",
    "content": "describe(\"frontend/model-catalog\", () => {\n  it(\"returns catalog models when the payload is valid\", async () => {\n    const { getModelCatalogModels } = await import(\n      \"../../lib/public/js/lib/model-catalog.js\"\n    );\n\n    expect(\n      getModelCatalogModels({\n        models: [{ key: \"openai/gpt-5.4\", label: \"GPT-5.4\" }],\n      }),\n    ).toEqual([{ key: \"openai/gpt-5.4\", label: \"GPT-5.4\" }]);\n    expect(getModelCatalogModels(null)).toEqual([]);\n  });\n\n  it(\"preserves an existing onboarding selection\", async () => {\n    const { getInitialOnboardingModelKey } = await import(\n      \"../../lib/public/js/lib/model-catalog.js\"\n    );\n\n    expect(\n      getInitialOnboardingModelKey({\n        catalog: [{ key: \"openai-codex/gpt-5.4\", label: \"GPT-5.4\" }],\n        currentModelKey: \"anthropic/claude-opus-4-6\",\n      }),\n    ).toBe(\"anthropic/claude-opus-4-6\");\n  });\n\n  it(\"picks the first featured onboarding model when nothing is selected\", async () => {\n    const { getInitialOnboardingModelKey } = await import(\n      \"../../lib/public/js/lib/model-catalog.js\"\n    );\n\n    expect(\n      getInitialOnboardingModelKey({\n        catalog: [\n          { key: \"openai-codex/gpt-5.4\", label: \"GPT-5.4\" },\n          { key: \"anthropic/claude-opus-4-7\", label: \"Opus 4.7\" },\n          { key: \"anthropic/claude-opus-4-6\", label: \"Opus 4.6\" },\n        ],\n      }),\n    ).toBe(\"anthropic/claude-opus-4-7\");\n  });\n\n  it(\"reports whether the catalog is still refreshing\", async () => {\n    const { isModelCatalogRefreshing } = await import(\n      \"../../lib/public/js/lib/model-catalog.js\"\n    );\n\n    expect(isModelCatalogRefreshing({ refreshing: true })).toBe(true);\n    expect(isModelCatalogRefreshing({ refreshing: false })).toBe(false);\n  });\n\n  it(\"forces a real fetch when preloading the onboarding model catalog\", async () => {\n    vi.resetModules();\n    global.fetch = vi.fn().mockResolvedValue({\n      status: 200,\n      ok: true,\n      json: async () => ({\n        models: [{ key: \"openai/gpt-5.4\", label: \"GPT-5.4\" }],\n      }),\n    });\n\n    const {\n      getCached,\n      invalidateCache,\n      setCached,\n    } = await import(\"../../lib/public/js/lib/api-cache.js\");\n    const {\n      kModelCatalogCacheKey,\n      preloadModelCatalog,\n    } = await import(\"../../lib/public/js/lib/model-catalog.js\");\n\n    invalidateCache(kModelCatalogCacheKey);\n    setCached(kModelCatalogCacheKey, {\n      models: [{ key: \"fallback/model\", label: \"Fallback\" }],\n    });\n\n    const result = await preloadModelCatalog();\n\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"/api/models\",\n      expect.objectContaining({ headers: expect.any(Headers) }),\n    );\n    expect(result).toEqual({\n      models: [{ key: \"openai/gpt-5.4\", label: \"GPT-5.4\" }],\n    });\n    expect(getCached(kModelCatalogCacheKey)).toEqual(result);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/model-config.test.js",
    "content": "const loadModelConfig = async () =>\n  import(\"../../lib/public/js/lib/model-config.js\");\n\ndescribe(\"frontend/model-config\", () => {\n  it(\"maps openai-codex auth provider to openai\", async () => {\n    const modelConfig = await loadModelConfig();\n    expect(modelConfig.getAuthProviderFromModelProvider(\"openai-codex\")).toBe(\"openai\");\n    expect(modelConfig.getAuthProviderFromModelProvider(\"volcengine-plan\")).toBe(\n      \"volcengine\",\n    );\n    expect(modelConfig.getAuthProviderFromModelProvider(\"byteplus-plan\")).toBe(\n      \"byteplus\",\n    );\n    expect(modelConfig.getAuthProviderFromModelProvider(\"google\")).toBe(\"google\");\n  });\n\n  it(\"returns visible AI field keys for provider\", async () => {\n    const modelConfig = await loadModelConfig();\n    const keys = modelConfig.getVisibleAiFieldKeys(\"openai-codex\");\n    expect(keys.has(\"OPENAI_API_KEY\")).toBe(false);\n    expect(keys.has(\"ANTHROPIC_API_KEY\")).toBe(false);\n    const zaiKeys = modelConfig.getVisibleAiFieldKeys(\"zai\");\n    expect(zaiKeys.has(\"ZAI_API_KEY\")).toBe(true);\n    const volcengineKeys = modelConfig.getVisibleAiFieldKeys(\"volcengine-plan\");\n    expect(volcengineKeys.has(\"VOLCANO_ENGINE_API_KEY\")).toBe(true);\n  });\n\n  it(\"picks featured models in defined preference order\", async () => {\n    const modelConfig = await loadModelConfig();\n    const featured = modelConfig.getFeaturedModels([\n      { key: \"google/gemini-3.1-pro-preview\", label: \"Gemini 3.1 Pro\" },\n      { key: \"anthropic/claude-opus-4-7\", label: \"Opus 4.7\" },\n      { key: \"anthropic/claude-opus-4-6\", label: \"Opus 4.6\" },\n      { key: \"openai-codex/gpt-5.3-codex\", label: \"Codex 5.3\" },\n      { key: \"openai-codex/gpt-5.4\", label: \"GPT-5.4\" },\n      { key: \"openai-codex/gpt-5.5\", label: \"GPT-5.5\" },\n    ]);\n\n    expect(featured.map((entry) => entry.key)).toEqual([\n      \"anthropic/claude-opus-4-7\",\n      \"anthropic/claude-opus-4-6\",\n      \"openai-codex/gpt-5.3-codex\",\n      \"openai-codex/gpt-5.5\",\n      \"google/gemini-3.1-pro-preview\",\n    ]);\n    expect(featured[0]?.featuredLabel).toBe(\"Opus 4.7\");\n    expect(featured[3]?.featuredLabel).toBe(\"GPT-5.5\");\n    expect(featured[4]?.featuredLabel).toBe(\"Gemini 3.1 Pro\");\n  });\n});\n"
  },
  {
    "path": "tests/frontend/pairing-utils.test.js",
    "content": "import {\n  getPreferredPairingChannel,\n  isChannelPaired,\n} from \"../../lib/public/js/components/onboarding/pairing-utils.js\";\n\ndescribe(\"frontend/onboarding/pairing-utils\", () => {\n  it(\"prefers telegram when both channel tokens are present\", () => {\n    const channel = getPreferredPairingChannel({\n      TELEGRAM_BOT_TOKEN: \"tg-token\",\n      DISCORD_BOT_TOKEN: \"dc-token\",\n    });\n\n    expect(channel).toBe(\"telegram\");\n  });\n\n  it(\"falls back to discord when telegram is missing\", () => {\n    const channel = getPreferredPairingChannel({\n      DISCORD_BOT_TOKEN: \"dc-token\",\n    });\n\n    expect(channel).toBe(\"discord\");\n  });\n\n  it(\"returns empty string when no channel tokens are present\", () => {\n    expect(getPreferredPairingChannel({})).toBe(\"\");\n  });\n\n  it(\"treats channel as paired only when status is paired and count > 0\", () => {\n    const channels = {\n      telegram: { status: \"paired\", paired: 1 },\n      discord: { status: \"configured\", paired: 0 },\n    };\n\n    expect(isChannelPaired(channels, \"telegram\")).toBe(true);\n    expect(isChannelPaired(channels, \"discord\")).toBe(false);\n    expect(isChannelPaired(channels, \"unknown\")).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/frontend/session-keys.test.js",
    "content": "import { describe, expect, it } from \"vitest\";\nimport {\n  getSessionChannelForIcon,\n  getSessionDisplayLabel,\n  getSessionKind,\n  parseChannelFromSessionKey,\n} from \"../../lib/public/js/lib/session-keys.js\";\n\ndescribe(\"session-keys display helpers\", () => {\n  it(\"getSessionDisplayLabel renders main thread from key\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"agent:main:main\",\n      }),\n    ).toBe(\"Main Thread\");\n  });\n\n  it(\"getSessionDisplayLabel renders bare main key as Main Thread\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"main\",\n      }),\n    ).toBe(\"Main Thread\");\n  });\n\n  it(\"getSessionDisplayLabel renders telegram direct as Direct message\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"agent:main:telegram:default:direct:1050\",\n      }),\n    ).toBe(\"Direct message\");\n  });\n\n  it(\"getSessionDisplayLabel renders non-telegram direct with id\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"agent:main:slack:direct:u02r12345\",\n      }),\n    ).toBe(\"Direct u02r12345\");\n  });\n\n  it(\"getSessionDisplayLabel uses enriched topic/group names when present\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"agent:main:telegram:group:-1003709908795:topic:4011\",\n        topicName: \"Rosebud\",\n        groupName: \"AlphaClaw\",\n      }),\n    ).toBe(\"Rosebud - AlphaClaw\");\n  });\n\n  it(\"getSessionDisplayLabel falls back to topic id/group id when names missing\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"agent:main:telegram:group:-1003709908795:topic:4011\",\n      }),\n    ).toBe(\"Topic 4011 - -1003709908795\");\n  });\n\n  it(\"getSessionDisplayLabel renders doctor runs with sequence number\", () => {\n    expect(\n      getSessionDisplayLabel({\n        key: \"agent:main:doctor:1\",\n      }),\n    ).toBe(\"Doctor Run #1\");\n  });\n\n  it(\"getSessionKind classifies session keys\", () => {\n    expect(getSessionKind(\"agent:main:main\")).toBe(\"main\");\n    expect(getSessionKind(\"agent:main:telegram:group:-1:topic:9\")).toBe(\"topic\");\n    expect(getSessionKind(\"agent:main:telegram:direct:7\")).toBe(\"direct\");\n    expect(getSessionKind(\"agent:main:slash:foo\")).toBe(\"slash\");\n    expect(getSessionKind(\"agent:main:subagent:worker:123\")).toBe(\"subagent\");\n    expect(getSessionKind(\"agent:main:custom:thing\")).toBe(\"other\");\n  });\n\n  it(\"parseChannelFromSessionKey detects telegram in key\", () => {\n    expect(parseChannelFromSessionKey(\"agent:main:telegram:direct:1\")).toBe(\n      \"telegram\",\n    );\n  });\n\n  it(\"getSessionChannelForIcon uses replyChannel when channel missing\", () => {\n    expect(\n      getSessionChannelForIcon({\n        key: \"agent:main:main\",\n        replyChannel: \"telegram\",\n      }),\n    ).toBe(\"telegram\");\n  });\n});\n"
  },
  {
    "path": "tests/frontend/syntax-highlighters.test.js",
    "content": "const loadSyntaxHighlighters = async () =>\n  import(\"../../lib/public/js/lib/syntax-highlighters/index.js\");\n\ndescribe(\"frontend/syntax-highlighters\", () => {\n  it(\"maps file extensions to expected syntax kinds\", async () => {\n    const { getFileSyntaxKind } = await loadSyntaxHighlighters();\n\n    expect(getFileSyntaxKind(\"notes/readme.md\")).toBe(\"markdown\");\n    expect(getFileSyntaxKind(\"logs/events.jsonl\")).toBe(\"json\");\n    expect(getFileSyntaxKind(\"src/index.mjs\")).toBe(\"javascript\");\n    expect(getFileSyntaxKind(\"styles/app.scss\")).toBe(\"css\");\n    expect(getFileSyntaxKind(\"pages/home.html\")).toBe(\"html\");\n  });\n\n  it(\"keeps dashed JSON keys and values intact\", async () => {\n    const { highlightEditorLines } = await loadSyntaxHighlighters();\n    const lines = highlightEditorLines('{\"my-key\":\"value-with-dash\"}', \"json\");\n\n    expect(lines).toHaveLength(1);\n    expect(lines[0].html).toContain('<span class=\"hl-key\">\"my-key\"</span>');\n    expect(lines[0].html).toContain('<span class=\"hl-string\">\"value-with-dash\"</span>');\n    expect(lines[0].html).not.toContain(\"<span class=\\\"hl-key\\\">\\\"my</span>\");\n    expect(lines[0].html).not.toContain(\"<span class=\\\"hl-string\\\">\\\"value</span>\");\n  });\n\n  it(\"highlights inline css/js inside html blocks\", async () => {\n    const { highlightEditorLines } = await loadSyntaxHighlighters();\n    const lines = highlightEditorLines(\n      [\n        \"<style>body { color: red; }</style>\",\n        \"<script>const count = 1;</script>\",\n      ].join(\"\\n\"),\n      \"html\",\n    );\n\n    expect(lines[0].html).toContain('<span class=\"hl-tag\">style</span>');\n    expect(lines[0].html).toContain('<span class=\"hl-attr\">color</span>');\n    expect(lines[1].html).toContain('<span class=\"hl-tag\">script</span>');\n    expect(lines[1].html).toContain('<span class=\"hl-keyword\">const</span>');\n  });\n});\n"
  },
  {
    "path": "tests/frontend/watchdog-helpers.test.js",
    "content": "const loadWatchdogHelpers = async () =>\n  import(\"../../lib/public/js/components/watchdog-tab/helpers.js\");\n\ndescribe(\"frontend/watchdog-helpers\", () => {\n  it(\"formats a watchdog export with logs\", async () => {\n    const { formatWatchdogCopyAllText } = await loadWatchdogHelpers();\n\n    const text = formatWatchdogCopyAllText({\n      logs: \"line 1\\nline 2\",\n      generatedAt: new Date(\"2026-03-22T23:15:00.000Z\"),\n    });\n\n    expect(text).toContain(\"# AlphaClaw Watchdog Export\");\n    expect(text).toContain(\"Generated at: 2026-03-22T23:15:00.000Z\");\n    expect(text).toContain(\"## Gateway Logs\");\n    expect(text).toContain(\"line 1\\nline 2\");\n  });\n\n  it(\"falls back to an empty-state label when logs are missing\", async () => {\n    const { formatWatchdogCopyAllText } = await loadWatchdogHelpers();\n\n    const text = formatWatchdogCopyAllText({\n      logs: \"\",\n      generatedAt: new Date(\"2026-03-22T23:20:00.000Z\"),\n    });\n\n    expect(text).toContain(\"## Gateway Logs\");\n    expect(text).toContain(\"No logs yet.\");\n  });\n});\n"
  },
  {
    "path": "tests/frontend/welcome-config.test.js",
    "content": "const loadWelcomeConfig = async () =>\n  import(\"../../lib/public/js/components/onboarding/welcome-config.js\");\n\ndescribe(\"frontend/welcome-config\", () => {\n  it(\"reports a target repo format error for invalid GitHub input\", async () => {\n    const welcomeConfig = await loadWelcomeConfig();\n\n    expect(\n      welcomeConfig.getWelcomeGroupError(\"github\", {\n        GITHUB_TOKEN: \"ghp_123\",\n        GITHUB_WORKSPACE_REPO: \"owner-only\",\n      }),\n    ).toBe('Target repo must be in \"owner/repo\" format.');\n  });\n\n  it(\"requires a source repo when import mode is selected\", async () => {\n    const welcomeConfig = await loadWelcomeConfig();\n\n    expect(\n      welcomeConfig.getWelcomeGroupError(\"github\", {\n        _GITHUB_FLOW: welcomeConfig.kGithubFlowImport,\n        GITHUB_TOKEN: \"ghp_123\",\n        GITHUB_WORKSPACE_REPO: \"owner/target-repo\",\n        _GITHUB_SOURCE_REPO: \"\",\n      }),\n    ).toBe('Enter the source repo as \"owner/repo\".');\n  });\n\n  it(\"returns a Codex-specific auth message for the AI step\", async () => {\n    const welcomeConfig = await loadWelcomeConfig();\n\n    expect(\n      welcomeConfig.getWelcomeGroupError(\n        \"ai\",\n        { MODEL_KEY: \"openai-codex/gpt-5.4\" },\n        {\n          selectedProvider: \"openai-codex\",\n          hasAi: false,\n          codexLoading: false,\n        },\n      ),\n    ).toBe(\"Connect Codex OAuth to continue.\");\n  });\n\n  it(\"requires both Slack tokens before the channels step can pass\", async () => {\n    const welcomeConfig = await loadWelcomeConfig();\n\n    expect(\n      welcomeConfig.getWelcomeGroupError(\"channels\", {\n        SLACK_BOT_TOKEN: \"xoxb-123\",\n        SLACK_APP_TOKEN: \"\",\n      }),\n    ).toBe(\"Add the Slack app token to continue with Slack.\");\n  });\n\n  it(\"finds the first invalid step in welcome order\", async () => {\n    const welcomeConfig = await loadWelcomeConfig();\n\n    const invalidGroup = welcomeConfig.findFirstInvalidWelcomeGroup(\n      {\n        GITHUB_TOKEN: \"ghp_123\",\n        GITHUB_WORKSPACE_REPO: \"owner/target-repo\",\n        MODEL_KEY: \"openai-codex/gpt-5.4\",\n      },\n      {\n        selectedProvider: \"openai-codex\",\n        hasAi: false,\n        codexLoading: false,\n      },\n    );\n\n    expect(invalidGroup?.id).toBe(\"ai\");\n  });\n});\n"
  },
  {
    "path": "tests/frontend/welcome-secret-review-utils.test.js",
    "content": "const loadWelcomeSecretReviewUtils = async () =>\n  import(\n    \"../../lib/public/js/components/onboarding/welcome-secret-review-utils.js\"\n  );\n\ndescribe(\"frontend/welcome secret review utils\", () => {\n  it(\"builds default approved secrets from high-confidence findings\", async () => {\n    const utils = await loadWelcomeSecretReviewUtils();\n    const secrets = [\n      {\n        configPath: \"channels.discord.token\",\n        confidence: \"high\",\n        suggestedEnvVar: \"DISCORD_BOT_TOKEN\",\n      },\n      {\n        configPath: \"models.providers.custom.apiKey\",\n        confidence: \"medium\",\n        suggestedEnvVar: \"CUSTOM_API_KEY\",\n      },\n    ];\n\n    expect(utils.buildApprovedImportSecrets(secrets)).toEqual([\n      {\n        configPath: \"channels.discord.token\",\n        confidence: \"high\",\n        suggestedEnvVar: \"DISCORD_BOT_TOKEN\",\n      },\n    ]);\n  });\n\n  it(\"builds onboarding vals from approved extracted secrets\", async () => {\n    const utils = await loadWelcomeSecretReviewUtils();\n    const approvedSecrets = [\n      {\n        suggestedEnvVar: \"DISCORD_BOT_TOKEN\",\n        value: \"discord-secret\",\n      },\n      {\n        suggestedEnvVar: \"BRAVE_API_KEY\",\n        value: \"brave-secret\",\n      },\n      {\n        suggestedEnvVar: \"\",\n        value: \"ignored\",\n      },\n    ];\n\n    expect(utils.buildApprovedImportVals(approvedSecrets)).toEqual({\n      DISCORD_BOT_TOKEN: \"discord-secret\",\n      BRAVE_API_KEY: \"brave-secret\",\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/agents-service.test.js",
    "content": "const { createAgentsService } = require(\"../../lib/server/agents/service\");\n\nconst buildFsMock = ({ initialConfig = {}, fileContents = {} } = {}) => {\n  let currentConfig = JSON.parse(JSON.stringify(initialConfig));\n  const files = new Set();\n  const directories = new Set();\n  const extraFiles = new Map(Object.entries(fileContents));\n  return {\n    existsSync: vi.fn(\n      (targetPath) => {\n        const normalizedTargetPath = String(targetPath || \"\");\n        if (files.has(normalizedTargetPath) || directories.has(normalizedTargetPath)) {\n          return true;\n        }\n        if (extraFiles.has(normalizedTargetPath)) {\n          return true;\n        }\n        const prefix = normalizedTargetPath.endsWith(\"/\")\n          ? normalizedTargetPath\n          : `${normalizedTargetPath}/`;\n        return Array.from(extraFiles.keys()).some((filePath) =>\n          String(filePath || \"\").startsWith(prefix),\n        );\n      },\n    ),\n    mkdirSync: vi.fn((targetPath) => {\n      directories.add(targetPath);\n    }),\n    rmSync: vi.fn(),\n    readdirSync: vi.fn((targetPath) => {\n      const normalizedTargetPath = String(targetPath || \"\");\n      const prefix = normalizedTargetPath.endsWith(\"/\")\n        ? normalizedTargetPath\n        : `${normalizedTargetPath}/`;\n      return Array.from(extraFiles.keys())\n        .filter((filePath) => filePath.startsWith(prefix))\n        .map((filePath) => filePath.slice(prefix.length))\n        .filter((fileName) => fileName && !fileName.includes(\"/\"));\n    }),\n    readFileSync: vi.fn((targetPath) => {\n      const normalizedTargetPath = String(targetPath || \"\");\n      if (normalizedTargetPath.endsWith(\"openclaw.json\")) {\n        return JSON.stringify(currentConfig);\n      }\n      if (extraFiles.has(normalizedTargetPath)) {\n        return String(extraFiles.get(normalizedTargetPath));\n      }\n      throw new Error(`ENOENT: ${normalizedTargetPath}`);\n    }),\n    writeFileSync: vi.fn((targetPath, content) => {\n      if (String(targetPath || \"\").endsWith(\"openclaw.json\")) {\n        currentConfig = JSON.parse(String(content || \"{}\"));\n        return;\n      }\n      files.add(targetPath);\n      extraFiles.set(String(targetPath || \"\"), String(content || \"\"));\n    }),\n    readConfig: () => currentConfig,\n  };\n};\n\ndescribe(\"server/agents/service\", () => {\n  it(\"creates an agent without replacing implicit main agent\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          defaults: {\n            model: {\n              primary: \"anthropic/claude-sonnet-4-6\",\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    service.createAgent({ id: \"ops\", name: \"Ops Agent\" });\n    const agents = service.listAgents();\n\n    expect(agents.map((entry) => entry.id)).toEqual([\"main\", \"ops\"]);\n    expect(agents.find((entry) => entry.id === \"main\")?.default).toBe(true);\n    expect(agents.find((entry) => entry.id === \"ops\")?.default).toBe(false);\n  });\n\n  it(\"sets a new default agent and unsets others\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", name: \"Main\", default: true },\n            { id: \"ops\", name: \"Ops\", default: false },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    service.setDefaultAgent(\"ops\");\n    const agents = service.listAgents();\n    expect(agents.find((entry) => entry.id === \"ops\")?.default).toBe(true);\n    expect(agents.find((entry) => entry.id === \"main\")?.default).toBe(false);\n  });\n\n  it(\"creates agent with custom workspace folder\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    const agent = service.createAgent({\n      id: \"sales\",\n      name: \"Sales Agent\",\n      workspaceFolder: \"workspace-sales-custom\",\n    });\n\n    expect(agent.workspace).toBe(\"/tmp/openclaw/workspace-sales-custom\");\n  });\n\n  it(\"removes the agent model key when clearing an override\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            {\n              id: \"main\",\n              default: true,\n              model: {\n                primary: \"anthropic/claude-sonnet-4-6\",\n              },\n            },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    const updated = service.updateAgent(\"main\", { model: null });\n    const config = fsMock.readConfig();\n\n    expect(updated).not.toHaveProperty(\"model\");\n    expect(config.agents.list[0]).not.toHaveProperty(\"model\");\n  });\n\n  it(\"persists tools config updates for agents\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            {\n              id: \"main\",\n              default: true,\n              tools: {\n                profile: \"full\",\n              },\n            },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    const updated = service.updateAgent(\"main\", {\n      tools: {\n        profile: \"minimal\",\n        alsoAllow: [\"read\"],\n        deny: [\"session_status\"],\n      },\n    });\n    const listed = service.listAgents().find((entry) => entry.id === \"main\");\n\n    expect(updated.tools).toEqual({\n      profile: \"minimal\",\n      alsoAllow: [\"read\"],\n      deny: [\"session_status\"],\n    });\n    expect(listed?.tools).toEqual({\n      profile: \"minimal\",\n      alsoAllow: [\"read\"],\n      deny: [\"session_status\"],\n    });\n    expect(fsMock.readConfig().agents.list[0].tools).toEqual({\n      profile: \"minimal\",\n      alsoAllow: [\"read\"],\n      deny: [\"session_status\"],\n    });\n  });\n\n  it(\"calculates workspace size recursively for an agent\", () => {\n    let currentConfig = {\n      agents: {\n        list: [\n          {\n            id: \"main\",\n            default: true,\n            workspace: \"/tmp/openclaw/workspace\",\n          },\n        ],\n      },\n    };\n    const statsByPath = new Map([\n      [\"/tmp/openclaw/workspace\", { type: \"dir\" }],\n      [\"/tmp/openclaw/workspace/notes.txt\", { type: \"file\", size: 120 }],\n      [\"/tmp/openclaw/workspace/nested\", { type: \"dir\" }],\n      [\n        \"/tmp/openclaw/workspace/nested/context.md\",\n        { type: \"file\", size: 880 },\n      ],\n    ]);\n    const entriesByDir = new Map([\n      [\"/tmp/openclaw/workspace\", [\"notes.txt\", \"nested\"]],\n      [\"/tmp/openclaw/workspace/nested\", [\"context.md\"]],\n    ]);\n    const fsMock = {\n      existsSync: vi.fn(() => true),\n      mkdirSync: vi.fn(),\n      rmSync: vi.fn(),\n      readFileSync: vi.fn((targetPath) => {\n        if (String(targetPath || \"\").endsWith(\"openclaw.json\")) {\n          return JSON.stringify(currentConfig);\n        }\n        return \"\";\n      }),\n      writeFileSync: vi.fn((targetPath, content) => {\n        if (String(targetPath || \"\").endsWith(\"openclaw.json\")) {\n          currentConfig = JSON.parse(String(content || \"{}\"));\n        }\n      }),\n      readdirSync: vi.fn(\n        (targetPath) => entriesByDir.get(String(targetPath || \"\")) || [],\n      ),\n      statSync: vi.fn((targetPath) => {\n        const entry = statsByPath.get(String(targetPath || \"\"));\n        if (!entry) throw new Error(\"ENOENT\");\n        return {\n          size: Number(entry.size || 0),\n          isFile: () => entry.type === \"file\",\n          isDirectory: () => entry.type === \"dir\",\n          isSymbolicLink: () => false,\n        };\n      }),\n    };\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(service.getAgentWorkspaceSize(\"main\")).toEqual({\n      workspacePath: \"/tmp/openclaw/workspace\",\n      exists: true,\n      sizeBytes: 1000,\n    });\n  });\n\n  it(\"removes bindings when deleting an agent\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        bindings: [\n          { agentId: \"ops\", match: { channel: \"telegram\" } },\n          { agentId: \"main\", match: { channel: \"telegram\" } },\n        ],\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    service.deleteAgent(\"ops\", { keepWorkspace: true });\n    const config = fsMock.readConfig();\n    expect(config.agents.list.map((entry) => entry.id)).toEqual([\"main\"]);\n    expect(config.bindings).toEqual([\n      { agentId: \"main\", match: { channel: \"telegram\" } },\n    ]);\n  });\n\n  it(\"deletes stored custom workspace path when keepWorkspace is false\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            {\n              id: \"ops\",\n              default: false,\n              workspace: \"/tmp/openclaw/workspace-ops-custom\",\n            },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    service.deleteAgent(\"ops\", { keepWorkspace: false });\n\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/workspace-ops-custom\",\n      { recursive: true, force: true },\n    );\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\"/tmp/openclaw/agents/ops\", {\n      recursive: true,\n      force: true,\n    });\n  });\n\n  it(\"does not attempt workspace deletes when deleting main is rejected\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false, workspace: \"/tmp/openclaw/workspace-ops\" },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(() =>\n      service.deleteAgent(\"main\", { keepWorkspace: false }),\n    ).toThrow(\"The default main agent cannot be deleted\");\n    expect(fsMock.rmSync).not.toHaveBeenCalled();\n  });\n\n  it(\"does not attempt workspace deletes when deleting current default agent is rejected\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: false },\n            { id: \"ops\", default: true, workspace: \"/tmp/openclaw/workspace-ops\" },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(() =>\n      service.deleteAgent(\"ops\", { keepWorkspace: false }),\n    ).toThrow(\"Default agent cannot be deleted\");\n    expect(fsMock.rmSync).not.toHaveBeenCalled();\n  });\n\n  it(\"lists configured channel accounts including default single-account channels\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        channels: {\n          telegram: {\n            enabled: true,\n            botToken: \"${TELEGRAM_BOT_TOKEN}\",\n          },\n          discord: {\n            accounts: {\n              default: { token: \"${DISCORD_BOT_TOKEN}\" },\n              alerts: { token: \"${DISCORD_ALERTS_TOKEN}\" },\n            },\n          },\n          slack: {\n            enabled: true,\n            botToken: \"${SLACK_BOT_TOKEN}\",\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(service.listConfiguredChannelAccounts()).toEqual([\n      {\n        channel: \"telegram\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            envKey: \"TELEGRAM_BOT_TOKEN\",\n            token: \"\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n        ],\n      },\n      {\n        channel: \"discord\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            envKey: \"DISCORD_BOT_TOKEN\",\n            token: \"\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n          {\n            id: \"alerts\",\n            name: \"\",\n            envKey: \"DISCORD_BOT_TOKEN_ALERTS\",\n            token: \"\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n        ],\n      },\n      {\n        channel: \"slack\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            envKey: \"SLACK_BOT_TOKEN\",\n            token: \"\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"includes explicit binding ownership in configured channel accounts\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        channels: {\n          telegram: {\n            enabled: true,\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\" },\n              alerts: { botToken: \"${TELEGRAM_ALERTS_TOKEN}\" },\n            },\n          },\n        },\n        bindings: [\n          { agentId: \"main\", match: { channel: \"telegram\" } },\n          {\n            agentId: \"ops\",\n            match: { channel: \"telegram\", accountId: \"alerts\" },\n          },\n          {\n            agentId: \"other\",\n            match: { channel: \"telegram\", peer: { kind: \"group\", id: \"-123\" } },\n          },\n        ],\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(service.listConfiguredChannelAccounts()).toEqual([\n      {\n        channel: \"telegram\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            envKey: \"TELEGRAM_BOT_TOKEN\",\n            token: \"\",\n            boundAgentId: \"main\",\n            paired: 0,\n            status: \"configured\",\n          },\n          {\n            id: \"alerts\",\n            name: \"\",\n            envKey: \"TELEGRAM_BOT_TOKEN_ALERTS\",\n            token: \"\",\n            boundAgentId: \"ops\",\n            paired: 0,\n            status: \"configured\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"includes paired status for named telegram accounts from credential files\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        channels: {\n          telegram: {\n            enabled: true,\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\" },\n              tester: { botToken: \"${TELEGRAM_BOT_TOKEN_TESTER}\" },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"tester\" },\n          },\n        ],\n      },\n      fileContents: {\n        \"/tmp/openclaw/credentials/telegram-tester-allowFrom.json\":\n          JSON.stringify({\n            allowFrom: [\"1050628644\"],\n          }),\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(service.listConfiguredChannelAccounts()).toEqual([\n      {\n        channel: \"telegram\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            envKey: \"TELEGRAM_BOT_TOKEN\",\n            token: \"\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n          {\n            id: \"tester\",\n            name: \"\",\n            envKey: \"TELEGRAM_BOT_TOKEN_TESTER\",\n            token: \"\",\n            boundAgentId: \"main\",\n            paired: 1,\n            status: \"paired\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"treats whatsapp owner-number self chat as paired when saved creds exist\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        channels: {\n          whatsapp: {\n            enabled: true,\n            accounts: {\n              default: {\n                name: \"WhatsApp\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      },\n      fileContents: {\n        \"/tmp/openclaw/credentials/whatsapp/default/creds.json\": \"{}\",\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: () => [{ key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" }],\n    });\n\n    expect(service.listConfiguredChannelAccounts()).toEqual([\n      {\n        channel: \"whatsapp\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"WhatsApp\",\n            envKey: \"WHATSAPP_OWNER_NUMBER\",\n            token: \"********\",\n            boundAgentId: \"\",\n            paired: 1,\n            status: \"paired\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"keeps whatsapp configured when owner number exists but saved creds do not\", () => {\n    const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;\n    process.env.WHATSAPP_OWNER_NUMBER = \"+15551234567\";\n    try {\n      const fsMock = buildFsMock({\n        initialConfig: {\n          channels: {\n            whatsapp: {\n              enabled: true,\n              accounts: {\n                default: {\n                  name: \"WhatsApp\",\n                  dmPolicy: \"pairing\",\n                },\n              },\n            },\n          },\n        },\n      });\n      const service = createAgentsService({\n        fs: fsMock,\n        OPENCLAW_DIR: \"/tmp/openclaw\",\n        readEnvFile: () => [{ key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" }],\n      });\n\n      expect(service.listConfiguredChannelAccounts()).toEqual([\n        {\n          channel: \"whatsapp\",\n          accounts: [\n            {\n              id: \"default\",\n              name: \"WhatsApp\",\n              envKey: \"WHATSAPP_OWNER_NUMBER\",\n              token: \"********\",\n              boundAgentId: \"\",\n              paired: 0,\n              status: \"configured\",\n            },\n          ],\n        },\n      ]);\n    } finally {\n      if (previousOwnerNumber === undefined) {\n        delete process.env.WHATSAPP_OWNER_NUMBER;\n      } else {\n        process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;\n      }\n    }\n  });\n\n  it(\"does not treat whatsapp allowFrom owner placeholder as paired without saved creds\", () => {\n    const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;\n    process.env.WHATSAPP_OWNER_NUMBER = \"+15551234567\";\n    try {\n      const fsMock = buildFsMock({\n        initialConfig: {\n          channels: {\n            whatsapp: {\n              enabled: true,\n              accounts: {\n                default: {\n                  name: \"WhatsApp\",\n                  allowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                  groupAllowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                  dmPolicy: \"allowlist\",\n                  groupPolicy: \"allowlist\",\n                  selfChatMode: true,\n                },\n              },\n            },\n          },\n        },\n      });\n      const service = createAgentsService({\n        fs: fsMock,\n        OPENCLAW_DIR: \"/tmp/openclaw\",\n        readEnvFile: () => [{ key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" }],\n      });\n\n      expect(service.listConfiguredChannelAccounts()).toEqual([\n        {\n          channel: \"whatsapp\",\n          accounts: [\n            {\n              id: \"default\",\n              name: \"WhatsApp\",\n              envKey: \"WHATSAPP_OWNER_NUMBER\",\n              token: \"********\",\n              boundAgentId: \"\",\n              paired: 0,\n              status: \"configured\",\n            },\n          ],\n        },\n      ]);\n    } finally {\n      if (previousOwnerNumber === undefined) {\n        delete process.env.WHATSAPP_OWNER_NUMBER;\n      } else {\n        process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;\n      }\n    }\n  });\n\n  it(\"treats whatsapp allowFrom owner placeholder as paired when saved creds exist\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        channels: {\n          whatsapp: {\n            enabled: true,\n            accounts: {\n              default: {\n                name: \"WhatsApp\",\n                allowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                groupAllowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                dmPolicy: \"allowlist\",\n                groupPolicy: \"allowlist\",\n                selfChatMode: true,\n              },\n            },\n          },\n        },\n      },\n      fileContents: {\n        \"/tmp/openclaw/credentials/whatsapp/default/creds.json\": \"{}\",\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: () => [{ key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" }],\n    });\n\n    expect(service.listConfiguredChannelAccounts()).toEqual([\n      {\n        channel: \"whatsapp\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"WhatsApp\",\n            envKey: \"WHATSAPP_OWNER_NUMBER\",\n            token: \"********\",\n            boundAgentId: \"\",\n            paired: 1,\n            status: \"paired\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"masks configured channel token values when listing accounts\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        channels: {\n          telegram: {\n            enabled: true,\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\" },\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: () => [{ key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" }],\n    });\n\n    expect(service.listConfiguredChannelAccounts()).toEqual([\n      {\n        channel: \"telegram\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            envKey: \"TELEGRAM_BOT_TOKEN\",\n            token: \"********\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"adds and removes bindings for an agent\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    const binding = service.addBinding(\"ops\", {\n      channel: \"telegram\",\n      accountId: \"alerts\",\n    });\n\n    expect(binding).toEqual({\n      agentId: \"ops\",\n      match: {\n        channel: \"telegram\",\n        accountId: \"alerts\",\n      },\n    });\n    expect(service.getBindingsForAgent(\"ops\")).toEqual([binding]);\n\n    service.removeBinding(\"ops\", {\n      channel: \"telegram\",\n      accountId: \"alerts\",\n    });\n\n    expect(service.getBindingsForAgent(\"ops\")).toEqual([]);\n  });\n\n  it(\"rejects bindings already assigned to another agent\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    expect(() =>\n      service.addBinding(\"ops\", {\n        channel: \"telegram\",\n        accountId: \"default\",\n      }),\n    ).toThrow('Binding already assigned to agent \"main\"');\n  });\n\n  it(\"creates a first channel account with the base env key and binding\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Telegram\",\n      accountId: \"default\",\n      token: \"123:abc\",\n      agentId: \"main\",\n    });\n\n    expect(result).toEqual({\n      channel: \"telegram\",\n      account: {\n        id: \"default\",\n        name: \"Telegram\",\n        envKey: \"TELEGRAM_BOT_TOKEN\",\n      },\n      binding: {\n        agentId: \"main\",\n        match: { channel: \"telegram\", accountId: \"default\" },\n      },\n    });\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n    ]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      1,\n      \"channels add --channel 'telegram' --name 'Telegram' --token '123:abc'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      2,\n      \"agents bind --agent 'main' --bind 'telegram:default'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                name: \"Telegram\",\n                botToken: \"${TELEGRAM_BOT_TOKEN}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      }),\n    );\n  });\n\n  it(\"retries channel add when OpenClaw reports a config mutation conflict\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n    let addAttempts = 0;\n    const clawCmd = vi.fn(async (command) => {\n      if (String(command).startsWith(\"channels add\")) {\n        addAttempts += 1;\n        if (addAttempts === 1) {\n          return {\n            ok: false,\n            stdout: \"\",\n            stderr:\n              \"ConfigMutationConflictError: config changed since last load\",\n          };\n        }\n      }\n      return { ok: true, stdout: \"\", stderr: \"\" };\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      clawCmd,\n    });\n\n    try {\n      const result = await service.createChannelAccount({\n        provider: \"telegram\",\n        name: \"Telegram\",\n        accountId: \"default\",\n        token: \"123:abc\",\n        agentId: \"main\",\n      });\n\n      expect(result.channel).toBe(\"telegram\");\n      expect(addAttempts).toBe(2);\n      expect(warnSpy).toHaveBeenCalledWith(\n        \"[alphaclaw] Retrying openclaw channels add after config mutation conflict\",\n      );\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        1,\n        \"channels add --channel 'telegram' --name 'Telegram' --token '123:abc'\",\n        { quiet: true, timeoutMs: 30000 },\n      );\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        2,\n        \"channels add --channel 'telegram' --name 'Telegram' --token '123:abc'\",\n        { quiet: true, timeoutMs: 30000 },\n      );\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        3,\n        \"agents bind --agent 'main' --bind 'telegram:default'\",\n        { quiet: true, timeoutMs: 30000 },\n      );\n      expect(fsMock.readConfig().channels.telegram.accounts.default).toEqual(\n        expect.objectContaining({\n          botToken: \"${TELEGRAM_BOT_TOKEN}\",\n          dmPolicy: \"pairing\",\n          name: \"Telegram\",\n        }),\n      );\n    } finally {\n      warnSpy.mockRestore();\n    }\n  });\n\n  it(\"migrates single-account channel config before adding another account\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            botToken: \"${TELEGRAM_BOT_TOKEN}\",\n            dmPolicy: \"pairing\",\n            allowFrom: [\"1050\"],\n          },\n        },\n        bindings: [{ agentId: \"main\", match: { channel: \"telegram\" } }],\n      },\n    });\n    const writeEnvFile = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n      ]),\n      writeEnvFile,\n      reloadEnv: vi.fn(),\n      clawCmd,\n    });\n\n    await service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Alerts\",\n      accountId: \"alerts\",\n      token: \"456:def\",\n      agentId: \"ops\",\n    });\n\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n      { key: \"TELEGRAM_BOT_TOKEN_ALERTS\", value: \"456:def\" },\n    ]);\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      1,\n      \"channels add --channel 'telegram' --account 'alerts' --name 'Alerts' --token '456:def'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      2,\n      \"agents bind --agent 'ops' --bind 'telegram:alerts'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${TELEGRAM_BOT_TOKEN}\",\n                dmPolicy: \"pairing\",\n                allowFrom: [\"1050\"],\n              },\n              alerts: {\n                name: \"Alerts\",\n                botToken: \"${TELEGRAM_BOT_TOKEN_ALERTS}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      }),\n    );\n  });\n\n  it(\"sanitizes plaintext legacy single-account token during migration\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            botToken: \"123:abc\",\n            dmPolicy: \"pairing\",\n          },\n        },\n      },\n    });\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n      ]),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      clawCmd,\n    });\n\n    await service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Alerts\",\n      accountId: \"alerts\",\n      token: \"456:def\",\n      agentId: \"ops\",\n    });\n\n    const config = fsMock.readConfig();\n    expect(config.channels.telegram.accounts.default.botToken).toBe(\n      \"${TELEGRAM_BOT_TOKEN}\",\n    );\n  });\n\n  it(\"sanitizes plaintext tokens in existing account entries after channel add\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            accounts: {\n              default: {\n                botToken: \"123:abc\",\n                dmPolicy: \"pairing\",\n              },\n            },\n            defaultAccount: \"default\",\n          },\n        },\n      },\n    });\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n      ]),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      clawCmd,\n    });\n\n    await service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Alerts\",\n      accountId: \"alerts\",\n      token: \"456:def\",\n      agentId: \"ops\",\n    });\n\n    const config = fsMock.readConfig();\n    expect(config.channels.telegram.accounts.default.botToken).toBe(\n      \"${TELEGRAM_BOT_TOKEN}\",\n    );\n    expect(config.channels.telegram.accounts.alerts.botToken).toBe(\n      \"${TELEGRAM_BOT_TOKEN_ALERTS}\",\n    );\n  });\n\n  it(\"ensures provider plugin allowlist before channel add cli call\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        plugins: {\n          allow: [\"discord\", \"usage-tracker\"],\n          entries: {\n            discord: { enabled: true },\n            \"usage-tracker\": { enabled: true },\n          },\n        },\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const restartGateway = vi.fn(async () => {});\n    const clawCmd = vi.fn(async (command) => {\n      if (String(command).startsWith(\"channels add\")) {\n        const currentConfig = fsMock.readConfig();\n        expect(currentConfig.plugins).toEqual({\n          allow: [\"discord\", \"usage-tracker\", \"telegram\"],\n          entries: {\n            discord: { enabled: true },\n            \"usage-tracker\": { enabled: true },\n            telegram: { enabled: true },\n          },\n        });\n      }\n      return { ok: true, stdout: \"\", stderr: \"\" };\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      restartGateway,\n      clawCmd,\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Telegram\",\n      accountId: \"default\",\n      token: \"123:abc\",\n      agentId: \"main\",\n    });\n\n    expect(result.channel).toBe(\"telegram\");\n    expect(writeEnvFile.mock.invocationCallOrder[0]).toBeLessThan(\n      fsMock.writeFileSync.mock.invocationCallOrder[0],\n    );\n    expect(writeEnvFile.mock.invocationCallOrder[0]).toBeLessThan(\n      restartGateway.mock.invocationCallOrder[0],\n    );\n    expect(restartGateway.mock.invocationCallOrder[0]).toBeLessThan(\n      fsMock.writeFileSync.mock.invocationCallOrder[0],\n    );\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      1,\n      \"channels add --channel 'telegram' --name 'Telegram' --token '123:abc'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n  });\n\n  it(\"creates a discord channel account via channels add cli\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"discord\",\n      name: \"Discord\",\n      accountId: \"default\",\n      token: \"discord-token\",\n      agentId: \"main\",\n    });\n\n    expect(result).toEqual({\n      channel: \"discord\",\n      account: {\n        id: \"default\",\n        name: \"Discord\",\n        envKey: \"DISCORD_BOT_TOKEN\",\n      },\n      binding: {\n        agentId: \"main\",\n        match: { channel: \"discord\", accountId: \"default\" },\n      },\n    });\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n      { key: \"DISCORD_BOT_TOKEN\", value: \"discord-token\" },\n    ]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      1,\n      \"channels add --channel 'discord' --name 'Discord' --token 'discord-token'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      2,\n      \"agents bind --agent 'main' --bind 'discord:default'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          discord: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                name: \"Discord\",\n                token: \"${DISCORD_BOT_TOKEN}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n        plugins: {\n          allow: [\"discord\"],\n          entries: {\n            discord: { enabled: true },\n          },\n        },\n      }),\n    );\n  });\n\n  it(\"creates a slack channel account with bot and app tokens\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"slack\",\n      name: \"Slack\",\n      accountId: \"default\",\n      token: \"xoxb-bot-token\",\n      appToken: \"xapp-app-token\",\n      agentId: \"main\",\n    });\n\n    expect(result).toEqual({\n      channel: \"slack\",\n      account: {\n        id: \"default\",\n        name: \"Slack\",\n        envKey: \"SLACK_BOT_TOKEN\",\n      },\n      binding: {\n        agentId: \"main\",\n        match: { channel: \"slack\", accountId: \"default\" },\n      },\n    });\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-bot-token\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-app-token\" },\n    ]);\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      1,\n      \"channels add --channel 'slack' --name 'Slack' --bot-token 'xoxb-bot-token' --app-token 'xapp-app-token'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                name: \"Slack\",\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      }),\n    );\n  });\n\n  it(\"requires app token when creating a slack channel account\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      clawCmd: vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" })),\n    });\n\n    await expect(\n      service.createChannelAccount({\n        provider: \"slack\",\n        name: \"Slack\",\n        accountId: \"default\",\n        token: \"xoxb-bot-token\",\n        agentId: \"main\",\n      }),\n    ).rejects.toThrow(\"Slack App Token is required\");\n  });\n\n  it(\"rejects concurrent channel account creation requests\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    let releaseRestart = () => {};\n    const restartGateway = vi.fn(\n      () =>\n        new Promise((resolve) => {\n          releaseRestart = resolve;\n        }),\n    );\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      restartGateway,\n      clawCmd: vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" })),\n    });\n\n    const firstCreatePromise = service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Telegram\",\n      accountId: \"default\",\n      token: \"123:abc\",\n      agentId: \"main\",\n    });\n\n    await expect(\n      service.createChannelAccount({\n        provider: \"telegram\",\n        name: \"Telegram 2\",\n        accountId: \"alerts\",\n        token: \"456:def\",\n        agentId: \"main\",\n      }),\n    ).rejects.toThrow(\"A channel account creation is already in progress\");\n\n    releaseRestart();\n    await expect(firstCreatePromise).resolves.toEqual(\n      expect.objectContaining({\n        channel: \"telegram\",\n      }),\n    );\n  });\n\n  it(\"rolls back env and config when channel add CLI step fails\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const initialEnvVars = [{ key: \"OPENAI_API_KEY\", value: \"sk-test\" }];\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async (command) => {\n      if (String(command).startsWith(\"channels add\")) {\n        return { ok: false, stdout: \"\", stderr: \"CLI add failed\" };\n      }\n      return { ok: true, stdout: \"\", stderr: \"\" };\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => initialEnvVars),\n      writeEnvFile,\n      reloadEnv,\n      restartGateway: vi.fn(async () => {}),\n      clawCmd,\n    });\n\n    await expect(\n      service.createChannelAccount({\n        provider: \"telegram\",\n        name: \"Telegram\",\n        accountId: \"default\",\n        token: \"123:abc\",\n        agentId: \"main\",\n      }),\n    ).rejects.toThrow(\"CLI add failed\");\n\n    expect(writeEnvFile).toHaveBeenNthCalledWith(1, [\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n    ]);\n    expect(writeEnvFile).toHaveBeenNthCalledWith(2, initialEnvVars);\n    expect(reloadEnv).toHaveBeenCalledTimes(2);\n    expect(fsMock.readConfig()).toEqual({\n      agents: {\n        list: [{ id: \"main\", default: true }],\n      },\n    });\n  });\n\n  it(\"prevents creating multiple discord channel accounts\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          discord: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                token: \"${DISCORD_BOT_TOKEN}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"DISCORD_BOT_TOKEN\", value: \"discord-token\" },\n      ]),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      clawCmd: vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" })),\n    });\n\n    await expect(\n      service.createChannelAccount({\n        provider: \"discord\",\n        name: \"Discord 2\",\n        accountId: \"alerts\",\n        token: \"discord-token-2\",\n        agentId: \"main\",\n      }),\n    ).rejects.toThrow(\"Discord supports a single channel account\");\n  });\n\n  it(\"creates an additional named slack channel account with suffixed env vars\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      },\n    });\n    const writeEnvFile = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-bot-token\" },\n        { key: \"SLACK_APP_TOKEN\", value: \"xapp-app-token\" },\n      ]),\n      writeEnvFile,\n      reloadEnv: vi.fn(),\n      clawCmd,\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"slack\",\n      name: \"Slack Alerts\",\n      accountId: \"alerts\",\n      token: \"xoxb-bot-token-2\",\n      appToken: \"xapp-app-token-2\",\n      agentId: \"main\",\n    });\n\n    expect(result).toEqual({\n      channel: \"slack\",\n      account: {\n        id: \"alerts\",\n        name: \"Slack Alerts\",\n        envKey: \"SLACK_BOT_TOKEN_ALERTS\",\n      },\n      binding: {\n        agentId: \"main\",\n        match: { channel: \"slack\", accountId: \"alerts\" },\n      },\n    });\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-bot-token\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-app-token\" },\n      { key: \"SLACK_BOT_TOKEN_ALERTS\", value: \"xoxb-bot-token-2\" },\n      { key: \"SLACK_APP_TOKEN_ALERTS\", value: \"xapp-app-token-2\" },\n    ]);\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      1,\n      \"channels add --channel 'slack' --account 'alerts' --name 'Slack Alerts' --bot-token 'xoxb-bot-token-2' --app-token 'xapp-app-token-2'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(clawCmd).toHaveBeenNthCalledWith(\n      2,\n      \"agents bind --agent 'main' --bind 'slack:alerts'\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                dmPolicy: \"pairing\",\n              },\n              alerts: {\n                name: \"Slack Alerts\",\n                botToken: \"${SLACK_BOT_TOKEN_ALERTS}\",\n                appToken: \"${SLACK_APP_TOKEN_ALERTS}\",\n                dmPolicy: \"pairing\",\n              },\n            },\n          },\n        },\n      }),\n    );\n  });\n\n  it(\"creates a whatsapp channel account with allowlist defaults\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const restartGateway = vi.fn(async () => {});\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile,\n      reloadEnv,\n      restartGateway,\n      clawCmd: vi.fn(async () => ({ ok: true })),\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"whatsapp\",\n      name: \"WhatsApp\",\n      accountId: \"default\",\n      token: \"+15551234567\",\n      agentId: \"main\",\n    });\n\n    expect(result).toMatchObject({\n      channel: \"whatsapp\",\n      account: {\n        id: \"default\",\n        name: \"WhatsApp\",\n        envKey: \"WHATSAPP_OWNER_NUMBER\",\n      },\n      binding: {\n        agentId: \"main\",\n        match: { channel: \"whatsapp\", accountId: \"default\" },\n      },\n    });\n    expect(writeEnvFile).toHaveBeenCalledWith(\n      expect.arrayContaining([\n        { key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" },\n      ]),\n    );\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(restartGateway).toHaveBeenCalled();\n    const savedConfig = fsMock.readConfig();\n    expect(savedConfig.channels?.whatsapp?.accounts?.default).toMatchObject({\n      name: \"WhatsApp\",\n      dmPolicy: \"allowlist\",\n      groupPolicy: \"allowlist\",\n      selfChatMode: true,\n    });\n  });\n\n  it(\"prevents creating multiple whatsapp channel accounts\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          whatsapp: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                allowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n              },\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n      readEnvFile: vi.fn(() => [{ key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" }]),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      restartGateway: vi.fn(async () => {}),\n      clawCmd: vi.fn(async () => ({ ok: true })),\n    });\n\n    await expect(\n      service.createChannelAccount({\n        provider: \"whatsapp\",\n        name: \"WhatsApp 2\",\n        accountId: \"alerts\",\n        token: \"+15557654321\",\n        agentId: \"main\",\n      }),\n    ).rejects.toThrow(\"WhatsApp supports a single channel account\");\n  });\n\n  it(\"runs channel account login for whatsapp\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {},\n    });\n    const clawCmd = vi.fn(async () => ({\n      ok: true,\n      stdout: \"QR code displayed\",\n      stderr: \"\",\n    }));\n    const restartGateway = vi.fn(async () => {});\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      restartGateway,\n      clawCmd,\n    });\n\n    const result = await service.runChannelAccountLogin({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n    });\n\n    expect(result.ok).toBe(true);\n    expect(result.completed).toBe(true);\n    expect(clawCmd).toHaveBeenCalledWith(\n      expect.stringContaining(\"channels login\"),\n      expect.objectContaining({ quiet: true }),\n    );\n    expect(restartGateway).not.toHaveBeenCalled();\n  });\n\n  it(\"does not restart gateway when whatsapp login is not complete\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {},\n    });\n    const clawCmd = vi.fn(async () => ({\n      ok: false,\n      stdout: \"Waiting for WhatsApp connection...\",\n      stderr: \"\",\n    }));\n    const restartGateway = vi.fn(async () => {});\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      restartGateway,\n      clawCmd,\n    });\n\n    const result = await service.runChannelAccountLogin({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n    });\n\n    expect(result.ok).toBe(false);\n    expect(result.completed).toBe(false);\n    expect(restartGateway).not.toHaveBeenCalled();\n  });\n\n  it(\"reports whatsapp login linked status when saved creds exist\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {},\n      fileContents: {\n        \"/test/.openclaw/credentials/whatsapp/default/creds.json\": \"{}\",\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n    });\n\n    expect(\n      service.getChannelAccountLoginStatus({\n        provider: \"whatsapp\",\n        accountId: \"default\",\n      }),\n    ).toEqual({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n      linked: true,\n    });\n  });\n\n  it(\"reports whatsapp login unlinked status when saved creds do not exist\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {},\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n    });\n\n    expect(\n      service.getChannelAccountLoginStatus({\n        provider: \"whatsapp\",\n        accountId: \"default\",\n      }),\n    ).toEqual({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n      linked: false,\n    });\n  });\n\n  it(\"rejects channel login for non-whatsapp providers\", async () => {\n    const fsMock = buildFsMock({ initialConfig: {} });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/test/.openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      restartGateway: vi.fn(async () => {}),\n      clawCmd: vi.fn(async () => ({ ok: true })),\n    });\n\n    await expect(\n      service.runChannelAccountLogin({\n        provider: \"telegram\",\n        accountId: \"default\",\n      }),\n    ).rejects.toThrow(\"Channel login is currently only supported for WhatsApp\");\n  });\n\n  it(\"updates channel account name and bound agent\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n              alerts: {\n                botToken: \"${TELEGRAM_BOT_TOKEN_ALERTS}\",\n                name: \"Alerts\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n          {\n            agentId: \"ops\",\n            match: { channel: \"telegram\", accountId: \"alerts\" },\n          },\n        ],\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n    });\n\n    const result = service.updateChannelAccount({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n      name: \"Alerts Bot\",\n      agentId: \"main\",\n    });\n\n    expect(result).toEqual({\n      channel: \"telegram\",\n      account: {\n        id: \"alerts\",\n        name: \"Alerts Bot\",\n        boundAgentId: \"main\",\n      },\n      tokenUpdated: false,\n    });\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          telegram: expect.objectContaining({\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n              alerts: {\n                botToken: \"${TELEGRAM_BOT_TOKEN_ALERTS}\",\n                name: \"Alerts Bot\",\n              },\n            },\n          }),\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"alerts\" },\n          },\n        ],\n      }),\n    );\n  });\n\n  it(\"updates channel account token when provided\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"ops\", default: false },\n          ],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n              alerts: {\n                botToken: \"${TELEGRAM_BOT_TOKEN_ALERTS}\",\n                name: \"Alerts\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n          {\n            agentId: \"ops\",\n            match: { channel: \"telegram\", accountId: \"alerts\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"old-token\" },\n      { key: \"TELEGRAM_BOT_TOKEN_ALERTS\", value: \"old-alerts-token\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n    });\n\n    const result = service.updateChannelAccount({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n      name: \"Alerts Bot\",\n      agentId: \"ops\",\n      token: \"new-alerts-token\",\n    });\n\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"old-token\" },\n      { key: \"TELEGRAM_BOT_TOKEN_ALERTS\", value: \"new-alerts-token\" },\n    ]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(result.tokenUpdated).toBe(true);\n  });\n\n  it(\"updates slack app token when provided\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                name: \"Slack\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"slack\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-old\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-old\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n    });\n\n    const result = service.updateChannelAccount({\n      provider: \"slack\",\n      accountId: \"default\",\n      name: \"Slack\",\n      agentId: \"main\",\n      appToken: \"xapp-new\",\n    });\n\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-old\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-new\" },\n    ]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(result.tokenUpdated).toBe(true);\n  });\n\n  it(\"does not rewrite env when updated token is unchanged\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"same-token\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n    });\n\n    const result = service.updateChannelAccount({\n      provider: \"telegram\",\n      accountId: \"default\",\n      name: \"Telegram\",\n      agentId: \"main\",\n      token: \"same-token\",\n    });\n\n    expect(writeEnvFile).not.toHaveBeenCalled();\n    expect(reloadEnv).not.toHaveBeenCalled();\n    expect(result.tokenUpdated).toBe(false);\n  });\n\n  it(\"skips token update when token is empty on update\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => []),\n      writeEnvFile,\n      reloadEnv,\n    });\n\n    service.updateChannelAccount({\n      provider: \"telegram\",\n      accountId: \"default\",\n      name: \"My Bot\",\n      agentId: \"main\",\n    });\n\n    expect(writeEnvFile).not.toHaveBeenCalled();\n    expect(reloadEnv).not.toHaveBeenCalled();\n  });\n\n  it(\"loads channel account token by provider/account id\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"token-123\" },\n      ]),\n    });\n\n    const result = service.getChannelAccountToken({\n      provider: \"telegram\",\n      accountId: \"default\",\n    });\n\n    expect(result).toEqual({\n      provider: \"telegram\",\n      accountId: \"default\",\n      envKey: \"TELEGRAM_BOT_TOKEN\",\n      token: \"token-123\",\n    });\n  });\n\n  it(\"loads slack channel bot and app tokens by provider/account id\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                name: \"Slack\",\n              },\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-token-123\" },\n        { key: \"SLACK_APP_TOKEN\", value: \"xapp-token-123\" },\n      ]),\n    });\n\n    const result = service.getChannelAccountToken({\n      provider: \"slack\",\n      accountId: \"default\",\n    });\n\n    expect(result).toEqual({\n      provider: \"slack\",\n      accountId: \"default\",\n      envKey: \"SLACK_BOT_TOKEN\",\n      token: \"xoxb-token-123\",\n      appEnvKey: \"SLACK_APP_TOKEN\",\n      appToken: \"xapp-token-123\",\n    });\n  });\n\n  it(\"loads named slack channel bot and app tokens by provider/account id\", () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                name: \"Slack\",\n              },\n              alerts: {\n                botToken: \"${SLACK_BOT_TOKEN_ALERTS}\",\n                appToken: \"${SLACK_APP_TOKEN_ALERTS}\",\n                name: \"Slack Alerts\",\n              },\n            },\n          },\n        },\n      },\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile: vi.fn(() => [\n        { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-token-123\" },\n        { key: \"SLACK_APP_TOKEN\", value: \"xapp-token-123\" },\n        { key: \"SLACK_BOT_TOKEN_ALERTS\", value: \"xoxb-alerts-token\" },\n        { key: \"SLACK_APP_TOKEN_ALERTS\", value: \"xapp-alerts-token\" },\n      ]),\n    });\n\n    const result = service.getChannelAccountToken({\n      provider: \"slack\",\n      accountId: \"alerts\",\n    });\n\n    expect(result).toEqual({\n      provider: \"slack\",\n      accountId: \"alerts\",\n      envKey: \"SLACK_BOT_TOKEN_ALERTS\",\n      token: \"xoxb-alerts-token\",\n      appEnvKey: \"SLACK_APP_TOKEN_ALERTS\",\n      appToken: \"xapp-alerts-token\",\n    });\n  });\n\n  it(\"deletes channel accounts and removes their env entry\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n              alerts: {\n                botToken: \"${TELEGRAM_BOT_TOKEN_ALERTS}\",\n                name: \"Alerts\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"alerts\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n      { key: \"TELEGRAM_BOT_TOKEN_ALERTS\", value: \"456:def\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => {\n      const config = fsMock.readConfig();\n      return {\n        ok: true,\n        stdout: \"\",\n        stderr: \"\",\n        apply: (() => {\n          delete config.channels.telegram.accounts.alerts;\n          fsMock.writeFileSync(\n            \"/tmp/openclaw/openclaw.json\",\n            JSON.stringify(config),\n          );\n        })(),\n      };\n    });\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.deleteChannelAccount({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(clawCmd).toHaveBeenCalledWith(\n      \"channels remove --channel 'telegram' --account 'alerts' --delete\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n    ]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n        ],\n      }),\n    );\n  });\n\n  it(\"deletes the final telegram account and removes the provider entry\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            dmPolicy: \"pairing\",\n            groupPolicy: \"allowlist\",\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.deleteChannelAccount({\n      provider: \"telegram\",\n      accountId: \"default\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(clawCmd).toHaveBeenCalledWith(\n      \"channels remove --channel 'telegram' --account 'default' --delete\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(writeEnvFile).toHaveBeenCalledWith([]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {},\n        bindings: [],\n      }),\n    );\n  });\n\n  it(\"deletes discord channels via direct config instead of channel cli\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          discord: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { token: \"${DISCORD_BOT_TOKEN}\", name: \"Discord\" },\n            },\n          },\n        },\n        plugins: {\n          allow: [\"discord\"],\n          entries: {\n            discord: { enabled: true },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"discord\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"DISCORD_BOT_TOKEN\", value: \"discord-token\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.deleteChannelAccount({\n      provider: \"discord\",\n      accountId: \"default\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(clawCmd).not.toHaveBeenCalled();\n    expect(writeEnvFile).toHaveBeenCalledWith([]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {},\n        plugins: {\n          allow: [\"discord\"],\n          entries: {\n            discord: { enabled: false },\n          },\n        },\n        bindings: [],\n      }),\n    );\n  });\n\n  it(\"deletes whatsapp channels via channel cli and disables the plugin entry\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          whatsapp: {\n            enabled: true,\n            dmPolicy: \"pairing\",\n            groupPolicy: \"allowlist\",\n            debounceMs: 0,\n            mediaMaxMb: 50,\n          },\n        },\n        plugins: {\n          allow: [\"whatsapp\"],\n          entries: {\n            whatsapp: { enabled: true },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"whatsapp\", accountId: \"default\" },\n          },\n        ],\n      },\n      fileContents: {\n        \"/tmp/openclaw/credentials/creds.json\": \"{}\",\n        \"/tmp/openclaw/credentials/creds.json.bak\": \"{}\",\n        \"/tmp/openclaw/credentials/session-foo.json\": \"{}\",\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => {\n      const config = fsMock.readConfig();\n      delete config.channels.whatsapp;\n      fsMock.writeFileSync(\n        \"/tmp/openclaw/openclaw.json\",\n        JSON.stringify(config),\n      );\n      return { ok: true, stdout: \"\", stderr: \"\" };\n    });\n    const restartGateway = vi.fn(async () => {});\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n      restartGateway,\n    });\n\n    const result = await service.deleteChannelAccount({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(clawCmd).toHaveBeenCalledWith(\n      \"channels remove --channel 'whatsapp' --account 'default' --delete\",\n      { quiet: true, timeoutMs: 30000 },\n    );\n    expect(writeEnvFile).toHaveBeenCalledWith([]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(restartGateway).toHaveBeenCalledTimes(1);\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/credentials/whatsapp/default\",\n      { recursive: true, force: true },\n    );\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/credentials/whatsapp\",\n      { recursive: true, force: true },\n    );\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/credentials/creds.json\",\n      { force: true },\n    );\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/credentials/creds.json.bak\",\n      { force: true },\n    );\n    expect(fsMock.rmSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/credentials/session-foo.json\",\n      { force: true },\n    );\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {},\n        plugins: {\n          allow: [\"whatsapp\"],\n          entries: {\n            whatsapp: { enabled: false },\n          },\n        },\n        bindings: [],\n      }),\n    );\n  });\n\n  it(\"deletes slack channel env vars including app token\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                name: \"Slack\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"slack\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-token\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-token\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.deleteChannelAccount({\n      provider: \"slack\",\n      accountId: \"default\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(writeEnvFile).toHaveBeenCalledWith([]);\n    expect(reloadEnv).toHaveBeenCalled();\n  });\n\n  it(\"deletes named slack channel env vars and keeps default slack tokens\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                name: \"Slack\",\n              },\n              alerts: {\n                botToken: \"${SLACK_BOT_TOKEN_ALERTS}\",\n                appToken: \"${SLACK_APP_TOKEN_ALERTS}\",\n                name: \"Slack Alerts\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"slack\", accountId: \"default\" },\n          },\n          {\n            agentId: \"main\",\n            match: { channel: \"slack\", accountId: \"alerts\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-token\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-token\" },\n      { key: \"SLACK_BOT_TOKEN_ALERTS\", value: \"xoxb-alerts-token\" },\n      { key: \"SLACK_APP_TOKEN_ALERTS\", value: \"xapp-alerts-token\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.deleteChannelAccount({\n      provider: \"slack\",\n      accountId: \"alerts\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-token\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-token\" },\n    ]);\n    expect(reloadEnv).toHaveBeenCalled();\n    expect(fsMock.readConfig()).toEqual(\n      expect.objectContaining({\n        channels: {\n          slack: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: {\n                botToken: \"${SLACK_BOT_TOKEN}\",\n                appToken: \"${SLACK_APP_TOKEN}\",\n                name: \"Slack\",\n              },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"slack\", accountId: \"default\" },\n          },\n        ],\n      }),\n    );\n  });\n\n  it(\"overwrites orphaned env var when channel is not in config\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n      { key: \"TELEGRAM_BOT_TOKEN_OLD_BOT\", value: \"123:abc\" },\n    ]);\n    const writeEnvFile = vi.fn();\n    const reloadEnv = vi.fn();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile,\n      reloadEnv,\n      clawCmd,\n    });\n\n    const result = await service.createChannelAccount({\n      provider: \"telegram\",\n      name: \"Telegram\",\n      accountId: \"default\",\n      token: \"123:abc\",\n      agentId: \"main\",\n    });\n\n    expect(result.account.envKey).toBe(\"TELEGRAM_BOT_TOKEN\");\n    expect(writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"sk-test\" },\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n    ]);\n  });\n\n  it(\"still blocks duplicate token when the other channel is configured\", async () => {\n    const fsMock = buildFsMock({\n      initialConfig: {\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        channels: {\n          telegram: {\n            enabled: true,\n            defaultAccount: \"default\",\n            accounts: {\n              default: { botToken: \"${TELEGRAM_BOT_TOKEN}\", name: \"Telegram\" },\n            },\n          },\n        },\n        bindings: [\n          {\n            agentId: \"main\",\n            match: { channel: \"telegram\", accountId: \"default\" },\n          },\n        ],\n      },\n    });\n    const readEnvFile = vi.fn(() => [\n      { key: \"TELEGRAM_BOT_TOKEN\", value: \"123:abc\" },\n    ]);\n    const service = createAgentsService({\n      fs: fsMock,\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      readEnvFile,\n      writeEnvFile: vi.fn(),\n      reloadEnv: vi.fn(),\n      clawCmd: vi.fn(async () => ({ ok: true })),\n    });\n\n    await expect(\n      service.createChannelAccount({\n        provider: \"telegram\",\n        name: \"Second Bot\",\n        accountId: \"second\",\n        token: \"123:abc\",\n        agentId: \"main\",\n      }),\n    ).rejects.toThrow(\"Channel token already exists in TELEGRAM_BOT_TOKEN\");\n  });\n});\n"
  },
  {
    "path": "tests/server/alphaclaw-version.test.js",
    "content": "const childProcess = require(\"child_process\");\nconst fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  kNpmPackageRoot,\n  kOpenclawUpdateCopyTimeoutMs,\n  kRootDir,\n} = require(\"../../lib/server/constants\");\nconst modulePath = require.resolve(\"../../lib/server/alphaclaw-version\");\nconst originalExec = childProcess.exec;\n\nconst createFetchResponse = ({ ok = true, status = 200, body = {} } = {}) => ({\n  ok,\n  status,\n  text: vi.fn(async () =>\n    typeof body === \"string\" ? body : JSON.stringify(body),\n  ),\n});\n\nconst loadVersionModule = ({ execMock } = {}) => {\n  if (execMock) childProcess.exec = execMock;\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nconst createService = ({\n  env = {},\n  readOpenclawVersion = () => \"2026.4.1\",\n  fetchMock = vi.fn(),\n  execMock = vi.fn(),\n  fsImpl = fs,\n} = {}) => {\n  const { createAlphaclawVersionService } = loadVersionModule({ execMock });\n  const service = createAlphaclawVersionService({\n    env,\n    readOpenclawVersion,\n    fetchImpl: fetchMock,\n    fsImpl,\n  });\n  return { service, fetchMock, execMock };\n};\n\ndescribe(\"server/alphaclaw-version\", () => {\n  afterEach(() => {\n    childProcess.exec = originalExec;\n    delete require.cache[modulePath];\n  });\n\n  it(\"reads current version from package.json\", () => {\n    const { service } = createService();\n    const version = service.readAlphaclawVersion();\n\n    const expectedPkg = JSON.parse(\n      fs.readFileSync(path.join(kNpmPackageRoot, \"package.json\"), \"utf8\"),\n    );\n    expect(version).toBe(expectedPkg.version);\n  });\n\n  it(\"returns local self-update status from npm\", async () => {\n    const fetchMock = vi.fn(async (url) => {\n      expect(url).toBe(\"https://registry.npmjs.org/@chrysb%2falphaclaw\");\n      return createFetchResponse({\n        body: {\n          \"dist-tags\": { latest: \"99.0.0\" },\n        },\n      });\n    });\n    const { service } = createService({\n      env: {},\n      readOpenclawVersion: () => \"2026.4.10\",\n      fetchMock,\n      fsImpl: { ...fs, existsSync: vi.fn(() => false) },\n    });\n\n    const status = await service.getVersionStatus(false);\n\n    expect(status).toEqual(\n      expect.objectContaining({\n        ok: true,\n        currentVersion: expect.any(String),\n        currentOpenclawVersion: \"2026.4.10\",\n        latestVersion: \"99.0.0\",\n        hasUpdate: true,\n        updateStrategy: expect.objectContaining({\n          action: \"self-update\",\n          provider: \"self-hosted\",\n        }),\n      }),\n    );\n  });\n\n  it(\"returns template-managed status for railway deployments\", async () => {\n    const fetchMock = vi.fn(async (url) => {\n      expect(url).toContain(\n        \"https://raw.githubusercontent.com/chrysb/openclaw-railway-template/main/package.json\",\n      );\n      return createFetchResponse({\n        body: {\n          dependencies: {\n            \"@chrysb/alphaclaw\": \"0.8.10\",\n            openclaw: \"2026.4.10\",\n          },\n        },\n      });\n    });\n    const { service } = createService({\n      env: { RAILWAY_ENVIRONMENT: \"production\" },\n      readOpenclawVersion: () => \"2026.4.5\",\n      fetchMock,\n    });\n\n    const status = await service.getVersionStatus(true);\n\n    expect(status).toEqual(\n      expect.objectContaining({\n        ok: true,\n        latestVersion: \"0.8.10\",\n        latestOpenclawVersion: \"2026.4.10\",\n        hasUpdate: true,\n        updateStrategy: expect.objectContaining({\n          action: \"instructions\",\n          provider: \"railway\",\n          templateRepoUrl:\n            \"https://github.com/chrysb/openclaw-railway-template.git\",\n        }),\n      }),\n    );\n  });\n\n  it(\"derives the OpenClaw version from the template-pinned AlphaClaw package when the template omits a direct openclaw pin\", async () => {\n    const fetchMock = vi.fn(async (url) => {\n      if (\n        String(url).includes(\n          \"https://raw.githubusercontent.com/chrysb/openclaw-railway-template/main/package.json\",\n        )\n      ) {\n        return createFetchResponse({\n          body: {\n            dependencies: {\n              \"@chrysb/alphaclaw\": \"0.9.2\",\n            },\n          },\n        });\n      }\n\n      expect(url).toBe(\"https://registry.npmjs.org/@chrysb%2falphaclaw\");\n      return createFetchResponse({\n        body: {\n          \"dist-tags\": { latest: \"0.9.6\" },\n          versions: {\n            \"0.9.2\": {\n              dependencies: {\n                openclaw: \"2026.4.11\",\n              },\n            },\n            \"0.9.6\": {\n              dependencies: {\n                openclaw: \"2026.4.14\",\n              },\n            },\n          },\n        },\n      });\n    });\n    const { service } = createService({\n      env: { RAILWAY_ENVIRONMENT: \"production\" },\n      readOpenclawVersion: () => \"2026.4.5\",\n      fetchMock,\n    });\n\n    const status = await service.getVersionStatus(true);\n\n    expect(status).toEqual(\n      expect.objectContaining({\n        ok: true,\n        latestVersion: \"0.9.2\",\n        latestOpenclawVersion: \"2026.4.11\",\n      }),\n    );\n  });\n\n  it(\"includes a direct Railway dashboard link when project metadata is available\", async () => {\n    const fetchMock = vi.fn(async () =>\n      createFetchResponse({\n        body: {\n          dependencies: {\n            \"@chrysb/alphaclaw\": \"0.8.10\",\n            openclaw: \"2026.4.10\",\n          },\n        },\n      }),\n    );\n    const { service } = createService({\n      env: {\n        RAILWAY_ENVIRONMENT: \"production\",\n        RAILWAY_PROJECT_ID: \"582da512-0510-4844-9ffb-efe89b88e1e9\",\n        RAILWAY_SERVICE_ID: \"b3ea8fbd-9727-4b5c-adbe-8a3a8ab2dd2c\",\n        RAILWAY_ENVIRONMENT_ID: \"181e3f67-233a-41b9-9485-f64235eb764d\",\n      },\n      fetchMock,\n    });\n\n    const status = await service.getVersionStatus(true);\n\n    expect(status.updateStrategy).toEqual(\n      expect.objectContaining({\n        provider: \"railway\",\n        primaryActionLabel: \"Update on Railway\",\n        primaryActionUrl:\n          \"https://railway.com/project/582da512-0510-4844-9ffb-efe89b88e1e9/service/b3ea8fbd-9727-4b5c-adbe-8a3a8ab2dd2c?environmentId=181e3f67-233a-41b9-9485-f64235eb764d\",\n      }),\n    );\n  });\n\n  it(\"includes a direct Render dashboard link when service metadata is available\", async () => {\n    const fetchMock = vi.fn(async () =>\n      createFetchResponse({\n        body: {\n          dependencies: {\n            \"@chrysb/alphaclaw\": \"0.8.10\",\n            openclaw: \"2026.4.10\",\n          },\n        },\n      }),\n    );\n    const { service } = createService({\n      env: {\n        RENDER: \"true\",\n        RENDER_SERVICE_ID: \"srv-d776lrvpm1nc73e08c9g\",\n      },\n      fetchMock,\n    });\n\n    const status = await service.getVersionStatus(true);\n\n    expect(status.updateStrategy).toEqual(\n      expect.objectContaining({\n        provider: \"render\",\n        primaryActionLabel: \"Update on Render\",\n        primaryActionUrl:\n          \"https://dashboard.render.com/web/srv-d776lrvpm1nc73e08c9g\",\n      }),\n    );\n  });\n\n  it(\"triggers the managed deployment bridge for apex containers\", async () => {\n    const fetchMock = vi.fn(async (url, options = {}) => {\n      if (String(url).includes(\"raw.githubusercontent.com\")) {\n        return createFetchResponse({\n          body: {\n            dependencies: {\n              \"@chrysb/alphaclaw\": \"0.8.7\",\n              openclaw: \"2026.4.10\",\n            },\n          },\n        });\n      }\n      if (String(url).includes(\"/commits/main\")) {\n        return createFetchResponse({\n          body: { sha: \"aded043defd05bba6787bca75ac6ed8dffd43c6e\" },\n        });\n      }\n      expect(url).toBe(\"http://host.docker.internal:3180/update\");\n      expect(options.method).toBe(\"POST\");\n      expect(options.headers.Authorization).toBe(\"Bearer bridge-token\");\n      expect(JSON.parse(options.body)).toEqual({\n        repo: \"https://github.com/chrysb/openclaw-apex-template.git\",\n        ref: \"aded043defd05bba6787bca75ac6ed8dffd43c6e\",\n        alphaclawVersion: \"0.8.7\",\n        openclawVersion: \"2026.4.10\",\n      });\n      return createFetchResponse({\n        body: { ok: true, phase: \"queued\", noop: false },\n      });\n    });\n    const { service } = createService({\n      env: {\n        ALPHACLAW_MANAGED_UPDATE_URL: \"http://host.docker.internal:3180/update\",\n        ALPHACLAW_MANAGED_UPDATE_TOKEN: \"bridge-token\",\n        ALPHACLAW_TEMPLATE_REPO_URL:\n          \"https://github.com/chrysb/openclaw-apex-template.git\",\n      },\n      readOpenclawVersion: () => \"2026.4.5\",\n      fetchMock,\n    });\n\n    const result = await service.updateAlphaclaw();\n\n    expect(result.status).toBe(200);\n    expect(result.body).toEqual(\n      expect.objectContaining({\n        ok: true,\n        managedUpdate: true,\n        restarting: true,\n        latestVersion: \"0.8.7\",\n        latestOpenclawVersion: \"2026.4.10\",\n      }),\n    );\n  });\n\n  it(\"returns Apex migration instructions when the deployment provider is apex but the bridge is missing\", async () => {\n    const fetchMock = vi.fn(async (url) => {\n      if (String(url).includes(\"raw.githubusercontent.com\")) {\n        return createFetchResponse({\n          body: {\n            dependencies: {\n              \"@chrysb/alphaclaw\": \"0.8.7\",\n              openclaw: \"2026.4.10\",\n            },\n          },\n        });\n      }\n      if (String(url).includes(\"/commits/main\")) {\n        return createFetchResponse({\n          body: { sha: \"aded043defd05bba6787bca75ac6ed8dffd43c6e\" },\n        });\n      }\n      throw new Error(`Unexpected fetch call: ${String(url)}`);\n    });\n    const { service } = createService({\n      env: {\n        ALPHACLAW_DEPLOYMENT_PROVIDER: \"apex\",\n        ALPHACLAW_TEMPLATE_REPO_URL:\n          \"https://github.com/chrysb/openclaw-apex-template.git\",\n      },\n      fetchMock,\n    });\n\n    const status = await service.getVersionStatus(true);\n\n    expect(status.updateStrategy).toEqual(\n      expect.objectContaining({\n        provider: \"apex\",\n        action: \"instructions\",\n        primaryActionLabel: \"Done\",\n      }),\n    );\n\n    const result = await service.updateAlphaclaw();\n    expect(result.status).toBe(409);\n    expect(result.body.updateStrategy).toEqual(\n      expect.objectContaining({\n        provider: \"apex\",\n        action: \"instructions\",\n        primaryActionLabel: \"Done\",\n      }),\n    );\n  });\n\n  it(\"returns instructions-only rejection for railway deployments\", async () => {\n    const fetchMock = vi.fn(async () =>\n      createFetchResponse({\n        body: {\n          dependencies: {\n            \"@chrysb/alphaclaw\": \"0.8.10\",\n            openclaw: \"2026.4.10\",\n          },\n        },\n      }),\n    );\n    const { service } = createService({\n      env: { RAILWAY_ENVIRONMENT: \"production\" },\n      fetchMock,\n    });\n\n    const result = await service.updateAlphaclaw();\n\n    expect(result.status).toBe(409);\n    expect(result.body.ok).toBe(false);\n    expect(result.body.updateStrategy).toEqual(\n      expect.objectContaining({\n        provider: \"railway\",\n        action: \"instructions\",\n      }),\n    );\n  });\n\n  it(\"returns 409 while another self-update is in progress\", async () => {\n    const callbacks = [];\n    const execMock = vi.fn().mockImplementation((cmd, opts, callback) => {\n      callbacks.push(callback);\n    });\n    const fetchMock = vi.fn(async () =>\n      createFetchResponse({\n        body: {\n          \"dist-tags\": { latest: \"99.0.0\" },\n        },\n      }),\n    );\n    const { service } = createService({\n      fetchMock,\n      execMock,\n      fsImpl: { ...fs, existsSync: vi.fn(() => false) },\n    });\n\n    const firstPromise = service.updateAlphaclaw();\n    await new Promise((resolve) => setImmediate(resolve));\n\n    const secondResult = await service.updateAlphaclaw();\n    expect(secondResult.status).toBe(409);\n    expect(secondResult.body).toEqual({\n      ok: false,\n      error: \"AlphaClaw update already in progress\",\n    });\n\n    callbacks[0](null, \"installed\", \"\");\n    await new Promise((resolve) => {\n      setImmediate(resolve);\n    });\n    callbacks[1](null, \"\", \"\");\n    await firstPromise;\n  });\n\n  it(\"returns successful self-update result with restarting flag\", async () => {\n    const execMock = vi.fn().mockImplementation((cmd, opts, callback) => {\n      callback(null, \"added 1 package\", \"\");\n    });\n    const { service } = createService({\n      execMock,\n      fetchMock: vi.fn(),\n      fsImpl: { ...fs, existsSync: vi.fn(() => false) },\n    });\n\n    const result = await service.updateAlphaclaw();\n\n    expect(result.status).toBe(200);\n    expect(result.body.ok).toBe(true);\n    expect(result.body.restarting).toBe(true);\n    expect(result.body.previousVersion).toBeTruthy();\n    expect(execMock).toHaveBeenCalledTimes(2);\n    expect(execMock).toHaveBeenNthCalledWith(\n      1,\n      \"npm install --omit=dev --prefer-online --package-lock=false\",\n      expect.objectContaining({\n        cwd: expect.stringContaining(path.join(os.tmpdir(), \"alphaclaw-update-\")),\n        env: expect.objectContaining({\n          npm_config_update_notifier: \"false\",\n          npm_config_fund: \"false\",\n          npm_config_audit: \"false\",\n        }),\n        timeout: 180000,\n      }),\n      expect.any(Function),\n    );\n    expect(execMock).toHaveBeenNthCalledWith(\n      2,\n      expect.stringMatching(/^cp -af /),\n      expect.objectContaining({ timeout: kOpenclawUpdateCopyTimeoutMs }),\n      expect.any(Function),\n    );\n  });\n\n  it(\"returns 500 when npm install fails\", async () => {\n    const execMock = vi.fn().mockImplementation((cmd, opts, callback) => {\n      callback(\n        new Error(\"npm ERR! network timeout\"),\n        \"\",\n        \"npm ERR! network timeout\",\n      );\n    });\n    const { service } = createService({\n      execMock,\n      fsImpl: { ...fs, existsSync: vi.fn(() => false) },\n    });\n\n    const result = await service.updateAlphaclaw();\n\n    expect(result.status).toBe(500);\n    expect(result.body.ok).toBe(false);\n    expect(result.body.error).toContain(\"npm ERR!\");\n  });\n\n  it(\"writes update marker to kRootDir on successful self-update\", async () => {\n    const execMock = vi.fn().mockImplementation((cmd, opts, callback) => {\n      callback(null, \"added 1 package\", \"\");\n    });\n    const writeSpy = vi.spyOn(fs, \"writeFileSync\");\n    const { service } = createService({\n      execMock,\n      fsImpl: { ...fs, existsSync: vi.fn(() => false) },\n    });\n\n    const result = await service.updateAlphaclaw();\n\n    expect(result.status).toBe(200);\n    const markerPath = path.join(kRootDir, \".alphaclaw-update-pending\");\n    const markerCall = writeSpy.mock.calls.find(\n      (call) => call[0] === markerPath,\n    );\n    expect(markerCall).toBeTruthy();\n    const markerData = JSON.parse(markerCall[1]);\n    expect(markerData).toHaveProperty(\"from\");\n    expect(markerData).toHaveProperty(\"ts\");\n\n    writeSpy.mockRestore();\n  });\n});\n"
  },
  {
    "path": "tests/server/auth-profiles.test.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst os = require(\"os\");\n\nlet tmpDir;\nlet ap;\n\nconst readJson = (relPath) =>\n  JSON.parse(\n    fs.readFileSync(path.join(tmpDir, \".openclaw\", relPath), \"utf8\"),\n  );\n\nbeforeAll(() => {\n  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), \"ac-auth-test-\"));\n  process.env.ALPHACLAW_ROOT_DIR = tmpDir;\n\n  const openclawDir = path.join(tmpDir, \".openclaw\");\n  const agentDir = path.join(openclawDir, \"agents\", \"main\", \"agent\");\n  fs.mkdirSync(agentDir, { recursive: true });\n  fs.writeFileSync(\n    path.join(openclawDir, \"openclaw.json\"),\n    JSON.stringify(\n      {\n        agents: {\n          defaults: {\n            model: { primary: \"anthropic/claude-opus-4-6\" },\n            models: { \"anthropic/claude-opus-4-6\": {} },\n          },\n        },\n        gateway: { port: 18789 },\n      },\n      null,\n      2,\n    ),\n  );\n\n  const { createAuthProfiles } = require(\"../../lib/server/auth-profiles\");\n  ap = createAuthProfiles();\n});\n\nbeforeEach(() => {\n  const openclawDir = path.join(tmpDir, \".openclaw\");\n  fs.writeFileSync(\n    path.join(openclawDir, \"openclaw.json\"),\n    JSON.stringify(\n      {\n        agents: {\n          defaults: {\n            model: { primary: \"anthropic/claude-opus-4-6\" },\n            models: { \"anthropic/claude-opus-4-6\": {} },\n          },\n        },\n        gateway: { port: 18789 },\n      },\n      null,\n      2,\n    ),\n  );\n  const storePath = path.join(\n    openclawDir,\n    \"agents\",\n    \"main\",\n    \"agent\",\n    \"auth-profiles.json\",\n  );\n  if (fs.existsSync(storePath)) fs.unlinkSync(storePath);\n});\n\nafterAll(() => {\n  delete process.env.ALPHACLAW_ROOT_DIR;\n  fs.rmSync(tmpDir, { recursive: true, force: true });\n});\n\ndescribe(\"server/auth-profiles\", () => {\n  it(\"upserts an api_key profile and syncs openclaw.json\", () => {\n    ap.upsertProfile(\"anthropic:default\", {\n      type: \"api_key\",\n      provider: \"anthropic\",\n      key: \"sk-ant-test-key\",\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.version).toBe(1);\n    expect(store.profiles[\"anthropic:default\"]).toEqual({\n      type: \"api_key\",\n      provider: \"anthropic\",\n      key: \"sk-ant-test-key\",\n    });\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.auth.profiles[\"anthropic:default\"]).toEqual({\n      provider: \"anthropic\",\n      mode: \"api_key\",\n    });\n    expect(config.gateway.port).toBe(18789);\n  });\n\n  it(\"upserts a token profile and syncs config mode\", () => {\n    ap.upsertProfile(\"anthropic:manual\", {\n      type: \"token\",\n      provider: \"anthropic\",\n      token: \"sk-ant-oat01-test\",\n      expires: 9999999999999,\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"anthropic:manual\"].type).toBe(\"token\");\n    expect(store.profiles[\"anthropic:manual\"].token).toBe(\"sk-ant-oat01-test\");\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.auth.profiles[\"anthropic:manual\"].mode).toBe(\"token\");\n  });\n\n  it(\"upserts an oauth profile and syncs config\", () => {\n    ap.upsertProfile(\"openai-codex:codex-cli\", {\n      type: \"oauth\",\n      provider: \"openai-codex\",\n      access: \"jwt-access\",\n      refresh: \"rt-refresh\",\n      expires: 9999999999999,\n      accountId: \"test-account\",\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"openai-codex:codex-cli\"].type).toBe(\"oauth\");\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.auth.profiles[\"openai-codex:codex-cli\"].mode).toBe(\"oauth\");\n  });\n\n  it(\"removes a profile and cleans config reference\", () => {\n    ap.upsertProfile(\"google:default\", {\n      type: \"api_key\",\n      provider: \"google\",\n      key: \"AItest\",\n    });\n\n    let config = readJson(\"openclaw.json\");\n    expect(config.auth.profiles[\"google:default\"]).toBeDefined();\n\n    ap.removeProfile(\"google:default\");\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"google:default\"]).toBeUndefined();\n\n    config = readJson(\"openclaw.json\");\n    expect(config.auth?.profiles?.[\"google:default\"]).toBeUndefined();\n  });\n\n  it(\"preserves order, lastGood, and usageStats on write\", () => {\n    const storePath = path.join(\n      tmpDir,\n      \".openclaw\",\n      \"agents\",\n      \"main\",\n      \"agent\",\n      \"auth-profiles.json\",\n    );\n    fs.writeFileSync(\n      storePath,\n      JSON.stringify({\n        version: 1,\n        profiles: {\n          \"anthropic:default\": {\n            type: \"api_key\",\n            provider: \"anthropic\",\n            key: \"existing\",\n          },\n        },\n        order: { anthropic: [\"anthropic:default\"] },\n        lastGood: { anthropic: \"anthropic:default\" },\n        usageStats: { total: 42 },\n      }),\n    );\n\n    ap.upsertProfile(\"google:default\", {\n      type: \"api_key\",\n      provider: \"google\",\n      key: \"AItest\",\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.order).toEqual({ anthropic: [\"anthropic:default\"] });\n    expect(store.lastGood).toEqual({ anthropic: \"anthropic:default\" });\n    expect(store.usageStats).toEqual({ total: 42 });\n    expect(store.profiles[\"anthropic:default\"].key).toBe(\"existing\");\n    expect(store.profiles[\"google:default\"].key).toBe(\"AItest\");\n  });\n\n  it(\"normalizes secrets (strips whitespace and line breaks)\", () => {\n    ap.upsertProfile(\"anthropic:default\", {\n      type: \"api_key\",\n      provider: \"anthropic\",\n      key: \"  sk-ant-key\\r\\n  \",\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"anthropic:default\"].key).toBe(\"sk-ant-key\");\n  });\n\n  it(\"preserves existing config keys when writing openclaw.json\", () => {\n    ap.upsertProfile(\"anthropic:default\", {\n      type: \"api_key\",\n      provider: \"anthropic\",\n      key: \"test\",\n    });\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.agents.defaults.model.primary).toBe(\n      \"anthropic/claude-opus-4-6\",\n    );\n    expect(config.agents.defaults.models).toEqual({\n      \"anthropic/claude-opus-4-6\": {},\n    });\n    expect(config.gateway.port).toBe(18789);\n  });\n\n  it(\"setModelConfig writes primary and configuredModels\", () => {\n    ap.setModelConfig({\n      primary: \"openai/gpt-5.1-codex\",\n      configuredModels: {\n        \"openai/gpt-5.1-codex\": {},\n        \"anthropic/claude-opus-4-6\": {},\n      },\n    });\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.agents.defaults.model.primary).toBe(\"openai/gpt-5.1-codex\");\n    expect(config.agents.defaults.models).toEqual({\n      \"openai/gpt-5.1-codex\": {},\n      \"anthropic/claude-opus-4-6\": {},\n    });\n    expect(config.gateway.port).toBe(18789);\n  });\n\n  it(\"legacy upsertCodexProfile writes oauth and syncs config\", () => {\n    ap.upsertCodexProfile({\n      access: \"jwt\",\n      refresh: \"rt\",\n      expires: 9999999999999,\n      accountId: \"acct\",\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"openai-codex:codex-cli\"]).toEqual({\n      type: \"oauth\",\n      provider: \"openai-codex\",\n      access: \"jwt\",\n      refresh: \"rt\",\n      expires: 9999999999999,\n      accountId: \"acct\",\n    });\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.auth.profiles[\"openai-codex:codex-cli\"].mode).toBe(\"oauth\");\n  });\n\n  it(\"legacy removeCodexProfiles removes all codex profiles\", () => {\n    ap.upsertCodexProfile({\n      access: \"jwt\",\n      refresh: \"rt\",\n      expires: 1,\n    });\n\n    let store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"openai-codex:codex-cli\"]).toBeDefined();\n\n    ap.removeCodexProfiles();\n\n    store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"openai-codex:codex-cli\"]).toBeUndefined();\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.auth?.profiles?.[\"openai-codex:codex-cli\"]).toBeUndefined();\n  });\n\n  it(\"does not write auth refs into incomplete pre-onboarding config\", () => {\n    fs.writeFileSync(\n      path.join(tmpDir, \".openclaw\", \"openclaw.json\"),\n      JSON.stringify(\n        {\n          auth: {\n            profiles: {},\n          },\n          gateway: { port: 18789 },\n        },\n        null,\n        2,\n      ),\n    );\n\n    ap.upsertCodexProfile({\n      access: \"jwt\",\n      refresh: \"rt\",\n      expires: 9999999999999,\n      accountId: \"acct\",\n    });\n\n    const store = readJson(\"agents/main/agent/auth-profiles.json\");\n    expect(store.profiles[\"openai-codex:codex-cli\"]).toBeDefined();\n\n    const config = readJson(\"openclaw.json\");\n    expect(config.auth?.profiles || {}).toEqual({});\n    expect(config.gateway.port).toBe(18789);\n  });\n});\n"
  },
  {
    "path": "tests/server/chat-ws.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { WebSocketServer } = require(\"ws\");\n\nconst { createChatWsService } = require(\"../../lib/server/chat-ws\");\n\nconst waitForListening = (server) =>\n  new Promise((resolve) => {\n    server.once(\"listening\", resolve);\n  });\n\nconst closeWsServer = (server) =>\n  new Promise((resolve) => {\n    server.close(() => resolve());\n  });\n\ndescribe(\"server/chat-ws\", () => {\n  let originalGatewayToken;\n  let tempDir;\n  let gatewayServer;\n  let gatewaySocket;\n\n  beforeEach(() => {\n    originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-chat-ws-\"));\n  });\n\n  afterEach(async () => {\n    if (gatewaySocket && gatewaySocket.readyState === 1) {\n      gatewaySocket.close();\n    }\n    if (gatewayServer) {\n      await closeWsServer(gatewayServer);\n      gatewayServer = null;\n    }\n    if (originalGatewayToken === undefined) {\n      delete process.env.OPENCLAW_GATEWAY_TOKEN;\n    } else {\n      process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;\n    }\n    if (tempDir) {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n      tempDir = \"\";\n    }\n  });\n\n  it(\"connects as the direct-local backend client that preserves operator scopes\", async () => {\n    const captured = {\n      connectParams: null,\n      headers: null,\n      historyParams: null,\n    };\n    gatewayServer = new WebSocketServer({ host: \"127.0.0.1\", port: 0 });\n    await waitForListening(gatewayServer);\n\n    gatewayServer.on(\"connection\", (socket, request) => {\n      gatewaySocket = socket;\n      captured.headers = request.headers;\n      socket.send(JSON.stringify({ type: \"event\", event: \"connect.challenge\" }));\n      socket.on(\"message\", (rawData) => {\n        const frame = JSON.parse(String(rawData || \"\"));\n        if (frame.method === \"connect\") {\n          captured.connectParams = frame.params;\n          socket.send(\n            JSON.stringify({\n              type: \"res\",\n              id: frame.id,\n              ok: true,\n              payload: { type: \"hello-ok\" },\n            }),\n          );\n          return;\n        }\n        if (frame.method === \"chat.history\") {\n          captured.historyParams = frame.params;\n          socket.send(\n            JSON.stringify({\n              type: \"res\",\n              id: frame.id,\n              ok: true,\n              payload: { messages: [] },\n            }),\n          );\n        }\n      });\n    });\n\n    fs.writeFileSync(\n      path.join(tempDir, \"openclaw.json\"),\n      JSON.stringify({ gateway: { auth: { token: \"${OPENCLAW_GATEWAY_TOKEN}\" } } }),\n    );\n    process.env.OPENCLAW_GATEWAY_TOKEN = \"bridge-token\";\n\n    const service = createChatWsService({\n      fs,\n      openclawDir: tempDir,\n      getGatewayPort: () => gatewayServer.address().port,\n    });\n\n    const history = await service.fetchHistory(\"agent:main:main\");\n\n    expect(history).toEqual({ messages: [], rawHistory: { messages: [] } });\n    expect(captured.headers.origin).toBeUndefined();\n    expect(captured.connectParams).toMatchObject({\n      client: {\n        id: \"gateway-client\",\n        mode: \"backend\",\n      },\n      role: \"operator\",\n      auth: { token: \"bridge-token\" },\n    });\n    expect(captured.connectParams.scopes).toEqual(\n      expect.arrayContaining([\"operator.admin\", \"operator.read\", \"operator.write\"]),\n    );\n    expect(captured.historyParams).toEqual({\n      sessionKey: \"agent:main:main\",\n      limit: 200,\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/commands.test.js",
    "content": "const childProcess = require(\"child_process\");\n\nconst modulePath = require.resolve(\"../../lib/server/commands\");\nconst originalExec = childProcess.exec;\n\nconst loadCommandsModule = ({ execMock }) => {\n  childProcess.exec = execMock;\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\ndescribe(\"server/commands\", () => {\n  afterEach(() => {\n    childProcess.exec = originalExec;\n    delete require.cache[modulePath];\n  });\n\n  it(\"attaches trimmed stdout and stderr to shellCmd errors\", async () => {\n    const execMock = vi.fn((cmd, opts, callback) => {\n      callback(new Error(\"boom\"), ' {\"ok\":true} \\n', \" noisy stderr \\n\");\n    });\n    const { createCommands } = loadCommandsModule({ execMock });\n    const { shellCmd } = createCommands({\n      gatewayEnv: () => ({ OPENCLAW_GATEWAY_TOKEN: \"token\" }),\n    });\n\n    await expect(shellCmd(\"openclaw models list --all --json\")).rejects.toMatchObject({\n      message: \"boom\",\n      stdout: '{\"ok\":true}',\n      stderr: \"noisy stderr\",\n      cmd: \"openclaw models list --all --json\",\n    });\n  });\n\n  it(\"preserves timeout metadata on clawCmd failures\", async () => {\n    const timeoutError = Object.assign(new Error(\"Command failed\"), {\n      code: null,\n      killed: true,\n      signal: \"SIGTERM\",\n    });\n    const execMock = vi.fn((cmd, opts, callback) => {\n      callback(timeoutError, \"\", \"\");\n    });\n    const { createCommands } = loadCommandsModule({ execMock });\n    const { clawCmd } = createCommands({\n      gatewayEnv: () => ({ OPENCLAW_GATEWAY_TOKEN: \"token\" }),\n    });\n\n    const result = await clawCmd(\"nodes status --json\", {\n      quiet: true,\n      timeoutMs: 1234,\n    });\n\n    expect(execMock).toHaveBeenCalledWith(\n      \"openclaw nodes status --json\",\n      expect.objectContaining({\n        timeout: 1234,\n        killSignal: \"SIGTERM\",\n      }),\n      expect.any(Function),\n    );\n    expect(result).toMatchObject({\n      ok: false,\n      stdout: \"\",\n      stderr: \"\",\n      code: null,\n      killed: true,\n      signal: \"SIGTERM\",\n      timedOut: true,\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/cost-utils.test.js",
    "content": "const { deriveCostBreakdown } = require(\"../../lib/server/cost-utils\");\n\ndescribe(\"server/cost-utils\", () => {\n  it(\"prices Claude Opus 4.7 including prompt cache tokens\", () => {\n    const breakdown = deriveCostBreakdown({\n      provider: \"anthropic\",\n      model: \"anthropic/claude-opus-4-7\",\n      inputTokens: 100_000,\n      outputTokens: 10_000,\n      cacheReadTokens: 800_000,\n      cacheWriteTokens: 20_000,\n    });\n\n    expect(breakdown.pricingFound).toBe(true);\n    expect(breakdown.inputCost).toBeCloseTo(0.5, 8);\n    expect(breakdown.outputCost).toBeCloseTo(0.25, 8);\n    expect(breakdown.cacheReadCost).toBeCloseTo(0.4, 8);\n    expect(breakdown.cacheWriteCost).toBeCloseTo(0.125, 8);\n    expect(breakdown.totalCost).toBeCloseTo(1.275, 8);\n  });\n\n  it(\"matches Claude Opus 4.7 dot-form model IDs\", () => {\n    const breakdown = deriveCostBreakdown({\n      provider: \"anthropic\",\n      model: \"claude-opus-4.7\",\n      inputTokens: 1_000_000,\n    });\n\n    expect(breakdown.pricingFound).toBe(true);\n    expect(breakdown.totalCost).toBeCloseTo(5, 8);\n  });\n});\n"
  },
  {
    "path": "tests/server/cron-service.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { createCronService } = require(\"../../lib/server/cron-service\");\n\nconst createOpenclawDirWithCronJobs = (jobs = []) => {\n  const openclawDir = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-cron-\"));\n  fs.mkdirSync(path.join(openclawDir, \"cron\"), { recursive: true });\n  fs.writeFileSync(\n    path.join(openclawDir, \"cron\", \"jobs.json\"),\n    JSON.stringify({ version: 1, jobs }),\n    \"utf8\",\n  );\n  return openclawDir;\n};\n\ndescribe(\"server/cron-service\", () => {\n  it(\"uses plain cron commands without --json for run/toggle/edit\", async () => {\n    const openclawDir = createOpenclawDirWithCronJobs([\n      {\n        id: \"job-a\",\n        name: \"Job A\",\n        enabled: true,\n        createdAtMs: 1,\n        schedule: { kind: \"cron\", expr: \"0 8 * * *\" },\n        sessionTarget: \"isolated\",\n        wakeMode: \"now\",\n        payload: { kind: \"agentTurn\", message: \"old prompt\" },\n        state: {},\n      },\n    ]);\n    const clawCmd = vi\n      .fn()\n      .mockResolvedValueOnce({ ok: true, stdout: \"ran job-a\" })\n      .mockResolvedValueOnce({ ok: true, stdout: \"disabled job-a\" })\n      .mockResolvedValueOnce({ ok: true, stdout: \"enabled job-a\" })\n      .mockResolvedValueOnce({ ok: true, stdout: \"updated prompt\" })\n      .mockResolvedValueOnce({ ok: true, stdout: \"updated routing\" });\n    try {\n      const cronService = createCronService({\n        clawCmd,\n        OPENCLAW_DIR: openclawDir,\n        getSessionUsageByKeyPattern: vi.fn(() => ({})),\n      });\n\n      const runResult = await cronService.runJobNow(\"job-a\");\n      expect(clawCmd).toHaveBeenCalledTimes(1);\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        1,\n        \"cron run 'job-a'\",\n        expect.objectContaining({ quiet: true }),\n      );\n      expect(runResult.raw).toBe(\"ran job-a\");\n\n      const result = await cronService.setJobEnabled({\n        jobId: \"job-a\",\n        enabled: false,\n      });\n\n      expect(clawCmd).toHaveBeenCalledTimes(2);\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        2,\n        \"cron disable 'job-a'\",\n        expect.objectContaining({ quiet: true }),\n      );\n      expect(result.raw).toBe(\"disabled job-a\");\n      expect(result.parsed).toBeNull();\n\n      const secondResult = await cronService.setJobEnabled({\n        jobId: \"job-a\",\n        enabled: true,\n      });\n      expect(clawCmd).toHaveBeenCalledTimes(3);\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        3,\n        \"cron enable 'job-a'\",\n        expect.objectContaining({ quiet: true }),\n      );\n      expect(secondResult.raw).toBe(\"enabled job-a\");\n\n      const promptResult = await cronService.updateJobPrompt({\n        jobId: \"job-a\",\n        message: \"hello world\",\n      });\n      expect(clawCmd).toHaveBeenCalledTimes(4);\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        4,\n        \"cron edit 'job-a' --message 'hello world'\",\n        expect.objectContaining({ quiet: true }),\n      );\n      expect(promptResult.raw).toBe(\"updated prompt\");\n\n      const routingResult = await cronService.updateJobRouting({\n        jobId: \"job-a\",\n        sessionTarget: \"isolated\",\n        wakeMode: \"next-heartbeat\",\n        deliveryMode: \"announce\",\n        deliveryChannel: \"telegram\",\n        deliveryTo: \"123\",\n      });\n      expect(clawCmd).toHaveBeenCalledTimes(5);\n      expect(clawCmd).toHaveBeenNthCalledWith(\n        5,\n        \"cron edit 'job-a' --session 'isolated' --wake 'next-heartbeat' --announce --channel 'telegram' --to '123'\",\n        expect.objectContaining({ quiet: true }),\n      );\n      expect(routingResult.raw).toBe(\"updated routing\");\n    } finally {\n      fs.rmSync(openclawDir, { recursive: true, force: true });\n    }\n  });\n\n  it(\"uses --system-event when editing main systemEvent job prompts\", async () => {\n    const openclawDir = createOpenclawDirWithCronJobs([\n      {\n        id: \"job-main\",\n        name: \"Main Job\",\n        enabled: true,\n        createdAtMs: 1,\n        schedule: { kind: \"cron\", expr: \"0 8 * * *\" },\n        sessionTarget: \"main\",\n        wakeMode: \"now\",\n        payload: { kind: \"systemEvent\", text: \"old prompt\" },\n        state: {},\n      },\n    ]);\n    try {\n      const clawCmd = vi.fn().mockResolvedValue({ ok: true, stdout: \"updated prompt\" });\n      const cronService = createCronService({\n        clawCmd,\n        OPENCLAW_DIR: openclawDir,\n        getSessionUsageByKeyPattern: vi.fn(() => ({})),\n      });\n\n      const result = await cronService.updateJobPrompt({\n        jobId: \"job-main\",\n        message: \"new prompt\",\n      });\n\n      expect(clawCmd).toHaveBeenCalledWith(\n        \"cron edit 'job-main' --system-event 'new prompt'\",\n        expect.objectContaining({ quiet: true }),\n      );\n      expect(result.raw).toBe(\"updated prompt\");\n    } finally {\n      fs.rmSync(openclawDir, { recursive: true, force: true });\n    }\n  });\n});\n"
  },
  {
    "path": "tests/server/doctor-db.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst { kDoctorCardStatus, kDoctorPriority, kDoctorRunStatus } = require(\"../../lib/server/doctor/constants\");\n\nconst loadDoctorDb = () => {\n  const modulePath = require.resolve(\"../../lib/server/db/doctor\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nlet currentDoctorDb = null;\nlet currentRootDir = \"\";\n\nconst createDoctorDbContext = (prefix) => {\n  currentRootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));\n  currentDoctorDb = loadDoctorDb();\n  const dbResult = currentDoctorDb.initDoctorDb({ rootDir: currentRootDir });\n  return {\n    ...currentDoctorDb,\n    ...dbResult,\n    rootDir: currentRootDir,\n  };\n};\n\ndescribe(\"server/doctor-db\", () => {\n  afterEach(() => {\n    if (currentDoctorDb?.closeDoctorDb) {\n      currentDoctorDb.closeDoctorDb();\n      currentDoctorDb = null;\n    }\n    if (currentRootDir) {\n      fs.rmSync(currentRootDir, { recursive: true, force: true });\n      currentRootDir = \"\";\n    }\n  });\n\n  it(\"initializes doctor.db under root db directory\", () => {\n    const result = createDoctorDbContext(\"doctor-db-init-\");\n\n    expect(result.path).toBe(path.join(result.rootDir, \"db\", \"doctor.db\"));\n    expect(fs.existsSync(result.path)).toBe(true);\n  });\n\n  it(\"stores runs and cards with aggregated counts\", () => {\n    const {\n      createDoctorRun,\n      insertDoctorCards,\n      completeDoctorRun,\n      getInitialWorkspaceBaseline,\n      getDoctorRun,\n      getLatestDoctorRun,\n      getDoctorCardsByRunId,\n      setInitialWorkspaceBaseline,\n      updateDoctorCardStatus,\n    } = createDoctorDbContext(\"doctor-db-cards-\");\n    setInitialWorkspaceBaseline({\n      fingerprint: \"initial-fingerprint\",\n      manifest: { \"README.md\": \"hash-readme\" },\n      capturedAt: \"2026-03-06T00:00:00.000Z\",\n    });\n\n    const runId = createDoctorRun({\n      engine: \"gateway_agent\",\n      workspaceRoot: \"/tmp/workspace\",\n      workspaceFingerprint: \"fingerprint-123\",\n      workspaceManifest: { \"AGENTS.md\": \"hash-1\" },\n      promptVersion: \"doctor-v1\",\n      reusedFromRunId: 9,\n    });\n    insertDoctorCards({\n      runId,\n      cards: [\n        {\n          priority: kDoctorPriority.P0,\n          category: \"guidance\",\n          title: \"Misplaced tools guidance\",\n          summary: \"Tool guidance lives in the wrong file\",\n          recommendation: \"Move tool guidance into TOOLS.md\",\n          evidence: [{ type: \"path\", path: \"README.md\" }],\n          targetPaths: [\"README.md\", \"hooks/bootstrap/TOOLS.md\"],\n          fixPrompt: \"Move the tool guidance safely\",\n          status: kDoctorCardStatus.open,\n        },\n        {\n          priority: kDoctorPriority.P2,\n          category: \"cleanup\",\n          title: \"Duplicate notes\",\n          summary: \"Low-value duplication\",\n          recommendation: \"Consolidate the duplicate notes\",\n          evidence: [],\n          targetPaths: [\"docs/notes.md\"],\n          fixPrompt: \"Consolidate the duplicate notes safely\",\n          status: kDoctorCardStatus.dismissed,\n        },\n      ],\n    });\n    completeDoctorRun({\n      id: runId,\n      status: kDoctorRunStatus.completed,\n      summary: \"Found 2 recommendations\",\n      rawResult: { cards: [] },\n    });\n\n    const run = getDoctorRun(runId);\n    const latestRun = getLatestDoctorRun();\n    const cards = getDoctorCardsByRunId(runId);\n    const initialBaseline = getInitialWorkspaceBaseline();\n\n    expect(run.status).toBe(kDoctorRunStatus.completed);\n    expect(initialBaseline).toEqual({\n      fingerprint: \"initial-fingerprint\",\n      manifest: { \"README.md\": \"hash-readme\" },\n      capturedAt: \"2026-03-06T00:00:00.000Z\",\n    });\n    expect(run.workspaceFingerprint).toBe(\"fingerprint-123\");\n    expect(run.workspaceManifest).toEqual({ \"AGENTS.md\": \"hash-1\" });\n    expect(run.reusedFromRunId).toBe(9);\n    expect(run.cardCount).toBe(2);\n    expect(run.priorityCounts).toEqual({ P0: 1, P1: 0, P2: 1 });\n    expect(run.statusCounts).toEqual({ open: 1, dismissed: 1, fixed: 0 });\n    expect(cards).toHaveLength(2);\n    expect(latestRun.id).toBe(runId);\n\n    const updatedCard = updateDoctorCardStatus({\n      id: cards[0].id,\n      status: kDoctorCardStatus.fixed,\n    });\n    expect(updatedCard.status).toBe(kDoctorCardStatus.fixed);\n  });\n});\n"
  },
  {
    "path": "tests/server/doctor-normalize.test.js",
    "content": "const {\n  normalizeDoctorResult,\n} = require(\"../../lib/server/doctor/normalize\");\n\ndescribe(\"server/doctor-normalize\", () => {\n  it(\"normalizes nested JSON output into AlphaClaw Doctor cards\", () => {\n    const rawOutput = JSON.stringify({\n      runId: \"abc\",\n      status: \"ok\",\n      result: JSON.stringify({\n        summary: \"Workspace guidance has drift\",\n        findings: [\n          {\n            severity: \"high\",\n            category: \"guidance\",\n            title: \"Tools guidance drift\",\n            description: \"Tool guidance is duplicated in README.md\",\n            recommendedAction: \"Move tool guidance into TOOLS.md\",\n            evidence: [\"README.md duplicates TOOLS.md\"],\n            paths: [\"README.md\", \"hooks/bootstrap/TOOLS.md\"],\n          },\n        ],\n      }),\n    });\n\n    const result = normalizeDoctorResult(rawOutput);\n\n    expect(result.summary).toBe(\"Workspace guidance has drift\");\n    expect(result.cards).toEqual([\n      expect.objectContaining({\n        priority: \"P0\",\n        category: \"guidance\",\n        title: \"Tools guidance drift\",\n        recommendation: \"Move tool guidance into TOOLS.md\",\n        targetPaths: [{ path: \"README.md\" }, { path: \"hooks/bootstrap/TOOLS.md\" }],\n        status: \"open\",\n      }),\n    ]);\n    expect(result.cards[0].fixPrompt).toContain(\"Move tool guidance into TOOLS.md\");\n    expect(result.cards[0].evidence).toEqual([\n      { type: \"text\", text: \"README.md duplicates TOOLS.md\" },\n    ]);\n  });\n\n  it(\"extracts Doctor JSON when prose surrounds the payload\", () => {\n    const rawOutput = `Now I have a complete picture. Here's the analysis:\\n\\n${JSON.stringify({\n      summary: \"Fresh workspace with drift risk\",\n      cards: [\n        {\n          priority: \"P1\",\n          category: \"redundancy\",\n          title: \"Duplicated UI guidance\",\n          summary: \"Two files repeat the same guidance\",\n          recommendation: \"Centralize the detailed guidance into one place\",\n          evidence: [\n            { type: \"path\", path: \"hooks/bootstrap/TOOLS.md\" },\n            { type: \"path\", path: \"hooks/bootstrap/AGENTS.md\" },\n          ],\n          targetPaths: [\"hooks/bootstrap/TOOLS.md\"],\n          fixPrompt: \"Reduce duplication safely\",\n          status: \"open\",\n        },\n      ],\n    })}\\n\\nThat is the full result.`;\n\n    const result = normalizeDoctorResult(rawOutput);\n\n    expect(result.summary).toBe(\"Fresh workspace with drift risk\");\n    expect(result.cards).toEqual([\n      expect.objectContaining({\n        priority: \"P1\",\n        category: \"redundancy\",\n        title: \"Duplicated UI guidance\",\n        recommendation: \"Centralize the detailed guidance into one place\",\n        targetPaths: [{ path: \"hooks/bootstrap/TOOLS.md\" }],\n      }),\n    ]);\n  });\n\n  it(\"extracts Doctor JSON from agent payloads text wrappers\", () => {\n    const rawOutput = JSON.stringify({\n      runId: \"6650ca1c-be0f-4c15-afb4-3d995c904e2e\",\n      status: \"ok\",\n      summary: \"completed\",\n      result: {\n        payloads: [\n          {\n            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\\\":[]}\",\n            mediaUrl: null,\n          },\n        ],\n      },\n    });\n\n    const result = normalizeDoctorResult(rawOutput);\n\n    expect(result.summary).toBe(\n      \"Healthy post-bootstrap workspace. No changes since last scan. No drift, contradictions, or misplaced guidance detected.\",\n    );\n    expect(result.cards).toEqual([]);\n  });\n\n  it(\"throws when the payload does not include recognizable Doctor cards\", () => {\n    expect(() => normalizeDoctorResult('{\"ok\":true,\"summary\":\"no cards here\"}')).toThrow(\n      \"Doctor response did not include a recognizable cards payload\",\n    );\n  });\n});\n"
  },
  {
    "path": "tests/server/doctor-prompt.test.js",
    "content": "const { buildDoctorPrompt } = require(\"../../lib/server/doctor/prompt\");\n\ndescribe(\"server/doctor-prompt\", () => {\n  it(\"includes OpenClaw default-template context for AGENTS.md\", () => {\n    const prompt = buildDoctorPrompt({\n      workspaceRoot: \"/tmp/workspace\",\n      managedRoot: \"/tmp/managed\",\n      promptVersion: \"doctor-v1\",\n    });\n\n    expect(prompt).toContain(\"OpenClaw default context:\");\n    expect(prompt).toContain(\"`AGENTS.md` is the workspace home file in the default OpenClaw template.\");\n    expect(prompt).toContain(\"Do not treat default-template content as drift just because it is broad or multi-purpose.\");\n  });\n\n  it(\"includes Project Context truncation guidance\", () => {\n    const prompt = buildDoctorPrompt({\n      workspaceRoot: \"/tmp/workspace\",\n      managedRoot: \"/tmp/managed\",\n      promptVersion: \"doctor-v1\",\n    });\n\n    expect(prompt).toContain(\"Large injected files are truncated per-file at 20000 chars by default\");\n    expect(prompt).toContain(\"OpenClaw trims oversized injected files by keeping the first 70%\");\n    expect(prompt).toContain(\"`BOOTSTRAP.md` is first-run only\");\n  });\n\n  it(\"tells the analyzer not to propose structural changes to AlphaClaw-managed files\", () => {\n    const prompt = buildDoctorPrompt({\n      workspaceRoot: \"/tmp/workspace\",\n      managedRoot: \"/tmp/managed\",\n      lockedPaths: [\"hooks/bootstrap/TOOLS.md\"],\n      promptVersion: \"doctor-v1\",\n    });\n\n    expect(prompt).toContain(\"AlphaClaw ownership rules:\");\n    expect(prompt).toContain(\n      \"Do not recommend splitting, renaming, relocating, or otherwise restructuring AlphaClaw-managed files solely for cleanliness or purity.\",\n    );\n    expect(prompt).toContain(\n      \"Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure\",\n    );\n  });\n\n  it(\"frames dismissed findings as suppressed and fixed findings as context only\", () => {\n    const prompt = buildDoctorPrompt({\n      workspaceRoot: \"/tmp/workspace\",\n      managedRoot: \"/tmp/managed\",\n      resolvedCards: [\n        {\n          status: \"dismissed\",\n          title: \"Cleanup docs\",\n          category: \"workspace\",\n        },\n        {\n          status: \"fixed\",\n          title: \"Stale docs remain\",\n          category: \"workspace\",\n        },\n      ],\n      promptVersion: \"doctor-v1\",\n    });\n\n    expect(prompt).toContain(\"Previously dismissed findings\");\n    expect(prompt).toContain(\"[dismissed] Cleanup docs (workspace)\");\n    expect(prompt).toContain(\"Previously fixed findings\");\n    expect(prompt).toContain(\"[fixed] Stale docs remain (workspace)\");\n    expect(prompt).toContain(\n      'Do not re-suggest findings that appear in the \"Previously dismissed\" list above',\n    );\n    expect(prompt).toContain(\n      \"Previously fixed findings may be re-suggested if the underlying issue is still present\",\n    );\n    expect(prompt).toContain(\n      \"If a previously fixed finding is still present, you may call that out in the summary or card wording\",\n    );\n    expect(prompt).not.toContain(\"Previously resolved findings\");\n  });\n});\n"
  },
  {
    "path": "tests/server/doctor-service.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst loadDoctorDb = () => {\n  const modulePath = require.resolve(\"../../lib/server/db/doctor\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nconst loadDoctorService = () => {\n  const modulePath = require.resolve(\"../../lib/server/doctor/service\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nconst repeatText = (length, character = \"A\") => character.repeat(length);\n\nlet currentDoctorDb = null;\n\nconst loadManagedDoctorDb = () => {\n  currentDoctorDb = loadDoctorDb();\n  return currentDoctorDb;\n};\n\ndescribe(\"server/doctor-service\", () => {\n  afterEach(() => {\n    if (currentDoctorDb?.closeDoctorDb) {\n      currentDoctorDb.closeDoctorDb();\n      currentDoctorDb = null;\n    }\n  });\n\n  it(\"reuses the previous completed run when the workspace fingerprint is unchanged\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-workspace-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-service-db-\"));\n    fs.writeFileSync(\n      path.join(workspaceRoot, \"AGENTS.md\"),\n      \"# Workspace Guidance\\n\\nKeep this concise.\\n\",\n      \"utf8\",\n    );\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const clawCmd = vi.fn(async () => ({\n      ok: true,\n      stdout: JSON.stringify({\n        summary: \"Should not be called\",\n        cards: [],\n      }),\n    }));\n    const { createDoctorService } = loadDoctorService();\n    const doctorService = createDoctorService({\n      clawCmd,\n      listDoctorRuns: doctorDb.listDoctorRuns,\n      listDoctorCards: doctorDb.listDoctorCards,\n      getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n      setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n      createDoctorRun: doctorDb.createDoctorRun,\n      completeDoctorRun: doctorDb.completeDoctorRun,\n      insertDoctorCards: doctorDb.insertDoctorCards,\n      getDoctorRun: doctorDb.getDoctorRun,\n      getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n      getDoctorCard: doctorDb.getDoctorCard,\n      updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n      workspaceRoot,\n      managedRoot: workspaceRoot,\n    });\n\n    const imported = doctorService.importDoctorResult({\n      rawOutput: JSON.stringify({\n        summary: \"Initial findings\",\n        cards: [\n          {\n            priority: \"P1\",\n            category: \"redundancy\",\n            title: \"Duplicated UI guidance\",\n            summary: \"Two files describe the same flow\",\n            recommendation: \"Keep one file authoritative\",\n            evidence: [{ type: \"path\", path: \"AGENTS.md\" }],\n            targetPaths: [\"AGENTS.md\"],\n            fixPrompt: \"Consolidate the duplicated guidance safely.\",\n            status: \"open\",\n          },\n        ],\n      }),\n    });\n\n    const rerun = doctorService.runDoctor();\n    const latestRun = doctorDb.getDoctorRun(rerun.runId);\n\n    expect(imported.ok).toBe(true);\n    expect(rerun.ok).toBe(true);\n    expect(rerun.reusedPreviousRun).toBe(true);\n    expect(rerun.sourceRunId).toBe(imported.runId);\n    expect(clawCmd).not.toHaveBeenCalled();\n    expect(latestRun.engine).toBe(\"deterministic_reuse\");\n    expect(latestRun.reusedFromRunId).toBe(imported.runId);\n    expect(latestRun.summary).toMatch(/^No workspace changes since last scan/);\n    expect(doctorDb.getDoctorCardsByRunId(rerun.runId)).toHaveLength(1);\n  });\n\n  it(\"runs Doctor analysis in a dedicated doctor session\", async () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-session-workspace-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-session-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), \"# Workspace Guidance\\n\", \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const clawCmd = vi.fn(async () => ({\n      ok: true,\n      stdout: JSON.stringify({\n        summary: \"Healthy workspace\",\n        cards: [],\n      }),\n      stderr: \"\",\n      code: 0,\n    }));\n    const { buildDoctorIdempotencyKey, buildDoctorSessionKey, createDoctorService } =\n      loadDoctorService();\n    const doctorService = createDoctorService({\n      clawCmd,\n      listDoctorRuns: doctorDb.listDoctorRuns,\n      listDoctorCards: doctorDb.listDoctorCards,\n      getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n      setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n      createDoctorRun: doctorDb.createDoctorRun,\n      completeDoctorRun: doctorDb.completeDoctorRun,\n      insertDoctorCards: doctorDb.insertDoctorCards,\n      getDoctorRun: doctorDb.getDoctorRun,\n      getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n      getDoctorCard: doctorDb.getDoctorCard,\n      updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n      workspaceRoot,\n      managedRoot: workspaceRoot,\n    });\n\n    const result = doctorService.runDoctor();\n    await new Promise((resolve) => setTimeout(resolve, 0));\n\n    expect(result.ok).toBe(true);\n    expect(clawCmd).toHaveBeenCalledTimes(1);\n    expect(clawCmd.mock.calls[0][0]).toContain(\"gateway call agent --expect-final --json\");\n    expect(clawCmd.mock.calls[0][0]).toContain(\n      `\"idempotencyKey\":\"${buildDoctorIdempotencyKey(result.runId)}\"`,\n    );\n    expect(clawCmd.mock.calls[0][0]).toContain(\n      `\"sessionKey\":\"${buildDoctorSessionKey(result.runId)}\"`,\n    );\n  });\n\n  it(\"does not suppress previously fixed findings on later Doctor runs\", async () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-fixed-rerun-workspace-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-fixed-rerun-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), \"# Workspace Guidance\\n\", \"utf8\");\n    fs.writeFileSync(path.join(workspaceRoot, \"README.md\"), \"# Initial docs\\n\", \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    let runCount = 0;\n    const clawCmd = vi.fn(async () => {\n      runCount += 1;\n      return {\n        ok: true,\n        stdout: JSON.stringify({\n          summary: `Run ${runCount}`,\n          cards: [\n            {\n              priority: \"P1\",\n              category: \"workspace\",\n              title: \"Stale docs remain\",\n              summary: \"README still contains stale guidance\",\n              recommendation: \"Update README to match the current workspace\",\n              evidence: [{ type: \"path\", path: \"README.md\" }],\n              targetPaths: [\"README.md\"],\n              fixPrompt: \"Update README safely.\",\n              status: \"open\",\n            },\n          ],\n        }),\n        stderr: \"\",\n        code: 0,\n      };\n    });\n    const { createDoctorService } = loadDoctorService();\n    const buildDoctorService = () =>\n      createDoctorService({\n        clawCmd,\n        listDoctorRuns: doctorDb.listDoctorRuns,\n        listDoctorCards: doctorDb.listDoctorCards,\n        getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n        setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n        createDoctorRun: doctorDb.createDoctorRun,\n        completeDoctorRun: doctorDb.completeDoctorRun,\n        insertDoctorCards: doctorDb.insertDoctorCards,\n        getDoctorRun: doctorDb.getDoctorRun,\n        getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n        getDoctorCard: doctorDb.getDoctorCard,\n        updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n        workspaceRoot,\n        managedRoot: workspaceRoot,\n      });\n    const doctorService = buildDoctorService();\n\n    const firstRun = doctorService.runDoctor();\n    await new Promise((resolve) => setTimeout(resolve, 0));\n    const firstRunCards = doctorDb.getDoctorCardsByRunId(firstRun.runId);\n    doctorService.setCardStatus({\n      cardId: firstRunCards[0].id,\n      status: \"fixed\",\n    });\n\n    fs.writeFileSync(path.join(workspaceRoot, \"README.md\"), \"# Updated docs\\n\", \"utf8\");\n\n    const secondRun = buildDoctorService().runDoctor();\n    await new Promise((resolve) => setTimeout(resolve, 0));\n\n    expect(clawCmd).toHaveBeenCalledTimes(2);\n    expect(clawCmd.mock.calls[1][0]).toContain(\"Previously fixed findings\");\n    expect(clawCmd.mock.calls[1][0]).toContain(\"[fixed] Stale docs remain (workspace)\");\n    expect(clawCmd.mock.calls[1][0]).toContain(\n      \"Previously fixed findings may be re-suggested if the underlying issue is still present\",\n    );\n    expect(doctorDb.getDoctorCardsByRunId(secondRun.runId)).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          title: \"Stale docs remain\",\n          status: \"open\",\n        }),\n      ]),\n    );\n  });\n\n  it(\"reports meaningful workspace drift only after a stale completed run\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-drift-workspace-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-drift-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), \"# Guidance\\n\", \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const listDoctorRuns = ({ limit } = {}) =>\n      doctorDb.listDoctorRuns({ limit }).map((run) => ({\n        ...run,\n        startedAt: \"2000-01-01T00:00:00.000Z\",\n        completedAt: \"2000-01-01T00:00:00.000Z\",\n      }));\n\n    const { createDoctorService } = loadDoctorService();\n    const buildDoctorService = () =>\n      createDoctorService({\n        clawCmd: vi.fn(),\n        listDoctorRuns,\n        listDoctorCards: doctorDb.listDoctorCards,\n        getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n        setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n        createDoctorRun: doctorDb.createDoctorRun,\n        completeDoctorRun: doctorDb.completeDoctorRun,\n        insertDoctorCards: doctorDb.insertDoctorCards,\n        getDoctorRun: doctorDb.getDoctorRun,\n        getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n        getDoctorCard: doctorDb.getDoctorCard,\n        updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n        workspaceRoot,\n        managedRoot: workspaceRoot,\n      });\n\n    const doctorService = buildDoctorService();\n\n    doctorService.importDoctorResult({\n      rawOutput: JSON.stringify({\n        summary: \"Baseline findings\",\n        cards: [],\n      }),\n    });\n\n    fs.writeFileSync(path.join(workspaceRoot, \"README.md\"), \"# Updated docs\\n\", \"utf8\");\n    fs.mkdirSync(path.join(workspaceRoot, \"skills\"), { recursive: true });\n    fs.writeFileSync(path.join(workspaceRoot, \"skills\", \"note.md\"), \"extra guidance\\n\", \"utf8\");\n\n    const refreshedDoctorService = buildDoctorService();\n    const status = refreshedDoctorService.buildStatus();\n\n    expect(status.needsInitialRun).toBe(false);\n    expect(status.stale).toBe(true);\n    expect(status.changeSummary.hasBaseline).toBe(true);\n    expect(status.changeSummary.changedFilesCount).toBe(2);\n    expect(status.changeSummary.hasMeaningfulChanges).toBe(true);\n    expect(status.changeSummary.deltaScore).toBeGreaterThanOrEqual(4);\n  });\n\n  it(\"uses the persisted initial baseline before the first completed run\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-initial-baseline-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-initial-baseline-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), \"# Guidance\\n\", \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const { createDoctorService } = loadDoctorService();\n    const buildDoctorService = () =>\n      createDoctorService({\n        clawCmd: vi.fn(),\n        listDoctorRuns: doctorDb.listDoctorRuns,\n        listDoctorCards: doctorDb.listDoctorCards,\n        getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n        setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n        createDoctorRun: doctorDb.createDoctorRun,\n        completeDoctorRun: doctorDb.completeDoctorRun,\n        insertDoctorCards: doctorDb.insertDoctorCards,\n        getDoctorRun: doctorDb.getDoctorRun,\n        getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n        getDoctorCard: doctorDb.getDoctorCard,\n        updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n        workspaceRoot,\n        managedRoot: workspaceRoot,\n      });\n\n    const doctorService = buildDoctorService();\n\n    const initialStatus = doctorService.buildStatus();\n    fs.writeFileSync(path.join(workspaceRoot, \"README.md\"), \"# Added after baseline\\n\", \"utf8\");\n    const nextStatus = buildDoctorService().buildStatus();\n\n    expect(initialStatus.needsInitialRun).toBe(true);\n    expect(initialStatus.changeSummary.hasBaseline).toBe(true);\n    expect(initialStatus.changeSummary.baselineSource).toBe(\"initial_install\");\n    expect(nextStatus.changeSummary.changedFilesCount).toBe(1);\n    expect(nextStatus.changeSummary.hasMeaningfulChanges).toBe(false);\n  });\n\n  it(\"reports healthy Project Context files without truncation\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-healthy-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-healthy-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), \"# Guidance\\nKeep it short.\\n\", \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const { createDoctorService } = loadDoctorService();\n    const doctorService = createDoctorService({\n      clawCmd: vi.fn(),\n      listDoctorRuns: doctorDb.listDoctorRuns,\n      listDoctorCards: doctorDb.listDoctorCards,\n      getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n      setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n      createDoctorRun: doctorDb.createDoctorRun,\n      completeDoctorRun: doctorDb.completeDoctorRun,\n      insertDoctorCards: doctorDb.insertDoctorCards,\n      getDoctorRun: doctorDb.getDoctorRun,\n      getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n      getDoctorCard: doctorDb.getDoctorCard,\n      updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n      workspaceRoot,\n      managedRoot: workspaceRoot,\n    });\n\n    const status = doctorService.buildStatus();\n\n    expect(status.bootstrapContext.hasActiveTruncation).toBe(false);\n    expect(status.bootstrapContext.activeTruncatedFiles).toEqual([]);\n  });\n\n  it(\"reports per-file Project Context truncation in Doctor status\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-file-limit-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-file-limit-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), repeatText(20001), \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const { createDoctorService } = loadDoctorService();\n    const doctorService = createDoctorService({\n      clawCmd: vi.fn(),\n      listDoctorRuns: doctorDb.listDoctorRuns,\n      listDoctorCards: doctorDb.listDoctorCards,\n      getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n      setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n      createDoctorRun: doctorDb.createDoctorRun,\n      completeDoctorRun: doctorDb.completeDoctorRun,\n      insertDoctorCards: doctorDb.insertDoctorCards,\n      getDoctorRun: doctorDb.getDoctorRun,\n      getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n      getDoctorCard: doctorDb.getDoctorCard,\n      updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n      workspaceRoot,\n      managedRoot: workspaceRoot,\n    });\n\n    const status = doctorService.buildStatus();\n\n    expect(status.bootstrapContext.hasActiveTruncation).toBe(true);\n    expect(status.bootstrapContext.hasTotalLimitTruncation).toBe(false);\n    expect(status.bootstrapContext.activeTruncatedFiles).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          path: \"AGENTS.md\",\n          rawChars: 20001,\n          truncatedByFileLimit: true,\n          truncatedByTotalLimit: false,\n          reason: \"file_limit\",\n        }),\n      ]),\n    );\n  });\n\n  it(\"reports total Project Context truncation when active injected files exceed the total cap\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-total-limit-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-total-limit-db-\"));\n    const activeProjectContextFiles = [\n      \"AGENTS.md\",\n      \"SOUL.md\",\n      \"TOOLS.md\",\n      \"IDENTITY.md\",\n      \"USER.md\",\n      \"HEARTBEAT.md\",\n      \"hooks/bootstrap/AGENTS.md\",\n      \"hooks/bootstrap/TOOLS.md\",\n    ];\n    fs.mkdirSync(path.join(workspaceRoot, \"hooks\", \"bootstrap\"), { recursive: true });\n    for (const filePath of activeProjectContextFiles) {\n      fs.writeFileSync(path.join(workspaceRoot, filePath), repeatText(20000), \"utf8\");\n    }\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const { createDoctorService } = loadDoctorService();\n    const doctorService = createDoctorService({\n      clawCmd: vi.fn(),\n      listDoctorRuns: doctorDb.listDoctorRuns,\n      listDoctorCards: doctorDb.listDoctorCards,\n      getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n      setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n      createDoctorRun: doctorDb.createDoctorRun,\n      completeDoctorRun: doctorDb.completeDoctorRun,\n      insertDoctorCards: doctorDb.insertDoctorCards,\n      getDoctorRun: doctorDb.getDoctorRun,\n      getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n      getDoctorCard: doctorDb.getDoctorCard,\n      updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n      workspaceRoot,\n      managedRoot: workspaceRoot,\n    });\n\n    const status = doctorService.buildStatus();\n\n    expect(status.bootstrapContext.hasActiveTruncation).toBe(true);\n    expect(status.bootstrapContext.hasTotalLimitTruncation).toBe(true);\n    expect(status.bootstrapContext.activeInjectedChars).toBe(\n      status.bootstrapContext.bootstrapTotalMaxChars,\n    );\n    expect(status.bootstrapContext.activeTruncatedFiles).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          path: \"hooks/bootstrap/TOOLS.md\",\n          truncatedByTotalLimit: true,\n        }),\n      ]),\n    );\n  });\n\n  it(\"adds deterministic truncation cards alongside imported Doctor findings\", () => {\n    const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-import-\"));\n    const dbRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"doctor-bootstrap-import-db-\"));\n    fs.writeFileSync(path.join(workspaceRoot, \"AGENTS.md\"), repeatText(20001), \"utf8\");\n\n    const doctorDb = loadManagedDoctorDb();\n    doctorDb.initDoctorDb({ rootDir: dbRoot });\n\n    const { createDoctorService } = loadDoctorService();\n    const doctorService = createDoctorService({\n      clawCmd: vi.fn(),\n      listDoctorRuns: doctorDb.listDoctorRuns,\n      listDoctorCards: doctorDb.listDoctorCards,\n      getInitialWorkspaceBaseline: doctorDb.getInitialWorkspaceBaseline,\n      setInitialWorkspaceBaseline: doctorDb.setInitialWorkspaceBaseline,\n      createDoctorRun: doctorDb.createDoctorRun,\n      completeDoctorRun: doctorDb.completeDoctorRun,\n      insertDoctorCards: doctorDb.insertDoctorCards,\n      getDoctorRun: doctorDb.getDoctorRun,\n      getDoctorCardsByRunId: doctorDb.getDoctorCardsByRunId,\n      getDoctorCard: doctorDb.getDoctorCard,\n      updateDoctorCardStatus: doctorDb.updateDoctorCardStatus,\n      workspaceRoot,\n      managedRoot: workspaceRoot,\n    });\n\n    const imported = doctorService.importDoctorResult({\n      rawOutput: JSON.stringify({\n        summary: \"Imported findings\",\n        cards: [\n          {\n            priority: \"P2\",\n            category: \"workspace\",\n            title: \"Small cleanup\",\n            summary: \"Minor cleanup item\",\n            recommendation: \"Tidy the note\",\n            evidence: [{ type: \"path\", path: \"AGENTS.md\" }],\n            targetPaths: [\"AGENTS.md\"],\n            fixPrompt: \"Tidy the note safely.\",\n            status: \"open\",\n          },\n        ],\n      }),\n    });\n\n    const cards = doctorDb.getDoctorCardsByRunId(imported.runId);\n\n    expect(cards).toHaveLength(2);\n    expect(cards).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          priority: \"P0\",\n          title: \"AGENTS.md is being truncated in Project Context\",\n        }),\n        expect.objectContaining({\n          priority: \"P2\",\n          title: \"Small cleanup\",\n        }),\n      ]),\n    );\n  });\n});\n"
  },
  {
    "path": "tests/server/exec-defaults-config.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  ensureManagedExecDefaults,\n} = require(\"../../lib/server/exec-defaults-config\");\n\nconst createTempOpenclawDir = () =>\n  fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-exec-defaults-test-\"));\n\ndescribe(\"server/exec-defaults-config\", () => {\n  it(\"fills missing managed exec defaults for openclaw.json and exec-approvals.json\", () => {\n    const openclawDir = createTempOpenclawDir();\n    fs.writeFileSync(\n      path.join(openclawDir, \"openclaw.json\"),\n      JSON.stringify(\n        {\n          tools: {\n            profile: \"full\",\n          },\n          channels: {\n            telegram: { enabled: true },\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir });\n\n    expect(result).toEqual({\n      changed: true,\n      openclawChanged: true,\n      approvalsChanged: true,\n    });\n\n    const openclawConfig = JSON.parse(\n      fs.readFileSync(path.join(openclawDir, \"openclaw.json\"), \"utf8\"),\n    );\n    expect(openclawConfig.tools).toEqual({\n      profile: \"full\",\n      exec: {\n        security: \"full\",\n        strictInlineEval: false,\n      },\n    });\n    expect(openclawConfig.channels.telegram).toEqual({ enabled: true });\n\n    const approvals = JSON.parse(\n      fs.readFileSync(path.join(openclawDir, \"exec-approvals.json\"), \"utf8\"),\n    );\n    expect(approvals).toEqual({\n      version: 1,\n      defaults: {\n        security: \"full\",\n        ask: \"off\",\n        askFallback: \"full\",\n      },\n      agents: {},\n    });\n  });\n\n  it(\"preserves existing exec settings when they are already configured\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const openclawPath = path.join(openclawDir, \"openclaw.json\");\n    const approvalsPath = path.join(openclawDir, \"exec-approvals.json\");\n    const openclawContent = JSON.stringify(\n      {\n        tools: {\n          profile: \"full\",\n          exec: {\n            host: \"node\",\n            node: \"mac-1\",\n            security: \"allowlist\",\n            ask: \"always\",\n            strictInlineEval: true,\n          },\n        },\n      },\n      null,\n      2,\n    );\n    const approvalsContent =\n      JSON.stringify(\n        {\n          version: 1,\n          defaults: {\n            security: \"allowlist\",\n            ask: \"always\",\n            askFallback: \"deny\",\n          },\n          agents: {\n            main: {\n              security: \"allowlist\",\n            },\n          },\n        },\n        null,\n        2,\n      ) + \"\\n\";\n    fs.writeFileSync(openclawPath, openclawContent, \"utf8\");\n    fs.writeFileSync(approvalsPath, approvalsContent, \"utf8\");\n\n    const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir });\n\n    expect(result).toEqual({\n      changed: false,\n      openclawChanged: false,\n      approvalsChanged: false,\n    });\n    expect(fs.readFileSync(openclawPath, \"utf8\")).toBe(openclawContent);\n    expect(fs.readFileSync(approvalsPath, \"utf8\")).toBe(approvalsContent);\n  });\n\n  it(\"does not add or change openclaw exec subkeys when tools.exec already exists\", () => {\n    const openclawDir = createTempOpenclawDir();\n    fs.writeFileSync(\n      path.join(openclawDir, \"openclaw.json\"),\n      JSON.stringify(\n        {\n          tools: {\n            profile: \"full\",\n            exec: {\n              host: \"gateway\",\n              ask: \"off\",\n            },\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir });\n\n    expect(result).toEqual({\n      changed: true,\n      openclawChanged: false,\n      approvalsChanged: true,\n    });\n\n    const openclawConfig = JSON.parse(\n      fs.readFileSync(path.join(openclawDir, \"openclaw.json\"), \"utf8\"),\n    );\n    expect(openclawConfig.tools.exec).toEqual({\n      host: \"gateway\",\n      ask: \"off\",\n    });\n  });\n\n  it(\"does not add or change exec approvals defaults when defaults is a non-empty object\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const openclawPath = path.join(openclawDir, \"openclaw.json\");\n    const approvalsPath = path.join(openclawDir, \"exec-approvals.json\");\n    const openclawContent = JSON.stringify(\n      {\n        tools: {\n          profile: \"full\",\n          exec: {\n            host: \"gateway\",\n          },\n        },\n      },\n      null,\n      2,\n    );\n    const approvalsContent =\n      JSON.stringify(\n        {\n          socket: {\n            path: \"/data/.openclaw/exec-approvals.sock\",\n            token: \"\",\n          },\n          defaults: {\n            ask: \"always\",\n          },\n        },\n        null,\n        2,\n      ) + \"\\n\";\n    fs.writeFileSync(openclawPath, openclawContent, \"utf8\");\n    fs.writeFileSync(approvalsPath, approvalsContent, \"utf8\");\n\n    const result = ensureManagedExecDefaults({ fsModule: fs, openclawDir });\n\n    expect(result).toEqual({\n      changed: false,\n      openclawChanged: false,\n      approvalsChanged: false,\n    });\n    expect(fs.readFileSync(approvalsPath, \"utf8\")).toBe(approvalsContent);\n  });\n});\n"
  },
  {
    "path": "tests/server/express-runtime-guard.test.js",
    "content": "const expressPackage = require(\"express/package.json\");\n\ndescribe(\"server runtime dependency guard\", () => {\n  it(\"resolves express v4 at top-level runtime\", () => {\n    const majorVersion = Number.parseInt(\n      String(expressPackage.version || \"\").split(\".\")[0] || \"0\",\n      10,\n    );\n    expect(majorVersion).toBe(4);\n  });\n});\n"
  },
  {
    "path": "tests/server/gateway.test.js",
    "content": "const childProcess = require(\"child_process\");\nconst fs = require(\"fs\");\nconst net = require(\"net\");\nconst path = require(\"path\");\nconst {\n  ALPHACLAW_DIR,\n  kOnboardingMarkerPath,\n  OPENCLAW_DIR,\n} = require(\"../../lib/server/constants\");\nconst {\n  kDefaultOpenclawCompileCacheDir,\n} = require(\"../../lib/server/openclaw-runtime-env\");\n\nconst kLegacyControlUiSkillPath = path.join(OPENCLAW_DIR, \"skills\", \"control-ui\", \"SKILL.md\");\n\nconst modulePath = require.resolve(\"../../lib/server/gateway\");\nconst originalSpawn = childProcess.spawn;\nconst originalExecSync = childProcess.execSync;\nconst originalExistsSync = fs.existsSync;\nconst originalMkdirSync = fs.mkdirSync;\nconst originalReaddirSync = fs.readdirSync;\nconst originalReadFileSync = fs.readFileSync;\nconst originalRmSync = fs.rmSync;\nconst originalWriteFileSync = fs.writeFileSync;\nconst originalCreateConnection = net.createConnection;\n\nconst createSocket = (isRunning) => ({\n  setTimeout: vi.fn(),\n  destroy: vi.fn(),\n  on(event, handler) {\n    if (isRunning && event === \"connect\") {\n      setImmediate(handler);\n    }\n    if (!isRunning && event === \"error\") {\n      setImmediate(handler);\n    }\n    return this;\n  },\n});\n\nconst createChild = () => ({\n  pid: 1234,\n  stdout: { on: vi.fn() },\n  stderr: { on: vi.fn() },\n  on: vi.fn(),\n  kill: vi.fn(),\n  exitCode: null,\n  killed: false,\n});\n\ndescribe(\"server/gateway restart behavior\", () => {\n  afterEach(() => {\n    childProcess.spawn = originalSpawn;\n    childProcess.execSync = originalExecSync;\n    fs.existsSync = originalExistsSync;\n    fs.mkdirSync = originalMkdirSync;\n    fs.readdirSync = originalReaddirSync;\n    fs.readFileSync = originalReadFileSync;\n    fs.rmSync = originalRmSync;\n    fs.writeFileSync = originalWriteFileSync;\n    net.createConnection = originalCreateConnection;\n    delete require.cache[modulePath];\n  });\n\n  it(\"uses force restart when a managed child exists\", async () => {\n    const spawnMock = vi.fn(() => createChild());\n    const execSyncMock = vi.fn(() => \"\");\n    childProcess.spawn = spawnMock;\n    childProcess.execSync = execSyncMock;\n    fs.existsSync = vi.fn(() => true);\n    net.createConnection = vi.fn(() => createSocket(false));\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    fs.readFileSync = vi.fn(() =>\n      JSON.stringify({\n        agents: {\n          defaults: {\n            model: {\n              primary: \"openai/gpt-5.1-codex\",\n            },\n          },\n        },\n      }),\n    );\n\n    await gateway.startGateway();\n    expect(spawnMock).toHaveBeenCalledTimes(1);\n\n    const reloadEnv = vi.fn();\n    gateway.restartGateway(reloadEnv);\n\n    expect(reloadEnv).toHaveBeenCalledTimes(1);\n    expect(execSyncMock).toHaveBeenCalledTimes(1);\n    expect(execSyncMock).toHaveBeenCalledWith(\"openclaw gateway --force\", {\n      env: expect.any(Object),\n      timeout: 15000,\n      encoding: \"utf8\",\n    });\n    expect(spawnMock).toHaveBeenCalledTimes(1);\n    const firstChild = spawnMock.mock.results[0].value;\n    expect(firstChild.kill).not.toHaveBeenCalled();\n  });\n\n  it(\"exports the durable OpenClaw state dir in gateway env\", () => {\n    const previousCompileCache = process.env.NODE_COMPILE_CACHE;\n    const previousNoRespawn = process.env.OPENCLAW_NO_RESPAWN;\n    delete process.env.NODE_COMPILE_CACHE;\n    delete process.env.OPENCLAW_NO_RESPAWN;\n    try {\n      delete require.cache[modulePath];\n      const gateway = require(modulePath);\n\n      expect(gateway.gatewayEnv()).toEqual(\n        expect.objectContaining({\n          HOME: expect.any(String),\n          OPENCLAW_HOME: expect.any(String),\n          OPENCLAW_CONFIG_PATH: `${OPENCLAW_DIR}/openclaw.json`,\n          OPENCLAW_STATE_DIR: OPENCLAW_DIR,\n          XDG_CONFIG_HOME: OPENCLAW_DIR,\n          NODE_COMPILE_CACHE: kDefaultOpenclawCompileCacheDir,\n          OPENCLAW_NO_RESPAWN: \"1\",\n        }),\n      );\n      expect(gateway.gatewayEnv().HOME).toBe(gateway.gatewayEnv().OPENCLAW_HOME);\n    } finally {\n      if (previousCompileCache === undefined) {\n        delete process.env.NODE_COMPILE_CACHE;\n      } else {\n        process.env.NODE_COMPILE_CACHE = previousCompileCache;\n      }\n      if (previousNoRespawn === undefined) {\n        delete process.env.OPENCLAW_NO_RESPAWN;\n      } else {\n        process.env.OPENCLAW_NO_RESPAWN = previousNoRespawn;\n      }\n    }\n  });\n\n  it(\"uses force restart when no managed child exists\", () => {\n    const spawnMock = vi.fn(() => createChild());\n    const execSyncMock = vi.fn(() => \"\");\n    childProcess.spawn = spawnMock;\n    childProcess.execSync = execSyncMock;\n    fs.existsSync = vi.fn(() => true);\n    net.createConnection = vi.fn(() => createSocket(false));\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    fs.readFileSync = vi.fn(() =>\n      JSON.stringify({\n        agents: {\n          defaults: {\n            model: {\n              primary: \"openai/gpt-5.1-codex\",\n            },\n          },\n        },\n      }),\n    );\n\n    const reloadEnv = vi.fn();\n    gateway.restartGateway(reloadEnv);\n\n    expect(reloadEnv).toHaveBeenCalledTimes(1);\n    expect(execSyncMock).toHaveBeenCalledTimes(1);\n    expect(execSyncMock).toHaveBeenCalledWith(\"openclaw gateway --force\", {\n      env: expect.any(Object),\n      timeout: 15000,\n      encoding: \"utf8\",\n    });\n    expect(spawnMock).not.toHaveBeenCalled();\n  });\n\n  it(\"retries channel plugin preflight after cleaning stale install stages\", () => {\n    const firstError = new Error(\n      \"ENOTEMPTY: directory not empty, rmdir '/app/node_modules/openclaw/dist/extensions/telegram/.openclaw-install-stage/node_modules/typebox/build/type/engine'\",\n    );\n    const execSyncMock = vi\n      .fn()\n      .mockImplementationOnce(() => {\n        throw firstError;\n      })\n      .mockReturnValueOnce(\"{}\");\n    childProcess.execSync = execSyncMock;\n    fs.existsSync = vi.fn((targetPath) => targetPath === `${OPENCLAW_DIR}/openclaw.json`);\n    fs.readFileSync = vi.fn((targetPath, ...args) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        return JSON.stringify({\n          channels: {\n            telegram: { enabled: true },\n          },\n        });\n      }\n      return originalReadFileSync(targetPath, ...args);\n    });\n    let stagePresent = true;\n    fs.readdirSync = vi.fn((targetPath) => {\n      if (String(targetPath).endsWith(\"/dist/extensions\")) {\n        return [{ name: \"telegram\", isDirectory: () => true }];\n      }\n      if (String(targetPath).endsWith(\"/dist/extensions/telegram\")) {\n        return [\n          ...(stagePresent\n            ? [{ name: \".openclaw-install-stage\", isDirectory: () => true }]\n            : []),\n          { name: \"node_modules\", isDirectory: () => true },\n        ];\n      }\n      return [];\n    });\n    fs.rmSync = vi.fn(() => {\n      stagePresent = false;\n    });\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    gateway.prepareOpenclawChannelPlugins();\n\n    expect(execSyncMock).toHaveBeenCalledTimes(2);\n    expect(execSyncMock).toHaveBeenNthCalledWith(1, \"openclaw plugins list --json\", {\n      env: expect.any(Object),\n      timeout: 120000,\n      encoding: \"utf8\",\n    });\n    expect(execSyncMock).toHaveBeenNthCalledWith(2, \"openclaw plugins list --json\", {\n      env: expect.any(Object),\n      timeout: 120000,\n      encoding: \"utf8\",\n    });\n    expect(fs.rmSync).toHaveBeenCalledWith(\n      expect.stringContaining(\"/telegram/.openclaw-install-stage\"),\n      expect.objectContaining({ recursive: true, force: true }),\n    );\n  });\n\n  it(\"marks managed child exit as expected before force restart\", async () => {\n    const child = createChild();\n    const spawnMock = vi.fn(() => child);\n    const execSyncMock = vi.fn(() => \"\");\n    const exitHandler = vi.fn();\n    childProcess.spawn = spawnMock;\n    childProcess.execSync = execSyncMock;\n    fs.existsSync = vi.fn(() => true);\n    net.createConnection = vi.fn(() => createSocket(false));\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    gateway.setGatewayExitHandler(exitHandler);\n    fs.readFileSync = vi.fn(() =>\n      JSON.stringify({\n        agents: {\n          defaults: {\n            model: {\n              primary: \"openai/gpt-5.1-codex\",\n            },\n          },\n        },\n      }),\n    );\n\n    await gateway.startGateway();\n    gateway.restartGateway(vi.fn());\n\n    const exitRegistration = child.on.mock.calls.find((call) => call[0] === \"exit\");\n    expect(exitRegistration).toBeTruthy();\n\n    const [, onExit] = exitRegistration;\n    onExit(0, null);\n\n    expect(exitHandler).toHaveBeenCalledWith(\n      expect.objectContaining({\n        code: 0,\n        signal: null,\n        expectedExit: true,\n      }),\n    );\n  });\n\n  it(\"does not treat auth-only openclaw config as onboarded\", () => {\n    fs.existsSync = vi.fn((targetPath) => targetPath === `${OPENCLAW_DIR}/openclaw.json`);\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    fs.readFileSync = vi.fn(() =>\n      JSON.stringify({\n        auth: {\n          profiles: {\n            \"openai-codex:codex-cli\": {\n              provider: \"openai-codex\",\n              mode: \"oauth\",\n            },\n          },\n        },\n      }),\n    );\n\n    expect(gateway.isOnboarded()).toBe(false);\n  });\n\n  it(\"treats onboarding marker as source of truth\", () => {\n    fs.existsSync = vi.fn((targetPath) => targetPath === kOnboardingMarkerPath);\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    expect(gateway.isOnboarded()).toBe(true);\n  });\n\n  it(\"does not backfill onboarding marker from config with primary model\", () => {\n    fs.existsSync = vi.fn((targetPath) => targetPath === `${OPENCLAW_DIR}/openclaw.json`);\n    fs.mkdirSync = vi.fn();\n    fs.writeFileSync = vi.fn();\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    fs.readFileSync = vi.fn(() =>\n      JSON.stringify({\n        agents: {\n          defaults: {\n            model: {\n              primary: \"openai-codex/gpt-5.3-codex\",\n            },\n          },\n        },\n      }),\n    );\n\n    expect(gateway.isOnboarded()).toBe(false);\n    expect(fs.mkdirSync).not.toHaveBeenCalled();\n    expect(fs.writeFileSync).not.toHaveBeenCalled();\n  });\n\n  it(\"does not treat nested openclaw config as onboarded\", () => {\n    fs.existsSync = vi.fn(\n      (targetPath) => targetPath === `${OPENCLAW_DIR}/.openclaw/openclaw.json`,\n    );\n    fs.mkdirSync = vi.fn();\n    fs.writeFileSync = vi.fn();\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    expect(gateway.isOnboarded()).toBe(false);\n    expect(fs.mkdirSync).not.toHaveBeenCalled();\n    expect(fs.writeFileSync).not.toHaveBeenCalled();\n  });\n\n  it(\"backfills onboarding marker from legacy onboarding artifact\", () => {\n    fs.existsSync = vi.fn((targetPath) => targetPath === kLegacyControlUiSkillPath);\n    fs.mkdirSync = vi.fn();\n    fs.writeFileSync = vi.fn();\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    expect(gateway.isOnboarded()).toBe(true);\n    expect(fs.writeFileSync).toHaveBeenCalledWith(\n      kOnboardingMarkerPath,\n      expect.stringContaining('\"reason\": \"legacy_artifact_backfill\"'),\n    );\n  });\n\n  it(\"adds the setup origin to gateway control UI config\", () => {\n    let currentConfig = {\n      gateway: {},\n    };\n    fs.existsSync = vi.fn((targetPath) => targetPath === kOnboardingMarkerPath);\n    fs.writeFileSync = vi.fn((targetPath, contents) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        currentConfig = JSON.parse(contents);\n      }\n    });\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    fs.readFileSync = vi.fn((targetPath) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        return JSON.stringify(currentConfig);\n      }\n      return \"{}\";\n    });\n\n    const changed = gateway.ensureGatewayProxyConfig(\"https://setup.example.com\");\n\n    expect(changed).toBe(true);\n    expect(fs.writeFileSync).toHaveBeenCalledWith(\n      `${OPENCLAW_DIR}/openclaw.json`,\n      expect.any(String),\n    );\n    expect(currentConfig.gateway.trustedProxies).toEqual([\"127.0.0.1\"]);\n    expect(currentConfig.gateway.controlUi.allowedOrigins).toEqual([\n      \"https://setup.example.com\",\n    ]);\n  });\n\n  it(\"preserves existing allowed origins and remains idempotent\", () => {\n    let currentConfig = {\n      gateway: {\n        trustedProxies: [\"127.0.0.1\"],\n        controlUi: {\n          allowedOrigins: [\"https://existing.example.com\"],\n        },\n      },\n    };\n    fs.existsSync = vi.fn((targetPath) => targetPath === kOnboardingMarkerPath);\n    fs.writeFileSync = vi.fn((targetPath, contents) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        currentConfig = JSON.parse(contents);\n      }\n    });\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n    fs.readFileSync = vi.fn((targetPath) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        return JSON.stringify(currentConfig);\n      }\n      return \"{}\";\n    });\n\n    const firstChanged = gateway.ensureGatewayProxyConfig(\"https://setup.example.com\");\n    const secondChanged = gateway.ensureGatewayProxyConfig(\"https://setup.example.com\");\n\n    expect(firstChanged).toBe(true);\n    expect(secondChanged).toBe(false);\n    expect(currentConfig.gateway.controlUi.allowedOrigins).toEqual([\n      \"https://existing.example.com\",\n      \"https://setup.example.com\",\n    ]);\n    expect(fs.writeFileSync).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"reports channel status per account while preserving provider summary\", () => {\n    fs.existsSync = vi.fn(() => true);\n    fs.readdirSync = vi.fn((targetPath) => {\n      if (targetPath === `${OPENCLAW_DIR}/credentials`) {\n        return [\"telegram-default-allowFrom.json\", \"telegram-alerts-allowFrom.json\"];\n      }\n      return [];\n    });\n    fs.readFileSync = vi.fn((targetPath, ...args) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        return JSON.stringify({\n          channels: {\n            telegram: {\n              enabled: true,\n              accounts: {\n                default: { botToken: \"${TELEGRAM_BOT_TOKEN}\" },\n                alerts: { botToken: \"${TELEGRAM_BOT_TOKEN_ALERTS}\" },\n              },\n            },\n          },\n        });\n      }\n      if (targetPath === `${OPENCLAW_DIR}/credentials/telegram-default-allowFrom.json`) {\n        return JSON.stringify({ allowFrom: [\"1001\"] });\n      }\n      if (targetPath === `${OPENCLAW_DIR}/credentials/telegram-alerts-allowFrom.json`) {\n        return JSON.stringify({ allowFrom: [] });\n      }\n      return originalReadFileSync(targetPath, ...args);\n    });\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    expect(gateway.getChannelStatus()).toEqual({\n      telegram: {\n        status: \"paired\",\n        paired: 1,\n        accounts: {\n          default: { status: \"paired\", paired: 1 },\n          alerts: { status: \"configured\", paired: 0 },\n        },\n      },\n    });\n  });\n\n  it(\"treats legacy single-account telegram config as default account status\", () => {\n    fs.existsSync = vi.fn(() => true);\n    fs.readdirSync = vi.fn((targetPath) => {\n      if (targetPath === `${OPENCLAW_DIR}/credentials`) {\n        return [\"telegram-allowFrom.json\"];\n      }\n      return [];\n    });\n    fs.readFileSync = vi.fn((targetPath, ...args) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        return JSON.stringify({\n          channels: {\n            telegram: {\n              enabled: true,\n              botToken: \"${TELEGRAM_BOT_TOKEN}\",\n              dmPolicy: \"pairing\",\n            },\n          },\n        });\n      }\n      if (targetPath === `${OPENCLAW_DIR}/credentials/telegram-allowFrom.json`) {\n        return JSON.stringify({ allowFrom: [\"1001\", \"1002\"] });\n      }\n      return originalReadFileSync(targetPath, ...args);\n    });\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    expect(gateway.getChannelStatus()).toEqual({\n      telegram: {\n        status: \"paired\",\n        paired: 2,\n        accounts: {\n          default: { status: \"paired\", paired: 2 },\n        },\n      },\n    });\n  });\n\n  it(\"treats whatsapp owner-number self chat as paired when saved creds exist\", () => {\n    const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;\n    process.env.WHATSAPP_OWNER_NUMBER = \"+15551234567\";\n    try {\n    fs.existsSync = vi.fn(() => true);\n    fs.readdirSync = vi.fn(() => []);\n    fs.readFileSync = vi.fn((targetPath, ...args) => {\n      if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n        return JSON.stringify({\n          channels: {\n            whatsapp: {\n              enabled: true,\n              accounts: {\n                default: {\n                  name: \"WhatsApp\",\n                  dmPolicy: \"pairing\",\n                },\n              },\n            },\n          },\n        });\n      }\n      if (targetPath === `${OPENCLAW_DIR}/credentials/whatsapp/default/creds.json`) {\n        return \"{}\";\n      }\n      return originalReadFileSync(targetPath, ...args);\n    });\n    delete require.cache[modulePath];\n    const gateway = require(modulePath);\n\n    expect(gateway.getChannelStatus()).toEqual({\n      whatsapp: {\n        status: \"paired\",\n        paired: 1,\n        accounts: {\n          default: { status: \"paired\", paired: 1 },\n        },\n      },\n    });\n    } finally {\n      if (previousOwnerNumber === undefined) {\n        delete process.env.WHATSAPP_OWNER_NUMBER;\n      } else {\n        process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;\n      }\n    }\n  });\n\n  it(\"keeps whatsapp configured when owner number exists but saved creds do not\", () => {\n    const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;\n    process.env.WHATSAPP_OWNER_NUMBER = \"+15551234567\";\n    try {\n      fs.existsSync = vi.fn(() => true);\n      fs.readdirSync = vi.fn(() => []);\n      fs.readFileSync = vi.fn((targetPath, ...args) => {\n        if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n          return JSON.stringify({\n            channels: {\n              whatsapp: {\n                enabled: true,\n                accounts: {\n                  default: {\n                    name: \"WhatsApp\",\n                    dmPolicy: \"pairing\",\n                  },\n                },\n              },\n            },\n          });\n        }\n        return originalReadFileSync(targetPath, ...args);\n      });\n      delete require.cache[modulePath];\n      const gateway = require(modulePath);\n\n      expect(gateway.getChannelStatus()).toEqual({\n        whatsapp: {\n          status: \"configured\",\n          paired: 0,\n          accounts: {\n            default: { status: \"configured\", paired: 0 },\n          },\n        },\n      });\n    } finally {\n      if (previousOwnerNumber === undefined) {\n        delete process.env.WHATSAPP_OWNER_NUMBER;\n      } else {\n        process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;\n      }\n    }\n  });\n\n  it(\"does not treat whatsapp allowFrom owner placeholder as paired without saved creds\", () => {\n    const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;\n    process.env.WHATSAPP_OWNER_NUMBER = \"+15551234567\";\n    try {\n      fs.existsSync = vi.fn(() => true);\n      fs.readdirSync = vi.fn(() => []);\n      fs.readFileSync = vi.fn((targetPath, ...args) => {\n        if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n          return JSON.stringify({\n            channels: {\n              whatsapp: {\n                enabled: true,\n                accounts: {\n                  default: {\n                    name: \"WhatsApp\",\n                    allowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                    groupAllowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                    dmPolicy: \"allowlist\",\n                    groupPolicy: \"allowlist\",\n                    selfChatMode: true,\n                  },\n                },\n              },\n            },\n          });\n        }\n        return originalReadFileSync(targetPath, ...args);\n      });\n      delete require.cache[modulePath];\n      const gateway = require(modulePath);\n\n      expect(gateway.getChannelStatus()).toEqual({\n        whatsapp: {\n          status: \"configured\",\n          paired: 0,\n          accounts: {\n            default: { status: \"configured\", paired: 0 },\n          },\n        },\n      });\n    } finally {\n      if (previousOwnerNumber === undefined) {\n        delete process.env.WHATSAPP_OWNER_NUMBER;\n      } else {\n        process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;\n      }\n    }\n  });\n\n  it(\"treats whatsapp allowFrom owner placeholder as paired when saved creds exist\", () => {\n    const previousOwnerNumber = process.env.WHATSAPP_OWNER_NUMBER;\n    process.env.WHATSAPP_OWNER_NUMBER = \"+15551234567\";\n    try {\n      fs.existsSync = vi.fn(() => true);\n      fs.readdirSync = vi.fn(() => []);\n      fs.readFileSync = vi.fn((targetPath, ...args) => {\n        if (targetPath === `${OPENCLAW_DIR}/openclaw.json`) {\n          return JSON.stringify({\n            channels: {\n              whatsapp: {\n                enabled: true,\n                accounts: {\n                  default: {\n                    name: \"WhatsApp\",\n                    allowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                    groupAllowFrom: [\"${WHATSAPP_OWNER_NUMBER}\"],\n                    dmPolicy: \"allowlist\",\n                    groupPolicy: \"allowlist\",\n                    selfChatMode: true,\n                  },\n                },\n              },\n            },\n          });\n        }\n        if (targetPath === `${OPENCLAW_DIR}/credentials/whatsapp/default/creds.json`) {\n          return \"{}\";\n        }\n        return originalReadFileSync(targetPath, ...args);\n      });\n      delete require.cache[modulePath];\n      const gateway = require(modulePath);\n\n      expect(gateway.getChannelStatus()).toEqual({\n        whatsapp: {\n          status: \"paired\",\n          paired: 1,\n          accounts: {\n            default: { status: \"paired\", paired: 1 },\n          },\n        },\n      });\n    } finally {\n      if (previousOwnerNumber === undefined) {\n        delete process.env.WHATSAPP_OWNER_NUMBER;\n      } else {\n        process.env.WHATSAPP_OWNER_NUMBER = previousOwnerNumber;\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "tests/server/git-runtime.test.js",
    "content": "const {\n  resolveRealGitPath,\n  shouldRefreshHourlyGitSyncScript,\n} = require(\"../../lib/cli/git-runtime\");\n\ndescribe(\"cli/git runtime helpers\", () => {\n  it(\"resolves a real git path while skipping the installed shim\", () => {\n    const resolvedPath = resolveRealGitPath({\n      shimPath: \"/usr/local/bin/git\",\n      execSyncImpl: () => [\"/usr/local/bin/git\", \"/bin/git\"].join(\"\\n\"),\n      fsModule: {\n        constants: { X_OK: 1 },\n        accessSync(targetPath) {\n          if (targetPath !== \"/bin/git\") {\n            throw new Error(\"not executable\");\n          }\n        },\n      },\n    });\n\n    expect(resolvedPath).toBe(\"/bin/git\");\n  });\n\n  it(\"prefers the explicit hinted path when it is executable\", () => {\n    const resolvedPath = resolveRealGitPath({\n      shimPath: \"/usr/local/bin/git\",\n      hintedPath: \"/custom/git\",\n      execSyncImpl: () => \"\",\n      fsModule: {\n        constants: { X_OK: 1 },\n        accessSync(targetPath) {\n          if (targetPath !== \"/custom/git\") {\n            throw new Error(\"not executable\");\n          }\n        },\n      },\n    });\n\n    expect(resolvedPath).toBe(\"/custom/git\");\n  });\n\n  it(\"refreshes the managed hourly sync script when it changes or is missing\", () => {\n    expect(\n      shouldRefreshHourlyGitSyncScript({\n        packagedSyncScript: \"echo managed script\\n\",\n        installedSyncScript: \"\",\n      }),\n    ).toBe(true);\n\n    expect(\n      shouldRefreshHourlyGitSyncScript({\n        packagedSyncScript: \"echo managed script v2\\n\",\n        installedSyncScript: \"echo managed script v1\\n\",\n      }),\n    ).toBe(true);\n\n    expect(\n      shouldRefreshHourlyGitSyncScript({\n        packagedSyncScript: \"echo managed script\\n\",\n        installedSyncScript: \"echo managed script\\n\",\n      }),\n    ).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/server/git-shim.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { execFileSync } = require(\"child_process\");\n\nconst kGitShimPath = path.join(__dirname, \"../../lib/scripts/git\");\nconst kGitAskPassPath = path.join(__dirname, \"../../lib/scripts/git-askpass\");\n\nconst shellQuote = (value) => `'${String(value).replace(/'/g, `'\\\"'\\\"'`)}'`;\n\nconst createBehaviorHarness = ({ repoRoot }) => {\n  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-shim-\"));\n  const logPath = path.join(tempRoot, \"git.log\");\n  const askPassPath = path.join(tempRoot, \"git-askpass.sh\");\n  const realGitPath = path.join(tempRoot, \"git.real\");\n  const shimPath = path.join(tempRoot, \"git\");\n\n  fs.copyFileSync(kGitAskPassPath, askPassPath);\n  fs.chmodSync(askPassPath, 0o755);\n\n  fs.writeFileSync(\n    realGitPath,\n    [\n      \"#!/bin/sh\",\n      \"{\",\n      '  printf \"PWD=%s\\\\n\" \"$PWD\"',\n      \"  i=1\",\n      '  for arg in \"$@\"; do',\n      '    printf \"ARG_%s=%s\\\\n\" \"$i\" \"$arg\"',\n      \"    i=$((i + 1))\",\n      \"  done\",\n      '  printf \"GIT_ASKPASS=%s\\\\n\" \"${GIT_ASKPASS:-}\"',\n      '  printf \"GIT_TERMINAL_PROMPT=%s\\\\n\" \"${GIT_TERMINAL_PROMPT:-}\"',\n      '  if [ -n \"${GIT_ASKPASS:-}\" ]; then',\n      '    printf \"ASKPASS_USER=%s\\\\n\" \"$(\"$GIT_ASKPASS\" \"Username for https://github.com\")\"',\n      '    printf \"ASKPASS_PASS=%s\\\\n\" \"$(\"$GIT_ASKPASS\" \"Password for https://github.com\")\"',\n      \"  fi\",\n      `} > ${shellQuote(logPath)}`,\n      \"\",\n    ].join(\"\\n\"),\n    { mode: 0o755 },\n  );\n\n  const shimTemplate = fs.readFileSync(kGitShimPath, \"utf8\");\n  const shimContent = shimTemplate\n    .replace('REAL_GIT_HINT=\"@@REAL_GIT@@\"', `REAL_GIT_HINT=\"${realGitPath}\"`)\n    .replace('OPENCLAW_REPO_ROOT=\"@@OPENCLAW_REPO_ROOT@@\"', `OPENCLAW_REPO_ROOT=\"${repoRoot}\"`)\n    .replace('ASKPASS_PATH=\"/tmp/alphaclaw-git-askpass.sh\"', `ASKPASS_PATH=\"${askPassPath}\"`);\n  fs.writeFileSync(shimPath, shimContent, { mode: 0o755 });\n\n  return { tempRoot, logPath, shimPath };\n};\n\ndescribe(\"server git shim scripts\", () => {\n  it(\"keeps install-time placeholders in the shim template\", () => {\n    const content = fs.readFileSync(kGitShimPath, \"utf8\");\n    expect(content).toContain('REAL_GIT_HINT=\"@@REAL_GIT@@\"');\n    expect(content).toContain('OPENCLAW_REPO_ROOT=\"@@OPENCLAW_REPO_ROOT@@\"');\n  });\n\n  it(\"passes auth through for git -C repo push commands\", () => {\n    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-root-\"));\n    const repoRoot = path.join(tempRoot, \"repo\");\n    const outsideDir = path.join(tempRoot, \"outside\");\n    fs.mkdirSync(repoRoot, { recursive: true });\n    fs.mkdirSync(outsideDir, { recursive: true });\n\n    const harness = createBehaviorHarness({ repoRoot });\n    execFileSync(harness.shimPath, [\"-C\", repoRoot, \"push\", \"origin\", \"main\"], {\n      cwd: outsideDir,\n      env: {\n        ...process.env,\n        GITHUB_TOKEN: \"ghp_test_token\",\n      },\n      stdio: \"pipe\",\n    });\n\n    const log = fs.readFileSync(harness.logPath, \"utf8\");\n    expect(log).toContain(`ARG_1=-C`);\n    expect(log).toContain(`ARG_2=${repoRoot}`);\n    expect(log).toContain(\"ARG_3=push\");\n    expect(log).toContain(\"GIT_TERMINAL_PROMPT=0\");\n    expect(log).toContain(\"ASKPASS_USER=x-access-token\");\n    expect(log).toContain(\"ASKPASS_PASS=ghp_test_token\");\n  });\n\n  it(\"passes auth through for git -c key=value -C repo push commands\", () => {\n    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-root-\"));\n    const repoRoot = path.join(tempRoot, \"repo\");\n    const outsideDir = path.join(tempRoot, \"outside\");\n    fs.mkdirSync(repoRoot, { recursive: true });\n    fs.mkdirSync(outsideDir, { recursive: true });\n\n    const harness = createBehaviorHarness({ repoRoot });\n    execFileSync(harness.shimPath, [\"-c\", \"http.extraHeader=test\", \"-C\", repoRoot, \"push\", \"origin\", \"main\"], {\n      cwd: outsideDir,\n      env: {\n        ...process.env,\n        GITHUB_TOKEN: \"ghp_test_token\",\n      },\n      stdio: \"pipe\",\n    });\n\n    const log = fs.readFileSync(harness.logPath, \"utf8\");\n    expect(log).toContain(\"ARG_1=-c\");\n    expect(log).toContain(\"ARG_2=http.extraHeader=test\");\n    expect(log).toContain(\"ARG_3=-C\");\n    expect(log).toContain(`ARG_4=${repoRoot}`);\n    expect(log).toContain(\"ARG_5=push\");\n    expect(log).toContain(\"GIT_TERMINAL_PROMPT=0\");\n    expect(log).toContain(\"ASKPASS_PASS=ghp_test_token\");\n  });\n\n  it(\"loads GITHUB_TOKEN from repo .env when exec env is sanitized\", () => {\n    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-root-\"));\n    const repoRoot = path.join(tempRoot, \"repo\");\n    const outsideDir = path.join(tempRoot, \"outside\");\n    fs.mkdirSync(repoRoot, { recursive: true });\n    fs.mkdirSync(outsideDir, { recursive: true });\n    fs.writeFileSync(path.join(repoRoot, \".env\"), 'GITHUB_TOKEN=\"ghp_env_token\"\\n');\n\n    const harness = createBehaviorHarness({ repoRoot });\n    const env = { ...process.env };\n    delete env.GITHUB_TOKEN;\n    execFileSync(harness.shimPath, [\"-C\", repoRoot, \"push\", \"origin\", \"main\"], {\n      cwd: outsideDir,\n      env,\n      stdio: \"pipe\",\n    });\n\n    const log = fs.readFileSync(harness.logPath, \"utf8\");\n    expect(log).toContain(\"ARG_1=-C\");\n    expect(log).toContain(`ARG_2=${repoRoot}`);\n    expect(log).toContain(\"ARG_3=push\");\n    expect(log).toContain(\"GIT_TERMINAL_PROMPT=0\");\n    expect(log).toContain(\"ASKPASS_PASS=ghp_env_token\");\n  });\n\n  it(\"passes auth through when valued global options precede -C repo push commands\", () => {\n    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-root-\"));\n    const repoRoot = path.join(tempRoot, \"repo\");\n    const outsideDir = path.join(tempRoot, \"outside\");\n    fs.mkdirSync(repoRoot, { recursive: true });\n    fs.mkdirSync(outsideDir, { recursive: true });\n\n    const harness = createBehaviorHarness({ repoRoot });\n    execFileSync(\n      harness.shimPath,\n      [\"--super-prefix=subdir/\", \"--attr-source\", \"HEAD\", \"-C\", repoRoot, \"push\", \"origin\", \"main\"],\n      {\n        cwd: outsideDir,\n        env: {\n          ...process.env,\n          GITHUB_TOKEN: \"ghp_test_token\",\n        },\n        stdio: \"pipe\",\n      },\n    );\n\n    const log = fs.readFileSync(harness.logPath, \"utf8\");\n    expect(log).toContain(\"ARG_1=--super-prefix=subdir/\");\n    expect(log).toContain(\"ARG_2=--attr-source\");\n    expect(log).toContain(\"ARG_3=HEAD\");\n    expect(log).toContain(\"ARG_4=-C\");\n    expect(log).toContain(`ARG_5=${repoRoot}`);\n    expect(log).toContain(\"ARG_6=push\");\n    expect(log).toContain(\"GIT_TERMINAL_PROMPT=0\");\n    expect(log).toContain(\"ASKPASS_PASS=ghp_test_token\");\n  });\n\n  it(\"enables auth when the cwd is a symlinked workspace inside the repo root\", () => {\n    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-root-\"));\n    const repoRoot = path.join(tempRoot, \"repo\");\n    const workspaceDir = path.join(repoRoot, \"workspace\");\n    const symlinkRoot = path.join(tempRoot, \"home-openclaw\");\n    const symlinkWorkspaceDir = path.join(symlinkRoot, \"workspace\");\n    fs.mkdirSync(workspaceDir, { recursive: true });\n    fs.symlinkSync(repoRoot, symlinkRoot);\n\n    const harness = createBehaviorHarness({ repoRoot });\n    execFileSync(harness.shimPath, [\"push\", \"origin\", \"main\"], {\n      cwd: symlinkWorkspaceDir,\n      env: {\n        ...process.env,\n        GITHUB_TOKEN: \"ghp_test_token\",\n      },\n      stdio: \"pipe\",\n    });\n\n    const log = fs.readFileSync(harness.logPath, \"utf8\");\n    expect(log).toContain(`PWD=${fs.realpathSync(workspaceDir)}`);\n    expect(log).toContain(\"GIT_TERMINAL_PROMPT=0\");\n    expect(log).toContain(\"ASKPASS_PASS=ghp_test_token\");\n  });\n\n  it(\"does not inject auth for git commands outside the managed repo root\", () => {\n    const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-git-root-\"));\n    const repoRoot = path.join(tempRoot, \"repo\");\n    const outsideDir = path.join(tempRoot, \"outside\");\n    fs.mkdirSync(repoRoot, { recursive: true });\n    fs.mkdirSync(outsideDir, { recursive: true });\n\n    const harness = createBehaviorHarness({ repoRoot });\n    execFileSync(harness.shimPath, [\"push\", \"origin\", \"main\"], {\n      cwd: outsideDir,\n      env: {\n        ...process.env,\n        GITHUB_TOKEN: \"ghp_test_token\",\n      },\n      stdio: \"pipe\",\n    });\n\n    const log = fs.readFileSync(harness.logPath, \"utf8\");\n    expect(log).toContain(`PWD=${fs.realpathSync(outsideDir)}`);\n    expect(log).toContain(\"GIT_ASKPASS=\");\n    expect(log).not.toContain(\"ASKPASS_USER=\");\n  });\n\n  it(\"contains valid shell syntax for git askpass script\", () => {\n    execFileSync(\"sh\", [\"-n\", kGitAskPassPath], { stdio: \"pipe\" });\n    const content = fs.readFileSync(kGitAskPassPath, \"utf8\");\n    expect(content).toContain(\"*Username*)\");\n    expect(content).toContain(\"*Password*)\");\n  });\n});\n"
  },
  {
    "path": "tests/server/git-sync-path.test.js",
    "content": "const {\n  normalizeGitSyncFilePath,\n  validateGitSyncFilePath,\n} = require(\"../../lib/cli/git-sync\");\n\ndescribe(\"cli/git-sync path guards\", () => {\n  it(\"normalizes file paths for --file input\", () => {\n    expect(normalizeGitSyncFilePath(\"  ./workspace\\\\app\\\\config.json  \")).toBe(\n      \"workspace/app/config.json\",\n    );\n    expect(normalizeGitSyncFilePath(\"\")).toBe(\"\");\n  });\n\n  it(\"rejects unsafe file paths outside openclaw root\", () => {\n    expect(validateGitSyncFilePath(\"../secret.txt\")).toEqual(\n      expect.objectContaining({ ok: false }),\n    );\n    expect(validateGitSyncFilePath(\"/absolute/path.txt\")).toEqual(\n      expect.objectContaining({ ok: false }),\n    );\n    expect(validateGitSyncFilePath(\"nested/../escape.txt\")).toEqual(\n      expect.objectContaining({ ok: false }),\n    );\n  });\n\n  it(\"accepts safe relative file paths\", () => {\n    expect(validateGitSyncFilePath(\"workspace/app/config.json\")).toEqual({\n      ok: true,\n    });\n    expect(validateGitSyncFilePath(\"\")).toEqual({ ok: true });\n  });\n});\n"
  },
  {
    "path": "tests/server/gmail-push.test.js",
    "content": "const {\n  createGmailPushHandler,\n  createGmailPushEventDeduper,\n} = require(\"../../lib/server/gmail-push\");\n\nconst encodeEnvelope = ({ emailAddress, historyId, messageId = \"\" }) =>\n  Buffer.from(\n    JSON.stringify({\n      message: {\n        ...(messageId ? { messageId } : {}),\n        data: Buffer.from(\n          JSON.stringify({\n            emailAddress,\n            historyId,\n          }),\n          \"utf8\",\n        ).toString(\"base64\"),\n      },\n    }),\n    \"utf8\",\n  );\n\nconst createMockResponse = () => {\n  const response = {\n    statusCode: 200,\n    body: null,\n    status(code) {\n      this.statusCode = code;\n      return this;\n    },\n    json(payload) {\n      this.body = payload;\n      return this;\n    },\n    send(payload) {\n      this.body = payload;\n      return this;\n    },\n  };\n  return response;\n};\n\ndescribe(\"server/gmail-push dedupe\", () => {\n  it(\"ignores duplicate deliveries by Pub/Sub messageId\", async () => {\n    let proxyCalls = 0;\n    const markPushReceived = vi.fn();\n    const handler = createGmailPushHandler({\n      resolvePushToken: () => \"secret\",\n      resolveTargetByEmail: () => ({ accountId: \"acct-1\", port: 18801 }),\n      markPushReceived,\n      shouldProcessPushEvent: createGmailPushEventDeduper({ ttlMs: 24 * 60 * 60 * 1000 }),\n      proxyPushToServeImpl: async () => {\n        proxyCalls += 1;\n        return { statusCode: 204, body: \"\" };\n      },\n    });\n    const req = {\n      query: { token: \"secret\" },\n      headers: { \"content-type\": \"application/json\" },\n      body: encodeEnvelope({\n        messageId: \"pubsub-message-1\",\n        emailAddress: \"agent@example.com\",\n        historyId: \"1001\",\n      }),\n    };\n\n    const firstRes = createMockResponse();\n    await handler(req, firstRes);\n    const secondRes = createMockResponse();\n    await handler(req, secondRes);\n\n    expect(firstRes.statusCode).toBe(204);\n    expect(secondRes.statusCode).toBe(200);\n    expect(secondRes.body).toEqual({\n      ok: true,\n      ignored: true,\n      reason: \"duplicate_event\",\n    });\n    expect(proxyCalls).toBe(1);\n    expect(markPushReceived).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"allows Pub/Sub retries after downstream non-2xx responses\", async () => {\n    let proxyCalls = 0;\n    const markPushReceived = vi.fn();\n    const handler = createGmailPushHandler({\n      resolvePushToken: () => \"secret\",\n      resolveTargetByEmail: () => ({ accountId: \"acct-1\", port: 18801 }),\n      markPushReceived,\n      shouldProcessPushEvent: createGmailPushEventDeduper({ ttlMs: 24 * 60 * 60 * 1000 }),\n      proxyPushToServeImpl: async () => {\n        proxyCalls += 1;\n        if (proxyCalls === 1) {\n          return { statusCode: 500, body: \"retry me\" };\n        }\n        return { statusCode: 204, body: \"\" };\n      },\n    });\n    const req = {\n      query: { token: \"secret\" },\n      headers: { \"content-type\": \"application/json\" },\n      body: encodeEnvelope({\n        messageId: \"pubsub-message-retry\",\n        emailAddress: \"agent@example.com\",\n        historyId: \"1002\",\n      }),\n    };\n\n    const firstRes = createMockResponse();\n    await handler(req, firstRes);\n    const secondRes = createMockResponse();\n    await handler(req, secondRes);\n\n    expect(firstRes.statusCode).toBe(500);\n    expect(firstRes.body).toBe(\"retry me\");\n    expect(secondRes.statusCode).toBe(204);\n    expect(proxyCalls).toBe(2);\n    expect(markPushReceived).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"falls back to email+historyId dedupe when messageId is missing\", async () => {\n    let proxyCalls = 0;\n    const handler = createGmailPushHandler({\n      resolvePushToken: () => \"secret\",\n      resolveTargetByEmail: () => ({ accountId: \"acct-1\", port: 18801 }),\n      markPushReceived: vi.fn(),\n      shouldProcessPushEvent: createGmailPushEventDeduper({ ttlMs: 24 * 60 * 60 * 1000 }),\n      proxyPushToServeImpl: async () => {\n        proxyCalls += 1;\n        return { statusCode: 200, body: \"ok\" };\n      },\n    });\n\n    const firstReq = {\n      query: { token: \"secret\" },\n      headers: { \"content-type\": \"application/json\" },\n      body: encodeEnvelope({\n        emailAddress: \"agent@example.com\",\n        historyId: \"4242\",\n      }),\n    };\n    const secondReq = {\n      ...firstReq,\n      body: encodeEnvelope({\n        emailAddress: \"agent@example.com\",\n        historyId: \"4242\",\n      }),\n    };\n\n    const firstRes = createMockResponse();\n    await handler(firstReq, firstRes);\n    const secondRes = createMockResponse();\n    await handler(secondReq, secondRes);\n\n    expect(firstRes.statusCode).toBe(200);\n    expect(secondRes.statusCode).toBe(200);\n    expect(secondRes.body).toEqual({\n      ok: true,\n      ignored: true,\n      reason: \"duplicate_event\",\n    });\n    expect(proxyCalls).toBe(1);\n  });\n});\n"
  },
  {
    "path": "tests/server/gmail-watch.test.js",
    "content": "const { createGmailWatchService } = require(\"../../lib/server/gmail-watch\");\n\nconst createMemoryFs = (initialFiles = {}) => {\n  const files = new Map(\n    Object.entries(initialFiles).map(([filePath, contents]) => [\n      filePath,\n      String(contents),\n    ]),\n  );\n\n  return {\n    existsSync: (filePath) => files.has(filePath),\n    readFileSync: (filePath) => {\n      if (!files.has(filePath)) {\n        throw new Error(`File not found: ${filePath}`);\n      }\n      return files.get(filePath);\n    },\n    writeFileSync: (filePath, contents) => {\n      files.set(filePath, String(contents));\n    },\n    mkdirSync: () => {},\n    readJson: (filePath) => JSON.parse(String(files.get(filePath) || \"null\")),\n  };\n};\n\ndescribe(\"server/gmail-watch\", () => {\n  it(\"replaces the saved topic path when the project id changes\", () => {\n    const statePath = \"/tmp/gogcli/state.json\";\n    const configDir = \"/tmp/gogcli\";\n    const fs = createMemoryFs({\n      [statePath]: JSON.stringify({\n        version: 2,\n        accounts: [],\n        gmailPush: {\n          token: \"push-token\",\n          topics: {\n            default: \"projects/old-project/topics/gog-gmail-watch\",\n          },\n        },\n      }),\n    });\n    const service = createGmailWatchService({\n      fs,\n      constants: {\n        GOG_STATE_PATH: statePath,\n        GOG_CONFIG_DIR: configDir,\n        OPENCLAW_DIR: \"/tmp/.openclaw\",\n      },\n      gogCmd: async () => ({ ok: true, stdout: \"\", stderr: \"\" }),\n      getBaseUrl: () => \"https://alphaclaw.example\",\n      readGoogleCredentials: () => ({\n        projectId: null,\n      }),\n      readEnvFile: () => [],\n      writeEnvFile: () => {},\n      reloadEnv: () => {},\n      restartRequiredState: null,\n    });\n\n    const result = service.saveClientConfig({\n      req: {},\n      body: {\n        client: \"default\",\n        projectId: \"new-project\",\n      },\n    });\n\n    expect(result.topicPath).toBe(\n      \"projects/new-project/topics/gog-gmail-watch\",\n    );\n    expect(result.client.projectId).toBe(\"new-project\");\n    expect(fs.readJson(statePath)?.gmailPush?.topics?.default).toBe(\n      \"projects/new-project/topics/gog-gmail-watch\",\n    );\n  });\n\n  it(\"reports whether the Gmail transform already exists in client config\", () => {\n    const statePath = \"/tmp/gogcli/state.json\";\n    const configDir = \"/tmp/gogcli\";\n    const openclawDir = \"/tmp/.openclaw\";\n    const fs = createMemoryFs({\n      [statePath]: JSON.stringify({\n        version: 2,\n        accounts: [\n          {\n            id: \"acct-1\",\n            email: \"ops@example.com\",\n            client: \"default\",\n            services: [\"gmail:read\"],\n            gmailWatch: {},\n          },\n        ],\n        gmailPush: {\n          token: \"push-token\",\n          topics: {\n            default: \"projects/my-project/topics/gog-gmail-watch\",\n          },\n        },\n      }),\n      [`${openclawDir}/hooks/transforms/gmail/gmail-transform.mjs`]:\n        \"export default async function transform() {}\",\n    });\n    const service = createGmailWatchService({\n      fs,\n      constants: {\n        GOG_STATE_PATH: statePath,\n        GOG_CONFIG_DIR: configDir,\n        OPENCLAW_DIR: openclawDir,\n      },\n      gogCmd: async () => ({ ok: true, stdout: \"\", stderr: \"\" }),\n      getBaseUrl: () => \"https://alphaclaw.example\",\n      readGoogleCredentials: () => ({\n        projectId: \"my-project\",\n      }),\n      readEnvFile: () => [],\n      writeEnvFile: () => {},\n      reloadEnv: () => {},\n      restartRequiredState: null,\n    });\n\n    const result = service.getConfig({ req: {} });\n\n    expect(result.clients).toEqual([\n      expect.objectContaining({\n        client: \"default\",\n        configured: true,\n        transformExists: true,\n        webhookExists: false,\n      }),\n    ]);\n  });\n\n  it(\"reports webhookExists when gmail mapping is present in openclaw config\", () => {\n    const statePath = \"/tmp/gogcli/state.json\";\n    const configDir = \"/tmp/gogcli\";\n    const openclawDir = \"/tmp/.openclaw\";\n    const fs = createMemoryFs({\n      [statePath]: JSON.stringify({\n        version: 2,\n        accounts: [\n          {\n            id: \"acct-1\",\n            email: \"ops@example.com\",\n            client: \"default\",\n            services: [\"gmail:read\"],\n            gmailWatch: {},\n          },\n        ],\n        gmailPush: {\n          token: \"push-token\",\n          topics: {\n            default: \"projects/my-project/topics/gog-gmail-watch\",\n          },\n        },\n      }),\n      [`${openclawDir}/openclaw.json`]: JSON.stringify({\n        hooks: {\n          mappings: [{ match: { path: \"gmail\" } }],\n        },\n      }),\n    });\n    const service = createGmailWatchService({\n      fs,\n      constants: {\n        GOG_STATE_PATH: statePath,\n        GOG_CONFIG_DIR: configDir,\n        OPENCLAW_DIR: openclawDir,\n      },\n      gogCmd: async () => ({ ok: true, stdout: \"\", stderr: \"\" }),\n      getBaseUrl: () => \"https://alphaclaw.example\",\n      readGoogleCredentials: () => ({\n        projectId: \"my-project\",\n      }),\n      readEnvFile: () => [],\n      writeEnvFile: () => {},\n      reloadEnv: () => {},\n      restartRequiredState: null,\n    });\n\n    const result = service.getConfig({ req: {} });\n    expect(result.clients).toEqual([\n      expect.objectContaining({\n        client: \"default\",\n        webhookExists: true,\n      }),\n    ]);\n  });\n\n  it(\"preserves an existing custom Gmail transform while ensuring hook wiring\", () => {\n    const statePath = \"/tmp/gogcli/state.json\";\n    const configDir = \"/tmp/gogcli\";\n    const openclawDir = \"/tmp/.openclaw\";\n    const configPath = `${openclawDir}/openclaw.json`;\n    const transformPath = `${openclawDir}/hooks/transforms/gmail/gmail-transform.mjs`;\n    const customTransformSource =\n      \"export default async function transform(payload) {\\n\" +\n      \"  return { message: payload?.custom || \\\"custom\\\" };\\n\" +\n      \"}\\n\";\n    const fs = createMemoryFs({\n      [statePath]: JSON.stringify({\n        version: 2,\n        accounts: [],\n        gmailPush: {\n          token: \"push-token\",\n          topics: {},\n        },\n      }),\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n        hooks: {\n          enabled: true,\n          token: \"${WEBHOOK_TOKEN}\",\n          presets: [\"gmail\"],\n          mappings: [\n            {\n              match: { path: \"gmail\" },\n              action: \"agent\",\n              name: \"Gmail\",\n              wakeMode: \"now\",\n              transform: { module: \"gmail/gmail-transform.mjs\" },\n            },\n          ],\n        },\n      }),\n      [transformPath]: customTransformSource,\n    });\n    const service = createGmailWatchService({\n      fs,\n      constants: {\n        GOG_STATE_PATH: statePath,\n        GOG_CONFIG_DIR: configDir,\n        OPENCLAW_DIR: openclawDir,\n      },\n      gogCmd: async () => ({ ok: true, stdout: \"\", stderr: \"\" }),\n      getBaseUrl: () => \"https://alphaclaw.example\",\n      readGoogleCredentials: () => ({\n        projectId: \"my-project\",\n      }),\n      readEnvFile: () => [{ key: \"WEBHOOK_TOKEN\", value: \"existing-token\" }],\n      writeEnvFile: () => {},\n      reloadEnv: () => {},\n      restartRequiredState: null,\n    });\n\n    service.ensureHookWiring({\n      destination: {\n        channel: \"telegram\",\n        to: \"-100123\",\n        agentId: \"main\",\n      },\n    });\n\n    expect(fs.readFileSync(transformPath, \"utf8\")).toBe(customTransformSource);\n  });\n});\n"
  },
  {
    "path": "tests/server/gog-skill.test.js",
    "content": "const { buildGogSkillContent } = require(\"../../lib/server/gog-skill\");\n\ndescribe(\"server/gog-skill\", () => {\n  it(\"includes managed runtime guidance for direct gog shell usage\", () => {\n    const fs = {\n      readFileSync: vi.fn(() => \"## Sheets\\n\\n```bash\\ngog sheets get <id> 'Sheet1!A1:B2'\\n```\"),\n    };\n    const content = buildGogSkillContent({\n      fs,\n      accounts: [\n        {\n          email: \"chrys@example.com\",\n          client: \"default\",\n          authenticated: true,\n          services: [\"sheets:read\"],\n        },\n      ],\n    });\n\n    expect(content).toContain(\"## Runtime Notes\");\n    expect(content).toContain(\"$OPENCLAW_STATE_DIR\");\n    expect(content).toContain(\n      'XDG_CONFIG_HOME=\"${OPENCLAW_STATE_DIR:-$OPENCLAW_HOME/.openclaw}\"',\n    );\n    expect(content).toContain(\"--account <email>\");\n  });\n});\n"
  },
  {
    "path": "tests/server/helpers.test.js",
    "content": "const {\n  parseJsonFromNoisyOutput,\n  parseJwtPayload,\n  getCodexAccountId,\n  resolveGithubRepoUrl,\n  normalizeOnboardingModels,\n} = require(\"../../lib/server/helpers\");\nconst { CODEX_JWT_CLAIM_PATH } = require(\"../../lib/server/constants\");\n\nconst makeJwt = (payload) => {\n  const header = Buffer.from(JSON.stringify({ alg: \"none\", typ: \"JWT\" })).toString(\n    \"base64url\",\n  );\n  const body = Buffer.from(JSON.stringify(payload)).toString(\"base64url\");\n  return `${header}.${body}.sig`;\n};\n\ndescribe(\"server/helpers\", () => {\n  it(\"parses JSON from noisy command output\", () => {\n    const value = parseJsonFromNoisyOutput('log line\\n{\"ok\":true,\"count\":2}\\nextra');\n    expect(value).toEqual({ ok: true, count: 2 });\n  });\n\n  it(\"returns null when noisy output has no valid JSON\", () => {\n    expect(parseJsonFromNoisyOutput(\"no braces here\")).toBeNull();\n    expect(parseJsonFromNoisyOutput(\"start {bad json} end\")).toBeNull();\n  });\n\n  it(\"normalizes GitHub repository URLs and shorthands\", () => {\n    expect(resolveGithubRepoUrl(\"owner/repo\")).toBe(\"owner/repo\");\n    expect(resolveGithubRepoUrl(\"git@github.com:owner/repo.git\")).toBe(\"owner/repo\");\n    expect(resolveGithubRepoUrl(\"https://github.com/owner/repo\")).toBe(\"owner/repo\");\n  });\n\n  it(\"throws when repo input is not owner/repo format\", () => {\n    expect(() => resolveGithubRepoUrl(\"just-owner\")).toThrow(\n      'GITHUB_WORKSPACE_REPO must be in \"owner/repo\" format.',\n    );\n  });\n\n  it(\"parses JWT payload and extracts Codex account id\", () => {\n    const token = makeJwt({\n      [CODEX_JWT_CLAIM_PATH]: { chatgpt_account_id: \"acct_123\" },\n      sub: \"abc\",\n    });\n\n    const payload = parseJwtPayload(token);\n    expect(payload.sub).toBe(\"abc\");\n    expect(getCodexAccountId(token)).toBe(\"acct_123\");\n  });\n\n  it(\"returns null for invalid JWT payloads\", () => {\n    expect(parseJwtPayload(\"bad.token\")).toBeNull();\n    expect(getCodexAccountId(\"bad.token.value\")).toBeNull();\n  });\n\n  it(\"normalizes onboarding models by filtering, deduping, and sorting\", () => {\n    const normalized = normalizeOnboardingModels([\n      { key: \"unknown/model-a\", name: \"Ignore me\" },\n      { key: \"openai/gpt-5.1-codex\", name: \"OpenAI A\" },\n      { key: \"anthropic/claude-opus-4-6\", name: \"Opus 4.6\" },\n      { key: \"zai/glm-5\", name: \"GLM 5\" },\n      { key: \"minimax/MiniMax-M2.5\", name: \"MiniMax M2.5\" },\n      { key: \"openai/gpt-5.1-codex\", name: \"Duplicate\" },\n      { key: \"google/gemini-3.1-pro-preview\" },\n      { bad: \"shape\" },\n    ]);\n\n    expect(normalized).toEqual([\n      {\n        key: \"anthropic/claude-opus-4-6\",\n        provider: \"anthropic\",\n        label: \"Opus 4.6\",\n      },\n      {\n        key: \"google/gemini-3.1-pro-preview\",\n        provider: \"google\",\n        label: \"google/gemini-3.1-pro-preview\",\n      },\n      {\n        key: \"minimax/MiniMax-M2.5\",\n        provider: \"minimax\",\n        label: \"MiniMax M2.5\",\n      },\n      {\n        key: \"openai/gpt-5.1-codex\",\n        provider: \"openai\",\n        label: \"OpenAI A\",\n      },\n      {\n        key: \"zai/glm-5\",\n        provider: \"zai\",\n        label: \"GLM 5\",\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "tests/server/import-applier.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  promoteCloneToTarget,\n  alignHookTransforms,\n  applySecretExtraction,\n} = require(\"../../lib/server/onboarding/import/import-applier\");\n\nconst kTempDirs = [];\n\nconst createTempDir = () => {\n  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-import-applier-\"));\n  kTempDirs.push(tempDir);\n  return tempDir;\n};\n\nafterEach(() => {\n  while (kTempDirs.length > 0) {\n    fs.rmSync(kTempDirs.pop(), { recursive: true, force: true });\n  }\n});\n\ndescribe(\"import-applier\", () => {\n  it(\"merges imported files into an existing target directory\", () => {\n    const tempDir = createTempDir();\n    const targetDir = createTempDir();\n\n    fs.writeFileSync(\n      path.join(tempDir, \"openclaw.json\"),\n      JSON.stringify({ channels: { telegram: { enabled: true } } }, null, 2),\n      \"utf8\",\n    );\n    fs.mkdirSync(path.join(tempDir, \"workspace\"), { recursive: true });\n    fs.writeFileSync(\n      path.join(tempDir, \"workspace\", \"AGENTS.md\"),\n      \"# imported workspace\\n\",\n      \"utf8\",\n    );\n\n    fs.writeFileSync(\n      path.join(targetDir, \"openclaw.json\"),\n      JSON.stringify({ channels: { telegram: { enabled: false } } }, null, 2),\n      \"utf8\",\n    );\n    fs.writeFileSync(\n      path.join(targetDir, \"exec-approvals.json\"),\n      JSON.stringify({ version: 1, defaults: { security: \"full\" } }, null, 2),\n      \"utf8\",\n    );\n\n    const result = promoteCloneToTarget({\n      fs,\n      tempDir,\n      targetDir,\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(\n      JSON.parse(fs.readFileSync(path.join(targetDir, \"openclaw.json\"), \"utf8\")),\n    ).toEqual({\n      channels: { telegram: { enabled: true } },\n    });\n    expect(\n      fs.readFileSync(path.join(targetDir, \"workspace\", \"AGENTS.md\"), \"utf8\"),\n    ).toBe(\"# imported workspace\\n\");\n    expect(fs.existsSync(path.join(targetDir, \"exec-approvals.json\"))).toBe(true);\n    expect(fs.existsSync(tempDir)).toBe(false);\n  });\n\n  it(\"relocates mismatched hook transforms into _backup and writes a shim\", () => {\n    const baseDir = createTempDir();\n    const legacyTransformDir = path.join(\n      baseDir,\n      \"hooks\",\n      \"transforms\",\n      \"fathom-webhook\",\n      \"scripts\",\n    );\n    fs.mkdirSync(legacyTransformDir, { recursive: true });\n    fs.writeFileSync(\n      path.join(legacyTransformDir, \"fathom-transform.mjs\"),\n      \"export default async function transform(payload) {\\n  return payload;\\n}\\n\",\n      \"utf8\",\n    );\n    fs.writeFileSync(\n      path.join(legacyTransformDir, \"helper.mjs\"),\n      \"export const helper = true;\\n\",\n      \"utf8\",\n    );\n    fs.writeFileSync(\n      path.join(baseDir, \"openclaw.json\"),\n      JSON.stringify(\n        {\n          hooks: {\n            mappings: [\n              {\n                name: \"Fathom\",\n                match: { path: \"/fathom\" },\n                transform: {\n                  module: \"fathom-webhook/scripts/fathom-transform.mjs\",\n                },\n              },\n            ],\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    const result = alignHookTransforms({\n      fs,\n      baseDir,\n      configFiles: [\"openclaw.json\"],\n    });\n\n    expect(result).toEqual({ alignedCount: 1 });\n    expect(\n      fs.existsSync(\n        path.join(baseDir, \"hooks\", \"transforms\", \"fathom-webhook\"),\n      ),\n    ).toBe(false);\n    expect(\n      fs.existsSync(\n        path.join(\n          baseDir,\n          \"hooks\",\n          \"transforms\",\n          \"_backup\",\n          \"fathom-webhook\",\n          \"scripts\",\n          \"fathom-transform.mjs\",\n        ),\n      ),\n    ).toBe(true);\n    expect(\n      fs.existsSync(\n        path.join(\n          baseDir,\n          \"hooks\",\n          \"transforms\",\n          \"_backup\",\n          \"fathom-webhook\",\n          \"scripts\",\n          \"helper.mjs\",\n        ),\n      ),\n    ).toBe(true);\n\n    const shimPath = path.join(\n      baseDir,\n      \"hooks\",\n      \"transforms\",\n      \"fathom\",\n      \"fathom-transform.mjs\",\n    );\n    expect(fs.existsSync(shimPath)).toBe(true);\n    expect(fs.readFileSync(shimPath, \"utf8\")).toContain(\n      '../_backup/fathom-webhook/scripts/fathom-transform.mjs',\n    );\n\n    const updatedConfig = JSON.parse(\n      fs.readFileSync(path.join(baseDir, \"openclaw.json\"), \"utf8\"),\n    );\n    expect(updatedConfig.hooks.mappings[0].match.path).toBe(\"fathom\");\n    expect(updatedConfig.hooks.mappings[0].transform.module).toBe(\n      \"fathom/fathom-transform.mjs\",\n    );\n  });\n\n  it(\"normalizes imported hook paths with leading slashes\", () => {\n    const baseDir = createTempDir();\n    const configPath = path.join(baseDir, \"openclaw.json\");\n    fs.writeFileSync(\n      configPath,\n      JSON.stringify(\n        {\n          hooks: {\n            mappings: [\n              {\n                name: \"Notion\",\n                match: { path: \"//notion-comments\" },\n                transform: {\n                  module: \"notion-comments/notion-comments-transform.mjs\",\n                },\n              },\n            ],\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    const result = alignHookTransforms({\n      fs,\n      baseDir,\n      configFiles: [\"openclaw.json\"],\n    });\n\n    expect(result).toEqual({ alignedCount: 0 });\n    const updatedConfig = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    expect(updatedConfig.hooks.mappings[0].match.path).toBe(\"notion-comments\");\n    expect(updatedConfig.hooks.mappings[0].transform.module).toBe(\n      \"notion-comments/notion-comments-transform.mjs\",\n    );\n  });\n\n  it(\"rewrites approved config secrets by config path before fallback replacement\", () => {\n    const baseDir = createTempDir();\n    const configPath = path.join(baseDir, \"openclaw.json\");\n    fs.writeFileSync(\n      configPath,\n      JSON.stringify(\n        {\n          channels: {\n            discord: {\n              token: \"discord-live-secret\",\n            },\n          },\n          notes: {\n            repeatedToken: \"discord-live-secret\",\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    const result = applySecretExtraction({\n      fs,\n      baseDir,\n      approvedSecrets: [\n        {\n          file: \"openclaw.json\",\n          configPath: \"channels.discord.token\",\n          value: \"discord-live-secret\",\n          suggestedEnvVar: \"DISCORD_BOT_TOKEN\",\n        },\n      ],\n    });\n\n    expect(result).toEqual({\n      envVars: [\n        {\n          key: \"DISCORD_BOT_TOKEN\",\n          value: \"discord-live-secret\",\n        },\n      ],\n    });\n\n    const updatedConfig = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    expect(updatedConfig.channels.discord.token).toBe(\"${DISCORD_BOT_TOKEN}\");\n    expect(updatedConfig.notes.repeatedToken).toBe(\"${DISCORD_BOT_TOKEN}\");\n  });\n});\n"
  },
  {
    "path": "tests/server/import-scanner.test.js",
    "content": "const path = require(\"path\");\nconst {\n  scanWorkspace,\n} = require(\"../../lib/server/onboarding/import/import-scanner\");\n\nconst createMockFs = (files = {}, dirs = []) => {\n  const fileMap = new Map(Object.entries(files));\n  const dirSet = new Set(dirs);\n\n  return {\n    statSync: (p) => {\n      const rel = p;\n      if (fileMap.has(rel))\n        return { isFile: () => true, isDirectory: () => false };\n      if (dirSet.has(rel))\n        return { isFile: () => false, isDirectory: () => true };\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    },\n    readFileSync: (p, enc) => {\n      const rel = p;\n      if (fileMap.has(rel)) return fileMap.get(rel);\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    },\n    readdirSync: (dirPath, opts) => {\n      const entries = [];\n      for (const [fp] of fileMap) {\n        const parent = path.dirname(fp);\n        if (parent === dirPath) {\n          const name = path.basename(fp);\n          entries.push({\n            name,\n            isFile: () => true,\n            isDirectory: () => false,\n          });\n        }\n      }\n      for (const dp of dirSet) {\n        const parent = path.dirname(dp);\n        if (parent === dirPath) {\n          const name = path.basename(dp);\n          if (!entries.some((e) => e.name === name)) {\n            entries.push({\n              name,\n              isFile: () => false,\n              isDirectory: () => true,\n            });\n          }\n        }\n      }\n      return entries;\n    },\n  };\n};\n\ndescribe(\"import-scanner\", () => {\n  it(\"detects an empty repo\", () => {\n    const fs = createMockFs({}, []);\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.isEmpty).toBe(true);\n    expect(result.hasOpenclawSetup).toBe(false);\n    expect(result.gatewayConfig.found).toBe(false);\n  });\n\n  it(\"detects openclaw.json as gateway config\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/openclaw.json\": JSON.stringify({\n        channels: { telegram: { botToken: \"123\" } },\n      }),\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.hasOpenclawSetup).toBe(true);\n    expect(result.gatewayConfig.found).toBe(true);\n    expect(result.gatewayConfig.files).toContain(\"openclaw.json\");\n    expect(result.sourceLayout).toEqual({\n      kind: \"full-openclaw-root\",\n      supported: true,\n      promoteSourceSubdir: \"\",\n    });\n  });\n\n  it(\"ignores openclaw.json5 as an unsupported config filename\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/openclaw.json5\": JSON.stringify({\n        channels: { telegram: { botToken: \"123\" } },\n      }),\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.hasOpenclawSetup).toBe(false);\n    expect(result.gatewayConfig.found).toBe(false);\n    expect(result.sourceLayout).toEqual({\n      kind: \"empty\",\n      supported: true,\n      promoteSourceSubdir: \"\",\n    });\n  });\n\n  it(\"rejects nested .openclaw/openclaw.json sources\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/.openclaw/openclaw.json\": \"{}\",\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.gatewayConfig.found).toBe(false);\n    expect(result.unsupportedNested).toEqual({\n      found: true,\n      files: [\".openclaw/openclaw.json\"],\n    });\n    expect(result.sourceLayout).toEqual({\n      kind: \"unsupported-nested-openclaw\",\n      supported: false,\n      error:\n        \"This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.\",\n    });\n  });\n\n  it(\"rejects nested .openclaw env files as unsupported\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/.openclaw/.env\": \"FOO=bar\",\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.envFiles.found).toBe(false);\n    expect(result.unsupportedNested).toEqual({\n      found: true,\n      files: [\".openclaw/.env\"],\n    });\n    expect(result.sourceLayout).toEqual({\n      kind: \"unsupported-nested-openclaw\",\n      supported: false,\n      error:\n        \"This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.\",\n    });\n  });\n\n  it(\"detects .env files\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/.env\": \"FOO=bar\",\n      \"/tmp/test/.env.local\": \"BAZ=qux\",\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.envFiles.found).toBe(true);\n    expect(result.envFiles.files).toEqual([\".env\", \".env.local\"]);\n  });\n\n  it(\"detects workspace markdown files\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/AGENTS.md\": \"# agents\",\n      \"/tmp/test/SOUL.md\": \"# soul\",\n      \"/tmp/test/CUSTOM.md\": \"# custom\",\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.workspaceFiles.found).toBe(true);\n    expect(result.workspaceFiles.files).toContain(\"AGENTS.md\");\n    expect(result.workspaceFiles.files).toContain(\"SOUL.md\");\n    expect(result.workspaceFiles.extraMarkdown).toContain(\"CUSTOM.md\");\n  });\n\n  it(\"detects cron jobs\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/cron/jobs.json\": JSON.stringify({\n        version: 1,\n        jobs: [\n          { id: \"job-1\", name: \"Weekday Morning Briefing\" },\n          { id: \"job-2\", name: \"Sunday Weekly Briefing\" },\n          { id: \"job-3\" },\n        ],\n      }),\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.cronJobs.found).toBe(true);\n    expect(result.cronJobs.files).toContain(\"cron/jobs.json\");\n    expect(result.cronJobs.jobCount).toBe(3);\n    expect(result.cronJobs.jobNames).toEqual([\n      \"Weekday Morning Briefing\",\n      \"Sunday Weekly Briefing\",\n      \"job-3\",\n    ]);\n  });\n\n  it(\"detects hook definitions from openclaw.json\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/openclaw.json\": JSON.stringify({\n        hooks: {\n          mappings: [\n            {\n              id: \"fathom\",\n              name: \"Fathom\",\n              match: { path: \"/fathom\" },\n            },\n            {\n              id: \"gmail\",\n              name: \"Gmail\",\n              match: { path: \"/gmail\" },\n            },\n          ],\n          internal: {\n            entries: {\n              \"session-memory\": { enabled: true },\n              \"command-logger\": { enabled: false },\n            },\n          },\n        },\n      }),\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.webhooks.found).toBe(true);\n    expect(result.webhooks.hookCount).toBe(4);\n    expect(result.webhooks.hookNames).toEqual([\n      \"Fathom (fathom)\",\n      \"Gmail (gmail)\",\n      \"internal:session-memory\",\n      \"internal:command-logger (disabled)\",\n    ]);\n  });\n\n  it(\"flags hook transform modules that do not match the expected layout\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/openclaw.json\": JSON.stringify({\n        hooks: {\n          mappings: [\n            {\n              id: \"fathom\",\n              name: \"Fathom\",\n              match: { path: \"/fathom\" },\n              transform: {\n                module: \"fathom-webhook/scripts/fathom-transform.mjs\",\n              },\n            },\n            {\n              id: \"gmail\",\n              name: \"Gmail\",\n              match: { path: \"/gmail\" },\n              transform: {\n                module: \"gmail/gmail-transform.mjs\",\n              },\n            },\n          ],\n        },\n      }),\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.webhooks.warningCount).toBe(1);\n    expect(result.webhooks.transformWarnings).toEqual([\n      {\n        hookLabel: \"Fathom (fathom)\",\n        actualPath:\n          \"hooks/transforms/fathom-webhook/scripts/fathom-transform.mjs\",\n        expectedPath: \"hooks/transforms/fathom/fathom-transform.mjs\",\n        message:\n          \"Uses hooks/transforms/fathom-webhook/scripts/fathom-transform.mjs; expected hooks/transforms/fathom/fathom-transform.mjs\",\n      },\n    ]);\n  });\n\n  it(\"detects managed file conflicts\", () => {\n    const fs = createMockFs(\n      {\n        \"/tmp/test/hooks/bootstrap/AGENTS.md\": \"custom\",\n        \"/tmp/test/.gitignore\": \"# user gitignore\",\n      },\n      [\"/tmp/test/.alphaclaw\"],\n    );\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.managedConflicts.found).toBe(true);\n    expect(result.managedConflicts.files).toContain(\n      \"hooks/bootstrap/AGENTS.md\",\n    );\n    expect(result.managedConflicts.files).toContain(\".gitignore\");\n    expect(result.managedConflicts.dirs).toContain(\".alphaclaw\");\n  });\n\n  it(\"detects deployment-managed env vars referenced by imported config\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/openclaw.json\": JSON.stringify({\n        hooks: {\n          token: \"repo-hook-token\",\n        },\n        gateway: {\n          auth: {\n            token: \"${GATEWAY_AUTH_TOKEN}\",\n          },\n        },\n      }),\n      \"/tmp/test/.env\":\n        \"OPENCLAW_GATEWAY_TOKEN=runtime\\nWEBHOOK_TOKEN=repo-value\\n\",\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.managedEnvConflicts).toEqual({\n      found: true,\n      vars: [\"OPENCLAW_GATEWAY_TOKEN\", \"WEBHOOK_TOKEN\"],\n      gatewayAuthNormalized: true,\n      webhookTokenNormalized: true,\n    });\n  });\n\n  it(\"flags credential dirs without importing\", () => {\n    const fs = createMockFs({}, [\n      \"/tmp/test/credentials\",\n      \"/tmp/test/identity\",\n    ]);\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.credentials.found).toBe(true);\n    expect(result.credentials.dirs).toContain(\"credentials\");\n    expect(result.credentials.dirs).toContain(\"identity\");\n  });\n\n  it(\"follows $include references in config\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/openclaw.json\": JSON.stringify({\n        channels: { $include: \"channels.json\" },\n      }),\n      \"/tmp/test/channels.json\": JSON.stringify({ telegram: {} }),\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.gatewayConfig.files).toContain(\"channels.json\");\n  });\n\n  it(\"classifies root workspace content as workspace-only\", () => {\n    const fs = createMockFs({\n      \"/tmp/test/AGENTS.md\": \"# agents\",\n      \"/tmp/test/skills/email/SKILL.md\": \"# skill\",\n    });\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.sourceLayout).toEqual({\n      kind: \"workspace-only\",\n      supported: true,\n      promoteSourceSubdir: \"\",\n    });\n  });\n\n  it(\"classifies nested workspace directory repos as workspace-only\", () => {\n    const fs = createMockFs(\n      {\n        \"/tmp/test/workspace/skills/email/SKILL.md\": \"# skill\",\n      },\n      [\n        \"/tmp/test/workspace\",\n        \"/tmp/test/workspace/skills\",\n        \"/tmp/test/workspace/skills/email\",\n      ],\n    );\n    const result = scanWorkspace({ fs, baseDir: \"/tmp/test\" });\n    expect(result.skills.files).toContain(\"email/SKILL.md\");\n    expect(result.sourceLayout).toEqual({\n      kind: \"workspace-only\",\n      supported: true,\n      promoteSourceSubdir: \"workspace\",\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/import-temp.test.js",
    "content": "const path = require(\"path\");\n\nconst {\n  cleanupStaleImportTempDirs,\n  isValidImportTempDir,\n  kImportTempTtlMs,\n} = require(\"../../lib/server/onboarding/import/import-temp\");\n\ndescribe(\"server/onboarding/import-temp\", () => {\n  it(\"rejects paths that escape the temp root\", () => {\n    const escapedPath = path.join(\n      require(\"os\").tmpdir(),\n      \"..\",\n      \"outside\",\n      \"alphaclaw-import-evil\",\n    );\n\n    expect(isValidImportTempDir(escapedPath)).toBe(false);\n  });\n\n  it(\"cleans up only stale managed import temp dirs\", () => {\n    const tempRoot = path.resolve(require(\"os\").tmpdir());\n    const staleDir = path.join(tempRoot, \"alphaclaw-import-stale\");\n    const freshDir = path.join(tempRoot, \"alphaclaw-import-fresh\");\n    const otherDir = path.join(tempRoot, \"other-dir\");\n    const removed = [];\n\n    const fsModule = {\n      readdirSync: vi.fn(() => [\n        { name: \"alphaclaw-import-stale\", isDirectory: () => true },\n        { name: \"alphaclaw-import-fresh\", isDirectory: () => true },\n        { name: \"other-dir\", isDirectory: () => true },\n      ]),\n      statSync: vi.fn((targetPath) => {\n        if (targetPath === staleDir) {\n          return { mtimeMs: 1000 };\n        }\n        if (targetPath === freshDir) {\n          return { mtimeMs: 5000 };\n        }\n        throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n      }),\n      rmSync: vi.fn((targetPath) => {\n        removed.push(targetPath);\n      }),\n    };\n\n    const result = cleanupStaleImportTempDirs({\n      fsModule,\n      nowMs: 1000 + kImportTempTtlMs + 1,\n    });\n\n    expect(result).toEqual({ removedCount: 1 });\n    expect(removed).toEqual([staleDir]);\n  });\n});\n"
  },
  {
    "path": "tests/server/internal-files-migration.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  buildManagedPaths,\n  migrateManagedInternalFiles,\n} = require(\"../../lib/server/internal-files-migration\");\n\nconst createTempOpenclawDir = () =>\n  fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-managed-files-test-\"));\n\ndescribe(\"server/internal-files-migration\", () => {\n  it(\"moves legacy managed files into .alphaclaw\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const legacyScriptPath = path.join(openclawDir, \"hourly-git-sync.sh\");\n    const legacyMarkerPath = path.join(openclawDir, \".cli-device-auto-approved\");\n    fs.writeFileSync(legacyScriptPath, \"echo legacy\\n\", { mode: 0o755 });\n    fs.writeFileSync(legacyMarkerPath, '{\"approvedAt\":\"x\"}\\n', \"utf8\");\n\n    const managedPaths = migrateManagedInternalFiles({\n      fs,\n      openclawDir,\n      logger: { error: vi.fn() },\n    });\n\n    expect(fs.existsSync(legacyScriptPath)).toBe(false);\n    expect(fs.existsSync(legacyMarkerPath)).toBe(false);\n    expect(fs.existsSync(managedPaths.hourlyGitSyncPath)).toBe(true);\n    expect(fs.existsSync(managedPaths.cliDeviceAutoApprovedPath)).toBe(true);\n    expect(fs.readFileSync(managedPaths.hourlyGitSyncPath, \"utf8\")).toContain(\"legacy\");\n  });\n\n  it(\"keeps new paths as source of truth when both old and new exist\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const managedPaths = buildManagedPaths({ openclawDir });\n    fs.mkdirSync(managedPaths.internalDir, { recursive: true });\n    fs.writeFileSync(managedPaths.hourlyGitSyncPath, \"echo new\\n\", \"utf8\");\n    fs.writeFileSync(managedPaths.cliDeviceAutoApprovedPath, '{\"approvedAt\":\"new\"}\\n', \"utf8\");\n    fs.writeFileSync(managedPaths.legacyHourlyGitSyncPath, \"echo old\\n\", \"utf8\");\n    fs.writeFileSync(managedPaths.legacyCliDeviceAutoApprovedPath, '{\"approvedAt\":\"old\"}\\n', \"utf8\");\n\n    migrateManagedInternalFiles({\n      fs,\n      openclawDir,\n      logger: { error: vi.fn() },\n    });\n\n    expect(fs.existsSync(managedPaths.legacyHourlyGitSyncPath)).toBe(false);\n    expect(fs.existsSync(managedPaths.legacyCliDeviceAutoApprovedPath)).toBe(false);\n    expect(fs.readFileSync(managedPaths.hourlyGitSyncPath, \"utf8\")).toBe(\"echo new\\n\");\n    expect(fs.readFileSync(managedPaths.cliDeviceAutoApprovedPath, \"utf8\")).toBe(\n      '{\"approvedAt\":\"new\"}\\n',\n    );\n  });\n\n  it(\"appends cron jobs-state gitignore entries when missing\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const gitignorePath = path.join(openclawDir, \".gitignore\");\n    fs.writeFileSync(gitignorePath, \"*\\n!cron/\\n!cron/jobs.json\\n\", \"utf8\");\n\n    migrateManagedInternalFiles({\n      fs,\n      openclawDir,\n      logger: { error: vi.fn() },\n    });\n\n    const next = fs.readFileSync(gitignorePath, \"utf8\");\n    expect(next).toContain(\"cron/jobs-state.json\");\n    expect(next).toContain(\"!hooks/\");\n  });\n\n  it(\"is idempotent across repeated runs\", () => {\n    const openclawDir = createTempOpenclawDir();\n    fs.writeFileSync(path.join(openclawDir, \"hourly-git-sync.sh\"), \"echo script\\n\", \"utf8\");\n\n    migrateManagedInternalFiles({\n      fs,\n      openclawDir,\n      logger: { error: vi.fn() },\n    });\n    migrateManagedInternalFiles({\n      fs,\n      openclawDir,\n      logger: { error: vi.fn() },\n    });\n\n    const managedPaths = buildManagedPaths({ openclawDir });\n    expect(fs.existsSync(managedPaths.hourlyGitSyncPath)).toBe(true);\n    expect(fs.existsSync(managedPaths.legacyHourlyGitSyncPath)).toBe(false);\n    expect(fs.existsSync(managedPaths.internalDir)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/server/login-throttle.test.js",
    "content": "const { createLoginThrottle } = require(\"../../lib/server/login-throttle\");\nconst {\n  kLoginWindowMs,\n  kLoginMaxAttempts,\n  kLoginBaseLockMs,\n  kLoginMaxLockMs,\n  kLoginStateTtlMs,\n} = require(\"../../lib/server/constants\");\n\ndescribe(\"server/login-throttle\", () => {\n  it(\"locks after max failures and reports retry-after while blocked\", () => {\n    const throttle = createLoginThrottle();\n    const now = 1_000;\n    const state = throttle.getOrCreateLoginAttemptState(\"client-1\", now);\n\n    for (let i = 0; i < kLoginMaxAttempts - 1; i += 1) {\n      expect(throttle.recordLoginFailure(state, now + i)).toEqual({\n        lockMs: 0,\n        locked: false,\n      });\n    }\n\n    const lockResult = throttle.recordLoginFailure(state, now + 100);\n    expect(lockResult.locked).toBe(true);\n    expect(lockResult.lockMs).toBeGreaterThanOrEqual(kLoginBaseLockMs);\n\n    const blocked = throttle.evaluateLoginThrottle(state, now + 101);\n    expect(blocked.blocked).toBe(true);\n    expect(blocked.retryAfterSec).toBeGreaterThan(0);\n  });\n\n  it(\"applies exponential backoff and caps lock at max lock\", () => {\n    const throttle = createLoginThrottle();\n    const state = throttle.getOrCreateLoginAttemptState(\"client-2\", 5_000);\n    let now = 5_000;\n\n    const getLockMsForStreak = () => {\n      for (let i = 0; i < kLoginMaxAttempts; i += 1) {\n        const result = throttle.recordLoginFailure(state, now + i);\n        if (result.locked) return result.lockMs;\n      }\n      return 0;\n    };\n\n    const firstLockMs = getLockMsForStreak();\n    now += kLoginWindowMs + firstLockMs + 1;\n    const secondLockMs = getLockMsForStreak();\n\n    expect(secondLockMs).toBeGreaterThanOrEqual(firstLockMs);\n    expect(secondLockMs).toBeLessThanOrEqual(kLoginMaxLockMs);\n  });\n\n  it(\"removes state on login success\", () => {\n    const throttle = createLoginThrottle();\n    const now = 10_000;\n    throttle.getOrCreateLoginAttemptState(\"client-3\", now);\n    throttle.recordLoginSuccess(\"client-3\");\n\n    const state = throttle.getOrCreateLoginAttemptState(\"client-3\", now + 1);\n    expect(state.attempts).toBe(0);\n    expect(state.failStreak).toBe(0);\n  });\n\n  it(\"cleans up stale states past TTL\", () => {\n    const throttle = createLoginThrottle();\n    const oldNow = 20_000;\n    throttle.getOrCreateLoginAttemptState(\"client-4\", oldNow);\n\n    vi.spyOn(Date, \"now\").mockReturnValue(oldNow + kLoginStateTtlMs + 1);\n    throttle.cleanupLoginAttemptStates();\n\n    const fresh = throttle.getOrCreateLoginAttemptState(\n      \"client-4\",\n      oldNow + kLoginStateTtlMs + 2,\n    );\n    expect(fresh.windowStart).toBe(oldNow + kLoginStateTtlMs + 2);\n  });\n});\n"
  },
  {
    "path": "tests/server/model-catalog-cache.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  createModelCatalogCache,\n  kModelCatalogBootstrapSource,\n  kModelCatalogRefreshBackoffMs,\n} = require(\"../../lib/server/model-catalog-cache\");\nconst { kFallbackOnboardingModels } = require(\"../../lib/server/constants\");\n\nconst flushPromises = async () => {\n  await Promise.resolve();\n  await Promise.resolve();\n};\n\nconst normalizeModels = (models = []) =>\n  (Array.isArray(models) ? models : [])\n    .filter((model) => model?.key)\n    .map((model) => ({\n      key: model.key,\n      provider: String(model.key).split(\"/\")[0] || \"\",\n      label: model.name || model.label || model.key,\n    }));\n\nconst writeCacheFile = ({\n  cachePath,\n  fetchedAt = 1000,\n  openclawVersion = null,\n  models = [],\n}) => {\n  fs.mkdirSync(path.dirname(cachePath), { recursive: true });\n  fs.writeFileSync(\n    cachePath,\n    `${JSON.stringify(\n      { version: 1, fetchedAt, openclawVersion, models },\n      null,\n      2,\n    )}\\n`,\n    \"utf8\",\n  );\n};\n\ndescribe(\"server/model-catalog-cache\", () => {\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"ships a full bootstrap model catalog for cold starts\", () => {\n    expect(kFallbackOnboardingModels.length).toBeGreaterThan(100);\n    expect(kFallbackOnboardingModels).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          key: \"anthropic/claude-opus-4-7\",\n          label: \"Claude Opus 4.7\",\n        }),\n      ]),\n    );\n  });\n\n  it(\"returns cached models immediately and shares a single in-flight refresh\", async () => {\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-cache-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n    writeCacheFile({\n      cachePath,\n      fetchedAt: 111,\n      models: normalizeModels([{ key: \"openai/gpt-cached\", label: \"Cached\" }]),\n    });\n\n    let resolveShell;\n    const shellCmd = vi.fn(\n      () =>\n        new Promise((resolve) => {\n          resolveShell = resolve;\n        }),\n    );\n    const parseJsonFromNoisyOutput = vi.fn(() => ({\n      models: [{ key: \"openai/gpt-fresh\", name: \"Fresh\" }],\n    }));\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      gatewayEnv: () => ({ OPENCLAW_GATEWAY_TOKEN: \"token\" }),\n      parseJsonFromNoisyOutput,\n      normalizeOnboardingModels: normalizeModels,\n      readOpenclawVersion: vi.fn(() => \"2026.4.14\"),\n    });\n\n    const first = await cache.getCatalogResponse();\n    const second = await cache.getCatalogResponse();\n\n    expect(first).toEqual({\n      ok: true,\n      source: \"cache\",\n      fetchedAt: 111,\n      stale: true,\n      refreshing: true,\n      models: normalizeModels([{ key: \"openai/gpt-cached\", label: \"Cached\" }]),\n    });\n    expect(second.source).toBe(\"cache\");\n    expect(second.refreshing).toBe(true);\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n\n    resolveShell(\"{}\");\n    await flushPromises();\n\n    const fresh = await cache.getCatalogResponse();\n    expect(fresh).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([{ key: \"openai/gpt-fresh\", name: \"Fresh\" }]),\n    });\n    const written = JSON.parse(fs.readFileSync(cachePath, \"utf8\"));\n    expect(written.openclawVersion).toBe(\"2026.4.14\");\n    expect(written.models).toEqual(\n      normalizeModels([{ key: \"openai/gpt-fresh\", name: \"Fresh\" }]),\n    );\n  });\n\n  it(\"bootstraps with the bundled catalog while the initial catalog loads in the background\", async () => {\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-bootstrap-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n\n    let resolveShell;\n    const shellCmd = vi.fn(\n      () =>\n        new Promise((resolve) => {\n          resolveShell = resolve;\n        }),\n    );\n    const parseJsonFromNoisyOutput = vi.fn(() => ({\n      models: [{ key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" }],\n    }));\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      parseJsonFromNoisyOutput,\n      normalizeOnboardingModels: normalizeModels,\n      readOpenclawVersion: vi.fn(() => \"2026.4.15\"),\n    });\n\n    const initial = await cache.getCatalogResponse();\n    const repeated = await cache.getCatalogResponse();\n\n    expect(initial).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n    expect(repeated.source).toBe(kModelCatalogBootstrapSource);\n    expect(repeated.refreshing).toBe(true);\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n\n    resolveShell(\"{}\");\n    await flushPromises();\n\n    const fresh = await cache.getCatalogResponse();\n    expect(fresh).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([\n        { key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" },\n      ]),\n    });\n  });\n\n  it(\"serves the bundled catalog without refreshing when dynamic refresh is disabled\", async () => {\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-bootstrap-only-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n    const shellCmd = vi.fn().mockResolvedValue(\"{}\");\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      normalizeOnboardingModels: normalizeModels,\n      shouldStartDynamicRefresh: () => false,\n    });\n\n    const initial = await cache.getCatalogResponse();\n    const repeated = await cache.getCatalogResponse();\n\n    expect(initial).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: false,\n      models: kFallbackOnboardingModels,\n    });\n    expect(repeated.refreshing).toBe(false);\n    expect(shellCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"serves stale disk cache without refreshing when dynamic refresh is disabled\", async () => {\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-cache-only-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n    writeCacheFile({\n      cachePath,\n      fetchedAt: 333,\n      models: normalizeModels([{ key: \"anthropic/claude-opus-4-7\" }]),\n    });\n    const shellCmd = vi.fn().mockResolvedValue(\"{}\");\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      normalizeOnboardingModels: normalizeModels,\n      shouldStartDynamicRefresh: () => false,\n    });\n\n    const response = await cache.getCatalogResponse();\n\n    expect(response).toEqual({\n      ok: true,\n      source: \"cache\",\n      fetchedAt: 333,\n      stale: true,\n      refreshing: false,\n      models: normalizeModels([{ key: \"anthropic/claude-opus-4-7\" }]),\n    });\n    expect(shellCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"marks a fresh memory cache stale when the openclaw version changes\", async () => {\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-version-bust-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n\n    let currentVersion = \"2026.4.14\";\n    let resolveRefresh;\n    const shellCmd = vi\n      .fn()\n      .mockResolvedValueOnce(\"{}\")\n      .mockImplementationOnce(\n        () =>\n          new Promise((resolve) => {\n            resolveRefresh = resolve;\n          }),\n      );\n    const parseJsonFromNoisyOutput = vi\n      .fn()\n      .mockReturnValueOnce({\n        models: [{ key: \"anthropic/claude-opus-4-6\", name: \"Claude Opus 4.6\" }],\n      })\n      .mockReturnValueOnce({\n        models: [{ key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" }],\n      });\n    const readOpenclawVersion = vi.fn(({ refresh } = {}) =>\n      refresh ? currentVersion : currentVersion,\n    );\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      parseJsonFromNoisyOutput,\n      normalizeOnboardingModels: normalizeModels,\n      readOpenclawVersion,\n    });\n\n    const bootstrap = await cache.getCatalogResponse();\n    expect(bootstrap).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n    await flushPromises();\n\n    const initial = await cache.getCatalogResponse();\n    expect(initial).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([\n        { key: \"anthropic/claude-opus-4-6\", name: \"Claude Opus 4.6\" },\n      ]),\n    });\n\n    currentVersion = \"2026.4.15\";\n\n    const stale = await cache.getCatalogResponse();\n    expect(stale).toEqual({\n      ok: true,\n      source: \"cache\",\n      fetchedAt: expect.any(Number),\n      stale: true,\n      refreshing: true,\n      models: normalizeModels([\n        { key: \"anthropic/claude-opus-4-6\", name: \"Claude Opus 4.6\" },\n      ]),\n    });\n    expect(shellCmd).toHaveBeenCalledTimes(2);\n\n    resolveRefresh(\"{}\");\n    await flushPromises();\n\n    const refreshed = await cache.getCatalogResponse();\n    expect(refreshed).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([\n        { key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" },\n      ]),\n    });\n\n    const written = JSON.parse(fs.readFileSync(cachePath, \"utf8\"));\n    expect(written.openclawVersion).toBe(\"2026.4.15\");\n  });\n\n  it(\"keeps serving cache after refresh failures and retries after backoff\", async () => {\n    vi.useFakeTimers();\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-backoff-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n    writeCacheFile({\n      cachePath,\n      fetchedAt: 222,\n      openclawVersion: \"2026.4.14\",\n      models: normalizeModels([{ key: \"openai/gpt-cached\", label: \"Cached\" }]),\n    });\n\n    const shellCmd = vi\n      .fn()\n      .mockRejectedValueOnce(new Error(\"boom\"))\n      .mockResolvedValueOnce(\"{}\");\n    const parseJsonFromNoisyOutput = vi.fn(() => ({\n      models: [{ key: \"openai/gpt-retried\", name: \"Retried\" }],\n    }));\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      parseJsonFromNoisyOutput,\n      normalizeOnboardingModels: normalizeModels,\n      readOpenclawVersion: vi.fn(() => \"2026.4.14\"),\n      setTimeoutFn: setTimeout,\n      clearTimeoutFn: clearTimeout,\n    });\n\n    const cached = await cache.getCatalogResponse();\n    expect(cached.source).toBe(\"cache\");\n    expect(cached.refreshing).toBe(true);\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n\n    await flushPromises();\n\n    const afterFailure = await cache.getCatalogResponse();\n    expect(afterFailure).toEqual({\n      ok: true,\n      source: \"cache\",\n      fetchedAt: 222,\n      stale: true,\n      refreshing: true,\n      models: normalizeModels([{ key: \"openai/gpt-cached\", label: \"Cached\" }]),\n    });\n\n    await vi.advanceTimersByTimeAsync(kModelCatalogRefreshBackoffMs - 1);\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n\n    await vi.advanceTimersByTimeAsync(1);\n    await flushPromises();\n    expect(shellCmd).toHaveBeenCalledTimes(2);\n\n    const fresh = await cache.getCatalogResponse();\n    expect(fresh).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([{ key: \"openai/gpt-retried\", name: \"Retried\" }]),\n    });\n  });\n\n  it(\"recovers model catalog JSON from failed command output\", async () => {\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-recover-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n    const err = new Error(\"plugin load failed\");\n    err.stdout =\n      'prefix\\n{\"models\":[{\"key\":\"anthropic/claude-opus-4-7\",\"name\":\"Claude Opus 4.7\"}]}\\n';\n    err.stderr =\n      '[plugins] google failed to load from /app/node_modules/openclaw/dist/extensions/google/index.js';\n    const shellCmd = vi.fn().mockRejectedValue(err);\n    const parseJsonFromNoisyOutput = vi.fn((raw) =>\n      String(raw).includes('\"models\"')\n        ? {\n            models: [\n              {\n                key: \"anthropic/claude-opus-4-7\",\n                name: \"Claude Opus 4.7\",\n              },\n            ],\n          }\n        : null,\n    );\n    const logger = { error: vi.fn(), warn: vi.fn() };\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      parseJsonFromNoisyOutput,\n      normalizeOnboardingModels: normalizeModels,\n      readOpenclawVersion: vi.fn(() => \"2026.4.24\"),\n      logger,\n    });\n\n    const bootstrap = await cache.getCatalogResponse();\n    expect(bootstrap).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n    await flushPromises();\n\n    const response = await cache.getCatalogResponse();\n\n    expect(response).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([\n        { key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" },\n      ]),\n    });\n    expect(logger.warn).toHaveBeenCalledWith(\n      expect.stringContaining(\"Recovered model catalog from failed command output\"),\n    );\n  });\n\n  it(\"serves the bundled catalog when no cache exists and retries after backoff\", async () => {\n    vi.useFakeTimers();\n    const tempRoot = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-model-catalog-fallback-\"),\n    );\n    const cachePath = path.join(tempRoot, \"cache\", \"model-catalog.json\");\n    const shellCmd = vi\n      .fn()\n      .mockRejectedValueOnce(new Error(\"boom\"))\n      .mockResolvedValueOnce(\"{}\");\n    const parseJsonFromNoisyOutput = vi.fn(() => ({\n      models: [{ key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" }],\n    }));\n    const cache = createModelCatalogCache({\n      cachePath,\n      shellCmd,\n      parseJsonFromNoisyOutput,\n      normalizeOnboardingModels: normalizeModels,\n      readOpenclawVersion: vi.fn(() => \"2026.4.14\"),\n      setTimeoutFn: setTimeout,\n      clearTimeoutFn: clearTimeout,\n    });\n\n    const response = await cache.getCatalogResponse();\n\n    expect(response).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n\n    await flushPromises();\n\n    await vi.advanceTimersByTimeAsync(kModelCatalogRefreshBackoffMs - 1);\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n\n    await vi.advanceTimersByTimeAsync(1);\n    await flushPromises();\n    expect(shellCmd).toHaveBeenCalledTimes(2);\n\n    const fresh = await cache.getCatalogResponse();\n    expect(fresh).toEqual({\n      ok: true,\n      source: \"openclaw\",\n      fetchedAt: expect.any(Number),\n      stale: false,\n      refreshing: false,\n      models: normalizeModels([\n        { key: \"anthropic/claude-opus-4-7\", name: \"Claude Opus 4.7\" },\n      ]),\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/oauth-callback-middleware.test.js",
    "content": "const http = require(\"http\");\nconst express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { createWebhookMiddleware } = require(\"../../lib/server/webhook-middleware\");\nconst {\n  createOauthCallbackMiddleware,\n} = require(\"../../lib/server/oauth-callback-middleware\");\n\nconst createGatewaySpyServer = async () => {\n  const calls = [];\n  const server = http.createServer((req, res) => {\n    const chunks = [];\n    req.on(\"data\", (chunk) => chunks.push(chunk));\n    req.on(\"end\", () => {\n      calls.push({\n        method: req.method,\n        url: req.url,\n        headers: req.headers,\n        bodyText: Buffer.concat(chunks).toString(\"utf8\"),\n      });\n      res.statusCode = 200;\n      res.setHeader(\"content-type\", \"application/json\");\n      res.end(JSON.stringify({ ok: true }));\n    });\n  });\n\n  await new Promise((resolve) => {\n    server.listen(0, \"127.0.0.1\", resolve);\n  });\n\n  const address = server.address();\n  return {\n    server,\n    calls,\n    gatewayUrl: `http://127.0.0.1:${address.port}`,\n  };\n};\n\nconst createApp = ({\n  gatewayUrl,\n  getOauthCallbackById = () => null,\n  markOauthCallbackUsed = () => {},\n}) => {\n  const app = express();\n  app.use([\"/hooks\", \"/webhook\"], express.raw({ type: \"*/*\", limit: \"5mb\" }));\n  const webhookMiddleware = createWebhookMiddleware({\n    gatewayUrl,\n    insertRequest: () => {},\n    maxPayloadBytes: 64 * 1024,\n  });\n  const oauthCallbackMiddleware = createOauthCallbackMiddleware({\n    getOauthCallbackById,\n    markOauthCallbackUsed,\n    webhookMiddleware,\n  });\n  app.all(\"/oauth/:id\", oauthCallbackMiddleware);\n  return app;\n};\n\ndescribe(\"server/oauth-callback-middleware\", () => {\n  it(\"returns 404 for unknown callback ids\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const app = createApp({ gatewayUrl });\n\n    try {\n      const response = await request(app).get(\"/oauth/unknown-id?code=abc\");\n      expect(response.status).toBe(404);\n      expect(calls).toHaveLength(0);\n    } finally {\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n\n  it(\"rewrites oauth callback requests into POST hook calls with injected auth\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const originalWebhookToken = process.env.WEBHOOK_TOKEN;\n    process.env.WEBHOOK_TOKEN = \"test-webhook-token\";\n    const app = createApp({\n      gatewayUrl,\n      getOauthCallbackById: (id) =>\n        id === \"abc123\"\n          ? { callbackId: \"abc123\", hookName: \"schwab-oauth\" }\n          : null,\n    });\n\n    try {\n      const response = await request(app).get(\"/oauth/abc123?code=AUTH&state=STATE\");\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(calls[0].url).toBe(\"/hooks/schwab-oauth?code=AUTH&state=STATE\");\n      expect(calls[0].headers.authorization).toBe(\"Bearer test-webhook-token\");\n      expect(JSON.parse(calls[0].bodyText)).toEqual({\n        code: \"AUTH\",\n        state: \"STATE\",\n      });\n    } finally {\n      process.env.WEBHOOK_TOKEN = originalWebhookToken;\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n\n  it(\"forwards requests without authorization header when WEBHOOK_TOKEN is missing\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const originalWebhookToken = process.env.WEBHOOK_TOKEN;\n    delete process.env.WEBHOOK_TOKEN;\n    const app = createApp({\n      gatewayUrl,\n      getOauthCallbackById: (id) =>\n        id === \"abc123\"\n          ? { callbackId: \"abc123\", hookName: \"schwab-oauth\" }\n          : null,\n    });\n\n    try {\n      const response = await request(app).get(\"/oauth/abc123?code=AUTH\");\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(calls[0].headers.authorization).toBeUndefined();\n    } finally {\n      process.env.WEBHOOK_TOKEN = originalWebhookToken;\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n});\n"
  },
  {
    "path": "tests/server/onboarding-github.test.js",
    "content": "const fs = require(\"fs\");\n\nconst {\n  cloneRepoToTemp,\n  ensureGithubRepoAccessible,\n  verifyGithubRepoForOnboarding,\n} = require(\"../../lib/server/onboarding/github\");\n\ndescribe(\"server/onboarding/github\", () => {\n  beforeEach(() => {\n    global.fetch = vi.fn();\n  });\n\n  it(\"clones without embedding the github token in the command line\", async () => {\n    const shellCmd = vi.fn(async (cmd, opts = {}) => {\n      expect(cmd).toContain('git clone --depth=1 \"https://github.com/my-org/source-repo.git\"');\n      expect(cmd).not.toContain(\"ghp_secret_token_value\");\n      expect(opts.env?.ALPHACLAW_GITHUB_TOKEN).toBe(\"ghp_secret_token_value\");\n      expect(typeof opts.env?.GIT_ASKPASS).toBe(\"string\");\n      expect(fs.existsSync(opts.env.GIT_ASKPASS)).toBe(true);\n      return \"\";\n    });\n\n    const result = await cloneRepoToTemp({\n      repoUrl: \"my-org/source-repo\",\n      githubToken: \"ghp_secret_token_value\",\n      shellCmd,\n    });\n\n    expect(result.ok).toBe(true);\n    expect(shellCmd).toHaveBeenCalledTimes(1);\n    const [, opts] = shellCmd.mock.calls[0];\n    expect(fs.existsSync(opts.env.GIT_ASKPASS)).toBe(false);\n  });\n\n  it(\"allows org-owned new repos when github token verification succeeds\", async () => {\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"tokudu\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [{ login: \"make-stories\" }],\n      });\n\n    const result = await verifyGithubRepoForOnboarding({\n      repoUrl: \"make-stories/new-workspace\",\n      githubToken: \"ghp_secret_token_value\",\n      mode: \"new\",\n    });\n\n    expect(result).toEqual({\n      ok: true,\n      repoExists: false,\n      repoIsEmpty: false,\n      createOwnerType: \"org\",\n      viewerLogin: \"tokudu\",\n    });\n  });\n\n  it(\"rejects new repos when the owner is not the token user or an accessible org\", async () => {\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"chrysbtest\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [],\n      });\n\n    const result = await verifyGithubRepoForOnboarding({\n      repoUrl: \"chrybtest/test81\",\n      githubToken: \"ghp_secret_token_value\",\n      mode: \"new\",\n    });\n\n    expect(result.ok).toBe(false);\n    expect(result.status).toBe(400);\n    expect(result.error).toContain('Repository owner \"chrybtest\"');\n    expect(result.error).toContain('authenticated GitHub user \"chrysbtest\"');\n  });\n\n  it(\"creates org-owned new repos through the organization endpoint\", async () => {\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"tokudu\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [{ login: \"make-stories\" }],\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        status: 201,\n        statusText: \"Created\",\n        json: async () => ({}),\n      });\n\n    const result = await ensureGithubRepoAccessible({\n      repoUrl: \"make-stories/new-workspace\",\n      repoName: \"new-workspace\",\n      githubToken: \"ghp_secret_token_value\",\n    });\n\n    expect(result).toEqual({ ok: true });\n    expect(global.fetch).toHaveBeenLastCalledWith(\n      \"https://api.github.com/orgs/make-stories/repos\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          name: \"new-workspace\",\n          private: true,\n          auto_init: false,\n        }),\n      }),\n    );\n  });\n\n  it(\"flags a user-owned repo as already taken when listing shows a hidden match\", async () => {\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"owner\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [{ name: \"repo\", full_name: \"owner/repo\" }],\n      });\n\n    const result = await verifyGithubRepoForOnboarding({\n      repoUrl: \"owner/repo\",\n      githubToken: \"github_pat_hidden_repo_token\",\n      mode: \"new\",\n    });\n\n    expect(result.ok).toBe(false);\n    expect(result.status).toBe(400);\n    expect(result.error).toContain('Repository \"owner/repo\" already exists');\n    expect(result.error).toContain(\"cannot inspect\");\n  });\n});\n"
  },
  {
    "path": "tests/server/onboarding-openclaw.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  buildOnboardArgs,\n  writeManagedImportOpenclawConfig,\n  writeSanitizedOpenclawConfig,\n} = require(\"../../lib/server/onboarding/openclaw\");\n\nconst createTempOpenclawDir = () =>\n  fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-onboarding-openclaw-test-\"));\n\ndescribe(\"server/onboarding/openclaw\", () => {\n  it(\"builds onboarding args from submitted vars instead of stale process env auth\", () => {\n    process.env.ANTHROPIC_TOKEN = \"sk-ant-oat01-stale-token\";\n\n    const args = buildOnboardArgs({\n      varMap: {\n        ANTHROPIC_API_KEY: \"sk-ant-api-fresh-key\",\n        OPENCLAW_GATEWAY_TOKEN: \"gw-token\",\n      },\n      selectedProvider: \"anthropic\",\n      hasCodexOauth: false,\n      workspaceDir: \"/tmp/workspace\",\n    });\n\n    expect(args).toContain(\"--anthropic-api-key\");\n    expect(args).toContain(\"sk-ant-api-fresh-key\");\n    expect(args).not.toContain(\"--token\");\n    expect(args).not.toContain(\"sk-ant-oat01-stale-token\");\n\n    delete process.env.ANTHROPIC_TOKEN;\n  });\n\n  it(\"only scrubs exact secret string values in JSON\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const pluginPath = \"/app/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker\";\n    fs.writeFileSync(\n      configPath,\n      JSON.stringify(\n        {\n          plugins: {\n            allow: [\"memory-core\"],\n            load: { paths: [pluginPath] },\n            entries: {},\n          },\n          channels: {},\n          notes: \"alphaclaw\",\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    writeSanitizedOpenclawConfig({\n      fs,\n      openclawDir,\n      varMap: { GOG_KEYRING_PASSWORD: \"alphaclaw\" },\n    });\n\n    const next = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    expect(next.notes).toBe(\"${GOG_KEYRING_PASSWORD}\");\n    expect(next.plugins.allow).toEqual([\"memory-core\", \"usage-tracker\"]);\n    expect(next.plugins.load.paths).toContain(pluginPath);\n    expect(next.plugins.load.paths).not.toContain(\n      \"/app/node_modules/@chrysb/${GOG_KEYRING_PASSWORD}/lib/plugin/usage-tracker\",\n    );\n  });\n\n  it(\"creates plugins.allow when missing before adding usage-tracker\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    fs.writeFileSync(\n      configPath,\n      JSON.stringify(\n        {\n          plugins: { load: { paths: [] }, entries: {} },\n          channels: {},\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    writeSanitizedOpenclawConfig({\n      fs,\n      openclawDir,\n      varMap: {},\n    });\n\n    const next = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    expect(next.plugins.allow).toEqual([\"usage-tracker\"]);\n    expect(next.plugins.entries[\"usage-tracker\"]).toEqual({\n      enabled: true,\n      hooks: { allowConversationAccess: true },\n    });\n  });\n\n  it(\"resets imported allowlist dmPolicy to pairing when re-enabling discord\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    fs.writeFileSync(\n      configPath,\n      JSON.stringify(\n        {\n          plugins: { allow: [], load: { paths: [] }, entries: {} },\n          channels: {\n            discord: {\n              enabled: false,\n              dmPolicy: \"allowlist\",\n              allowFrom: [],\n            },\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    writeManagedImportOpenclawConfig({\n      fs,\n      openclawDir,\n      varMap: { DISCORD_BOT_TOKEN: \"discord-live-secret\" },\n    });\n\n    const next = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    expect(next.channels.discord.enabled).toBe(true);\n    expect(next.channels.discord.dmPolicy).toBe(\"pairing\");\n    expect(next.channels.discord.token).toBe(\"${DISCORD_BOT_TOKEN}\");\n  });\n});\n"
  },
  {
    "path": "tests/server/onboarding-validation.test.js",
    "content": "const { validateOnboardingInput } = require(\"../../lib/server/onboarding/validation\");\n\nconst kBaseVars = () => [\n  { key: \"GITHUB_TOKEN\", value: \"ghp_test\" },\n  { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n  { key: \"TELEGRAM_BOT_TOKEN\", value: \"telegram_tok\" },\n];\n\nconst kResolveProvider = (modelKey) => String(modelKey || \"\").split(\"/\")[0] || \"\";\n\ndescribe(\"onboarding/validation\", () => {\n  it(\"accepts OPENROUTER_API_KEY when the selected model uses the openrouter provider\", () => {\n    const res = validateOnboardingInput({\n      vars: [...kBaseVars(), { key: \"OPENROUTER_API_KEY\", value: \"sk-or-test\" }],\n      modelKey: \"openrouter/nvidia/nemotron-3-nano\",\n      resolveModelProvider: kResolveProvider,\n      hasCodexOauthProfile: () => false,\n    });\n    expect(res.ok).toBe(true);\n  });\n\n  it(\"accepts MOONSHOT_API_KEY when the selected model uses the moonshot provider\", () => {\n    const res = validateOnboardingInput({\n      vars: [...kBaseVars(), { key: \"MOONSHOT_API_KEY\", value: \"sk-moonshot\" }],\n      modelKey: \"moonshot/kimi-k2-5\",\n      resolveModelProvider: kResolveProvider,\n      hasCodexOauthProfile: () => false,\n    });\n    expect(res.ok).toBe(true);\n  });\n\n  it(\"rejects openrouter model when only unrelated API keys are present\", () => {\n    const res = validateOnboardingInput({\n      vars: [...kBaseVars(), { key: \"MOONSHOT_API_KEY\", value: \"sk-ms\" }],\n      modelKey: \"openrouter/foo/bar\",\n      resolveModelProvider: kResolveProvider,\n      hasCodexOauthProfile: () => false,\n    });\n    expect(res.ok).toBe(false);\n    expect(res.error).toBe('Missing credentials for selected provider \"openrouter\"');\n  });\n\n  it(\"accepts whatsapp owner number as the required channel credential\", () => {\n    const res = validateOnboardingInput({\n      vars: [\n        { key: \"GITHUB_TOKEN\", value: \"ghp_test\" },\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n        { key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" },\n        { key: \"OPENAI_API_KEY\", value: \"sk-test-123\" },\n      ],\n      modelKey: \"openai/gpt-5.1-codex\",\n      resolveModelProvider: kResolveProvider,\n      hasCodexOauthProfile: () => false,\n    });\n    expect(res.ok).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/server/onboarding-workspace.test.js",
    "content": "const { resolveSetupUiUrl } = require(\"../../lib/server/onboarding/workspace\");\n\ndescribe(\"server/onboarding/workspace\", () => {\n  const kOriginalRailwayPublicDomain = process.env.RAILWAY_PUBLIC_DOMAIN;\n\n  afterEach(() => {\n    if (typeof kOriginalRailwayPublicDomain === \"undefined\") {\n      delete process.env.RAILWAY_PUBLIC_DOMAIN;\n      return;\n    }\n    process.env.RAILWAY_PUBLIC_DOMAIN = kOriginalRailwayPublicDomain;\n  });\n\n  it(\"falls back to Railway public domain when no explicit base URL is provided\", () => {\n    process.env.RAILWAY_PUBLIC_DOMAIN = \"alphaclaw-production.up.railway.app\";\n\n    expect(resolveSetupUiUrl(\"\")).toBe(\n      \"https://alphaclaw-production.up.railway.app\",\n    );\n  });\n});\n"
  },
  {
    "path": "tests/server/openclaw-runtime-env.test.js",
    "content": "const path = require(\"path\");\nconst { kRootDir } = require(\"../../lib/server/constants\");\nconst {\n  ensureOpenclawStartupEnv,\n  withOpenclawStartupEnv,\n} = require(\"../../lib/server/openclaw-runtime-env\");\n\ndescribe(\"server/openclaw-runtime-env\", () => {\n  it(\"defaults OpenClaw CLI startup settings to the stable AlphaClaw root\", () => {\n    const env = withOpenclawStartupEnv({ FOO: \"bar\" });\n\n    expect(env).toEqual(\n      expect.objectContaining({\n        FOO: \"bar\",\n        NODE_COMPILE_CACHE: path.join(\n          kRootDir,\n          \"cache\",\n          \"openclaw-compile-cache\",\n        ),\n        OPENCLAW_NO_RESPAWN: \"1\",\n      }),\n    );\n  });\n\n  it(\"preserves explicit OpenClaw startup settings\", () => {\n    const env = withOpenclawStartupEnv({\n      NODE_COMPILE_CACHE: \"/custom/cache\",\n      OPENCLAW_NO_RESPAWN: \"0\",\n    });\n\n    expect(env.NODE_COMPILE_CACHE).toBe(\"/custom/cache\");\n    expect(env.OPENCLAW_NO_RESPAWN).toBe(\"0\");\n  });\n\n  it(\"creates the compile cache directory and backfills missing process env values\", () => {\n    const fsModule = { mkdirSync: vi.fn() };\n    const logger = { warn: vi.fn() };\n    const env = {};\n\n    const result = ensureOpenclawStartupEnv({ fsModule, env, logger });\n\n    expect(fsModule.mkdirSync).toHaveBeenCalledWith(result.NODE_COMPILE_CACHE, {\n      recursive: true,\n    });\n    expect(env.NODE_COMPILE_CACHE).toBe(result.NODE_COMPILE_CACHE);\n    expect(env.OPENCLAW_NO_RESPAWN).toBe(\"1\");\n    expect(logger.warn).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/server/openclaw-version.test.js",
    "content": "const childProcess = require(\"child_process\");\n\nconst {\n  kNpmPackageRoot,\n  kOpenclawUpdateCopyTimeoutMs,\n} = require(\"../../lib/server/constants\");\nconst modulePath = require.resolve(\"../../lib/server/openclaw-version\");\nconst originalExec = childProcess.exec;\nconst originalExecSync = childProcess.execSync;\n\nconst loadVersionModule = ({ execMock, execSyncMock }) => {\n  childProcess.exec = execMock;\n  childProcess.execSync = execSyncMock;\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nconst createService = ({ isOnboarded = false } = {}) => {\n  const execMock = vi.fn();\n  const execSyncMock = vi.fn();\n  const { createOpenclawVersionService } = loadVersionModule({\n    execMock,\n    execSyncMock,\n  });\n  const gatewayEnv = vi.fn(() => ({ OPENCLAW_GATEWAY_TOKEN: \"token\" }));\n  const restartGateway = vi.fn();\n  const service = createOpenclawVersionService({\n    gatewayEnv,\n    restartGateway,\n    isOnboarded: () => isOnboarded,\n  });\n  return { service, gatewayEnv, restartGateway, execMock, execSyncMock };\n};\n\ndescribe(\"server/openclaw-version\", () => {\n  afterEach(() => {\n    childProcess.exec = originalExec;\n    childProcess.execSync = originalExecSync;\n    delete require.cache[modulePath];\n  });\n\n  it(\"reads current version and uses cache within TTL\", () => {\n    const { service, gatewayEnv, execSyncMock } = createService();\n    execSyncMock.mockReturnValue(\"openclaw 1.2.3\\n\");\n\n    const first = service.readOpenclawVersion();\n    const second = service.readOpenclawVersion();\n\n    expect(first).toBe(\"1.2.3\");\n    expect(second).toBe(\"1.2.3\");\n    expect(execSyncMock).toHaveBeenCalledTimes(1);\n    expect(execSyncMock).toHaveBeenCalledWith(\"openclaw --version\", {\n      env: gatewayEnv(),\n      timeout: 5000,\n      encoding: \"utf8\",\n    });\n  });\n\n  it(\"re-reads current version when refresh is requested\", () => {\n    const { service, execSyncMock } = createService();\n    execSyncMock\n      .mockReturnValueOnce(\"openclaw 1.2.3\\n\")\n      .mockReturnValueOnce(\"openclaw 1.2.4\\n\");\n\n    const first = service.readOpenclawVersion();\n    const refreshed = service.readOpenclawVersion({ refresh: true });\n\n    expect(first).toBe(\"1.2.3\");\n    expect(refreshed).toBe(\"1.2.4\");\n    expect(execSyncMock).toHaveBeenCalledTimes(2);\n  });\n\n  it(\"returns update availability when latest version is newer\", async () => {\n    const { service, execSyncMock } = createService();\n    execSyncMock.mockReturnValueOnce(\"openclaw 1.2.3\").mockReturnValueOnce(\n      JSON.stringify({\n        availability: { available: true, latestVersion: \"1.3.0\" },\n      }),\n    );\n\n    const status = await service.getVersionStatus(false);\n\n    expect(status).toEqual({\n      ok: true,\n      currentVersion: \"1.2.3\",\n      latestVersion: \"1.3.0\",\n      hasUpdate: true,\n    });\n  });\n\n  it(\"parses update status json from noisy CLI output\", async () => {\n    const { service, execSyncMock } = createService();\n    execSyncMock\n      .mockReturnValueOnce(\"openclaw 1.2.3\")\n      .mockReturnValueOnce(\n        `[plugins] [auth]\\n${JSON.stringify({\n          availability: { available: true, latestVersion: \"1.3.0\" },\n        })}`,\n      );\n\n    const status = await service.getVersionStatus(false);\n\n    expect(status).toEqual({\n      ok: true,\n      currentVersion: \"1.2.3\",\n      latestVersion: \"1.3.0\",\n      hasUpdate: true,\n    });\n  });\n\n  it(\"returns error status when update status command fails\", async () => {\n    const { service, execSyncMock } = createService();\n    execSyncMock\n      .mockReturnValueOnce(\"openclaw 1.2.3\")\n      .mockImplementationOnce(() => {\n        throw new Error(\"status check failed\");\n      });\n\n    const status = await service.getVersionStatus(false);\n\n    expect(status.ok).toBe(false);\n    expect(status.currentVersion).toBe(\"1.2.3\");\n    expect(status.latestVersion).toBe(null);\n    expect(status.hasUpdate).toBe(false);\n    expect(status.error).toContain(\"status check failed\");\n  });\n\n  it(\"updates openclaw and restarts gateway when onboarded\", async () => {\n    const { service, restartGateway, execMock, execSyncMock } = createService({\n      isOnboarded: true,\n    });\n    execSyncMock\n      .mockReturnValueOnce(\"openclaw 1.0.0\")\n      .mockReturnValueOnce(\"openclaw 1.1.0\")\n      .mockReturnValueOnce(\n        JSON.stringify({\n          availability: { available: false, latestVersion: \"1.1.0\" },\n        }),\n      );\n    execMock.mockImplementation((cmd, opts, callback) => {\n      callback(null, \"installed\", \"\");\n    });\n\n    const result = await service.updateOpenclaw();\n\n    expect(result.status).toBe(200);\n    expect(result.body).toEqual(\n      expect.objectContaining({\n        ok: true,\n        previousVersion: \"1.0.0\",\n        currentVersion: \"1.1.0\",\n        latestVersion: \"1.1.0\",\n        hasUpdate: false,\n        restarted: true,\n        updated: true,\n      }),\n    );\n    expect(execMock).toHaveBeenCalledTimes(2);\n    expect(execMock).toHaveBeenNthCalledWith(\n      1,\n      \"npm install --omit=dev --prefer-online --package-lock=false\",\n      expect.objectContaining({\n        env: expect.objectContaining({\n          npm_config_update_notifier: \"false\",\n          npm_config_fund: \"false\",\n          npm_config_audit: \"false\",\n        }),\n        timeout: 180000,\n      }),\n      expect.any(Function),\n    );\n    expect(execMock).toHaveBeenNthCalledWith(\n      2,\n      expect.stringMatching(/^cp -af /),\n      expect.objectContaining({ timeout: kOpenclawUpdateCopyTimeoutMs }),\n      expect.any(Function),\n    );\n    expect(restartGateway).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"returns 409 while another update is in progress\", async () => {\n    const { service, execMock, execSyncMock } = createService();\n    execSyncMock.mockImplementation((command) => {\n      if (command === \"openclaw --version\") {\n        return \"openclaw 1.0.0\";\n      }\n      if (command === \"openclaw update status --json\") {\n        return JSON.stringify({\n          availability: { available: true, latestVersion: \"1.1.0\" },\n        });\n      }\n      throw new Error(`Unexpected command: ${command}`);\n    });\n    const callbacks = [];\n    execMock.mockImplementation((cmd, opts, callback) => {\n      callbacks.push(callback);\n    });\n\n    const firstUpdatePromise = service.updateOpenclaw();\n    await new Promise((resolve) => {\n      setImmediate(resolve);\n    });\n    const secondUpdate = await service.updateOpenclaw();\n\n    expect(secondUpdate.status).toBe(409);\n    expect(secondUpdate.body).toEqual({\n      ok: false,\n      error: \"OpenClaw update already in progress\",\n    });\n\n    callbacks[0](null, \"installed\", \"\");\n    await new Promise((resolve) => {\n      setImmediate(resolve);\n    });\n    callbacks[1](null, \"\", \"\");\n    await firstUpdatePromise;\n  });\n});\n"
  },
  {
    "path": "tests/server/operation-events.test.js",
    "content": "const { createOperationEventsService } = require(\"../../lib/server/operation-events\");\n\nconst createReqMock = () => {\n  const handlers = new Map();\n  return {\n    on: vi.fn((event, handler) => {\n      handlers.set(String(event || \"\"), handler);\n    }),\n    emitClose: () => {\n      const closeHandler = handlers.get(\"close\");\n      if (typeof closeHandler === \"function\") {\n        closeHandler();\n      }\n    },\n  };\n};\n\ndescribe(\"server/operation-events\", () => {\n  it(\"stores and replays published events to subscribers\", () => {\n    const service = createOperationEventsService();\n    const { operationId } = service.createOperation({\n      type: \"channel-account-create\",\n    });\n    service.publish(operationId, {\n      event: \"phase\",\n      data: { label: \"Starting\" },\n    });\n\n    const req = createReqMock();\n    const res = {\n      status: vi.fn(() => res),\n      setHeader: vi.fn(),\n      flushHeaders: vi.fn(),\n      write: vi.fn(),\n    };\n    const subscribed = service.subscribe({ operationId, req, res });\n\n    expect(subscribed).toBe(true);\n    expect(res.write).toHaveBeenNthCalledWith(1, \": connected\\n\\n\");\n    expect(res.write).toHaveBeenNthCalledWith(\n      2,\n      expect.stringContaining(\"event: phase\"),\n    );\n    expect(res.write).toHaveBeenNthCalledWith(\n      2,\n      expect.stringContaining('\"label\":\"Starting\"'),\n    );\n  });\n\n  it(\"caps buffered events to max per operation\", () => {\n    const service = createOperationEventsService();\n    const { operationId } = service.createOperation();\n    for (let idx = 1; idx <= 205; idx += 1) {\n      service.publish(operationId, {\n        event: \"phase\",\n        data: { idx },\n      });\n    }\n\n    const operation = service.getOperation(operationId);\n    expect(operation.events).toHaveLength(200);\n    expect(operation.events[0].id).toBe(\"6\");\n    expect(operation.events[199].id).toBe(\"205\");\n  });\n\n  it(\"removes expired operations after subscriber disconnect\", () => {\n    vi.useFakeTimers();\n    try {\n      const service = createOperationEventsService({ ttlMs: 100 });\n      const { operationId } = service.createOperation();\n      service.complete(operationId, { ok: true });\n\n      const req = createReqMock();\n      const res = {\n        status: vi.fn(() => res),\n        setHeader: vi.fn(),\n        flushHeaders: vi.fn(),\n        write: vi.fn(),\n      };\n      expect(service.subscribe({ operationId, req, res })).toBe(true);\n      vi.advanceTimersByTime(101);\n      req.emitClose();\n\n      expect(service.getOperation(operationId)).toBeNull();\n    } finally {\n      vi.useRealTimers();\n    }\n  });\n\n  it(\"returns false when subscribing to unknown operation\", () => {\n    const service = createOperationEventsService();\n    const req = createReqMock();\n    const res = {\n      status: vi.fn(() => res),\n      setHeader: vi.fn(),\n      flushHeaders: vi.fn(),\n      write: vi.fn(),\n    };\n    expect(\n      service.subscribe({\n        operationId: \"missing-operation\",\n        req,\n        res,\n      }),\n    ).toBe(false);\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-agents.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerAgentRoutes } = require(\"../../lib/server/routes/agents\");\n\nconst createAgentsServiceMock = () => ({\n  listAgents: vi.fn(() => [{ id: \"main\", name: \"Main Agent\", default: true }]),\n  listConfiguredChannelAccounts: vi.fn(() => [\n    {\n      channel: \"telegram\",\n      accounts: [\n        {\n          id: \"default\",\n          name: \"\",\n          boundAgentId: \"\",\n          paired: 0,\n          status: \"configured\",\n        },\n      ],\n    },\n  ]),\n  createChannelAccount: vi.fn((input) => ({\n    channel: input.provider,\n    account: {\n      id: input.accountId || \"default\",\n      name: input.name,\n      envKey: \"TELEGRAM_BOT_TOKEN\",\n    },\n    binding: {\n      agentId: input.agentId,\n      match: {\n        channel: input.provider,\n        accountId: input.accountId || \"default\",\n      },\n    },\n  })),\n  updateChannelAccount: vi.fn((input) => ({\n    channel: input.provider,\n    account: {\n      id: input.accountId || \"default\",\n      name: input.name,\n      boundAgentId: input.agentId,\n    },\n    tokenUpdated: !!String(input?.token || \"\").trim(),\n  })),\n  getChannelAccountToken: vi.fn((input) => ({\n    provider: input.provider,\n    accountId: input.accountId || \"default\",\n    envKey: \"TELEGRAM_BOT_TOKEN\",\n    token: \"123:abc\",\n  })),\n  deleteChannelAccount: vi.fn(() => ({ ok: true })),\n  runChannelAccountLogin: vi.fn(() => ({\n    ok: true,\n    stdout: \"QR code displayed\",\n    stderr: \"\",\n    code: 0,\n    completed: true,\n  })),\n  getChannelAccountLoginStatus: vi.fn((input) => ({\n    provider: input.provider,\n    accountId: input.accountId || \"default\",\n    linked: true,\n  })),\n  getAgent: vi.fn((id) =>\n    id === \"main\" ? { id: \"main\", name: \"Main Agent\", default: true } : null,\n  ),\n  getAgentWorkspaceSize: vi.fn(() => ({\n    workspacePath: \"/tmp/openclaw/workspace\",\n    exists: true,\n    sizeBytes: 3072,\n  })),\n  getBindingsForAgent: vi.fn(() => [\n    { agentId: \"main\", match: { channel: \"telegram\", accountId: \"default\" } },\n  ]),\n  createAgent: vi.fn((input) => ({\n    id: input.id,\n    name: input.name || input.id,\n    default: false,\n  })),\n  updateAgent: vi.fn((id, patch) => ({ id, ...patch })),\n  addBinding: vi.fn((id, input) => ({ agentId: id, match: { ...input } })),\n  removeBinding: vi.fn(() => ({ ok: true })),\n  deleteAgent: vi.fn(() => ({ ok: true })),\n  setDefaultAgent: vi.fn((id) => ({ id, default: true })),\n});\n\nconst createApp = (\n  agentsService,\n  restartRequiredState = { markRequired: vi.fn() },\n  operationEvents = null,\n) => {\n  const app = express();\n  app.use(express.json());\n  registerAgentRoutes({\n    app,\n    agentsService,\n    restartRequiredState,\n    operationEvents,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/agents\", () => {\n  it(\"lists configured channel accounts on GET /api/channels/accounts\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\"/api/channels/accounts\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.channels).toEqual([\n      {\n        channel: \"telegram\",\n        accounts: [\n          {\n            id: \"default\",\n            name: \"\",\n            boundAgentId: \"\",\n            paired: 0,\n            status: \"configured\",\n          },\n        ],\n      },\n    ]);\n  });\n\n  it(\"creates a configured channel account on POST /api/channels/accounts\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const restartRequiredState = { markRequired: vi.fn() };\n    const app = createApp(agentsService, restartRequiredState);\n\n    const response = await request(app).post(\"/api/channels/accounts\").send({\n      provider: \"telegram\",\n      name: \"Alerts\",\n      accountId: \"alerts\",\n      token: \"123:abc\",\n      agentId: \"main\",\n    });\n\n    expect(response.status).toBe(201);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.restartRequired).toBeUndefined();\n    expect(restartRequiredState.markRequired).not.toHaveBeenCalled();\n    expect(agentsService.createChannelAccount).toHaveBeenCalledWith({\n      provider: \"telegram\",\n      name: \"Alerts\",\n      accountId: \"alerts\",\n      token: \"123:abc\",\n      agentId: \"main\",\n    });\n  });\n\n  it(\"starts channel create job on POST /api/channels/accounts/jobs\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const operationEvents = {\n      createOperation: vi.fn(() => ({ operationId: \"op-1\" })),\n      publish: vi.fn(),\n      complete: vi.fn(),\n      fail: vi.fn(),\n      subscribe: vi.fn(() => true),\n    };\n    const app = createApp(\n      agentsService,\n      { markRequired: vi.fn() },\n      operationEvents,\n    );\n\n    const response = await request(app)\n      .post(\"/api/channels/accounts/jobs\")\n      .send({\n        provider: \"telegram\",\n        name: \"Alerts\",\n        accountId: \"alerts\",\n        token: \"123:abc\",\n        agentId: \"main\",\n      });\n\n    expect(response.status).toBe(202);\n    expect(response.body).toEqual({\n      ok: true,\n      operationId: \"op-1\",\n      streamUrl: \"/api/operations/op-1/events\",\n    });\n    expect(operationEvents.createOperation).toHaveBeenCalledWith({\n      type: \"channel-account-create\",\n    });\n  });\n\n  it(\"streams operation events on GET /api/operations/:id/events\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const operationEvents = {\n      createOperation: vi.fn(),\n      publish: vi.fn(),\n      complete: vi.fn(),\n      fail: vi.fn(),\n      subscribe: vi.fn(({ res }) => {\n        res.status(200).send(\"ok\");\n        return true;\n      }),\n    };\n    const app = createApp(\n      agentsService,\n      { markRequired: vi.fn() },\n      operationEvents,\n    );\n\n    const response = await request(app).get(\"/api/operations/op-1/events\");\n\n    expect(response.status).toBe(200);\n    expect(operationEvents.subscribe).toHaveBeenCalled();\n  });\n\n  it(\"updates a configured channel account on PUT /api/channels/accounts\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).put(\"/api/channels/accounts\").send({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n      name: \"Alerts Bot\",\n      agentId: \"main\",\n    });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.updateChannelAccount).toHaveBeenCalledWith({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n      name: \"Alerts Bot\",\n      agentId: \"main\",\n    });\n    expect(response.body.restartRequired).toBe(false);\n  });\n\n  it(\"marks restart required when a channel token is updated\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const restartRequiredState = { markRequired: vi.fn() };\n    const app = createApp(agentsService, restartRequiredState);\n\n    const response = await request(app).put(\"/api/channels/accounts\").send({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n      name: \"Alerts Bot\",\n      agentId: \"main\",\n      token: \"new-token\",\n    });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.restartRequired).toBe(true);\n    expect(restartRequiredState.markRequired).toHaveBeenCalledWith(\n      \"channel_token_updated\",\n    );\n  });\n\n  it(\"loads a channel account token on GET /api/channels/accounts/token\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\n      \"/api/channels/accounts/token?provider=telegram&accountId=default\",\n    );\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.token).toBe(\"123:abc\");\n    expect(agentsService.getChannelAccountToken).toHaveBeenCalledWith({\n      provider: \"telegram\",\n      accountId: \"default\",\n    });\n  });\n\n  it(\"returns slack app token fields on GET /api/channels/accounts/token\", async () => {\n    const agentsService = createAgentsServiceMock();\n    agentsService.getChannelAccountToken.mockReturnValueOnce({\n      provider: \"slack\",\n      accountId: \"default\",\n      envKey: \"SLACK_BOT_TOKEN\",\n      token: \"xoxb-token\",\n      appEnvKey: \"SLACK_APP_TOKEN\",\n      appToken: \"xapp-token\",\n    });\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\n      \"/api/channels/accounts/token?provider=slack&accountId=default\",\n    );\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.token).toBe(\"xoxb-token\");\n    expect(response.body.appToken).toBe(\"xapp-token\");\n  });\n\n  it(\"runs channel login on POST /api/channels/accounts/login\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app)\n      .post(\"/api/channels/accounts/login\")\n      .send({ provider: \"whatsapp\", accountId: \"default\" });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.completed).toBe(true);\n    expect(response.body.code).toBe(0);\n    expect(agentsService.runChannelAccountLogin).toHaveBeenCalledWith({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n    });\n  });\n\n  it(\"returns login output with completed=false when CLI login is not complete\", async () => {\n    const agentsService = createAgentsServiceMock();\n    agentsService.runChannelAccountLogin.mockReturnValue({\n      ok: false,\n      stdout: \"Waiting for WhatsApp connection...\",\n      stderr: \"\",\n      code: 1,\n    });\n    const app = createApp(agentsService);\n\n    const response = await request(app)\n      .post(\"/api/channels/accounts/login\")\n      .send({ provider: \"whatsapp\", accountId: \"default\" });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.completed).toBe(false);\n    expect(response.body.stdout).toContain(\"Waiting for WhatsApp connection\");\n  });\n\n  it(\"returns 400 for unsupported channel login provider\", async () => {\n    const agentsService = createAgentsServiceMock();\n    agentsService.runChannelAccountLogin.mockImplementation(() => {\n      throw new Error(\"Channel login is currently only supported for WhatsApp\");\n    });\n    const app = createApp(agentsService);\n\n    const response = await request(app)\n      .post(\"/api/channels/accounts/login\")\n      .send({ provider: \"telegram\", accountId: \"default\" });\n\n    expect(response.status).toBe(400);\n    expect(response.body.ok).toBe(false);\n  });\n\n  it(\"returns whatsapp login status on GET /api/channels/accounts/login-status\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\n      \"/api/channels/accounts/login-status?provider=whatsapp&accountId=default\",\n    );\n\n    expect(response.status).toBe(200);\n    expect(response.body).toEqual({\n      ok: true,\n      provider: \"whatsapp\",\n      accountId: \"default\",\n      linked: true,\n    });\n    expect(agentsService.getChannelAccountLoginStatus).toHaveBeenCalledWith({\n      provider: \"whatsapp\",\n      accountId: \"default\",\n    });\n  });\n\n  it(\"deletes a configured channel account on DELETE /api/channels/accounts\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).delete(\"/api/channels/accounts\").send({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n    });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.deleteChannelAccount).toHaveBeenCalledWith({\n      provider: \"telegram\",\n      accountId: \"alerts\",\n    });\n  });\n\n  it(\"lists agents on GET /api/agents\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\"/api/agents\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.agents).toEqual([\n      { id: \"main\", name: \"Main Agent\", default: true },\n    ]);\n  });\n\n  it(\"loads a single agent on GET /api/agents/:id\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\"/api/agents/main\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.agent).toEqual({\n      id: \"main\",\n      name: \"Main Agent\",\n      default: true,\n    });\n    expect(agentsService.getAgent).toHaveBeenCalledWith(\"main\");\n  });\n\n  it(\"returns 404 on GET /api/agents/:id when missing\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\"/api/agents/missing\");\n\n    expect(response.status).toBe(404);\n    expect(response.body.ok).toBe(false);\n  });\n\n  it(\"creates an agent on POST /api/agents\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).post(\"/api/agents\").send({\n      id: \"ops\",\n      name: \"Ops Agent\",\n    });\n\n    expect(response.status).toBe(201);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.createAgent).toHaveBeenCalledWith({\n      id: \"ops\",\n      name: \"Ops Agent\",\n    });\n  });\n\n  it(\"loads workspace size on GET /api/agents/:id/workspace-size\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\"/api/agents/main/workspace-size\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.sizeBytes).toBe(3072);\n    expect(agentsService.getAgentWorkspaceSize).toHaveBeenCalledWith(\"main\");\n  });\n\n  it(\"updates an agent on PUT /api/agents/:id\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).put(\"/api/agents/main\").send({\n      name: \"Primary Agent\",\n    });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.agent).toEqual({\n      id: \"main\",\n      name: \"Primary Agent\",\n    });\n    expect(agentsService.updateAgent).toHaveBeenCalledWith(\"main\", {\n      name: \"Primary Agent\",\n    });\n  });\n\n  it(\"returns 404 on PUT /api/agents/:id when missing\", async () => {\n    const agentsService = createAgentsServiceMock();\n    agentsService.updateAgent.mockImplementation(() => {\n      throw new Error('Agent \"missing\" not found');\n    });\n    const app = createApp(agentsService);\n\n    const response = await request(app).put(\"/api/agents/missing\").send({\n      name: \"Missing\",\n    });\n\n    expect(response.status).toBe(404);\n    expect(response.body.ok).toBe(false);\n  });\n\n  it(\"returns 409 for duplicate agent ids\", async () => {\n    const agentsService = createAgentsServiceMock();\n    agentsService.createAgent.mockImplementation(() => {\n      throw new Error('Agent \"ops\" already exists');\n    });\n    const app = createApp(agentsService);\n\n    const response = await request(app).post(\"/api/agents\").send({ id: \"ops\" });\n\n    expect(response.status).toBe(409);\n    expect(response.body.ok).toBe(false);\n  });\n\n  it(\"sets default agent on POST /api/agents/:id/default\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).post(\"/api/agents/ops/default\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.setDefaultAgent).toHaveBeenCalledWith(\"ops\");\n  });\n\n  it(\"deletes an agent on DELETE /api/agents/:id\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).delete(\n      \"/api/agents/ops?keepWorkspace=false\",\n    );\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.deleteAgent).toHaveBeenCalledWith(\"ops\", {\n      keepWorkspace: false,\n    });\n  });\n\n  it(\"returns 400 on DELETE /api/agents/:id for guard rails\", async () => {\n    const agentsService = createAgentsServiceMock();\n    agentsService.deleteAgent.mockImplementation(() => {\n      throw new Error(\"The default main agent cannot be deleted\");\n    });\n    const app = createApp(agentsService);\n\n    const response = await request(app).delete(\"/api/agents/main\");\n\n    expect(response.status).toBe(400);\n    expect(response.body.ok).toBe(false);\n  });\n\n  it(\"lists bindings on GET /api/agents/:id/bindings\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).get(\"/api/agents/main/bindings\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.bindings).toEqual([\n      { agentId: \"main\", match: { channel: \"telegram\", accountId: \"default\" } },\n    ]);\n  });\n\n  it(\"adds bindings on POST /api/agents/:id/bindings\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app).post(\"/api/agents/main/bindings\").send({\n      channel: \"telegram\",\n      accountId: \"default\",\n    });\n\n    expect(response.status).toBe(201);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.addBinding).toHaveBeenCalledWith(\"main\", {\n      channel: \"telegram\",\n      accountId: \"default\",\n    });\n  });\n\n  it(\"removes bindings on DELETE /api/agents/:id/bindings\", async () => {\n    const agentsService = createAgentsServiceMock();\n    const app = createApp(agentsService);\n\n    const response = await request(app)\n      .delete(\"/api/agents/main/bindings\")\n      .send({\n        channel: \"telegram\",\n        accountId: \"default\",\n      });\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(agentsService.removeBinding).toHaveBeenCalledWith(\"main\", {\n      channel: \"telegram\",\n      accountId: \"default\",\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-auth.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst loadAuthRoutes = () => {\n  vi.resetModules();\n  const modulePath = require.resolve(\"../../lib/server/routes/auth\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nconst createLoginThrottleMock = () => ({\n  getClientKey: vi.fn(() => \"client-key\"),\n  getOrCreateLoginAttemptState: vi.fn(() => ({ attempts: 0 })),\n  evaluateLoginThrottle: vi.fn(() => ({ blocked: false, retryAfterSec: 0 })),\n  recordLoginFailure: vi.fn(() => ({ lockMs: 0, locked: false })),\n  recordLoginSuccess: vi.fn(),\n  cleanupLoginAttemptStates: vi.fn(),\n});\n\nconst createTestApp = ({ setupPassword, loginThrottle } = {}) => {\n  if (typeof setupPassword === \"string\") {\n    process.env.SETUP_PASSWORD = setupPassword;\n  } else {\n    delete process.env.SETUP_PASSWORD;\n  }\n\n  const { registerAuthRoutes } = loadAuthRoutes();\n  const app = express();\n  app.use(express.json());\n  const throttle = loginThrottle || createLoginThrottleMock();\n  registerAuthRoutes({ app, loginThrottle: throttle });\n\n  app.get(\"/api/protected\", (req, res) => res.json({ ok: true }));\n  app.get(\"/setup/protected\", (req, res) => res.json({ ok: true }));\n\n  return { app, throttle };\n};\n\ndescribe(\"server/routes/auth\", () => {\n  afterEach(() => {\n    delete process.env.SETUP_PASSWORD;\n  });\n\n  it(\"returns 503 when setup password is unset\", async () => {\n    const { app, throttle } = createTestApp({ setupPassword: \"\" });\n\n    const login = await request(app).post(\"/api/auth/login\").send({ password: \"any\" });\n    expect(login.status).toBe(503);\n    expect(login.body.ok).toBe(false);\n\n    const protectedRes = await request(app).get(\"/api/protected\");\n    expect(protectedRes.status).toBe(503);\n    expect(throttle.getClientKey).not.toHaveBeenCalled();\n  });\n\n  it(\"returns 429 and retry-after header when throttle blocks\", async () => {\n    const { app, throttle } = createTestApp({ setupPassword: \"secret\" });\n    throttle.evaluateLoginThrottle.mockReturnValue({\n      blocked: true,\n      retryAfterSec: 12,\n    });\n\n    const res = await request(app).post(\"/api/auth/login\").send({ password: \"wrong\" });\n\n    expect(res.status).toBe(429);\n    expect(res.headers[\"retry-after\"]).toBe(\"12\");\n    expect(res.body.ok).toBe(false);\n    expect(throttle.recordLoginFailure).not.toHaveBeenCalled();\n  });\n\n  it(\"returns 401 for invalid credentials and records failure\", async () => {\n    const { app, throttle } = createTestApp({ setupPassword: \"secret\" });\n\n    const res = await request(app).post(\"/api/auth/login\").send({ password: \"wrong\" });\n\n    expect(res.status).toBe(401);\n    expect(res.body).toEqual({ ok: false, error: \"Invalid credentials\" });\n    expect(throttle.recordLoginFailure).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"sets auth cookie on success and allows protected API by cookie\", async () => {\n    const { app, throttle } = createTestApp({ setupPassword: \"secret\" });\n\n    const login = await request(app).post(\"/api/auth/login\").send({ password: \"secret\" });\n\n    expect(login.status).toBe(200);\n    expect(login.body).toEqual({ ok: true });\n    expect(throttle.recordLoginSuccess).toHaveBeenCalledTimes(1);\n\n    const setCookieHeader = login.headers[\"set-cookie\"]?.[0] || \"\";\n    const tokenMatch = setCookieHeader.match(/setup_token=([^;]+)/);\n    expect(tokenMatch).toBeTruthy();\n    const cookie = setCookieHeader.split(\";\")[0];\n    const protectedRes = await request(app).get(\"/api/protected\").set(\"Cookie\", cookie);\n    expect(protectedRes.status).toBe(200);\n    expect(protectedRes.body).toEqual({ ok: true });\n  });\n\n  it(\"rejects query-string token auth\", async () => {\n    const { app } = createTestApp({ setupPassword: \"secret\" });\n    const login = await request(app).post(\"/api/auth/login\").send({ password: \"secret\" });\n    const setCookieHeader = login.headers[\"set-cookie\"]?.[0] || \"\";\n    const tokenMatch = setCookieHeader.match(/setup_token=([^;]+)/);\n    expect(tokenMatch).toBeTruthy();\n\n    const protectedRes = await request(app).get(`/api/protected?token=${tokenMatch[1]}`);\n    expect(protectedRes.status).toBe(401);\n    expect(protectedRes.body).toEqual({ error: \"Unauthorized\" });\n  });\n\n});\n"
  },
  {
    "path": "tests/server/routes-browse.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { execSync } = require(\"child_process\");\nconst express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerBrowseRoutes } = require(\"../../lib/server/routes/browse\");\n\nconst createTestRoot = () =>\n  fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-browse-test-\"));\n\nconst createApp = (kRootDir) => {\n  const app = express();\n  app.use(express.json());\n  registerBrowseRoutes({ app, fs, kRootDir });\n  return app;\n};\n\nconst runGit = (cwd, args) =>\n  execSync(`git ${args}`, {\n    cwd,\n    stdio: [\"ignore\", \"pipe\", \"pipe\"],\n  })\n    .toString()\n    .trim();\n\ndescribe(\"server/routes/browse\", () => {\n  it(\"returns browse tree rooted at configured directory\", async () => {\n    const rootDir = createTestRoot();\n    fs.mkdirSync(path.join(rootDir, \"devices\"), { recursive: true });\n    fs.mkdirSync(path.join(rootDir, \".alphaclaw\"), { recursive: true });\n    fs.writeFileSync(\n      path.join(rootDir, \"openclaw.json\"),\n      '{\"ok\":true}\\n',\n      \"utf8\",\n    );\n    fs.writeFileSync(\n      path.join(rootDir, \"devices\", \"paired.json\"),\n      \"[]\\n\",\n      \"utf8\",\n    );\n    fs.writeFileSync(\n      path.join(rootDir, \".alphaclaw\", \"hourly-git-sync.sh\"),\n      \"#!/bin/bash\\n\",\n      \"utf8\",\n    );\n    const app = createApp(rootDir);\n\n    const res = await request(app).get(\"/api/browse/tree\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(res.body.root).toEqual(\n      expect.objectContaining({\n        type: \"folder\",\n        path: \"\",\n        name: path.basename(rootDir),\n      }),\n    );\n    expect(res.body.root.children).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          type: \"folder\",\n          path: \"devices\",\n          name: \"devices\",\n        }),\n        expect.objectContaining({\n          type: \"file\",\n          path: \"openclaw.json\",\n          name: \"openclaw.json\",\n        }),\n      ]),\n    );\n    expect(\n      (res.body.root.children || []).some(\n        (entry) => entry?.name === \".alphaclaw\",\n      ),\n    ).toBe(false);\n  });\n\n  it(\"rejects path traversal on read\", async () => {\n    const rootDir = createTestRoot();\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/read\")\n      .query({ path: \"../outside.txt\" });\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain(\"Path must stay within\");\n  });\n\n  it(\"rejects path traversal on git diff\", async () => {\n    const rootDir = createTestRoot();\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/git-diff\")\n      .query({ path: \"../outside.txt\" });\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain(\"Path must stay within\");\n  });\n\n  it(\"rejects likely binary files on read\", async () => {\n    const rootDir = createTestRoot();\n    const binaryFilePath = path.join(rootDir, \"image.bin\");\n    fs.writeFileSync(binaryFilePath, Buffer.from([0x41, 0x00, 0x42]));\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/read\")\n      .query({ path: \"image.bin\" });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"Binary files are not editable\",\n    });\n  });\n\n  it(\"returns audio previews for supported audio files\", async () => {\n    const rootDir = createTestRoot();\n    const audioFilePath = path.join(rootDir, \"clip.mp3\");\n    fs.writeFileSync(audioFilePath, Buffer.from([0xff, 0xfb, 0x90, 0x64]));\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/read\")\n      .query({ path: \"clip.mp3\" });\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(res.body.kind).toBe(\"audio\");\n    expect(res.body.mimeType).toBe(\"audio/mpeg\");\n    expect(String(res.body.audioDataUrl || \"\")).toContain(\n      \"data:audio/mpeg;base64,\",\n    );\n    expect(res.body.content).toBe(\"\");\n  });\n\n  it(\"returns sqlite schema previews for sqlite files\", async () => {\n    let DatabaseSync = null;\n    try {\n      ({ DatabaseSync } = require(\"node:sqlite\"));\n    } catch {\n      // Runtime does not support node:sqlite.\n      return;\n    }\n    const rootDir = createTestRoot();\n    const dbPath = path.join(rootDir, \"test.sqlite\");\n    const database = new DatabaseSync(dbPath);\n    database.exec(\n      `\n        CREATE TABLE users (\n          id INTEGER PRIMARY KEY,\n          name TEXT NOT NULL\n        );\n        INSERT INTO users (name) VALUES ('Ada');\n      `,\n    );\n    database.close();\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/read\")\n      .query({ path: \"test.sqlite\" });\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(res.body.kind).toBe(\"sqlite\");\n    expect(res.body.sqliteSummary).toBeTruthy();\n    expect(Array.isArray(res.body.sqliteSummary.objects)).toBe(true);\n    expect(\n      res.body.sqliteSummary.objects.some((entry) => entry?.name === \"users\"),\n    ).toBe(true);\n    expect(res.body.content).toBe(\"\");\n  });\n\n  it(\"returns sqlite table rows for selected table\", async () => {\n    let DatabaseSync = null;\n    try {\n      ({ DatabaseSync } = require(\"node:sqlite\"));\n    } catch {\n      return;\n    }\n    const rootDir = createTestRoot();\n    const dbPath = path.join(rootDir, \"rows.sqlite\");\n    const database = new DatabaseSync(dbPath);\n    database.exec(\n      `\n        CREATE TABLE users (\n          id INTEGER PRIMARY KEY,\n          name TEXT NOT NULL\n        );\n        INSERT INTO users (name) VALUES ('Ada'), ('Grace');\n      `,\n    );\n    database.close();\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/sqlite-table\")\n      .query({ path: \"rows.sqlite\", table: \"users\", limit: \"1\", offset: \"1\" });\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(res.body.table).toBe(\"users\");\n    expect(Array.isArray(res.body.columns)).toBe(true);\n    expect(Array.isArray(res.body.rows)).toBe(true);\n    expect(res.body.rows.length).toBe(1);\n    expect(res.body.totalRows).toBe(2);\n    expect(res.body.limit).toBe(1);\n    expect(res.body.offset).toBe(1);\n  });\n\n  it(\"downloads files as attachments\", async () => {\n    const rootDir = createTestRoot();\n    const filePath = path.join(rootDir, \"download-me.txt\");\n    fs.writeFileSync(filePath, \"file payload\\n\", \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app)\n      .get(\"/api/browse/download\")\n      .query({ path: \"download-me.txt\" });\n\n    expect(res.status).toBe(200);\n    expect(String(res.headers[\"content-disposition\"] || \"\")).toContain(\n      'attachment; filename=\"download-me.txt\"',\n    );\n    expect(res.text).toBe(\"file payload\\n\");\n  });\n\n  it(\"writes file content and returns write result\", async () => {\n    const rootDir = createTestRoot();\n    const filePath = path.join(rootDir, \"openclaw.json\");\n    fs.writeFileSync(filePath, '{\"before\":true}\\n', \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).put(\"/api/browse/write\").send({\n      path: \"openclaw.json\",\n      content: '{\"after\":true}\\n',\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(res.body.path).toBe(\"openclaw.json\");\n    expect(fs.readFileSync(filePath, \"utf8\")).toBe('{\"after\":true}\\n');\n  });\n\n  it(\"rejects writes to locked bootstrap files\", async () => {\n    const rootDir = createTestRoot();\n    const lockedPath = path.join(rootDir, \"hooks\", \"bootstrap\", \"AGENTS.md\");\n    fs.mkdirSync(path.dirname(lockedPath), { recursive: true });\n    fs.writeFileSync(lockedPath, \"before\\n\", \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).put(\"/api/browse/write\").send({\n      path: \"hooks/bootstrap/AGENTS.md\",\n      content: \"after\\n\",\n    });\n\n    expect(res.status).toBe(403);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"This file is managed by AlphaClaw and cannot be edited.\",\n    });\n    expect(fs.readFileSync(lockedPath, \"utf8\")).toBe(\"before\\n\");\n  });\n\n  it(\"rejects writes to locked bootstrap files with workspace prefix\", async () => {\n    const rootDir = createTestRoot();\n    const lockedPath = path.join(\n      rootDir,\n      \"workspace\",\n      \"hooks\",\n      \"bootstrap\",\n      \"AGENTS.md\",\n    );\n    fs.mkdirSync(path.dirname(lockedPath), { recursive: true });\n    fs.writeFileSync(lockedPath, \"before\\n\", \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).put(\"/api/browse/write\").send({\n      path: \"workspace/hooks/bootstrap/AGENTS.md\",\n      content: \"after\\n\",\n    });\n\n    expect(res.status).toBe(403);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"This file is managed by AlphaClaw and cannot be edited.\",\n    });\n    expect(fs.readFileSync(lockedPath, \"utf8\")).toBe(\"before\\n\");\n  });\n\n  it(\"rejects writes to locked managed files under .alphaclaw\", async () => {\n    const rootDir = createTestRoot();\n    const lockedPath = path.join(rootDir, \".alphaclaw\", \"hourly-git-sync.sh\");\n    fs.mkdirSync(path.dirname(lockedPath), { recursive: true });\n    fs.writeFileSync(lockedPath, \"before\\n\", \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).put(\"/api/browse/write\").send({\n      path: \".alphaclaw/hourly-git-sync.sh\",\n      content: \"after\\n\",\n    });\n\n    expect(res.status).toBe(403);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"This file is managed by AlphaClaw and cannot be edited.\",\n    });\n    expect(fs.readFileSync(lockedPath, \"utf8\")).toBe(\"before\\n\");\n  });\n\n  it(\"deletes regular files\", async () => {\n    const rootDir = createTestRoot();\n    const filePath = path.join(rootDir, \"deleteme.txt\");\n    fs.writeFileSync(filePath, \"delete me\\n\", \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).delete(\"/api/browse/delete\").send({\n      path: \"deleteme.txt\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      path: \"deleteme.txt\",\n      type: \"file\",\n    });\n    expect(fs.existsSync(filePath)).toBe(false);\n  });\n\n  it(\"rejects deleting protected files\", async () => {\n    const rootDir = createTestRoot();\n    const filePath = path.join(rootDir, \"openclaw.json\");\n    fs.writeFileSync(filePath, '{\"ok\":true}\\n', \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).delete(\"/api/browse/delete\").send({\n      path: \"openclaw.json\",\n    });\n\n    expect(res.status).toBe(403);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"This path cannot be deleted from the explorer.\",\n    });\n    expect(fs.existsSync(filePath)).toBe(true);\n  });\n\n  it(\"deletes directories recursively\", async () => {\n    const rootDir = createTestRoot();\n    const dirPath = path.join(rootDir, \"delivery-queue\");\n    fs.mkdirSync(dirPath, { recursive: true });\n    fs.writeFileSync(path.join(dirPath, \"child.txt\"), \"hi\", \"utf8\");\n    const app = createApp(rootDir);\n\n    const res = await request(app).delete(\"/api/browse/delete\").send({\n      path: \"delivery-queue\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      path: \"delivery-queue\",\n      type: \"folder\",\n    });\n    expect(fs.existsSync(dirPath)).toBe(false);\n  });\n\n  it(\"restores a tracked deleted file from git\", async () => {\n    const rootDir = createTestRoot();\n    const app = createApp(rootDir);\n    const filePath = path.join(rootDir, \"restore-me.json\");\n    fs.writeFileSync(filePath, '{\"restore\":true}\\n', \"utf8\");\n\n    runGit(rootDir, \"init\");\n    runGit(rootDir, \"config user.email test@example.com\");\n    runGit(rootDir, \"config user.name Test User\");\n    runGit(rootDir, \"add restore-me.json\");\n    runGit(rootDir, \"commit -m \\\"test commit\\\"\");\n\n    fs.rmSync(filePath, { force: true });\n    expect(fs.existsSync(filePath)).toBe(false);\n\n    const res = await request(app).post(\"/api/browse/restore\").send({\n      path: \"restore-me.json\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      path: \"restore-me.json\",\n      restored: true,\n    });\n    expect(fs.existsSync(filePath)).toBe(true);\n    expect(fs.readFileSync(filePath, \"utf8\")).toBe('{\"restore\":true}\\n');\n  });\n\n  it(\"returns non-repo git summary outside git repositories\", async () => {\n    const rootDir = createTestRoot();\n    const app = createApp(rootDir);\n\n    const res = await request(app).get(\"/api/browse/git-summary\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        ok: true,\n        isRepo: false,\n        repoPath: path.resolve(rootDir),\n      }),\n    );\n  });\n\n  it(\"rejects git sync outside git repositories\", async () => {\n    const rootDir = createTestRoot();\n    const app = createApp(rootDir);\n\n    const res = await request(app).post(\"/api/browse/git-sync\").send({\n      message: \"sync changes\",\n    });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"No git repo at this root\",\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-cron.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerCronRoutes } = require(\"../../lib/server/routes/cron\");\n\nconst createDeps = () => ({\n  requireAuth: (req, res, next) => next(),\n  cronService: {\n    listJobs: vi.fn(() => ({\n      storePath: \"/tmp/openclaw/cron/jobs.json\",\n      jobs: [{ id: \"job-a\", name: \"Job A\", enabled: true, state: {} }],\n    })),\n    getStatus: vi.fn(() => ({\n      enabled: true,\n      jobs: 1,\n      enabledJobs: 1,\n      nextWakeAtMs: 1773291600000,\n    })),\n    getJobRuns: vi.fn(() => ({\n      entries: [{ ts: 1773291600000, status: \"ok\", jobId: \"job-a\", action: \"finished\" }],\n      total: 1,\n      offset: 0,\n      limit: 20,\n      hasMore: false,\n      nextOffset: null,\n    })),\n    runJobNow: vi.fn(async () => ({ parsed: { ok: true, ran: true } })),\n    setJobEnabled: vi.fn(async () => ({ parsed: { ok: true } })),\n    updateJobPrompt: vi.fn(async () => ({ parsed: { ok: true } })),\n    updateJobRouting: vi.fn(async () => ({ parsed: { ok: true } })),\n    getJobUsage: vi.fn(() => ({\n      totals: { totalTokens: 1000, totalCost: 0.01, runCount: 2 },\n      modelBreakdown: [],\n    })),\n    getJobRunTrends: vi.fn(() => ({\n      sinceMs: 0,\n      nowMs: 1773291600000,\n      bucket: \"day\",\n      points: [\n        {\n          startMs: 1773205200000,\n          endMs: 1773291600000,\n          ok: 1,\n          error: 0,\n          skipped: 0,\n          totalRuns: 1,\n          totalTokens: 500,\n          totalCost: 0.005,\n          costSamples: 1,\n          totalDurationMs: 5000,\n          durationSamples: 1,\n          avgDurationMs: 5000,\n        },\n      ],\n    })),\n    getBulkJobUsage: vi.fn(() => ({\n      sinceMs: 0,\n      byJobId: {\n        \"job-a\": {\n          totalTokens: 1000,\n          totalCost: 0.01,\n          runCount: 2,\n          avgTokensPerRun: 500,\n        },\n      },\n    })),\n    getBulkJobRuns: vi.fn(() => ({\n      sinceMs: 0,\n      byJobId: {\n        \"job-a\": {\n          entries: [{ ts: 1773291600000, status: \"ok\", jobId: \"job-a\" }],\n          total: 1,\n        },\n      },\n    })),\n  },\n});\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  registerCronRoutes({\n    app,\n    ...deps,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/cron\", () => {\n  it(\"returns job list\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n    const response = await request(app).get(\"/api/cron/jobs\");\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.jobs).toHaveLength(1);\n    expect(deps.cronService.listJobs).toHaveBeenCalledWith(\n      expect.objectContaining({ sortBy: \"nextRunAtMs\", sortDir: \"asc\" }),\n    );\n  });\n\n  it(\"returns run history page\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n    const response = await request(app).get(\"/api/cron/jobs/job-a/runs?limit=20&offset=0\");\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(response.body.runs.total).toBe(1);\n    expect(deps.cronService.getJobRuns).toHaveBeenCalledWith(\n      expect.objectContaining({ jobId: \"job-a\", limit: 20, offset: 0 }),\n    );\n  });\n\n  it(\"triggers run and prompt updates\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n    const runResponse = await request(app).post(\"/api/cron/jobs/job-a/run\");\n    expect(runResponse.status).toBe(200);\n    expect(deps.cronService.runJobNow).toHaveBeenCalledWith(\"job-a\");\n\n    const promptResponse = await request(app)\n      .put(\"/api/cron/jobs/job-a/prompt\")\n      .send({ message: \"new prompt\" });\n    expect(promptResponse.status).toBe(200);\n    expect(deps.cronService.updateJobPrompt).toHaveBeenCalledWith({\n      jobId: \"job-a\",\n      message: \"new prompt\",\n    });\n\n    const routingResponse = await request(app)\n      .put(\"/api/cron/jobs/job-a/routing\")\n      .send({ sessionTarget: \"isolated\", wakeMode: \"next-heartbeat\", deliveryMode: \"announce\" });\n    expect(routingResponse.status).toBe(200);\n    expect(deps.cronService.updateJobRouting).toHaveBeenCalledWith({\n      jobId: \"job-a\",\n      sessionTarget: \"isolated\",\n      wakeMode: \"next-heartbeat\",\n      deliveryMode: \"announce\",\n      deliveryChannel: \"\",\n      deliveryTo: \"\",\n    });\n  });\n\n  it(\"returns usage and toggles enabled state\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n    const usageResponse = await request(app).get(\"/api/cron/jobs/job-a/usage?days=7\");\n    expect(usageResponse.status).toBe(200);\n    expect(deps.cronService.getJobUsage).toHaveBeenCalledWith(\n      expect.objectContaining({ jobId: \"job-a\" }),\n    );\n    const trendsResponse = await request(app).get(\"/api/cron/jobs/job-a/trends?range=7d\");\n    expect(trendsResponse.status).toBe(200);\n    expect(trendsResponse.body.ok).toBe(true);\n    expect(Array.isArray(trendsResponse.body.trends.points)).toBe(true);\n    expect(deps.cronService.getJobRunTrends).toHaveBeenCalledWith(\n      expect.objectContaining({ jobId: \"job-a\", range: \"7d\" }),\n    );\n\n    const enableResponse = await request(app).post(\"/api/cron/jobs/job-a/enable\");\n    expect(enableResponse.status).toBe(200);\n    expect(deps.cronService.setJobEnabled).toHaveBeenCalledWith({\n      jobId: \"job-a\",\n      enabled: true,\n    });\n  });\n\n  it(\"returns bulk usage and bulk runs\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const bulkUsageResponse = await request(app).get(\"/api/cron/usage/bulk?days=30\");\n    expect(bulkUsageResponse.status).toBe(200);\n    expect(bulkUsageResponse.body.ok).toBe(true);\n    expect(bulkUsageResponse.body.usage.byJobId[\"job-a\"].avgTokensPerRun).toBe(500);\n    expect(deps.cronService.getBulkJobUsage).toHaveBeenCalledWith(\n      expect.objectContaining({ sinceMs: expect.any(Number) }),\n    );\n\n    const bulkRunsResponse = await request(app).get(\n      \"/api/cron/runs/bulk?sinceMs=12345&limitPerJob=40&sortDir=desc\",\n    );\n    expect(bulkRunsResponse.status).toBe(200);\n    expect(bulkRunsResponse.body.ok).toBe(true);\n    expect(bulkRunsResponse.body.runs.byJobId[\"job-a\"].entries).toHaveLength(1);\n    expect(deps.cronService.getBulkJobRuns).toHaveBeenCalledWith(\n      expect.objectContaining({ sinceMs: 12345, limitPerJob: 40, sortDir: \"desc\" }),\n    );\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-doctor.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerDoctorRoutes } = require(\"../../lib/server/routes/doctor\");\n\nconst createDoctorService = () => ({\n  buildStatus: vi.fn(() => ({\n    runInProgress: false,\n    stale: true,\n    needsInitialRun: true,\n    latestRun: null,\n  })),\n  runDoctor: vi.fn(() => ({ ok: true, runId: 42, status: { runInProgress: true } })),\n  importDoctorResult: vi.fn(({ rawOutput }) => ({\n    ok: true,\n    runId: 43,\n    run: { id: 43, summary: rawOutput ? \"Imported\" : \"\" },\n  })),\n  listDoctorRuns: vi.fn(() => [{ id: 42, status: \"running\", cardCount: 0 }]),\n  listDoctorCards: vi.fn(({ runId }) =>\n    String(runId || \"all\") === \"all\"\n      ? [\n          { id: 7, runId: 42, title: \"Fix drift\", status: \"open\" },\n          { id: 8, runId: 41, title: \"Cleanup docs\", status: \"dismissed\" },\n        ]\n      : [{ id: 7, runId: 42, title: \"Fix drift\", status: \"open\" }]),\n  getDoctorRun: vi.fn((id) =>\n    String(id) === \"42\"\n      ? { id: 42, status: \"completed\", cardCount: 1 }\n      : null),\n  getDoctorCardsByRunId: vi.fn((id) =>\n    String(id) === \"42\"\n      ? [{ id: 7, runId: 42, title: \"Fix drift\", status: \"open\" }]\n      : []),\n  setCardStatus: vi.fn(({ cardId, status }) => ({\n    id: Number(cardId),\n    status,\n  })),\n  requestCardFix: vi.fn(async ({ cardId, sessionId, replyChannel, replyTo }) => ({\n    ok: true,\n    stdout: \"sent\",\n    card: { id: Number(cardId), sessionId, replyChannel, replyTo },\n  })),\n});\n\nconst createApp = (doctorService) => {\n  const app = express();\n  app.use(express.json());\n  registerDoctorRoutes({\n    app,\n    requireAuth: (req, res, next) => next(),\n    doctorService,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/doctor\", () => {\n  it(\"returns Doctor status\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const res = await request(app).get(\"/api/doctor/status\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(doctorService.buildStatus).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"starts a Doctor run\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const res = await request(app).post(\"/api/doctor/run\").send({});\n\n    expect(res.status).toBe(202);\n    expect(res.body).toEqual({\n      ok: true,\n      runId: 42,\n      status: { runInProgress: true },\n    });\n  });\n\n  it(\"returns 200 when a Doctor run reuses previous findings\", async () => {\n    const doctorService = createDoctorService();\n    doctorService.runDoctor.mockReturnValue({\n      ok: true,\n      runId: 44,\n      reusedPreviousRun: true,\n      sourceRunId: 42,\n      status: { runInProgress: false },\n    });\n    const app = createApp(doctorService);\n\n    const res = await request(app).post(\"/api/doctor/run\").send({});\n\n    expect(res.status).toBe(200);\n    expect(res.body.reusedPreviousRun).toBe(true);\n    expect(res.body.sourceRunId).toBe(42);\n  });\n\n  it(\"returns 409 when a Doctor run is already in progress\", async () => {\n    const doctorService = createDoctorService();\n    doctorService.runDoctor.mockReturnValue({\n      ok: false,\n      alreadyRunning: true,\n      runId: 42,\n      status: { runInProgress: true },\n      error: \"Doctor run already in progress\",\n    });\n    const app = createApp(doctorService);\n\n    const res = await request(app).post(\"/api/doctor/run\").send({});\n\n    expect(res.status).toBe(409);\n    expect(res.body.error).toBe(\"Doctor run already in progress\");\n  });\n\n  it(\"imports a Doctor result without rerunning analysis\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const res = await request(app).post(\"/api/doctor/import\").send({\n      rawOutput: '{\"summary\":\"Imported\",\"cards\":[]}',\n    });\n\n    expect(res.status).toBe(201);\n    expect(doctorService.importDoctorResult).toHaveBeenCalledWith({\n      rawOutput: '{\"summary\":\"Imported\",\"cards\":[]}',\n    });\n    expect(res.body.runId).toBe(43);\n  });\n\n  it(\"returns run cards for an existing run\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const res = await request(app).get(\"/api/doctor/runs/42/cards\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.cards).toEqual([\n      { id: 7, runId: 42, title: \"Fix drift\", status: \"open\" },\n    ]);\n  });\n\n  it(\"returns aggregated Doctor cards with optional run filter\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const allCardsResponse = await request(app).get(\"/api/doctor/cards\");\n    const runCardsResponse = await request(app).get(\"/api/doctor/cards?runId=42\");\n\n    expect(allCardsResponse.status).toBe(200);\n    expect(allCardsResponse.body.cards).toHaveLength(2);\n    expect(doctorService.listDoctorCards).toHaveBeenNthCalledWith(1, { runId: \"all\" });\n    expect(runCardsResponse.status).toBe(200);\n    expect(doctorService.listDoctorCards).toHaveBeenNthCalledWith(2, { runId: \"42\" });\n  });\n\n  it(\"updates Doctor card status\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const res = await request(app).post(\"/api/doctor/cards/7/status\").send({\n      status: \"fixed\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body.card).toEqual({ id: 7, status: \"fixed\" });\n  });\n\n  it(\"sends a Doctor fix request with delivery fields\", async () => {\n    const doctorService = createDoctorService();\n    const app = createApp(doctorService);\n\n    const res = await request(app).post(\"/api/doctor/findings/7/fix\").send({\n      sessionId: \"session-123\",\n      replyChannel: \"telegram\",\n      replyTo: \"1050\",\n      prompt: \"Use the safer prompt\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(doctorService.requestCardFix).toHaveBeenCalledWith({\n      cardId: \"7\",\n      sessionId: \"session-123\",\n      replyChannel: \"telegram\",\n      replyTo: \"1050\",\n      prompt: \"Use the safer prompt\",\n    });\n    expect(res.body.ok).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-models.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst {\n  createModelCatalogCache,\n  kModelCatalogBootstrapSource,\n  kModelCatalogLoadTimeoutMs,\n} = require(\"../../lib/server/model-catalog-cache\");\nconst { registerModelRoutes } = require(\"../../lib/server/routes/models\");\nconst { kFallbackOnboardingModels } = require(\"../../lib/server/constants\");\n\nconst flushPromises = async () => {\n  await Promise.resolve();\n  await Promise.resolve();\n  await Promise.resolve();\n};\n\nconst createModelDeps = () => {\n  const deps = {\n    shellCmd: vi.fn(),\n    gatewayEnv: vi.fn(() => ({ OPENCLAW_GATEWAY_TOKEN: \"token\" })),\n    parseJsonFromNoisyOutput: vi.fn(() => ({})),\n    normalizeOnboardingModels: vi.fn(() => []),\n    readOpenclawVersion: vi.fn(() => \"2026.4.15\"),\n    isOnboarded: vi.fn(() => true),\n    readEnvFile: vi.fn(() => []),\n    writeEnvFile: vi.fn(),\n    reloadEnv: vi.fn(() => true),\n    authProfiles: {\n      getModelConfig: vi.fn(() => ({ primary: null, configuredModels: {} })),\n      listProfiles: vi.fn(() => []),\n      loadAuthStore: vi.fn(() => ({ profiles: {}, order: {} })),\n      setModelConfig: vi.fn(),\n      upsertProfile: vi.fn(),\n      getEnvVarForApiKeyProvider: vi.fn((provider) =>\n        provider === \"openai\" ? \"OPENAI_API_KEY\" : \"\",\n      ),\n      listApiKeyProviders: vi.fn(() => [\"openai\"]),\n      getDefaultProfileIdForApiKeyProvider: vi.fn((provider) =>\n        provider ? `${provider}:default` : \"\",\n      ),\n      upsertApiKeyProfileForEnvVar: vi.fn(),\n      removeApiKeyProfileForEnvVar: vi.fn(),\n      setAuthOrder: vi.fn(),\n      syncConfigAuthReferencesForAgent: vi.fn(),\n      removeProfile: vi.fn(),\n    },\n  };\n  return deps;\n};\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  const tempRoot = fs.mkdtempSync(\n    path.join(os.tmpdir(), \"alphaclaw-routes-models-\"),\n  );\n  const modelCatalogCache = createModelCatalogCache({\n    cachePath: path.join(tempRoot, \"cache\", \"model-catalog.json\"),\n    shellCmd: deps.shellCmd,\n    gatewayEnv: deps.gatewayEnv,\n    parseJsonFromNoisyOutput: deps.parseJsonFromNoisyOutput,\n    normalizeOnboardingModels: deps.normalizeOnboardingModels,\n    readOpenclawVersion: deps.readOpenclawVersion,\n    shouldStartDynamicRefresh: deps.isOnboarded,\n  });\n  registerModelRoutes({\n    app,\n    ...deps,\n    modelCatalogCache,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/models\", () => {\n  it(\"bootstraps with the bundled catalog, then returns normalized models from openclaw output\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"noise\");\n    deps.parseJsonFromNoisyOutput.mockReturnValue({\n      models: [{ key: \"openai/gpt-5.1-codex\", name: \"GPT\" }],\n    });\n    deps.normalizeOnboardingModels.mockReturnValue([\n      { key: \"openai/gpt-5.1-codex\", provider: \"openai\", label: \"GPT\" },\n    ]);\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n    expect(deps.shellCmd).toHaveBeenCalledWith(\"openclaw models list --all --json\", {\n      env: { OPENCLAW_GATEWAY_TOKEN: \"token\" },\n      timeout: kModelCatalogLoadTimeoutMs,\n    });\n\n    await flushPromises();\n\n    const refreshed = await request(app).get(\"/api/models\");\n\n    expect(refreshed.status).toBe(200);\n    expect(refreshed.body).toEqual(\n      expect.objectContaining({\n        ok: true,\n        source: \"openclaw\",\n        stale: false,\n        refreshing: false,\n        fetchedAt: expect.any(Number),\n        models: [{ key: \"openai/gpt-5.1-codex\", provider: \"openai\", label: \"GPT\" }],\n      }),\n    );\n  });\n\n  it(\"serves the bundled catalog while a dynamic refresh resolves empty\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"{}\");\n    deps.parseJsonFromNoisyOutput.mockReturnValue({ models: [] });\n    deps.normalizeOnboardingModels.mockReturnValue([]);\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n  });\n\n  it(\"serves the bundled catalog when the dynamic refresh command throws\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockRejectedValue(new Error(\"boom\"));\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: true,\n      models: kFallbackOnboardingModels,\n    });\n  });\n\n  it(\"serves the bundled catalog without launching openclaw before onboarding\", async () => {\n    const deps = createModelDeps();\n    deps.isOnboarded.mockReturnValue(false);\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      source: kModelCatalogBootstrapSource,\n      fetchedAt: null,\n      stale: true,\n      refreshing: false,\n      models: kFallbackOnboardingModels,\n    });\n    expect(deps.shellCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"returns model status payload on GET /api/models/status\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"{}\");\n    deps.parseJsonFromNoisyOutput.mockReturnValue({\n      resolvedDefault: \"openai/gpt-5.1-codex\",\n      fallbacks: [\"anthropic/claude-opus-4-6\"],\n      imageModel: \"google/gemini-3.1-pro-preview\",\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models/status\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      modelKey: \"openai/gpt-5.1-codex\",\n      fallbacks: [\"anthropic/claude-opus-4-6\"],\n      imageModel: \"google/gemini-3.1-pro-preview\",\n    });\n  });\n\n  it(\"recovers model status payload from failed command output\", async () => {\n    const deps = createModelDeps();\n    const err = new Error(\"plugin load failed\");\n    err.stdout =\n      'prefix\\n{\"resolvedDefault\":\"openai/gpt-5.1-codex\",\"fallbacks\":[\"anthropic/claude-opus-4-6\"],\"imageModel\":\"google/gemini-3.1-pro-preview\"}\\n';\n    err.stderr =\n      '[plugins] google failed to load from /app/node_modules/openclaw/dist/extensions/google/index.js';\n    deps.shellCmd.mockRejectedValue(err);\n    deps.parseJsonFromNoisyOutput.mockImplementation((raw) =>\n      String(raw).includes(\"resolvedDefault\")\n        ? {\n            resolvedDefault: \"openai/gpt-5.1-codex\",\n            fallbacks: [\"anthropic/claude-opus-4-6\"],\n            imageModel: \"google/gemini-3.1-pro-preview\",\n          }\n        : null,\n    );\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models/status\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      modelKey: \"openai/gpt-5.1-codex\",\n      fallbacks: [\"anthropic/claude-opus-4-6\"],\n      imageModel: \"google/gemini-3.1-pro-preview\",\n    });\n  });\n\n  it(\"validates modelKey on POST /api/models/set\", async () => {\n    const deps = createModelDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/models/set\").send({ modelKey: \"invalid\" });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({ ok: false, error: \"Missing modelKey\" });\n    expect(deps.shellCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"sets model when modelKey is valid\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"\");\n    const app = createApp(deps);\n\n    const res = await request(app)\n      .post(\"/api/models/set\")\n      .send({ modelKey: \"openai/gpt-5.1-codex\" });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n    expect(deps.shellCmd).toHaveBeenCalledWith(\n      'openclaw models set \"openai/gpt-5.1-codex\"',\n      {\n        env: { OPENCLAW_GATEWAY_TOKEN: \"token\" },\n        timeout: 30000,\n      },\n    );\n  });\n\n  it(\"re-syncs auth references on PUT /api/models/config\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"\");\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/models/config\").send({\n      primary: \"openai-codex/gpt-5.3-codex\",\n      configuredModels: {\n        \"openai-codex/gpt-5.3-codex\": {},\n      },\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n    expect(deps.authProfiles.setModelConfig).toHaveBeenCalledWith({\n      primary: \"openai-codex/gpt-5.3-codex\",\n      configuredModels: {\n        \"openai-codex/gpt-5.3-codex\": {},\n      },\n    });\n    expect(deps.authProfiles.syncConfigAuthReferencesForAgent).toHaveBeenCalledWith(\n      undefined,\n    );\n    expect(deps.shellCmd).toHaveBeenCalledWith(\n      'alphaclaw git-sync -m \"models: update config\" -f \"openclaw.json\"',\n      { timeout: 30000 },\n    );\n  });\n\n  it(\"prefills default api-key auth profiles from env vars on GET /api/models/config\", async () => {\n    const deps = createModelDeps();\n    deps.readEnvFile.mockReturnValue([{ key: \"GEMINI_API_KEY\", value: \"AI-live-123\" }]);\n    deps.authProfiles.listApiKeyProviders.mockReturnValue([\"google\"]);\n    deps.authProfiles.getEnvVarForApiKeyProvider.mockImplementation((provider) =>\n      provider === \"google\" ? \"GEMINI_API_KEY\" : \"\",\n    );\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/models/config\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.authProfiles).toEqual([\n      {\n        id: \"google:default\",\n        type: \"api_key\",\n        provider: \"google\",\n        key: \"AI-live-123\",\n      },\n    ]);\n  });\n\n  it(\"writes API-key model auth changes back to env vars\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"\");\n    deps.readEnvFile.mockReturnValue([{ key: \"OPENAI_API_KEY\", value: \"\" }]);\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/models/config\").send({\n      profiles: [\n        {\n          id: \"openai:default\",\n          type: \"api_key\",\n          provider: \"openai\",\n          key: \"sk-live-123\",\n        },\n      ],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"sk-live-123\" },\n    ]);\n    expect(deps.reloadEnv).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"removes API-key env vars when profile key is cleared\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"\");\n    deps.readEnvFile.mockReturnValue([{ key: \"OPENAI_API_KEY\", value: \"sk-live-123\" }]);\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/models/config\").send({\n      profiles: [\n        {\n          id: \"openai:default\",\n          type: \"api_key\",\n          provider: \"openai\",\n          key: \"\",\n        },\n      ],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.writeEnvFile).toHaveBeenCalledWith([]);\n    expect(deps.reloadEnv).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"writes newly supported provider API keys back to env vars\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"\");\n    deps.authProfiles.getEnvVarForApiKeyProvider.mockImplementation((provider) =>\n      provider === \"zai\" ? \"ZAI_API_KEY\" : \"\",\n    );\n    deps.readEnvFile.mockReturnValue([{ key: \"ZAI_API_KEY\", value: \"\" }]);\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/models/config\").send({\n      profiles: [\n        {\n          id: \"zai:default\",\n          type: \"api_key\",\n          provider: \"zai\",\n          key: \"zai-live-123\",\n        },\n      ],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.writeEnvFile).toHaveBeenCalledWith([\n      { key: \"ZAI_API_KEY\", value: \"zai-live-123\" },\n    ]);\n    expect(deps.reloadEnv).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"syncs env-backed api-key profiles into auth storage on PUT /api/models/config\", async () => {\n    const deps = createModelDeps();\n    deps.shellCmd.mockResolvedValue(\"\");\n    deps.readEnvFile.mockReturnValue([{ key: \"GEMINI_API_KEY\", value: \"AI-live-123\" }]);\n    deps.authProfiles.listApiKeyProviders.mockReturnValue([\"google\"]);\n    deps.authProfiles.getEnvVarForApiKeyProvider.mockImplementation((provider) =>\n      provider === \"google\" ? \"GEMINI_API_KEY\" : \"\",\n    );\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/models/config\").send({\n      primary: \"google/gemini-3.1-pro-preview\",\n      configuredModels: {\n        \"google/gemini-3.1-pro-preview\": {},\n      },\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).toHaveBeenCalledWith(\n      \"google\",\n      \"AI-live-123\",\n      undefined,\n    );\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-nodes.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerNodeRoutes } = require(\"../../lib/server/routes/nodes\");\n\nconst kNodeTimeoutEnvNames = [\n  \"ALPHACLAW_NODE_ROUTE_TIMEOUT_MS\",\n  \"ALPHACLAW_NODES_STATUS_TIMEOUT_MS\",\n  \"ALPHACLAW_NODES_PENDING_TIMEOUT_MS\",\n];\n\nconst withNodeTimeoutEnv = async (values, fn) => {\n  const previous = Object.fromEntries(\n    kNodeTimeoutEnvNames.map((name) => [name, process.env[name]]),\n  );\n  for (const name of kNodeTimeoutEnvNames) {\n    if (values[name] === undefined) {\n      delete process.env[name];\n    } else {\n      process.env[name] = values[name];\n    }\n  }\n  try {\n    return await fn();\n  } finally {\n    for (const [name, value] of Object.entries(previous)) {\n      if (value === undefined) {\n        delete process.env[name];\n      } else {\n        process.env[name] = value;\n      }\n    }\n  }\n};\n\nconst createApp = ({ clawCmd, fsModule } = {}) => {\n  const app = express();\n  app.use(express.json());\n  registerNodeRoutes({\n    app,\n    clawCmd,\n    openclawDir: \"/tmp/openclaw\",\n    gatewayToken: \"\",\n    fsModule:\n      fsModule || {\n        readFileSync: vi.fn(() => \"{}\"),\n        writeFileSync: vi.fn(),\n        mkdirSync: vi.fn(),\n      },\n  });\n  return app;\n};\n\ndescribe(\"server/routes/nodes\", () => {\n  it(\"uses default CLI timeouts for status and pending reads\", async () => {\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"nodes status --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({\n            nodes: [{ id: \"node-1\", paired: true }],\n            pending: [],\n          }),\n          stderr: \"\",\n        };\n      }\n      if (cmd === \"nodes pending --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({\n            pending: [{ requestId: \"node-2\" }],\n          }),\n          stderr: \"\",\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const app = createApp({ clawCmd });\n\n    const res = await request(app).get(\"/api/nodes\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      nodes: [{ id: \"node-1\", paired: true }],\n      pending: [{ requestId: \"node-2\", id: \"node-2\", nodeId: \"node-2\", paired: false }],\n    });\n    expect(clawCmd).toHaveBeenNthCalledWith(1, \"nodes status --json\", {\n      quiet: true,\n      timeoutMs: 12000,\n    });\n    expect(clawCmd).toHaveBeenNthCalledWith(2, \"nodes pending --json\", {\n      quiet: true,\n      timeoutMs: 12000,\n    });\n  });\n\n  it(\"supports env overrides for nodes CLI timeouts\", async () => {\n    await withNodeTimeoutEnv(\n      {\n        ALPHACLAW_NODE_ROUTE_TIMEOUT_MS: \"18000\",\n        ALPHACLAW_NODES_STATUS_TIMEOUT_MS: \"15000\",\n        ALPHACLAW_NODES_PENDING_TIMEOUT_MS: \"16000\",\n      },\n      async () => {\n        const clawCmd = vi.fn(async (cmd) => {\n          if (cmd === \"nodes status --json\") {\n            return {\n              ok: true,\n              stdout: JSON.stringify({ nodes: [], pending: [] }),\n              stderr: \"\",\n            };\n          }\n          if (cmd === \"nodes pending --json\") {\n            return {\n              ok: true,\n              stdout: JSON.stringify({ pending: [] }),\n              stderr: \"\",\n            };\n          }\n          return { ok: true, stdout: \"\", stderr: \"\" };\n        });\n        const app = createApp({ clawCmd });\n\n        const nodesRes = await request(app).get(\"/api/nodes\");\n        const routeRes = await request(app).post(\"/api/nodes/node-1/route\");\n\n        expect(nodesRes.status).toBe(200);\n        expect(routeRes.status).toBe(200);\n        expect(clawCmd).toHaveBeenNthCalledWith(1, \"nodes status --json\", {\n          quiet: true,\n          timeoutMs: 15000,\n        });\n        expect(clawCmd).toHaveBeenNthCalledWith(2, \"nodes pending --json\", {\n          quiet: true,\n          timeoutMs: 16000,\n        });\n        for (const call of clawCmd.mock.calls.slice(2)) {\n          expect(call[1]).toEqual({ quiet: true, timeoutMs: 18000 });\n        }\n      },\n    );\n  });\n\n  it(\"ignores invalid nodes CLI timeout env overrides\", async () => {\n    await withNodeTimeoutEnv(\n      {\n        ALPHACLAW_NODE_ROUTE_TIMEOUT_MS: \"0\",\n        ALPHACLAW_NODES_STATUS_TIMEOUT_MS: \"bogus\",\n        ALPHACLAW_NODES_PENDING_TIMEOUT_MS: \"-1\",\n      },\n      async () => {\n        const clawCmd = vi.fn(async (cmd) => {\n          if (cmd === \"nodes status --json\") {\n            return {\n              ok: true,\n              stdout: JSON.stringify({ nodes: [], pending: [] }),\n              stderr: \"\",\n            };\n          }\n          if (cmd === \"nodes pending --json\") {\n            return {\n              ok: true,\n              stdout: JSON.stringify({ pending: [] }),\n              stderr: \"\",\n            };\n          }\n          return { ok: true, stdout: \"\", stderr: \"\" };\n        });\n        const app = createApp({ clawCmd });\n\n        const nodesRes = await request(app).get(\"/api/nodes\");\n        const routeRes = await request(app).post(\"/api/nodes/node-1/route\");\n\n        expect(nodesRes.status).toBe(200);\n        expect(routeRes.status).toBe(200);\n        expect(clawCmd).toHaveBeenNthCalledWith(1, \"nodes status --json\", {\n          quiet: true,\n          timeoutMs: 12000,\n        });\n        expect(clawCmd).toHaveBeenNthCalledWith(2, \"nodes pending --json\", {\n          quiet: true,\n          timeoutMs: 12000,\n        });\n        for (const call of clawCmd.mock.calls.slice(2)) {\n          expect(call[1]).toEqual({ quiet: true, timeoutMs: 12000 });\n        }\n      },\n    );\n  });\n\n  it(\"surfaces status CLI timeouts with the configured timeout\", async () => {\n    await withNodeTimeoutEnv(\n      {\n        ALPHACLAW_NODES_STATUS_TIMEOUT_MS: \"9000\",\n      },\n      async () => {\n        const clawCmd = vi.fn(async () => ({\n          ok: false,\n          stdout: \"\",\n          stderr: \"\",\n          timedOut: true,\n        }));\n        const app = createApp({ clawCmd });\n\n        const res = await request(app).get(\"/api/nodes\");\n\n        expect(res.status).toBe(500);\n        expect(res.body).toEqual({\n          ok: false,\n          error: \"nodes status CLI timed out after 9000ms\",\n        });\n      },\n    );\n  });\n\n  it(\"surfaces node routing CLI timeouts with the configured timeout\", async () => {\n    await withNodeTimeoutEnv(\n      {\n        ALPHACLAW_NODE_ROUTE_TIMEOUT_MS: \"19000\",\n      },\n      async () => {\n        const clawCmd = vi.fn(async () => ({\n          ok: false,\n          stdout: \"\",\n          stderr: \"\",\n          killed: true,\n          signal: \"SIGTERM\",\n        }));\n        const app = createApp({ clawCmd });\n\n        const res = await request(app).post(\"/api/nodes/node-1/route\");\n\n        expect(res.status).toBe(500);\n        expect(res.body).toEqual({\n          ok: false,\n          error: \"node routing CLI timed out after 19000ms\",\n        });\n      },\n    );\n  });\n\n  it(\"falls back to status-derived pending nodes when pending command fails\", async () => {\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"nodes status --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({\n            nodes: [\n              { id: \"node-1\", paired: true },\n              { id: \"node-2\", paired: false },\n            ],\n          }),\n          stderr: \"\",\n        };\n      }\n      if (cmd === \"nodes pending --json\") {\n        return {\n          ok: false,\n          stdout: \"\",\n          stderr: \"timed out\",\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const app = createApp({ clawCmd });\n\n    const res = await request(app).get(\"/api/nodes\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([{ id: \"node-2\", paired: false }]);\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-onboarding.test.js",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst os = require(\"os\");\nconst express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerOnboardingRoutes } = require(\"../../lib/server/routes/onboarding\");\nconst { kSetupDir } = require(\"../../lib/server/constants\");\n\nconst createBaseDeps = ({ onboarded = false, hasCodexOauth = false } = {}) => {\n  const kOnboardingMarkerPath = \"/tmp/alphaclaw/onboarded.json\";\n  return {\n    fs: {\n      mkdirSync: vi.fn(),\n      existsSync: vi.fn((targetPath) =>\n        onboarded ? targetPath === kOnboardingMarkerPath : false,\n      ),\n      statSync: vi.fn(() => {\n        throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n      }),\n      readdirSync: vi.fn(() => []),\n      copyFileSync: vi.fn(),\n      rmSync: vi.fn(),\n      renameSync: vi.fn(),\n      readFileSync: vi.fn(() => \"{}\"),\n      writeFileSync: vi.fn(),\n      appendFileSync: vi.fn(),\n    },\n    constants: {\n      OPENCLAW_DIR: \"/tmp/openclaw\",\n      WORKSPACE_DIR: \"/tmp/openclaw/workspace\",\n      kOnboardingMarkerPath,\n      kSystemVars: new Set([\"WEBHOOK_TOKEN\", \"OPENCLAW_GATEWAY_TOKEN\"]),\n      kKnownKeys: new Set([\n        \"OPENAI_API_KEY\",\n        \"GITHUB_TOKEN\",\n        \"GITHUB_WORKSPACE_REPO\",\n        \"TELEGRAM_BOT_TOKEN\",\n        \"SLACK_BOT_TOKEN\",\n      ]),\n    },\n    shellCmd: vi.fn(async () => \"\"),\n    gatewayEnv: vi.fn(() => ({\n      HOME: \"/tmp/alphaclaw\",\n      OPENCLAW_HOME: \"/tmp/alphaclaw\",\n      OPENCLAW_CONFIG_PATH: \"/tmp/openclaw/openclaw.json\",\n      OPENCLAW_GATEWAY_TOKEN: \"tok\",\n      OPENCLAW_NO_RESPAWN: \"1\",\n      OPENCLAW_STATE_DIR: \"/tmp/openclaw\",\n      XDG_CONFIG_HOME: \"/tmp/openclaw\",\n      NODE_COMPILE_CACHE: \"/tmp/alphaclaw/cache/openclaw-compile-cache\",\n    })),\n    readEnvFile: vi.fn(() => []),\n    writeEnvFile: vi.fn(),\n    reloadEnv: vi.fn(),\n    isOnboarded: vi.fn(() => onboarded),\n    resolveGithubRepoUrl: vi.fn((value) => value),\n    resolveModelProvider: vi.fn((modelKey) => String(modelKey).split(\"/\")[0]),\n    hasCodexOauthProfile: vi.fn(() => hasCodexOauth),\n    authProfiles: {\n      getEnvVarForApiKeyProvider: vi.fn((provider) => {\n        const envKeys = {\n          anthropic: \"ANTHROPIC_API_KEY\",\n          openai: \"OPENAI_API_KEY\",\n          google: \"GEMINI_API_KEY\",\n        };\n        return envKeys[provider] || \"\";\n      }),\n      upsertApiKeyProfileForEnvVar: vi.fn(),\n      syncConfigAuthReferencesForAgent: vi.fn(),\n    },\n    ensureGatewayProxyConfig: vi.fn(),\n    getBaseUrl: vi.fn(() => \"https://example.com\"),\n    startGateway: vi.fn(),\n  };\n};\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  registerOnboardingRoutes({ app, ...deps });\n  return app;\n};\n\nconst makeValidBody = () => ({\n  modelKey: \"openai/gpt-5.1-codex\",\n  vars: [\n    { key: \"OPENAI_API_KEY\", value: \"sk-test-123456789\" },\n    { key: \"GITHUB_TOKEN\", value: \"ghp_test_123456789\" },\n    { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n    { key: \"TELEGRAM_BOT_TOKEN\", value: \"telegram_123456789\" },\n  ],\n});\n\nconst mockGithubVerifyAndCreate = ({\n  repoStatus = 404,\n  repoOk = false,\n  createOk = true,\n  scopes = \"repo\",\n  login = \"owner\",\n} = {}) => {\n  global.fetch.mockResolvedValueOnce({\n    ok: true,\n    headers: { get: () => scopes },\n    json: async () => ({ login }),\n  });\n  global.fetch.mockResolvedValueOnce({\n    ok: repoOk,\n    status: repoStatus,\n    statusText: repoStatus === 404 ? \"Not Found\" : \"OK\",\n    json: async () => ({ message: repoStatus === 404 ? \"Not Found\" : \"exists\" }),\n  });\n  if (repoStatus === 404 && login === \"owner\") {\n    global.fetch.mockResolvedValueOnce({\n      ok: true,\n      headers: { get: () => \"\" },\n      json: async () => [],\n    });\n  }\n  global.fetch.mockResolvedValueOnce({\n    ok: createOk,\n    status: createOk ? 201 : 400,\n    statusText: createOk ? \"Created\" : \"Bad Request\",\n    json: async () => (createOk ? {} : { message: \"create failed\" }),\n  });\n};\n\ndescribe(\"server/routes/onboarding\", () => {\n  beforeEach(() => {\n    global.fetch = vi.fn();\n  });\n\n  it(\"returns onboard status from dependency\", async () => {\n    const deps = createBaseDeps({ onboarded: true });\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/onboard/status\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ onboarded: true });\n  });\n\n  it(\"short-circuits when already onboarded\", async () => {\n    const deps = createBaseDeps({ onboarded: true });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: false, error: \"Already onboarded\" });\n  });\n\n  it(\"validates missing vars array\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send({ modelKey: \"openai/gpt-5.1\" });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({ ok: false, error: \"Missing vars array\" });\n  });\n\n  it(\"validates missing model selection\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send({ vars: [] });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({ ok: false, error: \"A model selection is required\" });\n  });\n\n  it(\"rejects overly large env var values before running onboarding\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    const body = makeValidBody();\n    body.vars = body.vars.map((entry) =>\n      entry.key === \"OPENAI_API_KEY\"\n        ? { ...entry, value: \"x\".repeat(5000) }\n        : entry,\n    );\n\n    const res = await request(app).post(\"/api/onboard\").send(body);\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"Value too long for OPENAI_API_KEY (max 4096 chars)\",\n    });\n    expect(deps.shellCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"requires codex oauth for openai-codex provider\", async () => {\n    const deps = createBaseDeps({ hasCodexOauth: false });\n    const app = createApp(deps);\n\n    const body = {\n      modelKey: \"openai-codex/gpt-5.3-codex\",\n      vars: [\n        { key: \"GITHUB_TOKEN\", value: \"ghp_test_123456789\" },\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"telegram_123456789\" },\n      ],\n    };\n\n    const res = await request(app).post(\"/api/onboard\").send(body);\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"Connect OpenAI Codex OAuth before continuing\",\n    });\n  });\n\n  it(\"rejects anthropic setup tokens with the wrong prefix\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send({\n      modelKey: \"anthropic/claude-opus-4-6\",\n      vars: [\n        { key: \"ANTHROPIC_TOKEN\", value: \"sk-ant-api03-not-a-setup-token\" },\n        { key: \"GITHUB_TOKEN\", value: \"ghp_test_123456789\" },\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"telegram_123456789\" },\n      ],\n    });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"ANTHROPIC_TOKEN must start with sk-ant-oat01-\",\n    });\n    expect(deps.shellCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"returns github error when repository check fails\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    global.fetch.mockRejectedValue(new Error(\"network down\"));\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"GitHub verification error: network down\",\n    });\n    expect(deps.writeEnvFile).toHaveBeenCalledTimes(1);\n    expect(deps.reloadEnv).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"allows existing source repos owned by a different accessible org\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"owner\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        status: 200,\n        statusText: \"OK\",\n        json: async () => ({ full_name: \"my-org/source-repo\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        status: 200,\n        statusText: \"OK\",\n        json: async () => [{ sha: \"abc123\" }],\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        status: 200,\n        json: async () => [\n          { name: \"package.json\", type: \"file\" },\n          { name: \"src\", type: \"dir\" },\n        ],\n      });\n    deps.shellCmd.mockResolvedValueOnce(\"\");\n\n    const verifyRes = await request(app).post(\"/api/onboard/github/verify\").send({\n      repo: \"my-org/source-repo\",\n      token: \"ghp_test_123456789\",\n      mode: \"existing\",\n    });\n\n    expect(verifyRes.status).toBe(200);\n    expect(verifyRes.body).toMatchObject({\n      ok: true,\n      repoExists: true,\n      repoIsEmpty: false,\n    });\n  });\n\n  it(\"allows new workspace repos owned by organizations when github verification passes\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"tokudu\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [{ login: \"make-stories\" }],\n      });\n\n    const res = await request(app).post(\"/api/onboard/github/verify\").send({\n      repo: \"make-stories/new-repo\",\n      token: \"ghp_test_123456789\",\n      mode: \"new\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      repoExists: false,\n      repoIsEmpty: false,\n      tempDir: null,\n    });\n  });\n\n  it(\"rejects new workspace repos with an owner typo during github verification\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"chrysbtest\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [],\n      });\n\n    const res = await request(app).post(\"/api/onboard/github/verify\").send({\n      repo: \"chrybtest/test81\",\n      token: \"ghp_test_123456789\",\n      mode: \"new\",\n    });\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain('Repository owner \"chrybtest\"');\n    expect(res.body.error).toContain('authenticated GitHub user \"chrysbtest\"');\n  });\n\n  it(\"surfaces a hidden repo-name conflict during github verification\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    global.fetch\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"repo\" },\n        json: async () => ({ login: \"owner\" }),\n      })\n      .mockResolvedValueOnce({\n        status: 404,\n        ok: false,\n        statusText: \"Not Found\",\n        json: async () => ({ message: \"Not Found\" }),\n      })\n      .mockResolvedValueOnce({\n        ok: true,\n        headers: { get: () => \"\" },\n        json: async () => [{ name: \"repo\", full_name: \"owner/repo\" }],\n      });\n\n    const res = await request(app).post(\"/api/onboard/github/verify\").send({\n      repo: \"owner/repo\",\n      token: \"github_pat_hidden_repo_token\",\n      mode: \"new\",\n    });\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain('Repository \"owner/repo\" already exists');\n    expect(res.body.error).toContain(\"cannot inspect\");\n  });\n\n  it(\"installs deterministic hourly git sync cron during successful onboarding\", async () => {\n    const deps = createBaseDeps();\n    deps.fs.readFileSync.mockImplementation((p) => {\n      if (p === \"/tmp/openclaw/openclaw.json\") return \"{}\";\n      if (p === path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\")) return \"Setup: {{SETUP_UI_URL}}\";\n      if (p === path.join(kSetupDir, \"hourly-git-sync.sh\")) return \"echo Auto-commit hourly sync\";\n      return \"{}\";\n    });\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n    expect(deps.startGateway).toHaveBeenCalledTimes(1);\n    expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).toHaveBeenCalledWith(\n      \"openai\",\n      \"sk-test-123456789\",\n    );\n    expect(deps.authProfiles.syncConfigAuthReferencesForAgent).toHaveBeenCalledTimes(1);\n    expect(deps.fs.copyFileSync).toHaveBeenCalledWith(\n      path.join(kSetupDir, \"core-prompts\", \"AGENTS.md\"),\n      \"/tmp/openclaw/workspace/hooks/bootstrap/AGENTS.md\",\n    );\n    const toolsWriteCall = deps.fs.writeFileSync.mock.calls.find(\n      ([path]) => path === \"/tmp/openclaw/workspace/hooks/bootstrap/TOOLS.md\",\n    );\n    expect(toolsWriteCall).toBeTruthy();\n    expect(toolsWriteCall[1]).toContain(\"https://example.com\");\n\n    expect(deps.fs.writeFileSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/.alphaclaw/hourly-git-sync.sh\",\n      expect.stringContaining(\"Auto-commit hourly sync\"),\n      expect.objectContaining({ mode: 0o755 }),\n    );\n\n    expect(deps.fs.writeFileSync).toHaveBeenCalledWith(\n      \"/etc/cron.d/openclaw-hourly-sync\",\n      expect.stringContaining(\n        '0 * * * * root bash \"/tmp/openclaw/.alphaclaw/hourly-git-sync.sh\"',\n      ),\n      expect.objectContaining({ mode: 0o644 }),\n    );\n\n    expect(deps.fs.writeFileSync).toHaveBeenCalledWith(\n      \"/tmp/alphaclaw/onboarded.json\",\n      expect.stringContaining('\"reason\": \"onboarding_complete\"'),\n    );\n\n    const initialPushCall = deps.shellCmd.mock.calls.find(([cmd]) =>\n      cmd.includes('alphaclaw git-sync -m \"initial setup\"'),\n    );\n    expect(initialPushCall).toBeTruthy();\n\n    const gitInitCall = deps.shellCmd.mock.calls.find(([cmd]) =>\n      cmd.includes('git remote add origin \"https://github.com/owner/repo.git\"'),\n    );\n    expect(gitInitCall).toBeTruthy();\n    expect(gitInitCall[0]).not.toContain(\"ghp_test_123456789\");\n\n    const openclawWriteCall = deps.fs.writeFileSync.mock.calls.find(\n      ([path]) => path === \"/tmp/openclaw/openclaw.json\",\n    );\n    expect(openclawWriteCall).toBeTruthy();\n    const writtenConfig = JSON.parse(openclawWriteCall[1]);\n    expect(writtenConfig.hooks.internal.enabled).toBe(true);\n    expect(writtenConfig.hooks.internal.entries[\"bootstrap-extra-files\"]).toEqual({\n      enabled: true,\n      paths: [\"hooks/bootstrap/AGENTS.md\", \"hooks/bootstrap/TOOLS.md\"],\n    });\n  });\n\n  it(\"rejects onboarding when workspace repo already exists\", async () => {\n    const deps = createBaseDeps();\n    deps.fs.readFileSync.mockImplementation((p) => {\n      if (p === \"/tmp/openclaw/openclaw.json\") return \"{}\";\n      if (p === path.join(kSetupDir, \"hourly-git-sync.sh\")) return \"echo Auto-commit hourly sync\";\n      return \"{}\";\n    });\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate({ repoStatus: 200, repoOk: true, createOk: true });\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain('Repository \"owner/repo\" already exists');\n  });\n\n  it(\"seeds anthropic api key auth profile during onboarding\", async () => {\n    const deps = createBaseDeps();\n    deps.fs.readFileSync.mockImplementation((p) => {\n      if (p === \"/tmp/openclaw/openclaw.json\") return \"{}\";\n      if (p === path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\")) return \"Setup: {{SETUP_UI_URL}}\";\n      if (p === path.join(kSetupDir, \"hourly-git-sync.sh\")) return \"echo Auto-commit hourly sync\";\n      return \"{}\";\n    });\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n\n    const res = await request(app).post(\"/api/onboard\").send({\n      modelKey: \"anthropic/claude-opus-4-6\",\n      vars: [\n        { key: \"ANTHROPIC_API_KEY\", value: \"sk-ant-api03-123456789\" },\n        { key: \"GITHUB_TOKEN\", value: \"ghp_test_123456789\" },\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"telegram_123456789\" },\n      ],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).toHaveBeenCalledWith(\n      \"anthropic\",\n      \"sk-ant-api03-123456789\",\n    );\n    expect(deps.authProfiles.syncConfigAuthReferencesForAgent).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"removes stale anthropic token env state when onboarding with an api key\", async () => {\n    const deps = createBaseDeps();\n    deps.readEnvFile.mockReturnValue([\n      { key: \"ANTHROPIC_TOKEN\", value: \"sk-ant-oat01-stale-token\" },\n      { key: \"GITHUB_TOKEN\", value: \"ghp_old\" },\n    ]);\n    deps.fs.readFileSync.mockImplementation((p) => {\n      if (p === \"/tmp/openclaw/openclaw.json\") return \"{}\";\n      if (p === path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\")) return \"Setup: {{SETUP_UI_URL}}\";\n      if (p === path.join(kSetupDir, \"hourly-git-sync.sh\")) return \"echo Auto-commit hourly sync\";\n      return \"{}\";\n    });\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n\n    const res = await request(app).post(\"/api/onboard\").send({\n      modelKey: \"anthropic/claude-opus-4-6\",\n      vars: [\n        { key: \"ANTHROPIC_API_KEY\", value: \"sk-ant-api-fresh-123456789\" },\n        { key: \"GITHUB_TOKEN\", value: \"ghp_test_123456789\" },\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n        { key: \"TELEGRAM_BOT_TOKEN\", value: \"telegram_123456789\" },\n      ],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.writeEnvFile).toHaveBeenCalled();\n    const savedVars = deps.writeEnvFile.mock.calls.at(-1)[0];\n    expect(savedVars.some((entry) => entry.key === \"ANTHROPIC_TOKEN\")).toBe(false);\n\n    const onboardCall = deps.shellCmd.mock.calls.find(([cmd]) =>\n      cmd.startsWith(\"openclaw onboard \"),\n    );\n    expect(onboardCall).toBeTruthy();\n    expect(onboardCall[0]).toContain(\"--anthropic-api-key\");\n    expect(onboardCall[0]).not.toContain(\"--token-provider\");\n    expect(onboardCall[0]).not.toContain(\"sk-ant-oat01-stale-token\");\n    expect(onboardCall[1]).toMatchObject({\n      env: expect.objectContaining({\n        HOME: expect.any(String),\n        OPENCLAW_CONFIG_PATH: \"/tmp/openclaw/openclaw.json\",\n        OPENCLAW_STATE_DIR: \"/tmp/openclaw\",\n        XDG_CONFIG_HOME: \"/tmp/openclaw\",\n      }),\n    });\n  });\n\n  it(\"sanitizes onboarding command failures to avoid leaking secrets\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n    deps.shellCmd.mockRejectedValueOnce(\n      new Error('Command failed: openclaw onboard --openai-api-key \"sk-test-secret-value\"'),\n    );\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(500);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"Onboarding command failed. Please verify credentials and try again.\",\n    });\n  });\n\n  it(\"redacts fine-grained GitHub tokens from onboarding errors\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n    deps.shellCmd.mockRejectedValueOnce(\n      new Error('boom github_pat_super_secret_value openclaw onboard'),\n    );\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(500);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).not.toContain(\"github_pat_super_secret_value\");\n    expect(res.body.error).toContain(\"***\");\n  });\n\n  it(\"returns a helpful OOM message when onboarding runs out of memory\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n    deps.shellCmd.mockRejectedValueOnce(\n      new Error(\"FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory\"),\n    );\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(500);\n    expect(res.body).toEqual({\n      ok: false,\n      error:\n        \"Onboarding ran out of memory. Please retry, and if it persists increase instance memory.\",\n    });\n  });\n\n  it(\"returns a helpful GitHub permissions message for repo access failures\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n    const err = new Error(\"Command failed: openclaw onboard\");\n    err.stderr = \"remote: Permission to owner/repo denied to user\";\n    deps.shellCmd.mockRejectedValueOnce(err);\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(500);\n    expect(res.body).toEqual({\n      ok: false,\n      error:\n        \"GitHub access failed. Verify your token permissions and workspace repo, then try again.\",\n    });\n  });\n\n  it(\"returns a helpful provider auth message for invalid credentials\", async () => {\n    const deps = createBaseDeps();\n    const app = createApp(deps);\n    mockGithubVerifyAndCreate();\n    deps.shellCmd.mockRejectedValueOnce(new Error(\"invalid_api_key\"));\n\n    const res = await request(app).post(\"/api/onboard\").send(makeValidBody());\n\n    expect(res.status).toBe(500);\n    expect(res.body).toEqual({\n      ok: false,\n      error:\n        \"Model provider authentication failed. Check your API key/token and try again.\",\n    });\n  });\n\n  it(\"fills missing imported env refs with placeholders during import onboarding\", async () => {\n    const deps = createBaseDeps();\n    mockGithubVerifyAndCreate();\n    const files = new Map([\n      [\n        \"/tmp/openclaw/openclaw.json\",\n        JSON.stringify({\n          env: {\n            vars: {\n              NOTION_API_KEY: \"${NOTION_API_KEY}\",\n            },\n          },\n          hooks: {\n            token: \"${WEBHOOK_TOKEN}\",\n            transformsDir: \"/root/.openclaw/hooks/transforms\",\n          },\n          channels: {\n            $include: \"channels.json\",\n          },\n          gateway: {\n            auth: {\n              token: \"${GATEWAY_AUTH_TOKEN}\",\n            },\n          },\n          talk: {\n            apiKey: \"${ELEVENLABS_API_KEY}\",\n          },\n        }),\n      ],\n      [\n        \"/tmp/openclaw/channels.json\",\n        JSON.stringify({\n          slack: {\n            botToken: \"${SLACK_BOT_TOKEN}\",\n            appToken: \"${SLACK_APP_TOKEN}\",\n            userToken: \"${SLACK_USER_TOKEN}\",\n          },\n        }),\n      ],\n      [\"/tmp/openclaw/.git\", \"gitdir\"],\n      [path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\"), \"Setup: {{SETUP_UI_URL}}\"],\n      [path.join(kSetupDir, \"hourly-git-sync.sh\"), \"echo Auto-commit hourly sync\"],\n    ]);\n    deps.fs.existsSync.mockImplementation((targetPath) => files.has(targetPath));\n    deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || \"{}\");\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send({\n      ...makeValidBody(),\n      vars: makeValidBody().vars.map((entry) =>\n        entry.key === \"GITHUB_WORKSPACE_REPO\"\n          ? { ...entry, value: \"owner/target-repo\" }\n          : entry,\n      ),\n      importMode: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n    expect(deps.writeEnvFile).toHaveBeenCalledWith(\n      expect.arrayContaining([\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/target-repo\" },\n        { key: \"SLACK_BOT_TOKEN\", value: \"placeholder\" },\n        { key: \"SLACK_APP_TOKEN\", value: \"placeholder\" },\n        { key: \"SLACK_USER_TOKEN\", value: \"placeholder\" },\n        { key: \"ELEVENLABS_API_KEY\", value: \"placeholder\" },\n        { key: \"NOTION_API_KEY\", value: \"placeholder\" },\n      ]),\n    );\n    expect(\n      deps.writeEnvFile.mock.calls.some(([vars]) =>\n        Array.isArray(vars) &&\n        vars.some((entry) => entry.key === \"GATEWAY_AUTH_TOKEN\"),\n      ),\n    ).toBe(false);\n    expect(files.get(\"/tmp/openclaw/openclaw.json\")).toContain(\n      '\"token\": \"${OPENCLAW_GATEWAY_TOKEN}\"',\n    );\n    expect(files.get(\"/tmp/openclaw/openclaw.json\")).toContain(\n      '\"botToken\": \"${TELEGRAM_BOT_TOKEN}\"',\n    );\n    expect(files.get(\"/tmp/openclaw/openclaw.json\")).toContain(\n      '\"usage-tracker\"',\n    );\n    expect(files.get(\"/tmp/openclaw/openclaw.json\")).toContain(\n      '\"bootstrap-extra-files\"',\n    );\n    expect(files.get(\"/tmp/openclaw/openclaw.json\")).toContain(\n      '\"strictInlineEval\": false',\n    );\n    expect(files.get(\"/tmp/openclaw/openclaw.json\")).not.toContain(\n      '\"transformsDir\"',\n    );\n    expect(files.get(\"/tmp/openclaw/exec-approvals.json\")).toContain(\n      '\"askFallback\": \"full\"',\n    );\n    expect(\n      deps.shellCmd.mock.calls.some(([cmd]) =>\n        cmd.includes(\n          'git init -b main && git remote add origin \"https://github.com/owner/target-repo.git\"',\n        ),\n      ),\n    ).toBe(true);\n    expect(deps.shellCmd).toHaveBeenCalledWith(\n      'openclaw models set \"openai/gpt-5.1-codex\"',\n      expect.objectContaining({\n        env: expect.objectContaining({ OPENCLAW_GATEWAY_TOKEN: \"tok\" }),\n      }),\n    );\n  });\n\n  it(\"does not treat nested openclaw config as an imported config during completion\", async () => {\n    const deps = createBaseDeps();\n    mockGithubVerifyAndCreate();\n    const files = new Map([\n      [\n        \"/tmp/openclaw/.openclaw/openclaw.json\",\n        JSON.stringify({\n          agents: {\n            defaults: {\n              model: {\n                primary: \"openai/gpt-5.1-codex\",\n              },\n            },\n          },\n        }),\n      ],\n      [path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\"), \"Setup: {{SETUP_UI_URL}}\"],\n      [path.join(kSetupDir, \"hourly-git-sync.sh\"), \"echo Auto-commit hourly sync\"],\n    ]);\n    deps.fs.existsSync.mockImplementation((targetPath) => files.has(targetPath));\n    deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || \"{}\");\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send({\n      ...makeValidBody(),\n      importMode: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n    expect(\n      deps.shellCmd.mock.calls.some(([cmd]) => cmd.startsWith(\"openclaw onboard \")),\n    ).toBe(true);\n    expect(\n      deps.shellCmd.mock.calls.some(([cmd]) => cmd.includes('git remote set-url origin')),\n    ).toBe(false);\n    expect(\n      deps.shellCmd.mock.calls.some(([cmd]) =>\n        cmd.includes('git init -b main && git remote add origin \"https://github.com/owner/repo.git\"'),\n      ),\n    ).toBe(true);\n  });\n\n  it(\"creates the target repo during import onboarding before git-sync\", async () => {\n    const deps = createBaseDeps();\n    mockGithubVerifyAndCreate({\n      repoStatus: 404,\n      repoOk: false,\n      createOk: true,\n      login: \"owner\",\n    });\n    const files = new Map([\n      [\"/tmp/openclaw/openclaw.json\", JSON.stringify({ gateway: { auth: {} } })],\n      [\"/tmp/openclaw/.git\", \"gitdir\"],\n      [path.join(kSetupDir, \"core-prompts\", \"TOOLS.md\"), \"Setup: {{SETUP_UI_URL}}\"],\n      [path.join(kSetupDir, \"hourly-git-sync.sh\"), \"echo Auto-commit hourly sync\"],\n    ]);\n    deps.fs.existsSync.mockImplementation((targetPath) => files.has(targetPath));\n    deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || \"{}\");\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard\").send({\n      ...makeValidBody(),\n      vars: makeValidBody().vars.map((entry) =>\n        entry.key === \"GITHUB_WORKSPACE_REPO\"\n          ? { ...entry, value: \"owner/import-target\" }\n          : entry,\n      ),\n      importMode: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true });\n    expect(global.fetch).toHaveBeenCalledWith(\n      \"https://api.github.com/user/repos\",\n      expect.objectContaining({\n        method: \"POST\",\n        body: JSON.stringify({\n          name: \"import-target\",\n          private: true,\n          auto_init: false,\n        }),\n      }),\n    );\n    expect(\n      deps.shellCmd.mock.calls.some(([cmd]) =>\n        cmd.includes(\n          'git init -b main && git remote add origin \"https://github.com/owner/import-target.git\"',\n        ),\n      ),\n    ).toBe(true);\n    expect(\n      deps.shellCmd.mock.calls.some(([cmd]) =>\n        cmd.includes('alphaclaw git-sync -m \"imported existing setup via AlphaClaw\"'),\n      ),\n    ).toBe(true);\n  });\n\n  it(\"rejects nested .openclaw import sources during scan\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-nested\");\n    deps.fs.existsSync.mockImplementation((targetPath) => targetPath === tempDir);\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir || targetPath === `${tempDir}/.openclaw`) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (targetPath === `${tempDir}/.openclaw/openclaw.json`) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [{ name: \".openclaw\", isFile: () => false, isDirectory: () => true }];\n      }\n      if (targetPath === `${tempDir}/.openclaw`) {\n        return [{ name: \"openclaw.json\", isFile: () => true, isDirectory: () => false }];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation((targetPath) =>\n      targetPath === `${tempDir}/.openclaw/openclaw.json` ? \"{}\" : \"{}\",\n    );\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/scan\").send({ tempDir });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error:\n        \"This import source contains a nested .openclaw config. Point the source at the OpenClaw root itself, or at a workspace-only repo instead.\",\n    });\n  });\n\n  it(\"promotes workspace-only imports into WORKSPACE_DIR\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-workspace\");\n    deps.fs.existsSync.mockImplementation((targetPath) => {\n      if (\n        targetPath === tempDir ||\n        targetPath === `${tempDir}/workspace` ||\n        targetPath === `${tempDir}/workspace/skills` ||\n        targetPath === `${tempDir}/workspace/skills/email`\n      ) {\n        return true;\n      }\n      return false;\n    });\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (\n        targetPath === tempDir ||\n        targetPath === `${tempDir}/workspace` ||\n        targetPath === `${tempDir}/workspace/skills` ||\n        targetPath === `${tempDir}/workspace/skills/email`\n      ) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (targetPath === `${tempDir}/workspace/skills/email/SKILL.md`) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [{ name: \"workspace\", isFile: () => false, isDirectory: () => true }];\n      }\n      if (targetPath === `${tempDir}/workspace`) {\n        return [{ name: \"skills\", isFile: () => false, isDirectory: () => true }];\n      }\n      if (targetPath === `${tempDir}/workspace/skills`) {\n        return [{ name: \"email\", isFile: () => false, isDirectory: () => true }];\n      }\n      if (targetPath === `${tempDir}/workspace/skills/email`) {\n        return [{ name: \"SKILL.md\", isFile: () => true, isDirectory: () => false }];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation(() => \"{}\");\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/apply\").send({\n      tempDir,\n      approvedSecrets: [],\n      skipSecretExtraction: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toMatchObject({\n      ok: true,\n      sourceLayout: {\n        kind: \"workspace-only\",\n        supported: true,\n        promoteSourceSubdir: \"workspace\",\n      },\n    });\n    expect(deps.fs.renameSync).toHaveBeenCalledWith(\n      `${tempDir}/workspace`,\n      deps.constants.WORKSPACE_DIR,\n    );\n  });\n\n  it(\"allows import apply when OPENCLAW_DIR already has partial runtime state\", async () => {\n    const deps = createBaseDeps();\n    const rootDir = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-routes-import-\"),\n    );\n    const tempDir = fs.mkdtempSync(\n      path.join(os.tmpdir(), \"alphaclaw-import-route-\"),\n    );\n    const openclawDir = path.join(rootDir, \".openclaw\");\n    const workspaceDir = path.join(openclawDir, \"workspace\");\n\n    deps.fs = fs;\n    deps.constants = {\n      ...deps.constants,\n      OPENCLAW_DIR: openclawDir,\n      WORKSPACE_DIR: workspaceDir,\n      kOnboardingMarkerPath: path.join(rootDir, \"onboarded.json\"),\n    };\n\n    try {\n      fs.mkdirSync(path.join(openclawDir, \"agents\", \"main\", \"agent\"), {\n        recursive: true,\n      });\n      fs.writeFileSync(\n        path.join(openclawDir, \"exec-approvals.json\"),\n        JSON.stringify({ version: 1 }, null, 2),\n      );\n      fs.writeFileSync(\n        path.join(openclawDir, \"agents\", \"main\", \"agent\", \"auth-profiles.json\"),\n        JSON.stringify({ version: 1, profiles: {} }, null, 2),\n      );\n\n      fs.writeFileSync(\n        path.join(tempDir, \"openclaw.json\"),\n        JSON.stringify(\n          {\n            channels: {\n              $include: \"channels.json\",\n            },\n            hooks: {\n              token: \"repo-hook-token\",\n            },\n          },\n          null,\n          2,\n        ),\n      );\n      fs.writeFileSync(\n        path.join(tempDir, \"channels.json\"),\n        JSON.stringify(\n          {\n            telegram: {\n              botToken: \"${TELEGRAM_BOT_TOKEN}\",\n            },\n          },\n          null,\n          2,\n        ),\n      );\n\n      const app = createApp(deps);\n      const res = await request(app).post(\"/api/onboard/import/apply\").send({\n        tempDir,\n        approvedSecrets: [],\n        skipSecretExtraction: true,\n      });\n\n      expect(res.status).toBe(200);\n      expect(res.body).toMatchObject({\n        ok: true,\n        sourceLayout: {\n          kind: \"full-openclaw-root\",\n          supported: true,\n          promoteSourceSubdir: \"\",\n        },\n      });\n      expect(\n        JSON.parse(fs.readFileSync(path.join(openclawDir, \"channels.json\"), \"utf8\")),\n      ).toEqual({\n        telegram: {\n          botToken: \"${TELEGRAM_BOT_TOKEN}\",\n        },\n      });\n      expect(fs.existsSync(path.join(openclawDir, \"exec-approvals.json\"))).toBe(\n        true,\n      );\n      expect(\n        fs.existsSync(\n          path.join(openclawDir, \"agents\", \"main\", \"agent\", \"auth-profiles.json\"),\n        ),\n      ).toBe(true);\n    } finally {\n      fs.rmSync(rootDir, { recursive: true, force: true });\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  it(\"returns unresolved placeholder review data after import apply\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-placeholder-review\");\n    const fileEntry = (name) => ({\n      name,\n      isFile: () => true,\n      isDirectory: () => false,\n    });\n    const dirEntry = (name) => ({\n      name,\n      isFile: () => false,\n      isDirectory: () => true,\n    });\n    const files = new Map([\n      [\n        path.join(tempDir, \"openclaw.json\"),\n        JSON.stringify({\n          env: {\n            vars: {\n              NOTION_API_KEY: \"${NOTION_API_KEY}\",\n            },\n          },\n          channels: {\n            $include: \"channels.json\",\n          },\n          gateway: {\n            auth: {\n              token: \"${GATEWAY_AUTH_TOKEN}\",\n            },\n          },\n          hooks: {\n            token: \"repo-hook-token\",\n          },\n        }),\n      ],\n      [\n        path.join(tempDir, \"channels.json\"),\n        JSON.stringify({\n          slack: {\n            botToken: \"${SLACK_BOT_TOKEN}\",\n            appToken: \"${SLACK_APP_TOKEN}\",\n          },\n        }),\n      ],\n    ]);\n    const directories = new Set([tempDir]);\n    deps.readEnvFile.mockReturnValue([\n      { key: \"NOTION_API_KEY\", value: \"notion-live-value\" },\n    ]);\n    deps.fs.existsSync.mockImplementation(\n      (targetPath) => directories.has(targetPath) || files.has(targetPath),\n    );\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (directories.has(targetPath)) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (files.has(targetPath)) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [fileEntry(\"openclaw.json\"), fileEntry(\"channels.json\")];\n      }\n      if (targetPath === deps.constants.OPENCLAW_DIR) {\n        return [];\n      }\n      if (targetPath === path.join(tempDir, \"workspace\")) {\n        return [];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || \"{}\");\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => {\n      if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) {\n        directories.delete(tempDir);\n        directories.add(targetPath);\n        for (const [filePath, contents] of [...files.entries()]) {\n          if (!filePath.startsWith(`${sourcePath}/`)) continue;\n          files.delete(filePath);\n          files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents);\n        }\n        return;\n      }\n      throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`);\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/apply\").send({\n      tempDir,\n      approvedSecrets: [],\n      skipSecretExtraction: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body.placeholderReview).toEqual({\n      found: true,\n      count: 2,\n      vars: [\n        { key: \"SLACK_APP_TOKEN\", status: \"missing\" },\n        { key: \"SLACK_BOT_TOKEN\", status: \"missing\" },\n      ],\n    });\n    expect(files.get(path.join(deps.constants.OPENCLAW_DIR, \"openclaw.json\"))).toContain(\n      '\"token\": \"${OPENCLAW_GATEWAY_TOKEN}\"',\n    );\n    expect(files.get(path.join(deps.constants.OPENCLAW_DIR, \"openclaw.json\"))).toContain(\n      '\"token\": \"${WEBHOOK_TOKEN}\"',\n    );\n  });\n\n  it(\"keeps imported channels enabled and clears pairing allowlists during import apply\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-reset-pairings\");\n    const fileEntry = (name) => ({\n      name,\n      isFile: () => true,\n      isDirectory: () => false,\n    });\n    const dirEntry = (name) => ({\n      name,\n      isFile: () => false,\n      isDirectory: () => true,\n    });\n    const files = new Map([\n      [\n        path.join(tempDir, \"openclaw.json\"),\n        JSON.stringify({\n          channels: {\n            $include: \"channels.json\",\n          },\n        }),\n      ],\n      [\n        path.join(tempDir, \"channels.json\"),\n        JSON.stringify({\n          telegram: {\n            enabled: true,\n            botToken: \"${TELEGRAM_BOT_TOKEN}\",\n            dmPolicy: \"allowlist\",\n            accounts: {\n              midas: {\n                enabled: true,\n                allowFrom: [\"legacy-user\"],\n              },\n            },\n            groupAllowFrom: [\"telegram-user\"],\n            groups: {\n              \"-100123\": {\n                enabled: true,\n              },\n            },\n          },\n          discord: {\n            enabled: true,\n            dmPolicy: \"allowlist\",\n            token: \"${DISCORD_BOT_TOKEN}\",\n            allowFrom: [\"discord-user\"],\n          },\n        }),\n      ],\n      [\n        path.join(tempDir, \"credentials\", \"telegram-main-allowFrom.json\"),\n        JSON.stringify({\n          allowFrom: [\"telegram-user\"],\n          source: \"imported\",\n        }),\n      ],\n      [\n        path.join(tempDir, \"credentials\", \"discord-main-allowFrom.json\"),\n        JSON.stringify({\n          allowFrom: [\"discord-user\"],\n          source: \"imported\",\n        }),\n      ],\n    ]);\n    const directories = new Set([tempDir, path.join(tempDir, \"credentials\")]);\n    deps.fs.existsSync.mockImplementation(\n      (targetPath) => directories.has(targetPath) || files.has(targetPath),\n    );\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (directories.has(targetPath)) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (files.has(targetPath)) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [\n          fileEntry(\"openclaw.json\"),\n          fileEntry(\"channels.json\"),\n          dirEntry(\"credentials\"),\n        ];\n      }\n      if (targetPath === path.join(tempDir, \"credentials\")) {\n        return [\n          fileEntry(\"telegram-main-allowFrom.json\"),\n          fileEntry(\"discord-main-allowFrom.json\"),\n        ];\n      }\n      if (targetPath === deps.constants.OPENCLAW_DIR) {\n        return [];\n      }\n      if (targetPath === path.join(deps.constants.OPENCLAW_DIR, \"credentials\")) {\n        return [\"telegram-main-allowFrom.json\", \"discord-main-allowFrom.json\"];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation(\n      (targetPath) => files.get(targetPath) || \"{}\",\n    );\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => {\n      if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) {\n        for (const directoryPath of [...directories]) {\n          if (\n            directoryPath === sourcePath ||\n            directoryPath.startsWith(`${sourcePath}/`)\n          ) {\n            directories.delete(directoryPath);\n            directories.add(`${targetPath}${directoryPath.slice(sourcePath.length)}`);\n          }\n        }\n        for (const [filePath, contents] of [...files.entries()]) {\n          if (!filePath.startsWith(`${sourcePath}/`)) continue;\n          files.delete(filePath);\n          files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents);\n        }\n        return;\n      }\n      throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`);\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/apply\").send({\n      tempDir,\n      approvedSecrets: [],\n      skipSecretExtraction: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(\n      JSON.parse(files.get(path.join(deps.constants.OPENCLAW_DIR, \"channels.json\"))),\n    ).toEqual({\n      telegram: {\n        enabled: true,\n        botToken: \"${TELEGRAM_BOT_TOKEN}\",\n        dmPolicy: \"pairing\",\n        groupAllowFrom: [],\n        groups: {\n          \"-100123\": {\n            enabled: true,\n          },\n        },\n      },\n      discord: {\n        enabled: true,\n        dmPolicy: \"pairing\",\n        token: \"${DISCORD_BOT_TOKEN}\",\n        allowFrom: [],\n      },\n    });\n    expect(\n      JSON.parse(\n        files.get(\n          path.join(\n            deps.constants.OPENCLAW_DIR,\n            \"credentials\",\n            \"telegram-main-allowFrom.json\",\n          ),\n        ),\n      ),\n    ).toEqual({\n      allowFrom: [],\n      source: \"imported\",\n    });\n    expect(\n      JSON.parse(\n        files.get(\n          path.join(\n            deps.constants.OPENCLAW_DIR,\n            \"credentials\",\n            \"discord-main-allowFrom.json\",\n          ),\n        ),\n      ),\n    ).toEqual({\n      allowFrom: [],\n      source: \"imported\",\n    });\n  });\n\n  it(\"returns prefill values from included channel config files during import apply\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-prefill-includes\");\n    const fileEntry = (name) => ({\n      name,\n      isFile: () => true,\n      isDirectory: () => false,\n    });\n    const files = new Map([\n      [\n        path.join(tempDir, \"openclaw.json\"),\n        JSON.stringify({\n          channels: {\n            $include: \"channels.json\",\n          },\n        }),\n      ],\n      [\n        path.join(tempDir, \"channels.json\"),\n        JSON.stringify({\n          discord: {\n            enabled: true,\n            token: \"MTQ3discord-secret\",\n          },\n        }),\n      ],\n    ]);\n    const directories = new Set([tempDir]);\n    deps.fs.existsSync.mockImplementation(\n      (targetPath) => directories.has(targetPath) || files.has(targetPath),\n    );\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (directories.has(targetPath)) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (files.has(targetPath)) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [fileEntry(\"openclaw.json\"), fileEntry(\"channels.json\")];\n      }\n      if (targetPath === deps.constants.OPENCLAW_DIR) {\n        return [];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation(\n      (targetPath) => files.get(targetPath) || \"{}\",\n    );\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => {\n      if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) {\n        directories.delete(tempDir);\n        directories.add(targetPath);\n        for (const [filePath, contents] of [...files.entries()]) {\n          if (!filePath.startsWith(`${sourcePath}/`)) continue;\n          files.delete(filePath);\n          files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents);\n        }\n        return;\n      }\n      throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`);\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/apply\").send({\n      tempDir,\n      approvedSecrets: [],\n      skipSecretExtraction: true,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body.preFill).toEqual({\n      DISCORD_BOT_TOKEN: \"MTQ3discord-secret\",\n    });\n  });\n\n  it(\"canonicalizes imported env refs for known config paths during import apply\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-canonical-env-ref\");\n    const fileEntry = (name) => ({\n      name,\n      isFile: () => true,\n      isDirectory: () => false,\n    });\n    const files = new Map([\n      [\n        path.join(tempDir, \"openclaw.json\"),\n        JSON.stringify({\n          tools: {\n            web: {\n              search: {\n                provider: \"brave\",\n                apiKey: \"${REDACTED_USE_ENV_VAR}\",\n              },\n            },\n          },\n        }),\n      ],\n      [path.join(tempDir, \".env\"), \"REDACTED_USE_ENV_VAR=brave-live-value\\n\"],\n    ]);\n    const directories = new Set([tempDir]);\n    deps.fs.existsSync.mockImplementation(\n      (targetPath) => directories.has(targetPath) || files.has(targetPath),\n    );\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (directories.has(targetPath)) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (files.has(targetPath)) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [fileEntry(\"openclaw.json\"), fileEntry(\".env\")];\n      }\n      if (targetPath === deps.constants.OPENCLAW_DIR) {\n        return [];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation(\n      (targetPath) => files.get(targetPath) || \"{}\",\n    );\n    deps.fs.writeFileSync.mockImplementation((targetPath, contents) => {\n      files.set(targetPath, String(contents));\n    });\n    deps.fs.renameSync.mockImplementation((sourcePath, targetPath) => {\n      if (sourcePath === tempDir && targetPath === deps.constants.OPENCLAW_DIR) {\n        directories.delete(tempDir);\n        directories.add(targetPath);\n        for (const [filePath, contents] of [...files.entries()]) {\n          if (!filePath.startsWith(`${sourcePath}/`)) continue;\n          files.delete(filePath);\n          files.set(`${targetPath}${filePath.slice(sourcePath.length)}`, contents);\n        }\n        return;\n      }\n      throw new Error(`Unexpected rename from ${sourcePath} to ${targetPath}`);\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/apply\").send({\n      tempDir,\n      approvedSecrets: [\n        {\n          file: \".env\",\n          configPath: \".env:REDACTED_USE_ENV_VAR\",\n          key: \"REDACTED_USE_ENV_VAR\",\n          value: \"brave-live-value\",\n          maskedValue: \"brav****alue\",\n          suggestedEnvVar: \"REDACTED_USE_ENV_VAR\",\n          confidence: \"high\",\n          source: \"env-file\",\n          fileName: \".env\",\n        },\n      ],\n      skipSecretExtraction: false,\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body.placeholderReview).toEqual({\n      found: false,\n      count: 0,\n      vars: [],\n    });\n    expect(res.body.canonicalizedEnvRefs).toBe(1);\n    expect(deps.writeEnvFile).toHaveBeenCalledWith([\n      { key: \"BRAVE_API_KEY\", value: \"brave-live-value\" },\n    ]);\n    const importedConfig = files.get(\n      path.join(deps.constants.OPENCLAW_DIR, \"openclaw.json\"),\n    );\n    expect(importedConfig).toContain('\"apiKey\": \"${BRAVE_API_KEY}\"');\n    expect(importedConfig).not.toContain(\"REDACTED_USE_ENV_VAR\");\n  });\n\n  it(\"rejects import apply when approved secrets were not in the server scan\", async () => {\n    const deps = createBaseDeps();\n    const tempDir = path.join(os.tmpdir(), \"alphaclaw-import-invalid-secret\");\n    const files = new Map([\n      [\n        path.join(tempDir, \"openclaw.json\"),\n        JSON.stringify({\n          models: {\n            providers: {\n              openai: {\n                apiKey: \"sk-live-real-secret\",\n              },\n            },\n          },\n        }),\n      ],\n    ]);\n    deps.fs.existsSync.mockImplementation(\n      (targetPath) => targetPath === tempDir || files.has(targetPath),\n    );\n    deps.fs.statSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return { isFile: () => false, isDirectory: () => true };\n      }\n      if (files.has(targetPath)) {\n        return { isFile: () => true, isDirectory: () => false };\n      }\n      throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n    });\n    deps.fs.readdirSync.mockImplementation((targetPath) => {\n      if (targetPath === tempDir) {\n        return [{ name: \"openclaw.json\", isFile: () => true, isDirectory: () => false }];\n      }\n      return [];\n    });\n    deps.fs.readFileSync.mockImplementation((targetPath) => files.get(targetPath) || \"{}\");\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/onboard/import/apply\").send({\n      tempDir,\n      approvedSecrets: [\n        {\n          file: \"../outside.json\",\n          configPath: \"models.providers.openai.apiKey\",\n          value: \"sk-live-real-secret\",\n          suggestedEnvVar: \"BAD KEY\",\n        },\n      ],\n      skipSecretExtraction: false,\n    });\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"Invalid approved secrets payload\",\n    });\n    expect(deps.writeEnvFile).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-pairings.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerPairingRoutes } = require(\"../../lib/server/routes/pairings\");\n\nconst createApp = ({ clawCmd, isOnboarded, fsModule, approveDevicePairingDirect }) => {\n  const app = express();\n  app.use(express.json());\n  registerPairingRoutes({\n    app,\n    clawCmd,\n    isOnboarded,\n    fsModule,\n    openclawDir: \"/tmp/openclaw\",\n    approveDevicePairingDirect,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/pairings\", () => {\n  it(\"lists pending pairings with account ids from CLI json output\", async () => {\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"pairing list --channel telegram --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({\n            requests: [\n              {\n                id: \"1050628644\",\n                code: \"ABCD1234\",\n                meta: { accountId: \"tester\" },\n              },\n            ],\n          }),\n          stderr: \"\",\n        };\n      }\n      if (cmd === \"pairing list --channel discord --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({ requests: [] }),\n          stderr: \"\",\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/openclaw.json\") {\n          return JSON.stringify({\n            channels: {\n              telegram: { enabled: true },\n              discord: { enabled: true },\n            },\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/pairings\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([\n      {\n        id: \"ABCD1234\",\n        code: \"ABCD1234\",\n        channel: \"telegram\",\n        accountId: \"tester\",\n        requesterId: \"1050628644\",\n      },\n    ]);\n  });\n\n  it(\"falls back to the local pairing store when CLI output is empty\", async () => {\n    const createdAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"pairing list --channel telegram --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({ requests: [] }),\n          stderr: \"\",\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/openclaw.json\") {\n          return JSON.stringify({\n            channels: {\n              telegram: { enabled: true },\n            },\n          });\n        }\n        if (targetPath === \"/tmp/openclaw/credentials/telegram-pairing.json\") {\n          return JSON.stringify({\n            version: 1,\n            requests: [\n              {\n                id: \"1050628644\",\n                code: \"ABCD1234\",\n                createdAt,\n                lastSeenAt: createdAt,\n                meta: { accountId: \"tester\" },\n              },\n            ],\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/pairings\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([\n      {\n        id: \"ABCD1234\",\n        code: \"ABCD1234\",\n        channel: \"telegram\",\n        accountId: \"tester\",\n        requesterId: \"1050628644\",\n        createdAt,\n      },\n    ]);\n  });\n\n  it(\"parses pending pairings from noisy stderr even when the command exits non-zero\", async () => {\n    const createdAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"pairing list --channel telegram --json\") {\n        return {\n          ok: false,\n          stdout: \"\",\n          stderr: [\n            \"00:20:56 [plugins] [usage-tracker] initialized db=/data/db/usage.db\",\n            \"{\",\n            '  \"channel\": \"telegram\",',\n            '  \"requests\": [',\n            \"    {\",\n            '      \"id\": \"1050628644\",',\n            '      \"code\": \"PCQPPPVM\",',\n            `      \"createdAt\": \"${createdAt}\",`,\n            `      \"lastSeenAt\": \"${createdAt}\",`,\n            '      \"meta\": { \"accountId\": \"default\" }',\n            \"    }\",\n            \"  ]\",\n            \"}\",\n            \"00:21:08 [plugins] ollama installed bundled runtime deps: @sinclair/typebox@0.34.49\",\n          ].join(\"\\n\"),\n          code: 1,\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/openclaw.json\") {\n          return JSON.stringify({\n            channels: {\n              telegram: { enabled: true },\n            },\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/pairings\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([\n      {\n        id: \"PCQPPPVM\",\n        code: \"PCQPPPVM\",\n        channel: \"telegram\",\n        accountId: \"default\",\n        requesterId: \"1050628644\",\n      },\n    ]);\n  });\n\n  it(\"includes pending store requests even when the channel is not enabled in config\", async () => {\n    const createdAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"{}\", stderr: \"\" }));\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/openclaw.json\") {\n          return JSON.stringify({ channels: {} });\n        }\n        if (targetPath === \"/tmp/openclaw/credentials/telegram-pairing.json\") {\n          return JSON.stringify({\n            version: 1,\n            requests: [\n              {\n                id: \"1050628644\",\n                code: \"PCQPPPVM\",\n                createdAt,\n                lastSeenAt: createdAt,\n                meta: { accountId: \"default\" },\n              },\n            ],\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/pairings\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([\n      {\n        id: \"PCQPPPVM\",\n        code: \"PCQPPPVM\",\n        channel: \"telegram\",\n        accountId: \"default\",\n        requesterId: \"1050628644\",\n        createdAt,\n      },\n    ]);\n  });\n\n  it(\"parses noisy json stdout without duplicating requester ids as codes\", async () => {\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"pairing list --channel telegram --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({ requests: [] }),\n          stderr: \"\",\n        };\n      }\n      if (cmd === \"pairing list --channel discord --json\") {\n        return {\n          ok: true,\n          stdout: [\n            \"debug preface\",\n            \"{\",\n            '  \"channel\": \"discord\",',\n            '  \"requests\": [',\n            \"    {\",\n            '      \"id\": \"21963048\",',\n            '      \"code\": \"TTK6H5HX\"',\n            \"    }\",\n            \"  ]\",\n            \"}\",\n          ].join(\"\\n\"),\n          stderr: \"\",\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/openclaw.json\") {\n          return JSON.stringify({\n            channels: {\n              telegram: { enabled: true },\n              discord: { enabled: true },\n            },\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/pairings\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([\n      {\n        id: \"TTK6H5HX\",\n        code: \"TTK6H5HX\",\n        channel: \"discord\",\n        accountId: \"default\",\n        requesterId: \"21963048\",\n      },\n    ]);\n  });\n\n  it(\"passes account id through on pairing approval\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const fsModule = {\n      existsSync: vi.fn(() => false),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).post(\"/api/pairings/ABCD1234/approve\").send({\n      channel: \"telegram\",\n      accountId: \"tester\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(clawCmd).toHaveBeenCalledWith(\n      \"pairing approve --channel 'telegram' --account 'tester' 'ABCD1234'\",\n    );\n  });\n\n  it(\"rejects invalid pairing approval input before running command\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const fsModule = {\n      existsSync: vi.fn(() => false),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const invalidChannelRes = await request(app)\n      .post(\"/api/pairings/ABCD1234/approve\")\n      .send({ channel: \"telegram; rm -rf /\" });\n    expect(invalidChannelRes.status).toBe(400);\n    expect(invalidChannelRes.body.ok).toBe(false);\n\n    const invalidAccountRes = await request(app)\n      .post(\"/api/pairings/ABCD1234/approve\")\n      .send({ channel: \"telegram\", accountId: \"bad account id\" });\n    expect(invalidAccountRes.status).toBe(400);\n    expect(invalidAccountRes.body.ok).toBe(false);\n\n    const invalidPairingIdRes = await request(app)\n      .post(\"/api/pairings/abc def/approve\")\n      .send({ channel: \"telegram\", accountId: \"tester\" });\n    expect(invalidPairingIdRes.status).toBe(400);\n    expect(invalidPairingIdRes.body.ok).toBe(false);\n\n    expect(clawCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"rejects pairing and removes matching request from store\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const fsModule = {\n      existsSync: vi.fn(() => false),\n      mkdirSync: vi.fn(),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/credentials/telegram-pairing.json\") {\n          return JSON.stringify({\n            version: 1,\n            requests: [\n              { code: \"ABCD1234\", meta: { accountId: \"tester\" } },\n              { code: \"OTHER111\", meta: { accountId: \"default\" } },\n            ],\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).post(\"/api/pairings/ABCD1234/reject\").send({\n      channel: \"telegram\",\n      accountId: \"tester\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true, removed: true });\n    expect(fsModule.writeFileSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/credentials/telegram-pairing.json\",\n      JSON.stringify(\n        {\n          version: 1,\n          requests: [{ code: \"OTHER111\", meta: { accountId: \"default\" } }],\n        },\n        null,\n        2,\n      ),\n    );\n  });\n\n  it(\"returns not found when reject target does not exist\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const fsModule = {\n      existsSync: vi.fn(() => false),\n      mkdirSync: vi.fn(),\n      readFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/credentials/telegram-pairing.json\") {\n          return JSON.stringify({\n            version: 1,\n            requests: [{ code: \"OTHER111\", meta: { accountId: \"default\" } }],\n          });\n        }\n        throw new Error(`unexpected read: ${targetPath}`);\n      }),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).post(\"/api/pairings/MISSING/reject\").send({\n      channel: \"telegram\",\n      accountId: \"tester\",\n    });\n\n    expect(res.status).toBe(404);\n    expect(res.body).toEqual({\n      ok: false,\n      removed: false,\n      error: \"Pairing request not found\",\n    });\n    expect(fsModule.writeFileSync).not.toHaveBeenCalled();\n  });\n\n  it(\"auto-approves the first pending CLI device request when marker is absent\", async () => {\n    let cliMarkerWritten = false;\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"devices list --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({\n            pending: [\n              {\n                requestId: \"req-cli-1\",\n                clientId: \"cli\",\n                clientMode: \"cli\",\n                platform: \"darwin\",\n                role: \"user\",\n                scopes: [\"chat\"],\n                ts: \"2026-02-22T00:00:00.000Z\",\n              },\n            ],\n          }),\n        };\n      }\n      if (cmd === \"devices approve req-cli-1\") {\n        return { ok: true, stdout: \"\", stderr: \"\" };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const approveDevicePairingDirect = vi.fn(async () => ({\n      status: \"approved\",\n      requestId: \"req-cli-1\",\n      device: { deviceId: \"cli-device-1\" },\n    }));\n    const fsModule = {\n      existsSync: vi.fn(() => cliMarkerWritten),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn((targetPath) => {\n        if (targetPath === \"/tmp/openclaw/.alphaclaw/.cli-device-auto-approved\") {\n          cliMarkerWritten = true;\n        }\n      }),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n      approveDevicePairingDirect,\n    });\n\n    const res = await request(app).get(\"/api/devices\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      pending: [],\n      cliAutoApproveComplete: true,\n    });\n    expect(clawCmd).not.toHaveBeenCalledWith(\"devices approve req-cli-1\", { quiet: true });\n    expect(approveDevicePairingDirect).toHaveBeenCalledWith(\n      \"req-cli-1\",\n      {\n        callerScopes: expect.arrayContaining([\"operator.admin\", \"operator.pairing\"]),\n      },\n      \"/tmp/openclaw\",\n    );\n    expect(fsModule.writeFileSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/.alphaclaw/.cli-device-auto-approved\",\n      expect.stringContaining(\"approvedAt\"),\n    );\n  });\n\n  it(\"parses noisy json stdout from devices list\", async () => {\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"devices list --json\") {\n        return {\n          ok: true,\n          stdout: [\n            \"some warning text\",\n            JSON.stringify({\n              pending: [\n                {\n                  requestId: \"req-ui-1\",\n                  clientId: \"openclaw-control-ui\",\n                  clientMode: \"webchat\",\n                  platform: \"MacIntel\",\n                  role: \"operator\",\n                  scopes: [\"operator.admin\"],\n                  ts: 1773506886016,\n                },\n              ],\n            }),\n          ].join(\"\\n\"),\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/devices\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.pending).toEqual([\n      expect.objectContaining({\n        id: \"req-ui-1\",\n        clientId: \"openclaw-control-ui\",\n        clientMode: \"webchat\",\n      }),\n    ]);\n    expect(clawCmd).toHaveBeenCalledWith(\"devices list --json\", {\n      quiet: true,\n      timeoutMs: 5000,\n    });\n  });\n\n  it(\"approves device pairing through the OpenClaw helper with admin caller scope\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const approveDevicePairingDirect = vi.fn(async () => ({\n      status: \"approved\",\n      requestId: \"req-admin-1\",\n      device: {\n        deviceId: \"admin-device-1\",\n        publicKey: \"public-key\",\n        clientId: \"openclaw-control-ui\",\n        tokens: {\n          operator: {\n            token: \"secret-token\",\n            role: \"operator\",\n            scopes: [\"operator.admin\"],\n          },\n        },\n      },\n    }));\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n      approveDevicePairingDirect,\n    });\n\n    const res = await request(app).post(\"/api/devices/req-admin-1/approve\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      requestId: \"req-admin-1\",\n      device: {\n        deviceId: \"admin-device-1\",\n        clientId: \"openclaw-control-ui\",\n      },\n    });\n    expect(approveDevicePairingDirect).toHaveBeenCalledWith(\n      \"req-admin-1\",\n      {\n        callerScopes: expect.arrayContaining([\"operator.admin\", \"operator.pairing\"]),\n      },\n      \"/tmp/openclaw\",\n    );\n    expect(clawCmd).not.toHaveBeenCalledWith(expect.stringContaining(\"devices approve\"));\n  });\n\n  it(\"returns a visible failure when direct device approval lacks scope\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"\", stderr: \"\" }));\n    const approveDevicePairingDirect = vi.fn(async () => ({\n      status: \"forbidden\",\n      reason: \"caller-missing-scope\",\n      scope: \"operator.admin\",\n    }));\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n      approveDevicePairingDirect,\n    });\n\n    const res = await request(app).post(\"/api/devices/req-admin-2/approve\");\n\n    expect(res.status).toBe(403);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"missing scope: operator.admin\",\n    });\n    expect(clawCmd).not.toHaveBeenCalledWith(expect.stringContaining(\"devices approve\"));\n  });\n\n  it(\"does not auto-approve when CLI marker already exists\", async () => {\n    const clawCmd = vi.fn(async (cmd) => {\n      if (cmd === \"devices list --json\") {\n        return {\n          ok: true,\n          stdout: JSON.stringify({\n            pending: [\n              {\n                requestId: \"req-cli-2\",\n                clientId: \"cli\",\n                clientMode: \"cli\",\n                platform: \"linux\",\n              },\n            ],\n          }),\n        };\n      }\n      return { ok: true, stdout: \"{}\", stderr: \"\" };\n    });\n    const fsModule = {\n      existsSync: vi.fn(() => true),\n      mkdirSync: vi.fn(),\n      writeFileSync: vi.fn(),\n    };\n    const app = createApp({\n      clawCmd,\n      isOnboarded: () => true,\n      fsModule,\n    });\n\n    const res = await request(app).get(\"/api/devices\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      pending: [\n        expect.objectContaining({\n          id: \"req-cli-2\",\n          clientId: \"cli\",\n          clientMode: \"cli\",\n        }),\n      ],\n      cliAutoApproveComplete: true,\n    });\n    expect(clawCmd).not.toHaveBeenCalledWith(\"devices approve req-cli-2\", { quiet: true });\n    expect(fsModule.writeFileSync).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-system.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerSystemRoutes } = require(\"../../lib/server/routes/system\");\n\nconst createSystemDeps = () => {\n  const deps = {\n    fs: {\n      existsSync: vi.fn(() => true),\n      readFileSync: vi.fn(() => {\n        throw new Error(\"no config\");\n      }),\n      writeFileSync: vi.fn(),\n      mkdirSync: vi.fn(),\n      rmSync: vi.fn(),\n    },\n    readEnvFile: vi.fn(() => []),\n    writeEnvFile: vi.fn(),\n    reloadEnv: vi.fn(() => true),\n    kKnownVars: [\n      {\n        key: \"OPENAI_API_KEY\",\n        label: \"OpenAI API Key\",\n        group: \"ai\",\n        hint: \"\",\n        features: [\"Models\", \"Embeddings\", \"TTS\", \"STT\"],\n      },\n      {\n        key: \"ANTHROPIC_TOKEN\",\n        label: \"Anthropic Setup Token\",\n        group: \"ai\",\n        hint: \"\",\n        features: [\"Models\"],\n        visibleInEnvars: false,\n      },\n      { key: \"GITHUB_TOKEN\", label: \"GitHub Access Token\", group: \"github\", hint: \"\" },\n    ],\n    kKnownKeys: new Set([\"OPENAI_API_KEY\", \"ANTHROPIC_TOKEN\", \"GITHUB_TOKEN\"]),\n    kSystemVars: new Set([\"PORT\", \"SETUP_PASSWORD\"]),\n    syncChannelConfig: vi.fn(),\n    isGatewayRunning: vi.fn(async () => true),\n    isOnboarded: vi.fn(() => true),\n    getChannelStatus: vi.fn(() => ({ telegram: \"ready\" })),\n    openclawVersionService: {\n      readOpenclawVersion: vi.fn(() => \"1.2.3\"),\n      getVersionStatus: vi.fn(async () => ({ ok: true, current: \"1.2.3\" })),\n      updateOpenclaw: vi.fn(async () => ({ status: 200, body: { ok: true } })),\n    },\n    alphaclawVersionService: {\n      readAlphaclawVersion: vi.fn(() => \"0.1.5\"),\n      getVersionStatus: vi.fn(async () => ({\n        ok: true,\n        currentVersion: \"0.1.5\",\n        currentOpenclawVersion: \"1.2.3\",\n        latestVersion: \"0.2.0\",\n        latestOpenclawVersion: \"1.3.0\",\n        hasUpdate: true,\n        updateStrategy: {\n          action: \"self-update\",\n          provider: \"self-hosted\",\n          label: \"This install\",\n          description: \"Update in place\",\n          steps: [],\n          primaryActionLabel: \"Update now\",\n        },\n      })),\n      updateAlphaclaw: vi.fn(async () => ({\n        status: 200,\n        body: { ok: true, previousVersion: \"0.1.5\", restarting: true },\n      })),\n      restartProcess: vi.fn(),\n    },\n    clawCmd: vi.fn(async () => ({ ok: true, stdout: \"\" })),\n    restartGateway: vi.fn(),\n    restartRequiredState: {\n      getSnapshot: vi.fn(async () => ({\n        restartRequired: false,\n        restartInProgress: false,\n        gatewayRunning: true,\n      })),\n      markRestartInProgress: vi.fn(),\n      clearRequired: vi.fn(),\n      markRestartComplete: vi.fn(),\n    },\n    topicRegistry: {\n      getGroup: vi.fn(() => null),\n    },\n    authProfiles: {\n      listApiKeyProviders: vi.fn(() => [\"openai\"]),\n      getEnvVarForApiKeyProvider: vi.fn((provider) =>\n        provider === \"openai\" ? \"OPENAI_API_KEY\" : \"\",\n      ),\n      upsertApiKeyProfileForEnvVar: vi.fn(),\n      removeApiKeyProfileForEnvVar: vi.fn(),\n    },\n    OPENCLAW_DIR: \"/tmp/openclaw\",\n  };\n  return deps;\n};\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  registerSystemRoutes({\n    app,\n    ...deps,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/system\", () => {\n  it(\"merges known vars and custom vars on GET /api/env\", async () => {\n    const deps = createSystemDeps();\n    deps.readEnvFile.mockReturnValue([\n      { key: \"OPENAI_API_KEY\", value: \"abc\" },\n      { key: \"PORT\", value: \"3000\" },\n      { key: \"CUSTOM_FLAG\", value: \"1\" },\n    ]);\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/env\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.vars).toEqual(\n      expect.arrayContaining([\n        expect.objectContaining({\n          key: \"OPENAI_API_KEY\",\n          value: \"abc\",\n          features: [\"Models\", \"Embeddings\", \"TTS\", \"STT\"],\n          source: \"env_file\",\n        }),\n        expect.objectContaining({\n          key: \"GITHUB_TOKEN\",\n          value: \"\",\n          source: \"unset\",\n        }),\n        expect.objectContaining({\n          key: \"CUSTOM_FLAG\",\n          value: \"1\",\n          group: \"custom\",\n        }),\n      ]),\n    );\n    expect(res.body.vars.some((entry) => entry.key === \"PORT\")).toBe(false);\n    expect(res.body.vars.some((entry) => entry.key === \"ANTHROPIC_TOKEN\")).toBe(false);\n    expect(res.body.vars.some((entry) => entry.key === \"GITHUB_WORKSPACE_REPO\")).toBe(\n      false,\n    );\n    expect(res.body.reservedKeys).toEqual(\n      expect.arrayContaining([\n        \"PORT\",\n        \"SETUP_PASSWORD\",\n        \"GITHUB_WORKSPACE_REPO\",\n        \"GOG_KEYRING_PASSWORD\",\n      ]),\n    );\n    expect(res.body.restartRequired).toBe(false);\n  });\n\n  it(\"rejects reserved vars on PUT /api/env\", async () => {\n    const deps = createSystemDeps();\n    deps.reloadEnv.mockReturnValue(true);\n    deps.readEnvFile.mockReturnValue([\n      { key: \"GITHUB_WORKSPACE_REPO\", value: \"owner/repo\" },\n    ]);\n    const app = createApp(deps);\n\n    const payload = {\n      vars: [\n        { key: \"OPENAI_API_KEY\", value: \"abc\" },\n        { key: \"PORT\", value: \"3000\" },\n        { key: \"GITHUB_WORKSPACE_REPO\", value: \"changed/repo\" },\n      ],\n    };\n\n    const res = await request(app).put(\"/api/env\").send(payload);\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain(\"Reserved environment variables cannot be edited\");\n    expect(res.body.error).toContain(\"PORT\");\n    expect(res.body.error).toContain(\"GITHUB_WORKSPACE_REPO\");\n    expect(deps.writeEnvFile).not.toHaveBeenCalled();\n    expect(deps.syncChannelConfig).not.toHaveBeenCalled();\n    expect(deps.restartGateway).not.toHaveBeenCalled();\n  });\n\n  it(\"rejects gog keyring password edits on PUT /api/env\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"GOG_KEYRING_PASSWORD\", value: \"changed\" }],\n    });\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain(\"GOG_KEYRING_PASSWORD\");\n    expect(deps.writeEnvFile).not.toHaveBeenCalled();\n    expect(deps.syncChannelConfig).not.toHaveBeenCalled();\n  });\n\n  it(\"does not restart gateway when env is unchanged\", async () => {\n    const deps = createSystemDeps();\n    deps.reloadEnv.mockReturnValue(false);\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"same\" }],\n    });\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({ ok: true, changed: false, restartRequired: false });\n    expect(deps.restartGateway).not.toHaveBeenCalled();\n  });\n\n  it(\"preserves hidden known vars on PUT /api/env\", async () => {\n    const deps = createSystemDeps();\n    deps.readEnvFile.mockReturnValue([\n      { key: \"ANTHROPIC_TOKEN\", value: \"hidden-token\" },\n    ]);\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"same\" }],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"same\" },\n      { key: \"ANTHROPIC_TOKEN\", value: \"hidden-token\" },\n    ]);\n  });\n\n  it(\"hides and preserves managed slack channel tokens on /api/env\", async () => {\n    const deps = createSystemDeps();\n    deps.readEnvFile.mockReturnValue([\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-hidden\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-hidden\" },\n    ]);\n    const app = createApp(deps);\n\n    const getRes = await request(app).get(\"/api/env\");\n    expect(getRes.status).toBe(200);\n    expect(getRes.body.vars.some((entry) => entry.key === \"SLACK_BOT_TOKEN\")).toBe(\n      false,\n    );\n    expect(getRes.body.vars.some((entry) => entry.key === \"SLACK_APP_TOKEN\")).toBe(\n      false,\n    );\n\n    const putRes = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"same\" }],\n    });\n    expect(putRes.status).toBe(200);\n    expect(deps.writeEnvFile).toHaveBeenCalledWith([\n      { key: \"OPENAI_API_KEY\", value: \"same\" },\n      { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-hidden\" },\n      { key: \"SLACK_APP_TOKEN\", value: \"xapp-hidden\" },\n    ]);\n  });\n\n  it(\"syncs API-key auth profiles from known env vars on save\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"sk-test-123\" }],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.authProfiles.getEnvVarForApiKeyProvider).toHaveBeenCalledWith(\"openai\");\n    expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).toHaveBeenCalledWith(\n      \"openai\",\n      \"sk-test-123\",\n    );\n  });\n\n  it(\"removes mirrored auth profile when synced env var is cleared\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"\" }],\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.authProfiles.removeApiKeyProfileForEnvVar).toHaveBeenCalledWith(\n      \"openai\",\n    );\n    expect(deps.authProfiles.upsertApiKeyProfileForEnvVar).not.toHaveBeenCalled();\n  });\n\n  it(\"keeps restartRequired true until gateway restart\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const firstSave = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"abc\" }],\n    });\n    expect(firstSave.status).toBe(200);\n    expect(firstSave.body.restartRequired).toBe(true);\n\n    deps.reloadEnv.mockReturnValue(false);\n    const secondSave = await request(app).put(\"/api/env\").send({\n      vars: [{ key: \"OPENAI_API_KEY\", value: \"abc\" }],\n    });\n    expect(secondSave.status).toBe(200);\n    expect(secondSave.body).toEqual({\n      ok: true,\n      changed: false,\n      restartRequired: true,\n    });\n\n    const envBeforeRestart = await request(app).get(\"/api/env\");\n    expect(envBeforeRestart.status).toBe(200);\n    expect(envBeforeRestart.body.restartRequired).toBe(true);\n\n    const restart = await request(app).post(\"/api/gateway/restart\");\n    expect(restart.status).toBe(200);\n    expect(restart.body).toEqual({ ok: true, restartRequired: false });\n    expect(deps.restartGateway).toHaveBeenCalledTimes(1);\n\n    const envAfterRestart = await request(app).get(\"/api/env\");\n    expect(envAfterRestart.status).toBe(200);\n    expect(envAfterRestart.body.restartRequired).toBe(false);\n  });\n\n  it(\"returns 400 when vars payload is missing\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/env\").send({});\n\n    expect(res.status).toBe(400);\n    expect(res.body).toEqual({ ok: false, error: \"Missing vars array\" });\n  });\n\n  it(\"reports running gateway status on GET /api/status\", async () => {\n    const deps = createSystemDeps();\n    deps.fs.existsSync.mockReturnValue(true);\n    deps.isGatewayRunning.mockResolvedValue(true);\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/status\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        gateway: \"running\",\n        configExists: true,\n        openclawVersion: \"1.2.3\",\n        syncCron: expect.objectContaining({\n          enabled: true,\n          schedule: \"0 * * * *\",\n        }),\n      }),\n    );\n  });\n\n  it(\"returns tokenized dashboard URL when OpenClaw CLI prints a token\", async () => {\n    const previousEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    delete process.env.OPENCLAW_GATEWAY_TOKEN;\n    try {\n      const deps = createSystemDeps();\n      deps.clawCmd.mockResolvedValueOnce({\n        ok: true,\n        stdout: \"Dashboard URL: http://127.0.0.1:18789/#token=abc123\",\n      });\n      const app = createApp(deps);\n\n      const res = await request(app).get(\"/api/gateway/dashboard\");\n\n      expect(res.status).toBe(200);\n      expect(res.body).toEqual({ ok: true, url: \"/openclaw/#token=abc123\" });\n    } finally {\n      if (previousEnvToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;\n      else process.env.OPENCLAW_GATEWAY_TOKEN = previousEnvToken;\n    }\n  });\n\n  it(\"falls back to plain configured gateway token for dashboard URL\", async () => {\n    const deps = createSystemDeps();\n    deps.clawCmd.mockResolvedValueOnce({\n      ok: true,\n      stdout: \"Dashboard URL: http://127.0.0.1:18789/\",\n    });\n    deps.fs.readFileSync.mockImplementation((filePath) => {\n      if (String(filePath).endsWith(\"openclaw.json\")) {\n        return JSON.stringify({ gateway: { auth: { token: \"cfg-token+value\" } } });\n      }\n      throw new Error(\"unexpected file\");\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/gateway/dashboard\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      url: \"/openclaw/#token=cfg-token%2Bvalue\",\n      source: \"config\",\n    });\n    expect(deps.clawCmd).not.toHaveBeenCalled();\n  });\n\n  it(\"falls back to OPENCLAW_GATEWAY_TOKEN from env file for dashboard URL\", async () => {\n    const previousEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    process.env.OPENCLAW_GATEWAY_TOKEN = \"\";\n    try {\n      const deps = createSystemDeps();\n      deps.clawCmd.mockResolvedValueOnce({\n        ok: true,\n        stdout: \"Dashboard URL: http://127.0.0.1:18789/\",\n      });\n      deps.readEnvFile.mockReturnValue([\n        { key: \"OPENCLAW_GATEWAY_TOKEN\", value: \"env-token\" },\n      ]);\n      const app = createApp(deps);\n\n      const res = await request(app).get(\"/api/gateway/dashboard\");\n\n      expect(res.status).toBe(200);\n      expect(res.body).toEqual({\n        ok: true,\n        url: \"/openclaw/#token=env-token\",\n        source: \"config\",\n      });\n    } finally {\n      if (previousEnvToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;\n      else process.env.OPENCLAW_GATEWAY_TOKEN = previousEnvToken;\n    }\n  });\n\n  it(\"resolves configured OPENCLAW_GATEWAY_TOKEN env refs for dashboard URL\", async () => {\n    const previousEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    process.env.OPENCLAW_GATEWAY_TOKEN = \"real-env-token+value\";\n    try {\n      const deps = createSystemDeps();\n      deps.clawCmd.mockResolvedValueOnce({\n        ok: true,\n        stdout: \"Dashboard URL: http://127.0.0.1:18789/\",\n      });\n      deps.fs.readFileSync.mockImplementation((filePath) => {\n        if (String(filePath).endsWith(\"openclaw.json\")) {\n          return JSON.stringify({\n            gateway: { auth: { token: \"${OPENCLAW_GATEWAY_TOKEN}\" } },\n          });\n        }\n        throw new Error(\"unexpected file\");\n      });\n      const app = createApp(deps);\n\n      const res = await request(app).get(\"/api/gateway/dashboard\");\n\n      expect(res.status).toBe(200);\n      expect(res.body).toEqual({\n        ok: true,\n        url: \"/openclaw/#token=real-env-token%2Bvalue\",\n        source: \"config\",\n      });\n    } finally {\n      if (previousEnvToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;\n      else process.env.OPENCLAW_GATEWAY_TOKEN = previousEnvToken;\n    }\n  });\n\n  it(\"resolves configured OPENCLAW_GATEWAY_TOKEN env refs from env file for dashboard URL\", async () => {\n    const previousEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    delete process.env.OPENCLAW_GATEWAY_TOKEN;\n    try {\n      const deps = createSystemDeps();\n      deps.clawCmd.mockResolvedValueOnce({\n        ok: true,\n        stdout: \"Dashboard URL: http://127.0.0.1:18789/\",\n      });\n      deps.fs.readFileSync.mockImplementation((filePath) => {\n        if (String(filePath).endsWith(\"openclaw.json\")) {\n          return JSON.stringify({\n            gateway: { auth: { token: \"${OPENCLAW_GATEWAY_TOKEN}\" } },\n          });\n        }\n        throw new Error(\"unexpected file\");\n      });\n      deps.readEnvFile.mockReturnValue([\n        { key: \"OPENCLAW_GATEWAY_TOKEN\", value: \"env-file-token\" },\n      ]);\n      const app = createApp(deps);\n\n      const res = await request(app).get(\"/api/gateway/dashboard\");\n\n      expect(res.status).toBe(200);\n      expect(res.body).toEqual({\n        ok: true,\n        url: \"/openclaw/#token=env-file-token\",\n        source: \"config\",\n      });\n    } finally {\n      if (previousEnvToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;\n      else process.env.OPENCLAW_GATEWAY_TOKEN = previousEnvToken;\n    }\n  });\n\n  it(\"resolves configured object SecretRefs for dashboard URL\", async () => {\n    const previousEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    process.env.OPENCLAW_GATEWAY_TOKEN = \"object-ref-token+value\";\n    try {\n      const deps = createSystemDeps();\n      deps.fs.readFileSync.mockImplementation((filePath) => {\n        if (String(filePath).endsWith(\"openclaw.json\")) {\n          return JSON.stringify({\n            gateway: {\n              auth: {\n                token: {\n                  source: \"env\",\n                  provider: \"default\",\n                  id: \"OPENCLAW_GATEWAY_TOKEN\",\n                },\n              },\n            },\n          });\n        }\n        throw new Error(\"unexpected file\");\n      });\n      const app = createApp(deps);\n\n      const res = await request(app).get(\"/api/gateway/dashboard\");\n\n      expect(res.status).toBe(200);\n      expect(res.body).toEqual({\n        ok: true,\n        url: \"/openclaw/#token=object-ref-token%2Bvalue\",\n        source: \"config\",\n      });\n      expect(deps.clawCmd).not.toHaveBeenCalled();\n    } finally {\n      if (previousEnvToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;\n      else process.env.OPENCLAW_GATEWAY_TOKEN = previousEnvToken;\n    }\n  });\n\n  it(\"marks dashboard URL as needing auth when no token can be resolved\", async () => {\n    const previousEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN;\n    delete process.env.OPENCLAW_GATEWAY_TOKEN;\n    try {\n      const deps = createSystemDeps();\n      deps.clawCmd.mockResolvedValueOnce({\n        ok: true,\n        stdout: \"Dashboard URL: http://127.0.0.1:18789/\",\n      });\n      const app = createApp(deps);\n\n      const res = await request(app).get(\"/api/gateway/dashboard\");\n\n      expect(res.status).toBe(200);\n      expect(res.body).toEqual({ ok: true, url: \"/openclaw\", needsAuth: true });\n    } finally {\n      if (previousEnvToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;\n      else process.env.OPENCLAW_GATEWAY_TOKEN = previousEnvToken;\n    }\n  });\n\n  it(\"returns sync cron status on GET /api/sync-cron\", async () => {\n    const deps = createSystemDeps();\n    deps.fs.readFileSync.mockReturnValueOnce(\n      JSON.stringify({ enabled: false, schedule: \"*/30 * * * *\" }),\n    );\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/sync-cron\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        ok: true,\n        enabled: false,\n        schedule: \"*/30 * * * *\",\n      }),\n    );\n  });\n\n  it(\"updates sync cron config on PUT /api/sync-cron\", async () => {\n    const deps = createSystemDeps();\n    deps.fs.readFileSync.mockReturnValueOnce(\n      JSON.stringify({ enabled: true, schedule: \"0 * * * *\" }),\n    );\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/sync-cron\").send({\n      enabled: true,\n      schedule: \"*/15 * * * *\",\n    });\n\n    expect(res.status).toBe(200);\n    expect(deps.fs.mkdirSync).toHaveBeenCalledWith(\"/tmp/openclaw/cron\", {\n      recursive: true,\n    });\n    expect(deps.fs.writeFileSync).toHaveBeenCalledWith(\n      \"/tmp/openclaw/cron/system-sync.json\",\n      expect.stringContaining('\"schedule\": \"*/15 * * * *\"'),\n    );\n    expect(deps.fs.writeFileSync).toHaveBeenCalledWith(\n      \"/etc/cron.d/openclaw-hourly-sync\",\n      expect.stringContaining('*/15 * * * * root bash \"/tmp/openclaw/.alphaclaw/hourly-git-sync.sh\"'),\n      expect.objectContaining({ mode: 0o644 }),\n    );\n    expect(res.body.ok).toBe(true);\n  });\n\n  it(\"returns alphaclaw version status on GET /api/alphaclaw/version\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/alphaclaw/version\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual(\n      expect.objectContaining({\n        ok: true,\n        currentVersion: \"0.1.5\",\n        currentOpenclawVersion: \"1.2.3\",\n        latestVersion: \"0.2.0\",\n        latestOpenclawVersion: \"1.3.0\",\n        hasUpdate: true,\n        updateStrategy: expect.objectContaining({\n          action: \"self-update\",\n          provider: \"self-hosted\",\n        }),\n      }),\n    );\n    expect(deps.alphaclawVersionService.getVersionStatus).toHaveBeenCalledWith(false);\n  });\n\n  it(\"passes refresh flag to alphaclaw version service\", async () => {\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    await request(app).get(\"/api/alphaclaw/version?refresh=1\");\n\n    expect(deps.alphaclawVersionService.getVersionStatus).toHaveBeenCalledWith(true);\n  });\n\n  it(\"returns update result and schedules restart on POST /api/alphaclaw/update\", async () => {\n    vi.useFakeTimers();\n    const deps = createSystemDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/alphaclaw/update\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      previousVersion: \"0.1.5\",\n      restarting: true,\n    });\n    expect(deps.alphaclawVersionService.updateAlphaclaw).toHaveBeenCalledTimes(1);\n    await vi.advanceTimersByTimeAsync(1000);\n    expect(deps.alphaclawVersionService.restartProcess).toHaveBeenCalledTimes(1);\n    vi.useRealTimers();\n  });\n\n  it(\"does not schedule a local restart for managed updates\", async () => {\n    vi.useFakeTimers();\n    const deps = createSystemDeps();\n    deps.alphaclawVersionService.updateAlphaclaw.mockResolvedValue({\n      status: 200,\n      body: {\n        ok: true,\n        previousVersion: \"0.1.5\",\n        latestVersion: \"0.2.0\",\n        latestOpenclawVersion: \"1.3.0\",\n        restarting: true,\n        managedUpdate: true,\n      },\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/alphaclaw/update\");\n\n    expect(res.status).toBe(200);\n    await vi.advanceTimersByTimeAsync(1000);\n    expect(deps.alphaclawVersionService.restartProcess).not.toHaveBeenCalled();\n    vi.useRealTimers();\n  });\n\n  it(\"returns error status when alphaclaw update fails\", async () => {\n    const deps = createSystemDeps();\n    deps.alphaclawVersionService.updateAlphaclaw.mockResolvedValue({\n      status: 500,\n      body: { ok: false, error: \"npm install failed\" },\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/alphaclaw/update\");\n\n    expect(res.status).toBe(500);\n    expect(res.body).toEqual({ ok: false, error: \"npm install failed\" });\n  });\n\n  it(\"returns 409 when alphaclaw update is already in progress\", async () => {\n    const deps = createSystemDeps();\n    deps.alphaclawVersionService.updateAlphaclaw.mockResolvedValue({\n      status: 409,\n      body: { ok: false, error: \"AlphaClaw update already in progress\" },\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/alphaclaw/update\");\n\n    expect(res.status).toBe(409);\n    expect(res.body).toEqual({\n      ok: false,\n      error: \"AlphaClaw update already in progress\",\n    });\n  });\n\n  it(\"returns raw session metadata on GET /api/agent/sessions\", async () => {\n    const deps = createSystemDeps();\n    deps.fs.readFileSync.mockImplementation((targetPath) => {\n      if (targetPath === \"/tmp/openclaw/openclaw.json\") {\n        return JSON.stringify({\n          channels: {\n            telegram: {\n              accounts: {\n                default: { name: \"Tester\" },\n                mac: { name: \"Mac\" },\n              },\n            },\n          },\n          bindings: [\n            { agentId: \"main\", match: { channel: \"telegram\", accountId: \"default\" } },\n            { agentId: \"morpheus\", match: { channel: \"telegram\", accountId: \"mac\" } },\n          ],\n        });\n      }\n      throw new Error(`unexpected read: ${targetPath}`);\n    });\n    deps.topicRegistry.getGroup.mockImplementation((groupId) =>\n      String(groupId) === \"-1003709908795\"\n        ? {\n            name: \"AlphaClaw\",\n            topics: {\n              \"4011\": { name: \"Rosebud\" },\n            },\n          }\n        : null,\n    );\n    deps.clawCmd.mockResolvedValue({\n      ok: true,\n      stdout: JSON.stringify({\n        sessions: [\n          { key: \"agent:main:main\", sessionId: \"main-session\", updatedAt: 10 },\n          {\n            key: \"agent:morpheus:telegram:direct:1050\",\n            sessionId: \"morpheus-direct-session\",\n            updatedAt: 11,\n          },\n          { key: \"agent:main:hook:abc\", sessionId: \"hook-session\", updatedAt: 9 },\n          { key: \"agent:main:cron:abc\", sessionId: \"cron-session\", updatedAt: 8 },\n          { key: \"agent:main:doctor:42\", sessionId: \"doctor-session\", updatedAt: 7 },\n          {\n            key: \"agent:main:telegram:direct:1050\",\n            sessionId: \"\",\n            updatedAt: 6,\n          },\n          {\n            key: \"agent:main:telegram:group:-1003709908795:topic:4011\",\n            sessionId: \"topic-session\",\n            updatedAt: 5,\n          },\n        ],\n      }),\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/agent/sessions\");\n\n    expect(res.status).toBe(200);\n    expect(deps.clawCmd).toHaveBeenCalledWith(\n      \"sessions --json --all-agents\",\n      { quiet: true },\n    );\n    expect(res.body.ok).toBe(true);\n    expect(res.body.sessions).toEqual([\n      {\n        key: \"agent:morpheus:telegram:direct:1050\",\n        sessionId: \"morpheus-direct-session\",\n        updatedAt: 11,\n        agentId: \"morpheus\",\n        agentLabel: \"Morpheus Agent\",\n        channel: \"telegram\",\n        groupName: \"\",\n        topicName: \"\",\n        replyChannel: \"telegram\",\n        replyTo: \"1050\",\n      },\n      {\n        key: \"agent:main:main\",\n        sessionId: \"main-session\",\n        updatedAt: 10,\n        agentId: \"main\",\n        agentLabel: \"Main Agent\",\n        channel: \"\",\n        groupName: \"\",\n        topicName: \"\",\n        replyChannel: \"\",\n        replyTo: \"\",\n      },\n      {\n        key: \"agent:main:hook:abc\",\n        sessionId: \"hook-session\",\n        updatedAt: 9,\n        agentId: \"main\",\n        agentLabel: \"Main Agent\",\n        channel: \"\",\n        groupName: \"\",\n        topicName: \"\",\n        replyChannel: \"\",\n        replyTo: \"\",\n      },\n      {\n        key: \"agent:main:cron:abc\",\n        sessionId: \"cron-session\",\n        updatedAt: 8,\n        agentId: \"main\",\n        agentLabel: \"Main Agent\",\n        channel: \"\",\n        groupName: \"\",\n        topicName: \"\",\n        replyChannel: \"\",\n        replyTo: \"\",\n      },\n      {\n        key: \"agent:main:doctor:42\",\n        sessionId: \"doctor-session\",\n        updatedAt: 7,\n        agentId: \"main\",\n        agentLabel: \"Main Agent\",\n        channel: \"\",\n        groupName: \"\",\n        topicName: \"\",\n        replyChannel: \"\",\n        replyTo: \"\",\n      },\n      {\n        key: \"agent:main:telegram:direct:1050\",\n        sessionId: \"\",\n        updatedAt: 6,\n        agentId: \"main\",\n        agentLabel: \"Main Agent\",\n        channel: \"telegram\",\n        groupName: \"\",\n        topicName: \"\",\n        replyChannel: \"telegram\",\n        replyTo: \"1050\",\n      },\n      {\n        key: \"agent:main:telegram:group:-1003709908795:topic:4011\",\n        sessionId: \"topic-session\",\n        updatedAt: 5,\n        agentId: \"main\",\n        agentLabel: \"Main Agent\",\n        channel: \"telegram\",\n        groupName: \"AlphaClaw\",\n        topicName: \"Rosebud\",\n        replyChannel: \"telegram\",\n        replyTo: \"-1003709908795:4011\",\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-telegram.test.js",
    "content": "const { buildTelegramGitSyncCommand } = require(\"../../lib/server/routes/telegram\");\n\ndescribe(\"server/routes/telegram\", () => {\n  it(\"quotes git-sync commit messages as a single shell arg\", () => {\n    const command = buildTelegramGitSyncCommand(\"rename-topic\", \"topic's name\");\n    expect(command).toBe(\n      \"alphaclaw git-sync -m 'telegram workspace: rename-topic topic'\\\"'\\\"'s name'\",\n    );\n  });\n\n  it(\"normalizes whitespace and keeps message content literal\", () => {\n    const command = buildTelegramGitSyncCommand(\n      \"create-topic\",\n      \"line one\\nline\\t two  $(touch /tmp/pwned)  `uname -a`\",\n    );\n    expect(command).toContain(\"$(touch /tmp/pwned)\");\n    expect(command).toContain(\"`uname -a`\");\n    expect(command).not.toContain(\"\\n\");\n    expect(command).not.toContain(\"\\t\");\n    expect(command.startsWith(\"alphaclaw git-sync -m '\")).toBe(true);\n    expect(command.endsWith(\"'\")).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-usage.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst topicRegistry = require(\"../../lib/server/topic-registry\");\nconst { registerUsageRoutes } = require(\"../../lib/server/routes/usage\");\n\nconst createDeps = () => ({\n  requireAuth: (req, res, next) => next(),\n  getDailySummary: vi.fn(() => ({ daily: [], totals: {} })),\n  getSessionsList: vi.fn(() => [\n    {\n      sessionId: \"agent:main:telegram:group:-1003832123427:topic:182\",\n      sessionKey: \"agent:main:telegram:group:-1003832123427:topic:182\",\n      totalTokens: 1200,\n      totalCost: 0.012,\n      lastActivityMs: 1730000000000,\n    },\n    {\n      sessionId: \"agent:main:telegram:direct:1050628644\",\n      sessionKey: \"agent:main:telegram:direct:1050628644\",\n      totalTokens: 800,\n      totalCost: 0.008,\n      lastActivityMs: 1730000001000,\n    },\n    {\n      sessionId: \"agent:main:hook:10bded75-e18b-4d0c-823f-99f296b4eedb\",\n      sessionKey: \"agent:main:hook:10bded75-e18b-4d0c-823f-99f296b4eedb\",\n      totalTokens: 640,\n      totalCost: 0.0064,\n      lastActivityMs: 1730000002000,\n    },\n    {\n      sessionId: \"agent:main:hook:gmail:19cb6d04b\",\n      sessionKey: \"agent:main:hook:gmail:19cb6d04b\",\n      totalTokens: 450,\n      totalCost: 0.0045,\n      lastActivityMs: 1730000003000,\n    },\n    {\n      sessionId: \"agent:main:cron:system-sync\",\n      sessionKey: \"agent:main:cron:system-sync\",\n      totalTokens: 320,\n      totalCost: 0.0032,\n      lastActivityMs: 1730000004000,\n    },\n  ]),\n  getSessionDetail: vi.fn(({ sessionId }) =>\n    sessionId === \"missing\"\n      ? null\n      : ({\n          sessionId,\n          sessionKey: sessionId,\n          modelBreakdown: [],\n          toolUsage: [],\n        })),\n  getSessionTimeSeries: vi.fn(() => ({ sessionId: \"abc\", points: [] })),\n});\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  registerUsageRoutes({\n    app,\n    ...deps,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/usage\", () => {\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it(\"caches summary payloads by days\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const firstResponse = await request(app).get(\"/api/usage/summary?days=30\");\n    const secondResponse = await request(app).get(\"/api/usage/summary?days=30\");\n\n    expect(firstResponse.status).toBe(200);\n    expect(firstResponse.body.ok).toBe(true);\n    expect(firstResponse.body.cached).toBe(false);\n    expect(secondResponse.status).toBe(200);\n    expect(secondResponse.body.cached).toBe(true);\n    expect(deps.getDailySummary).toHaveBeenCalledTimes(1);\n    expect(deps.getDailySummary).toHaveBeenCalledWith(\n      expect.objectContaining({ days: 30 }),\n    );\n  });\n\n  it(\"returns sessions with resolved labels on GET /api/usage/sessions\", async () => {\n    const deps = createDeps();\n    vi.spyOn(topicRegistry, \"getGroup\").mockReturnValue({\n      name: \"Workspace Name\",\n      topics: {\n        \"182\": { name: \"Topic Name\" },\n      },\n    });\n    const app = createApp(deps);\n\n    const response = await request(app).get(\"/api/usage/sessions?limit=25\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(deps.getSessionsList).toHaveBeenCalledWith({ limit: 25 });\n    expect(response.body.sessions[0].labels).toEqual([\n      { label: \"Main\", tone: \"cyan\" },\n      { label: \"Workspace Name\", tone: \"purple\" },\n      { label: \"Topic Name\", tone: \"gray\" },\n    ]);\n    expect(response.body.sessions[1].labels).toEqual([\n      { label: \"Main\", tone: \"cyan\" },\n      { label: \"Telegram Direct\", tone: \"blue\" },\n    ]);\n    expect(response.body.sessions[2].labels).toEqual([\n      { label: \"Main\", tone: \"cyan\" },\n      { label: \"Hook\", tone: \"purple\" },\n    ]);\n    expect(response.body.sessions[3].labels).toEqual([\n      { label: \"Main\", tone: \"cyan\" },\n      { label: \"Hook\", tone: \"purple\" },\n      { label: \"Gmail\", tone: \"gray\" },\n    ]);\n    expect(response.body.sessions[4].labels).toEqual([\n      { label: \"Main\", tone: \"cyan\" },\n      { label: \"Cron\", tone: \"blue\" },\n    ]);\n  });\n\n  it(\"returns 404 when session detail is missing\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const response = await request(app).get(\"/api/usage/sessions/missing\");\n\n    expect(response.status).toBe(404);\n    expect(response.body.ok).toBe(false);\n    expect(response.body.error).toBe(\"Session not found\");\n  });\n\n  it(\"parses maxPoints for session time series endpoint\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const response = await request(app).get(\"/api/usage/sessions/abc/timeseries?maxPoints=200\");\n\n    expect(response.status).toBe(200);\n    expect(response.body.ok).toBe(true);\n    expect(deps.getSessionTimeSeries).toHaveBeenCalledWith({\n      sessionId: \"abc\",\n      maxPoints: 200,\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-watchdog-test-notification.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerWatchdogRoutes } = require(\"../../lib/server/routes/watchdog\");\n\nconst createDeps = (overrides = {}) => {\n  const requireAuth = (req, res, next) => next();\n  const watchdog = {\n    getStatus: vi.fn(() => ({ lifecycle: \"running\", health: \"healthy\" })),\n    triggerRepair: vi.fn(async () => ({ ok: true })),\n    getSettings: vi.fn(() => ({ autoRepair: true, notificationsEnabled: true })),\n    updateSettings: vi.fn(({ autoRepair }) => ({ autoRepair, notificationsEnabled: true })),\n  };\n  const watchdogNotifier = {\n    notify: vi.fn(async () => ({\n      telegram: { sent: 1, failed: 0, skipped: false, targets: 1 },\n      discord: { sent: 0, failed: 0, skipped: true, targets: 0 },\n      slack: { sent: 0, failed: 0, skipped: true, targets: 0 },\n    })),\n  };\n  const getRecentEvents = vi.fn(() => []);\n  const readLogTail = vi.fn(() => \"\");\n  return {\n    requireAuth,\n    watchdog,\n    watchdogNotifier,\n    getRecentEvents,\n    readLogTail,\n    ...overrides,\n  };\n};\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  registerWatchdogRoutes({ app, ...deps });\n  return app;\n};\n\ndescribe(\"POST /api/watchdog/test-notification\", () => {\n  it(\"sends a test notification and returns per-channel results\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/watchdog/test-notification\");\n\n    expect(res.status).toBe(200);\n    expect(res.body.ok).toBe(true);\n    expect(res.body.result.telegram.sent).toBe(1);\n    expect(res.body.result.discord.skipped).toBe(true);\n    expect(deps.watchdogNotifier.notify).toHaveBeenCalledTimes(1);\n    expect(deps.watchdogNotifier.notify).toHaveBeenCalledWith(\n      expect.stringContaining(\"test notification\"),\n    );\n  });\n\n  it(\"returns 503 when notifier is not available\", async () => {\n    const deps = createDeps({ watchdogNotifier: null });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/watchdog/test-notification\");\n\n    expect(res.status).toBe(503);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toBe(\"Notifier not available\");\n  });\n\n  it(\"returns 500 when notify throws\", async () => {\n    const deps = createDeps({\n      watchdogNotifier: {\n        notify: vi.fn(async () => {\n          throw new Error(\"connection refused\");\n        }),\n      },\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/watchdog/test-notification\");\n\n    expect(res.status).toBe(500);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toBe(\"connection refused\");\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-watchdog.test.js",
    "content": "const express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { registerWatchdogRoutes } = require(\"../../lib/server/routes/watchdog\");\n\nconst createDeps = () => {\n  const requireAuth = (req, res, next) => next();\n  const watchdog = {\n    getStatus: vi.fn(() => ({ lifecycle: \"running\", health: \"healthy\" })),\n    triggerRepair: vi.fn(async () => ({ ok: true })),\n    getSettings: vi.fn(() => ({ autoRepair: true, notificationsEnabled: true })),\n    updateSettings: vi.fn(({ autoRepair }) => ({ autoRepair, notificationsEnabled: true })),\n  };\n  const getRecentEvents = vi.fn(() => [\n    { id: 1, eventType: \"crash\", status: \"failed\" },\n  ]);\n  const readLogTail = vi.fn(() => \"watchdog log line\");\n  return {\n    requireAuth,\n    watchdog,\n    getRecentEvents,\n    readLogTail,\n  };\n};\n\nconst createApp = (deps) => {\n  const app = express();\n  app.use(express.json());\n  registerWatchdogRoutes({\n    app,\n    ...deps,\n  });\n  return app;\n};\n\ndescribe(\"server/routes/watchdog\", () => {\n  it(\"returns watchdog status on GET /api/watchdog/status\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/watchdog/status\");\n\n    expect(res.status).toBe(200);\n    expect(res.body).toEqual({\n      ok: true,\n      status: { lifecycle: \"running\", health: \"healthy\" },\n    });\n    expect(deps.watchdog.getStatus).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"parses query params and returns events on GET /api/watchdog/events\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/watchdog/events?limit=25&includeRoutine=true\");\n\n    expect(res.status).toBe(200);\n    expect(deps.getRecentEvents).toHaveBeenCalledWith({\n      limit: 25,\n      includeRoutine: true,\n    });\n    expect(res.body.ok).toBe(true);\n    expect(Array.isArray(res.body.events)).toBe(true);\n  });\n\n  it(\"returns log tail as plain text on GET /api/watchdog/logs\", async () => {\n    const deps = createDeps();\n    const app = createApp(deps);\n\n    const res = await request(app).get(\"/api/watchdog/logs?tail=1024\");\n\n    expect(res.status).toBe(200);\n    expect(deps.readLogTail).toHaveBeenCalledWith(1024);\n    expect(res.text).toBe(\"watchdog log line\");\n    expect(res.headers[\"content-type\"]).toContain(\"text/plain\");\n  });\n\n  it(\"triggers repair and returns result on POST /api/watchdog/repair\", async () => {\n    const deps = createDeps();\n    deps.watchdog.triggerRepair.mockResolvedValue({\n      ok: false,\n      skipped: true,\n      reason: \"operation_in_progress\",\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).post(\"/api/watchdog/repair\");\n\n    expect(res.status).toBe(200);\n    expect(deps.watchdog.triggerRepair).toHaveBeenCalledTimes(1);\n    expect(res.body).toEqual({\n      ok: false,\n      result: {\n        ok: false,\n        skipped: true,\n        reason: \"operation_in_progress\",\n      },\n    });\n  });\n\n  it(\"returns 400 when updateSettings throws\", async () => {\n    const deps = createDeps();\n    deps.watchdog.updateSettings.mockImplementation(() => {\n      throw new Error(\"Expected autoRepair and/or notificationsEnabled boolean\");\n    });\n    const app = createApp(deps);\n\n    const res = await request(app).put(\"/api/watchdog/settings\").send({});\n\n    expect(res.status).toBe(400);\n    expect(res.body.ok).toBe(false);\n    expect(res.body.error).toContain(\"Expected autoRepair\");\n  });\n});\n"
  },
  {
    "path": "tests/server/routes-webhooks.test.js",
    "content": "const path = require(\"path\");\nconst express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst {\n  createWebhook,\n  getTransformRelativePath,\n} = require(\"../../lib/server/webhooks\");\nconst { registerWebhookRoutes } = require(\"../../lib/server/routes/webhooks\");\n\nconst createMemoryFs = (initialFiles = {}) => {\n  const files = new Map(\n    Object.entries(initialFiles).map(([filePath, contents]) => [\n      filePath,\n      String(contents),\n    ]),\n  );\n\n  return {\n    existsSync: (filePath) => files.has(filePath),\n    readFileSync: (filePath) => {\n      if (!files.has(filePath)) throw new Error(`File not found: ${filePath}`);\n      return files.get(filePath);\n    },\n    writeFileSync: (filePath, contents) => {\n      files.set(filePath, String(contents));\n    },\n    mkdirSync: () => {},\n    rmSync: () => {},\n    statSync: (filePath) => {\n      if (!files.has(filePath)) throw new Error(`File not found: ${filePath}`);\n      return {\n        birthtime: { toISOString: () => \"2026-03-08T00:00:00.000Z\" },\n        ctime: { toISOString: () => \"2026-03-08T00:00:00.000Z\" },\n      };\n    },\n  };\n};\n\nconst createApp = ({ fs, constants, webhooksDb }) => {\n  const app = express();\n  app.use(express.json());\n  registerWebhookRoutes({\n    app,\n    fs,\n    constants,\n    getBaseUrl: () => \"https://alphaclaw.example.com\",\n    webhooksDb,\n    restartRequiredState: {\n      markRequired: () => {},\n      getSnapshot: async () => ({ restartRequired: false }),\n    },\n  });\n  return app;\n};\n\ndescribe(\"server/routes/webhooks\", () => {\n  it(\"creates webhook oauth callback alias when requested at creation\", async () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      }),\n    });\n    const createOauthCallbackCalls = [];\n    const app = createApp({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      webhooksDb: {\n        getHookSummaries: () => [],\n        getRequests: () => [],\n        getRequestById: () => null,\n        deleteRequestsByHook: () => 0,\n        createOauthCallback: ({ hookName }) => {\n          createOauthCallbackCalls.push(hookName);\n          return {\n            callbackId: \"0123456789abcdef0123456789abcdef\",\n            hookName,\n            createdAt: \"2026-03-15T12:00:00.000Z\",\n            rotatedAt: null,\n            lastUsedAt: null,\n          };\n        },\n        getOauthCallbackByHook: () => null,\n        rotateOauthCallback: () => null,\n        deleteOauthCallback: () => 0,\n      },\n    });\n\n    const response = await request(app).post(\"/api/webhooks\").send({\n      name: \"schwab-oauth\",\n      oauthCallback: true,\n    });\n\n    expect(response.status).toBe(201);\n    expect(createOauthCallbackCalls).toEqual([\"schwab-oauth\"]);\n    expect(response.body?.webhook?.path).toBe(\"/hooks/schwab-oauth\");\n    expect(response.body?.webhook?.oauthCallbackId).toBe(\n      \"0123456789abcdef0123456789abcdef\",\n    );\n    expect(response.body?.webhook?.oauthCallbackUrl).toBe(\n      \"https://alphaclaw.example.com/oauth/0123456789abcdef0123456789abcdef\",\n    );\n    const transformPath = path.join(\n      openclawDir,\n      getTransformRelativePath(\"schwab-oauth\"),\n    );\n    const transformSource = fs.readFileSync(transformPath, \"utf8\");\n    expect(transformSource).toContain(\"message: message || fallbackMessage\");\n    expect(transformSource).toContain(\n      \"OAuth callback received (authorization code present)\",\n    );\n  });\n\n  it(\"deletes oauth callback alias when deleting webhook\", async () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      }),\n    });\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"schwab-oauth\",\n    });\n    const deleteOauthCallbackCalls = [];\n    const app = createApp({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      webhooksDb: {\n        getHookSummaries: () => [],\n        getRequests: () => [],\n        getRequestById: () => null,\n        deleteRequestsByHook: () => 0,\n        createOauthCallback: () => null,\n        getOauthCallbackByHook: () => null,\n        rotateOauthCallback: () => null,\n        deleteOauthCallback: (hookName) => {\n          deleteOauthCallbackCalls.push(hookName);\n          return 1;\n        },\n      },\n    });\n\n    const response = await request(app)\n      .delete(\"/api/webhooks/schwab-oauth\")\n      .send({ deleteTransformDir: false });\n\n    expect(response.status).toBe(200);\n    expect(deleteOauthCallbackCalls).toEqual([\"schwab-oauth\"]);\n    expect(response.body?.ok).toBe(true);\n  });\n\n  it(\"updates webhook destination mapping\", async () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"alpha\" },\n          ],\n        },\n      }),\n    });\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"route-test\",\n      destination: {\n        channel: \"direct\",\n        to: \"old-session\",\n        agentId: \"main\",\n      },\n    });\n    const app = createApp({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      webhooksDb: {\n        getHookSummaries: () => [],\n        getRequests: () => [],\n        getRequestById: () => null,\n        deleteRequestsByHook: () => 0,\n        createOauthCallback: () => null,\n        getOauthCallbackByHook: () => null,\n        rotateOauthCallback: () => null,\n        deleteOauthCallback: () => 0,\n      },\n    });\n\n    const response = await request(app)\n      .put(\"/api/webhooks/route-test/destination\")\n      .send({\n        destination: {\n          channel: \"group\",\n          to: \"new-session\",\n          agentId: \"alpha\",\n        },\n      });\n\n    expect(response.status).toBe(200);\n    expect(response.body?.ok).toBe(true);\n    expect(response.body?.webhook?.channel).toBe(\"group\");\n    expect(response.body?.webhook?.to).toBe(\"new-session\");\n    expect(response.body?.webhook?.agentId).toBe(\"alpha\");\n  });\n\n  it(\"uses the recent request window for webhook health\", async () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      }),\n    });\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"recent-health\",\n    });\n    const app = createApp({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      webhooksDb: {\n        getHookSummaries: () => [\n          {\n            hookName: \"recent-health\",\n            lastReceived: \"2026-04-02T12:00:00.000Z\",\n            totalCount: 30,\n            successCount: 25,\n            errorCount: 5,\n            recentTotalCount: 25,\n            recentSuccessCount: 25,\n            recentErrorCount: 0,\n            healthWindowSize: 25,\n          },\n        ],\n        getRequests: () => [],\n        getRequestById: () => null,\n        deleteRequestsByHook: () => 0,\n        createOauthCallback: () => null,\n        getOauthCallbackByHook: () => null,\n        rotateOauthCallback: () => null,\n        deleteOauthCallback: () => 0,\n      },\n    });\n\n    const response = await request(app).get(\"/api/webhooks\");\n\n    expect(response.status).toBe(200);\n    expect(response.body?.webhooks).toHaveLength(1);\n    expect(response.body?.webhooks?.[0]?.errorCount).toBe(5);\n    expect(response.body?.webhooks?.[0]?.recentErrorCount).toBe(0);\n    expect(response.body?.webhooks?.[0]?.healthWindowSize).toBe(25);\n    expect(response.body?.webhooks?.[0]?.health).toBe(\"green\");\n  });\n});\n"
  },
  {
    "path": "tests/server/secret-detector.test.js",
    "content": "const {\n  detectSecrets,\n  extractPreFillValues,\n  isSensitiveKey,\n  matchesValuePrefix,\n  maskValue,\n  parseEnvFileSecrets,\n} = require(\"../../lib/server/onboarding/import/secret-detector\");\n\nconst createMockFs = (files = {}) => ({\n  readFileSync: (p) => {\n    if (files[p] !== undefined) return files[p];\n    throw Object.assign(new Error(\"ENOENT\"), { code: \"ENOENT\" });\n  },\n  existsSync: (p) => files[p] !== undefined,\n});\n\ndescribe(\"secret-detector\", () => {\n  describe(\"isSensitiveKey\", () => {\n    it(\"matches token keys\", () => {\n      expect(isSensitiveKey(\"botToken\")).toBe(true);\n      expect(isSensitiveKey(\"TELEGRAM_BOT_TOKEN\")).toBe(true);\n      expect(isSensitiveKey(\"accessToken\")).toBe(true);\n    });\n\n    it(\"matches apiKey keys\", () => {\n      expect(isSensitiveKey(\"apiKey\")).toBe(true);\n      expect(isSensitiveKey(\"OPENAI_API_KEY\")).toBe(true);\n    });\n\n    it(\"matches secret/password keys\", () => {\n      expect(isSensitiveKey(\"clientSecret\")).toBe(true);\n      expect(isSensitiveKey(\"dbPassword\")).toBe(true);\n    });\n\n    it(\"excludes safe keys\", () => {\n      expect(isSensitiveKey(\"authDir\")).toBe(false);\n      expect(isSensitiveKey(\"authStore\")).toBe(false);\n      expect(isSensitiveKey(\"publicKey\")).toBe(false);\n    });\n\n    it(\"does not match normal keys\", () => {\n      expect(isSensitiveKey(\"enabled\")).toBe(false);\n      expect(isSensitiveKey(\"model\")).toBe(false);\n      expect(isSensitiveKey(\"channelId\")).toBe(false);\n    });\n  });\n\n  describe(\"matchesValuePrefix\", () => {\n    it(\"detects known token prefixes\", () => {\n      expect(matchesValuePrefix(\"sk-ant-abc123\").matched).toBe(true);\n      expect(matchesValuePrefix(\"ghp_abc123def456\").matched).toBe(true);\n      expect(matchesValuePrefix(\"github_pat_abc123\").matched).toBe(true);\n      expect(matchesValuePrefix(\"xoxb-123-456\").matched).toBe(true);\n      expect(matchesValuePrefix(\"AIzaSyAbc123\").matched).toBe(true);\n      expect(matchesValuePrefix(\"ntn_abc123\").matched).toBe(true);\n    });\n\n    it(\"does not match normal values\", () => {\n      expect(matchesValuePrefix(\"hello-world\").matched).toBe(false);\n      expect(matchesValuePrefix(\"anthropic/claude-3\").matched).toBe(false);\n      expect(matchesValuePrefix(\"true\").matched).toBe(false);\n    });\n  });\n\n  describe(\"maskValue\", () => {\n    it(\"masks short values fully\", () => {\n      expect(maskValue(\"abc\")).toBe(\"****\");\n    });\n\n    it(\"masks long values with prefix/suffix\", () => {\n      const masked = maskValue(\"sk-ant-abcdefghijklmnop\");\n      expect(masked).toMatch(/^sk-a\\*{4}mnop$/);\n    });\n  });\n\n  describe(\"detectSecrets\", () => {\n    it(\"detects secrets by config path mapping\", () => {\n      const cfg = {\n        channels: {\n          telegram: { botToken: \"123456:AAHBOT\" },\n        },\n        models: {\n          providers: {\n            anthropic: { apiKey: \"sk-ant-secret123456\" },\n          },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      expect(secrets.length).toBeGreaterThanOrEqual(2);\n      const telegram = secrets.find(\n        (s) => s.suggestedEnvVar === \"TELEGRAM_BOT_TOKEN\",\n      );\n      expect(telegram).toBeDefined();\n      expect(telegram.confidence).toBe(\"high\");\n\n      const anthropic = secrets.find(\n        (s) => s.suggestedEnvVar === \"ANTHROPIC_API_KEY\",\n      );\n      expect(anthropic).toBeDefined();\n    });\n\n    it(\"uses documented explicit env names for known providers\", () => {\n      const cfg = {\n        models: {\n          providers: {\n            zai: { apiKey: \"zai-secret-value-12345\" },\n            xai: { apiKey: \"xai-secret-value-12345\" },\n            minimax: { apiKey: \"minimax-secret-value-12345\" },\n            moonshot: { apiKey: \"moonshot-secret-value-12345\" },\n            \"kimi-coding\": { apiKey: \"kimi-secret-value-12345\" },\n            \"vercel-ai-gateway\": { apiKey: \"gateway-secret-value-12345\" },\n            volcengine: { apiKey: \"volcengine-secret-value-12345\" },\n          },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      expect(\n        secrets.find((s) => s.configPath === \"models.providers.zai.apiKey\")\n          ?.suggestedEnvVar,\n      ).toBe(\"ZAI_API_KEY\");\n      expect(\n        secrets.find((s) => s.configPath === \"models.providers.xai.apiKey\")\n          ?.suggestedEnvVar,\n      ).toBe(\"XAI_API_KEY\");\n      expect(\n        secrets.find((s) => s.configPath === \"models.providers.minimax.apiKey\")\n          ?.suggestedEnvVar,\n      ).toBe(\"MINIMAX_API_KEY\");\n      expect(\n        secrets.find((s) => s.configPath === \"models.providers.moonshot.apiKey\")\n          ?.suggestedEnvVar,\n      ).toBe(\"MOONSHOT_API_KEY\");\n      expect(\n        secrets.find(\n          (s) => s.configPath === \"models.providers.kimi-coding.apiKey\",\n        )?.suggestedEnvVar,\n      ).toBe(\"KIMI_API_KEY\");\n      expect(\n        secrets.find(\n          (s) => s.configPath === \"models.providers.vercel-ai-gateway.apiKey\",\n        )?.suggestedEnvVar,\n      ).toBe(\"AI_GATEWAY_API_KEY\");\n      expect(\n        secrets.find(\n          (s) => s.configPath === \"models.providers.volcengine.apiKey\",\n        )?.suggestedEnvVar,\n      ).toBe(\"VOLCANO_ENGINE_API_KEY\");\n    });\n\n    it(\"falls back to provider-scoped env names for unmapped model providers\", () => {\n      const cfg = {\n        models: {\n          providers: {\n            \"kimi-code\": { apiKey: \"kimi-secret-value-12345\" },\n            customproxy: { apiKey: \"custom-secret-value-12345\" },\n          },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      const kimi = secrets.find(\n        (s) => s.configPath === \"models.providers.kimi-code.apiKey\",\n      );\n      const customproxy = secrets.find(\n        (s) => s.configPath === \"models.providers.customproxy.apiKey\",\n      );\n      expect(kimi?.suggestedEnvVar).toBe(\"KIMI_CODE_API_KEY\");\n      expect(customproxy?.suggestedEnvVar).toBe(\"CUSTOMPROXY_API_KEY\");\n    });\n\n    it(\"detects secrets by value prefix\", () => {\n      const cfg = {\n        custom: { myField: \"ghp_abcdef1234567890123456\" },\n        models: {\n          providers: {\n            xai: { apiKey: \"xai-abcdef1234567890\" },\n          },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      const ghp = secrets.find((s) => s.source === \"value-prefix\");\n      const xai = secrets.find(\n        (s) => s.configPath === \"models.providers.xai.apiKey\",\n      );\n      expect(ghp).toBeDefined();\n      expect(ghp.confidence).toBe(\"high\");\n      expect(xai?.suggestedEnvVar).toBe(\"XAI_API_KEY\");\n    });\n\n    it(\"skips values that are already env var references\", () => {\n      const cfg = {\n        channels: {\n          telegram: { botToken: \"${TELEGRAM_BOT_TOKEN}\" },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      expect(secrets.length).toBe(0);\n    });\n\n    it(\"parses .env files\", () => {\n      const fs = createMockFs({\n        \"/base/.env\": \"ANTHROPIC_API_KEY=sk-ant-abc123\\nMODEL=claude-3\\n\",\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [],\n        envFiles: [\".env\"],\n      });\n      expect(secrets.length).toBe(2);\n      expect(secrets[0].suggestedEnvVar).toBe(\"ANTHROPIC_API_KEY\");\n      expect(secrets[0].source).toBe(\"env-file\");\n    });\n\n    it(\"detects duplicates across config and env\", () => {\n      const cfg = {\n        models: {\n          providers: {\n            anthropic: { apiKey: \"sk-ant-shared-value\" },\n          },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n        \"/base/.env\": \"ANTHROPIC_API_KEY=sk-ant-shared-value\\n\",\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [\".env\"],\n      });\n      const anthropic = secrets.find(\n        (s) => s.suggestedEnvVar === \"ANTHROPIC_API_KEY\",\n      );\n      expect(anthropic).toBeDefined();\n      expect(anthropic.duplicateIn).toBe(\".env\");\n    });\n\n    it(\"drops gateway.auth.token\", () => {\n      const cfg = {\n        gateway: { auth: { token: \"some-gateway-token-value\" } },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      const gw = secrets.find((s) => s.configPath === \"gateway.auth.token\");\n      expect(gw).toBeUndefined();\n    });\n\n    it(\"drops hooks.token because import normalizes it to WEBHOOK_TOKEN\", () => {\n      const cfg = {\n        hooks: { token: \"some-webhook-token-value\" },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const secrets = detectSecrets({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n        envFiles: [],\n      });\n      const webhook = secrets.find((s) => s.configPath === \"hooks.token\");\n      expect(webhook).toBeUndefined();\n    });\n  });\n\n  describe(\"extractPreFillValues\", () => {\n    it(\"extracts model, channel tokens, and provider keys\", () => {\n      const cfg = {\n        models: {\n          active: \"anthropic/claude-sonnet-4-20250514\",\n          providers: {\n            anthropic: { apiKey: \"sk-ant-abc\" },\n          },\n        },\n        channels: {\n          telegram: { botToken: \"123:AAH\" },\n          discord: { token: \"MTQ3xyz\" },\n        },\n        tools: {\n          web: { search: { apiKey: \"BSAabc\" } },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const preFill = extractPreFillValues({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n      });\n      expect(preFill.MODEL_KEY).toBe(\"anthropic/claude-sonnet-4-20250514\");\n      expect(preFill.ANTHROPIC_API_KEY).toBe(\"sk-ant-abc\");\n      expect(preFill.TELEGRAM_BOT_TOKEN).toBe(\"123:AAH\");\n      expect(preFill.DISCORD_BOT_TOKEN).toBe(\"MTQ3xyz\");\n      expect(preFill.BRAVE_API_KEY).toBe(\"BSAabc\");\n    });\n\n    it(\"skips values that are env var references\", () => {\n      const cfg = {\n        channels: {\n          telegram: { botToken: \"${TELEGRAM_BOT_TOKEN}\" },\n        },\n      };\n      const fs = createMockFs({\n        \"/base/openclaw.json\": JSON.stringify(cfg),\n      });\n      const preFill = extractPreFillValues({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"openclaw.json\"],\n      });\n      expect(preFill.TELEGRAM_BOT_TOKEN).toBeUndefined();\n    });\n\n    it(\"reads channel prefill values from standalone channel config files\", () => {\n      const fs = createMockFs({\n        \"/base/channels.json\": JSON.stringify({\n          discord: { token: \"MTQ3xyz\" },\n        }),\n      });\n      const preFill = extractPreFillValues({\n        fs,\n        baseDir: \"/base\",\n        configFiles: [\"channels.json\"],\n      });\n      expect(preFill.DISCORD_BOT_TOKEN).toBe(\"MTQ3xyz\");\n    });\n  });\n\n  describe(\"parseEnvFileSecrets\", () => {\n    it(\"parses key=value lines\", () => {\n      const content = \"FOO=bar\\n# comment\\nBAZ=qux\\n\";\n      const secrets = parseEnvFileSecrets(content, \".env\");\n      expect(secrets.length).toBe(2);\n      expect(secrets[0].key).toBe(\"FOO\");\n      expect(secrets[0].value).toBe(\"bar\");\n      expect(secrets[1].key).toBe(\"BAZ\");\n    });\n\n    it(\"skips empty lines and comments\", () => {\n      const content = \"\\n# header\\n\\nKEY=value\\n\";\n      const secrets = parseEnvFileSecrets(content, \".env\");\n      expect(secrets.length).toBe(1);\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/slack-api.test.js",
    "content": "const { createSlackApi } = require(\"../../lib/server/slack-api\");\n\nconst kOriginalFetch = global.fetch;\n\nafterEach(() => {\n  if (kOriginalFetch == null) {\n    delete global.fetch;\n  } else {\n    global.fetch = kOriginalFetch;\n  }\n});\n\ndescribe(\"server/slack-api\", () => {\n  it(\"createSlackApi returns API with all methods\", () => {\n    const api = createSlackApi(() => \"test-token\");\n\n    expect(typeof api.authTest).toBe(\"function\");\n    expect(typeof api.postMessage).toBe(\"function\");\n    expect(typeof api.postMessageInThread).toBe(\"function\");\n    expect(typeof api.addReaction).toBe(\"function\");\n    expect(typeof api.removeReaction).toBe(\"function\");\n    expect(typeof api.uploadFile).toBe(\"function\");\n    expect(typeof api.uploadTextSnippet).toBe(\"function\");\n    expect(typeof api.updateMessage).toBe(\"function\");\n    expect(typeof api.deleteMessage).toBe(\"function\");\n    expect(typeof api.pinMessage).toBe(\"function\");\n    expect(typeof api.unpinMessage).toBe(\"function\");\n    expect(typeof api.getUserInfo).toBe(\"function\");\n    expect(typeof api.getChannelInfo).toBe(\"function\");\n  });\n\n  it(\"postMessage requires token\", async () => {\n    const api = createSlackApi(() => null);\n\n    await expect(api.postMessage(\"C123\", \"test\")).rejects.toThrow(\n      /SLACK_BOT_TOKEN is not set/,\n    );\n  });\n\n  it(\"postMessage accepts threading options\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({ ok: true, ts: \"1234.5678\", channel: \"C123\" }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    await api.postMessage(\"C123\", \"Hello\", {\n      thread_ts: \"1234.5678\",\n      reply_broadcast: true,\n    });\n\n    expect(capturedPayload.channel).toBe(\"C123\");\n    expect(capturedPayload.text).toBe(\"Hello\");\n    expect(capturedPayload.thread_ts).toBe(\"1234.5678\");\n    expect(capturedPayload.reply_broadcast).toBe(true);\n    expect(capturedPayload.mrkdwn).toBe(true);\n  });\n\n  it(\"addReaction cleans emoji names\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({ ok: true }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n\n    await api.addReaction(\"C123\", \"1234.5678\", \":white_check_mark:\");\n    expect(capturedPayload.name).toBe(\"white_check_mark\");\n\n    await api.addReaction(\"C123\", \"1234.5678\", \"thumbsup\");\n    expect(capturedPayload.name).toBe(\"thumbsup\");\n  });\n\n  it(\"uploadTextSnippet converts string to buffer\", async () => {\n    let uploadUrlCalled = false;\n    let externalUploadCalled = false;\n    let completeUploadCalled = false;\n\n    global.fetch = async (url, options) => {\n      if (url.includes(\"files.getUploadURLExternal\")) {\n        uploadUrlCalled = true;\n        return {\n          ok: true,\n          json: async () => ({\n            ok: true,\n            upload_url: \"https://files.slack.com/upload/v1/ABC123\",\n            file_id: \"F123ABC\",\n          }),\n        };\n      }\n      if (url.includes(\"files.slack.com/upload\")) {\n        externalUploadCalled = true;\n        expect(Buffer.isBuffer(options.body)).toBe(true);\n        return { ok: true };\n      }\n      if (url.includes(\"files.completeUploadExternal\")) {\n        completeUploadCalled = true;\n        return {\n          ok: true,\n          json: async () => ({\n            ok: true,\n            files: [{ id: \"F123ABC\", title: \"Test Code\" }],\n          }),\n        };\n      }\n\n      return { ok: true, json: async () => ({ ok: true }) };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n\n    const result = await api.uploadTextSnippet(\"C123\", \"console.log('test');\", {\n      filename: \"test.js\",\n      title: \"Test Code\",\n    });\n\n    expect(uploadUrlCalled).toBe(true);\n    expect(externalUploadCalled).toBe(true);\n    expect(completeUploadCalled).toBe(true);\n\n    expect(result.files[0].id).toBe(\"F123ABC\");\n  });\n\n  it(\"handles API errors gracefully\", async () => {\n    global.fetch = async () => ({\n      ok: true,\n      json: async () => ({ ok: false, error: \"invalid_channel\" }),\n    });\n\n    const api = createSlackApi(() => \"test-token\");\n\n    await expect(api.postMessage(\"INVALID\", \"test\")).rejects.toThrow(/invalid_channel/);\n  });\n\n  it(\"updateMessage calls chat.update without mrkdwn field\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({\n          ok: true,\n          channel: \"C123\",\n          ts: \"1234.5678\",\n          text: \"Updated text\",\n        }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    await api.updateMessage(\"C123\", \"1234.5678\", \"Updated text\");\n\n    expect(capturedPayload.channel).toBe(\"C123\");\n    expect(capturedPayload.ts).toBe(\"1234.5678\");\n    expect(capturedPayload.text).toBe(\"Updated text\");\n    expect(capturedPayload.mrkdwn).toBeUndefined();\n  });\n\n  it(\"deleteMessage calls chat.delete\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({ ok: true, channel: \"C123\", ts: \"1234.5678\" }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    await api.deleteMessage(\"C123\", \"1234.5678\");\n\n    expect(capturedPayload.channel).toBe(\"C123\");\n    expect(capturedPayload.ts).toBe(\"1234.5678\");\n  });\n\n  it(\"pinMessage calls pins.add with channel and timestamp\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({ ok: true }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    await api.pinMessage(\"C123\", \"1234.5678\");\n\n    expect(capturedPayload.channel).toBe(\"C123\");\n    expect(capturedPayload.timestamp).toBe(\"1234.5678\");\n  });\n\n  it(\"unpinMessage calls pins.remove with channel and timestamp\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({ ok: true }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    await api.unpinMessage(\"C123\", \"1234.5678\");\n\n    expect(capturedPayload.channel).toBe(\"C123\");\n    expect(capturedPayload.timestamp).toBe(\"1234.5678\");\n  });\n\n  it(\"getUserInfo calls users.info with user ID\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({\n          ok: true,\n          user: { id: \"U123\", name: \"testuser\" },\n        }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    const result = await api.getUserInfo(\"U123\");\n\n    expect(capturedPayload.user).toBe(\"U123\");\n    expect(result.user.id).toBe(\"U123\");\n    expect(result.user.name).toBe(\"testuser\");\n  });\n\n  it(\"getChannelInfo calls conversations.info with channel ID\", async () => {\n    let capturedPayload = null;\n\n    global.fetch = async (url, options) => {\n      capturedPayload = JSON.parse(options.body);\n      return {\n        ok: true,\n        json: async () => ({\n          ok: true,\n          channel: { id: \"C123\", name: \"general\" },\n        }),\n      };\n    };\n\n    const api = createSlackApi(() => \"test-token\");\n    const result = await api.getChannelInfo(\"C123\");\n\n    expect(capturedPayload.channel).toBe(\"C123\");\n    expect(result.channel.id).toBe(\"C123\");\n    expect(result.channel.name).toBe(\"general\");\n  });\n});\n"
  },
  {
    "path": "tests/server/startup.test.js",
    "content": "const { runOnboardedBootSequence } = require(\"../../lib/server/startup\");\n\ndescribe(\"server/startup\", () => {\n  it(\"syncs gateway proxy config with the resolved setup URL before startup\", () => {\n    const callOrder = [];\n    const ensureManagedExecDefaults = vi.fn(() =>\n      callOrder.push(\"ensureManagedExecDefaults\"),\n    );\n    const ensureUsageTrackerPluginConfig = vi.fn(() =>\n      callOrder.push(\"ensureUsageTrackerPluginConfig\"),\n    );\n    const doSyncPromptFiles = vi.fn(() => callOrder.push(\"doSyncPromptFiles\"));\n    const reloadEnv = vi.fn(() => callOrder.push(\"reloadEnv\"));\n    const readEnvFile = vi.fn(() => {\n      callOrder.push(\"readEnvFile\");\n      return [{ key: \"OPENAI_API_KEY\", value: \"sk-test\" }];\n    });\n    const syncChannelConfig = vi.fn(() => callOrder.push(\"syncChannelConfig\"));\n    const resolveSetupUrl = vi.fn(() => {\n      callOrder.push(\"resolveSetupUrl\");\n      return \"https://setup.example.com\";\n    });\n    const ensureGatewayProxyConfig = vi.fn(() => callOrder.push(\"ensureGatewayProxyConfig\"));\n    const startGateway = vi.fn(() => callOrder.push(\"startGateway\"));\n    const watchdog = {\n      start: vi.fn(() => callOrder.push(\"watchdog.start\")),\n    };\n    const gmailWatchService = {\n      start: vi.fn(() => callOrder.push(\"gmailWatchService.start\")),\n    };\n\n    runOnboardedBootSequence({\n      ensureManagedExecDefaults,\n      ensureUsageTrackerPluginConfig,\n      doSyncPromptFiles,\n      reloadEnv,\n      syncChannelConfig,\n      readEnvFile,\n      ensureGatewayProxyConfig,\n      resolveSetupUrl,\n      startGateway,\n      watchdog,\n      gmailWatchService,\n    });\n\n    expect(ensureGatewayProxyConfig).toHaveBeenCalledWith(\"https://setup.example.com\");\n    expect(callOrder).toEqual([\n      \"ensureManagedExecDefaults\",\n      \"ensureUsageTrackerPluginConfig\",\n      \"doSyncPromptFiles\",\n      \"reloadEnv\",\n      \"readEnvFile\",\n      \"syncChannelConfig\",\n      \"resolveSetupUrl\",\n      \"ensureGatewayProxyConfig\",\n      \"startGateway\",\n      \"watchdog.start\",\n      \"gmailWatchService.start\",\n    ]);\n  });\n});\n"
  },
  {
    "path": "tests/server/telegram-workspace.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst { syncConfigForTelegram } = require(\"../../lib/server/telegram-workspace\");\n\nconst writeOpenclawConfig = ({ dir, config }) => {\n  fs.mkdirSync(dir, { recursive: true });\n  fs.writeFileSync(\n    path.join(dir, \"openclaw.json\"),\n    JSON.stringify(config, null, 2),\n    \"utf8\",\n  );\n};\n\nconst readOpenclawConfig = ({ dir }) =>\n  JSON.parse(fs.readFileSync(path.join(dir, \"openclaw.json\"), \"utf8\"));\n\ndescribe(\"server/telegram-workspace\", () => {\n  let tempRootDir = \"\";\n  let openclawDir = \"\";\n\n  beforeEach(() => {\n    tempRootDir = fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-test-\"));\n    openclawDir = path.join(tempRootDir, \".openclaw\");\n  });\n\n  afterEach(() => {\n    if (tempRootDir) {\n      fs.rmSync(tempRootDir, { recursive: true, force: true });\n    }\n  });\n\n  it(\"writes topic agentId to openclaw group topic config\", () => {\n    writeOpenclawConfig({\n      dir: openclawDir,\n      config: {\n        channels: {\n          telegram: {\n            groups: {\n              \"-1001234567890\": {\n                requireMention: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    const topicRegistry = {\n      getGroup: () => ({\n        topics: {\n          \"1\": { name: \"General\", agentId: \"main\" },\n          \"3\": {\n            name: \"Ops\",\n            agentId: \"ops\",\n            systemInstructions: \"Handle ops requests only.\",\n          },\n          \"5\": { name: \"No Overrides\" },\n        },\n      }),\n      getTotalTopicCount: () => 3,\n    };\n\n    syncConfigForTelegram({\n      fs,\n      openclawDir,\n      topicRegistry,\n      groupId: \"-1001234567890\",\n      requireMention: true,\n      resolvedUserId: \"\",\n    });\n\n    const nextConfig = readOpenclawConfig({ dir: openclawDir });\n    expect(nextConfig.channels.telegram.groups[\"-1001234567890\"].topics).toEqual({\n      \"1\": { agentId: \"main\" },\n      \"3\": { systemPrompt: \"Handle ops requests only.\", agentId: \"ops\" },\n    });\n  });\n\n  it(\"omits empty agentId values when syncing topic metadata\", () => {\n    writeOpenclawConfig({\n      dir: openclawDir,\n      config: {\n        channels: {\n          telegram: {\n            groups: {\n              \"-1001234567890\": {},\n            },\n          },\n        },\n      },\n    });\n\n    const topicRegistry = {\n      getGroup: () => ({\n        topics: {\n          \"2\": { name: \"Prompt Only\", systemInstructions: \"Only prompt.\" },\n          \"4\": { name: \"Blank Agent\", agentId: \"   \" },\n        },\n      }),\n      getTotalTopicCount: () => 2,\n    };\n\n    syncConfigForTelegram({\n      fs,\n      openclawDir,\n      topicRegistry,\n      groupId: \"-1001234567890\",\n      requireMention: false,\n      resolvedUserId: \"\",\n    });\n\n    const nextConfig = readOpenclawConfig({ dir: openclawDir });\n    expect(nextConfig.channels.telegram.groups[\"-1001234567890\"].topics).toEqual({\n      \"2\": { systemPrompt: \"Only prompt.\" },\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/topic-registry.test.js",
    "content": "const fs = require(\"fs\");\n\nconst topicRegistry = require(\"../../lib/server/topic-registry\");\n\ndescribe(\"server/topic-registry\", () => {\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it(\"filters groups by account id with default fallback\", () => {\n    vi.spyOn(fs, \"readFileSync\").mockImplementation((targetPath) => {\n      if (targetPath === topicRegistry.kRegistryPath) {\n        return JSON.stringify({\n          groups: {\n            \"-100a\": { name: \"Default Group\", topics: {} },\n            \"-100b\": { name: \"Mac Group\", accountId: \"mac\", topics: {} },\n          },\n        });\n      }\n      throw new Error(`Unexpected path read: ${targetPath}`);\n    });\n\n    expect(Object.keys(topicRegistry.getGroupsForAccount(\"default\"))).toEqual([\n      \"-100a\",\n    ]);\n    expect(Object.keys(topicRegistry.getGroupsForAccount(\"mac\"))).toEqual([\n      \"-100b\",\n    ]);\n  });\n\n  it(\"renders agent-scoped markdown by group ownership and topic routing\", () => {\n    vi.spyOn(fs, \"readFileSync\").mockImplementation((targetPath) => {\n      if (targetPath === topicRegistry.kRegistryPath) {\n        return JSON.stringify({\n          groups: {\n            \"-100owner\": {\n              name: \"Owner Group\",\n              agentId: \"scout\",\n              topics: {\n                \"1\": { name: \"General\" },\n                \"2\": { name: \"Routed\", agentId: \"researcher\" },\n              },\n            },\n            \"-100other\": {\n              name: \"Other Group\",\n              agentId: \"default\",\n              topics: {\n                \"3\": { name: \"Not Visible\" },\n                \"4\": { name: \"Visible Topic\", agentId: \"scout\" },\n              },\n            },\n          },\n        });\n      }\n      throw new Error(`Unexpected path read: ${targetPath}`);\n    });\n\n    const markdown = topicRegistry.renderTopicRegistryMarkdown({\n      agentId: \"scout\",\n    });\n    expect(markdown).toContain(\"Owner Group (-100owner) | General | 1\");\n    expect(markdown).toContain(\"Owner Group (-100owner) | Routed | 2\");\n    expect(markdown).toContain(\"Other Group (-100other) | Visible Topic | 4\");\n    expect(markdown).not.toContain(\"Other Group (-100other) | Not Visible | 3\");\n  });\n\n  it(\"returns empty markdown when no topics exist\", () => {\n    vi.spyOn(fs, \"readFileSync\").mockImplementation((targetPath) => {\n      if (targetPath === topicRegistry.kRegistryPath) {\n        return JSON.stringify({\n          groups: {\n            \"-100empty\": {\n              name: \"Empty Workspace\",\n              accountId: \"default\",\n              agentId: \"default\",\n              topics: {},\n            },\n          },\n        });\n      }\n      throw new Error(`Unexpected path read: ${targetPath}`);\n    });\n\n    const markdown = topicRegistry.renderTopicRegistryMarkdown({\n      includeSyncGuidance: true,\n    });\n    expect(markdown).toBe(\"\");\n  });\n});\n"
  },
  {
    "path": "tests/server/usage-db.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { DatabaseSync } = require(\"node:sqlite\");\nconst { deriveCostBreakdown } = require(\"../../lib/server/cost-utils\");\n\nconst loadUsageDb = () => {\n  const modulePath = require.resolve(\"../../lib/server/db/usage\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nlet currentUsageDb = null;\nlet currentDatabase = null;\nlet currentRootDir = \"\";\n\nconst createUsageDbContext = (prefix) => {\n  currentRootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));\n  currentUsageDb = loadUsageDb();\n  const { path: dbPath } = currentUsageDb.initUsageDb({ rootDir: currentRootDir });\n  currentDatabase = new DatabaseSync(dbPath);\n  return {\n    ...currentUsageDb,\n    database: currentDatabase,\n    rootDir: currentRootDir,\n  };\n};\n\ndescribe(\"server/usage-db\", () => {\n  afterEach(() => {\n    if (currentDatabase) {\n      currentDatabase.close();\n      currentDatabase = null;\n    }\n    if (currentUsageDb?.closeUsageDb) {\n      currentUsageDb.closeUsageDb();\n      currentUsageDb = null;\n    }\n    if (currentRootDir) {\n      fs.rmSync(currentRootDir, { recursive: true, force: true });\n      currentRootDir = \"\";\n    }\n  });\n\n  it(\"sums per-model costs for session detail totals\", () => {\n    const { database, getSessionDetail } = createUsageDbContext(\"usage-db-cost-\");\n\n    const insertUsageEvent = database.prepare(`\n      INSERT INTO usage_events (\n        timestamp,\n        session_id,\n        session_key,\n        run_id,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $run_id,\n        $provider,\n        $model,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens\n      )\n    `);\n\n    insertUsageEvent.run({\n      $timestamp: Date.now() - 1000,\n      $session_id: \"raw-session-1\",\n      $session_key: \"session-1\",\n      $run_id: \"run-1\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 1_000_000,\n      $output_tokens: 0,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 1_000_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: Date.now(),\n      $session_id: \"raw-session-1\",\n      $session_key: \"session-1\",\n      $run_id: \"run-2\",\n      $provider: \"anthropic\",\n      $model: \"claude-opus-4-6\",\n      $input_tokens: 0,\n      $output_tokens: 1_000_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 1_000_000,\n    });\n\n    const detail = getSessionDetail({ sessionId: \"session-1\" });\n    const expectedCost =\n      deriveCostBreakdown({\n        provider: \"openai\",\n        model: \"gpt-4o\",\n        inputTokens: 1_000_000,\n        outputTokens: 0,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n      }).totalCost +\n      deriveCostBreakdown({\n        provider: \"anthropic\",\n        model: \"claude-opus-4-6\",\n        inputTokens: 0,\n        outputTokens: 1_000_000,\n        cacheReadTokens: 0,\n        cacheWriteTokens: 0,\n      }).totalCost;\n    const summedBreakdownCost = detail.modelBreakdown.reduce(\n      (sum, row) => sum + Number(row.totalCost || 0),\n      0,\n    );\n\n    expect(detail).toBeTruthy();\n    expect(detail.totalCost).toBeCloseTo(expectedCost, 8);\n    expect(detail.totalCost).toBeCloseTo(summedBreakdownCost, 8);\n  });\n\n  it(\"returns cost distribution by agent and source\", () => {\n    const { database, getDailySummary } = createUsageDbContext(\"usage-db-agent-breakdown-\");\n    const now = Date.now();\n\n    const insertUsageEvent = database.prepare(`\n      INSERT INTO usage_events (\n        timestamp,\n        session_id,\n        session_key,\n        run_id,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $run_id,\n        $provider,\n        $model,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens\n      )\n    `);\n\n    insertUsageEvent.run({\n      $timestamp: now - 2_000,\n      $session_id: \"raw-a\",\n      $session_key: \"agent:main:telegram:direct:123\",\n      $run_id: \"run-a\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 1_000_000,\n      $output_tokens: 0,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 1_000_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now - 1_000,\n      $session_id: \"raw-b\",\n      $session_key: \"agent:main:hook:gmail:abc123\",\n      $run_id: \"run-b\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 0,\n      $output_tokens: 1_000_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 1_000_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now - 500,\n      $session_id: \"raw-c\",\n      $session_key: \"agent:ops:cron:nightly\",\n      $run_id: \"run-c\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 0,\n      $output_tokens: 1_000_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 1_000_000,\n    });\n\n    const summary = getDailySummary({ days: 7, timeZone: \"UTC\" });\n\n    expect(summary?.costByAgent).toBeTruthy();\n    expect(Array.isArray(summary.costByAgent.agents)).toBe(true);\n    expect(Array.isArray(summary.daily)).toBe(true);\n    expect(summary.daily.length).toBeGreaterThan(0);\n    expect(Array.isArray(summary.daily[0].sources)).toBe(true);\n    expect(Array.isArray(summary.daily[0].agents)).toBe(true);\n\n    const mainAgent = summary.costByAgent.agents.find((row) => row.agent === \"main\");\n    const opsAgent = summary.costByAgent.agents.find((row) => row.agent === \"ops\");\n\n    expect(mainAgent).toBeTruthy();\n    expect(opsAgent).toBeTruthy();\n    expect(mainAgent.totalCost).toBeCloseTo(12.5, 8);\n    expect(opsAgent.totalCost).toBeCloseTo(10, 8);\n\n    const mainChat = mainAgent.sourceBreakdown.find((row) => row.source === \"chat\");\n    const mainHooks = mainAgent.sourceBreakdown.find((row) => row.source === \"hooks\");\n    const mainCron = mainAgent.sourceBreakdown.find((row) => row.source === \"cron\");\n\n    expect(mainChat.totalCost).toBeCloseTo(2.5, 8);\n    expect(mainHooks.totalCost).toBeCloseTo(10, 8);\n    expect(mainCron.totalCost).toBeCloseTo(0, 8);\n\n    const opsCron = opsAgent.sourceBreakdown.find((row) => row.source === \"cron\");\n    expect(opsCron.totalCost).toBeCloseTo(10, 8);\n\n    const dailySources = summary.daily[0].sources;\n    const dailyAgents = summary.daily[0].agents;\n    const dailyChat = dailySources.find((row) => row.source === \"chat\");\n    const dailyHooks = dailySources.find((row) => row.source === \"hooks\");\n    const dailyCron = dailySources.find((row) => row.source === \"cron\");\n    const dailyMain = dailyAgents.find((row) => row.agent === \"main\");\n    const dailyOps = dailyAgents.find((row) => row.agent === \"ops\");\n\n    expect(dailyChat.totalCost).toBeCloseTo(2.5, 8);\n    expect(dailyHooks.totalCost).toBeCloseTo(10, 8);\n    expect(dailyCron.totalCost).toBeCloseTo(10, 8);\n    expect(dailyMain.totalCost).toBeCloseTo(12.5, 8);\n    expect(dailyOps.totalCost).toBeCloseTo(10, 8);\n\n    expect(summary.costByAgent.totals.totalCost).toBeCloseTo(22.5, 8);\n  });\n\n  it(\"applies tiered pricing per event, not aggregated totals\", () => {\n    const { database, getSessionDetail } = createUsageDbContext(\"usage-db-tiered-event-\");\n    const now = Date.now();\n\n    const insertUsageEvent = database.prepare(`\n      INSERT INTO usage_events (\n        timestamp,\n        session_id,\n        session_key,\n        run_id,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $run_id,\n        $provider,\n        $model,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens\n      )\n    `);\n\n    // Each event stays below the 200k threshold, so both should use 25/M output rate.\n    insertUsageEvent.run({\n      $timestamp: now - 1000,\n      $session_id: \"raw-tier-1\",\n      $session_key: \"session-tier-1\",\n      $run_id: \"run-tier-1\",\n      $provider: \"anthropic\",\n      $model: \"claude-opus-4-6\",\n      $input_tokens: 0,\n      $output_tokens: 150_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 150_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now,\n      $session_id: \"raw-tier-1\",\n      $session_key: \"session-tier-1\",\n      $run_id: \"run-tier-2\",\n      $provider: \"anthropic\",\n      $model: \"claude-opus-4-6\",\n      $input_tokens: 0,\n      $output_tokens: 150_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 150_000,\n    });\n\n    const detail = getSessionDetail({ sessionId: \"session-tier-1\" });\n\n    expect(detail).toBeTruthy();\n    expect(detail.totalCost).toBeCloseTo(7.5, 8);\n  });\n\n  it(\"aggregates usage by session key pattern\", () => {\n    const { database, getSessionUsageByKeyPattern } = createUsageDbContext(\"usage-db-pattern-\");\n    const now = Date.now();\n\n    const insertUsageEvent = database.prepare(`\n      INSERT INTO usage_events (\n        timestamp,\n        session_id,\n        session_key,\n        run_id,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $run_id,\n        $provider,\n        $model,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens\n      )\n    `);\n\n    insertUsageEvent.run({\n      $timestamp: now - 1000,\n      $session_id: \"raw-1\",\n      $session_key: \"agent:main:cron:job-123:run:1\",\n      $run_id: \"run-1\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 1_000_000,\n      $output_tokens: 0,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 1_000_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now,\n      $session_id: \"raw-2\",\n      $session_key: \"agent:main:cron:job-123:run:2\",\n      $run_id: \"run-2\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 0,\n      $output_tokens: 500_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 500_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now,\n      $session_id: \"raw-3\",\n      $session_key: \"agent:main:cron:job-999:run:1\",\n      $run_id: \"run-x\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 200_000,\n      $output_tokens: 0,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 200_000,\n    });\n\n    const usage = getSessionUsageByKeyPattern({\n      keyPattern: \"%:cron:job-123%\",\n      sinceMs: now - 10_000,\n    });\n\n    expect(usage.totals.totalTokens).toBe(1_500_000);\n    expect(usage.totals.runCount).toBe(2);\n    expect(usage.totals.totalCost).toBeCloseTo(7.5, 8);\n    expect(usage.modelBreakdown).toHaveLength(1);\n    expect(usage.modelBreakdown[0].model).toBe(\"gpt-4o\");\n  });\n\n  it(\"counts distinct cron runs correctly across multi-model events\", () => {\n    const { database, getSessionUsageByKeyPattern } =\n      createUsageDbContext(\"usage-db-pattern-run-count-\");\n    const now = Date.now();\n\n    const insertUsageEvent = database.prepare(`\n      INSERT INTO usage_events (\n        timestamp,\n        session_id,\n        session_key,\n        run_id,\n        provider,\n        model,\n        input_tokens,\n        output_tokens,\n        cache_read_tokens,\n        cache_write_tokens,\n        total_tokens\n      ) VALUES (\n        $timestamp,\n        $session_id,\n        $session_key,\n        $run_id,\n        $provider,\n        $model,\n        $input_tokens,\n        $output_tokens,\n        $cache_read_tokens,\n        $cache_write_tokens,\n        $total_tokens\n      )\n    `);\n\n    // Same run_id/session_key appears in multiple model rows (one cron run with tool/model fan-out).\n    insertUsageEvent.run({\n      $timestamp: now - 500,\n      $session_id: \"raw-run-shared\",\n      $session_key: \"agent:main:cron:daily-creative:shared\",\n      $run_id: \"run-shared\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 100_000,\n      $output_tokens: 0,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 100_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now - 400,\n      $session_id: \"raw-run-shared\",\n      $session_key: \"agent:main:cron:daily-creative:shared\",\n      $run_id: \"run-shared\",\n      $provider: \"anthropic\",\n      $model: \"claude-sonnet-4-6\",\n      $input_tokens: 40_000,\n      $output_tokens: 10_000,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 50_000,\n    });\n    insertUsageEvent.run({\n      $timestamp: now - 300,\n      $session_id: \"raw-run-next\",\n      $session_key: \"agent:main:cron:daily-creative:next\",\n      $run_id: \"run-next\",\n      $provider: \"openai\",\n      $model: \"gpt-4o\",\n      $input_tokens: 50_000,\n      $output_tokens: 0,\n      $cache_read_tokens: 0,\n      $cache_write_tokens: 0,\n      $total_tokens: 50_000,\n    });\n\n    const usage = getSessionUsageByKeyPattern({\n      keyPattern: \"%:cron:daily-creative:%\",\n      sinceMs: now - 10_000,\n    });\n\n    expect(usage.totals.eventCount).toBe(3);\n    expect(usage.totals.runCount).toBe(2);\n    expect(usage.totals.totalTokens).toBe(200_000);\n  });\n});\n"
  },
  {
    "path": "tests/server/usage-tracker-config.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst {\n  ensureUsageTrackerPluginConfig,\n  ensureUsageTrackerPluginEntry,\n  kUsageTrackerPluginPath,\n} = require(\"../../lib/server/usage-tracker-config\");\n\nconst createTempOpenclawDir = () =>\n  fs.mkdtempSync(path.join(os.tmpdir(), \"alphaclaw-usage-tracker-test-\"));\n\ndescribe(\"server/usage-tracker-config\", () => {\n  it(\"adds conversation access while preserving supported hook policy\", () => {\n    const cfg = {\n      plugins: {\n        allow: [\"memory-core\"],\n        load: { paths: [] },\n        entries: {\n          \"usage-tracker\": {\n            enabled: false,\n            hooks: {\n              allowPromptInjection: false,\n            },\n          },\n        },\n      },\n    };\n\n    const changed = ensureUsageTrackerPluginEntry(cfg);\n\n    expect(changed).toBe(true);\n    expect(cfg.plugins.allow).toEqual([\"memory-core\", \"usage-tracker\"]);\n    expect(cfg.plugins.load.paths).toContain(kUsageTrackerPluginPath);\n    expect(cfg.plugins.entries[\"usage-tracker\"]).toEqual({\n      enabled: true,\n      hooks: {\n        allowPromptInjection: false,\n        allowConversationAccess: true,\n      },\n    });\n  });\n\n  it(\"forces conversation access policy when an older alphaclaw config has it missing or false\", () => {\n    const cfg = {\n      plugins: {\n        allow: [\"usage-tracker\"],\n        load: { paths: [kUsageTrackerPluginPath] },\n        entries: {\n          \"usage-tracker\": {\n            enabled: true,\n            hooks: {\n              allowPromptInjection: false,\n              allowConversationAccess: false,\n            },\n          },\n        },\n      },\n    };\n\n    const changed = ensureUsageTrackerPluginEntry(cfg);\n\n    expect(changed).toBe(true);\n    expect(cfg.plugins.entries[\"usage-tracker\"].hooks).toEqual({\n      allowPromptInjection: false,\n      allowConversationAccess: true,\n    });\n  });\n\n  it(\"repairs existing openclaw configs on boot for older alphaclaw installs\", () => {\n    const openclawDir = createTempOpenclawDir();\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    fs.writeFileSync(\n      configPath,\n      JSON.stringify(\n        {\n          plugins: {\n            allow: [\"usage-tracker\"],\n            load: { paths: [kUsageTrackerPluginPath] },\n            entries: {\n              \"usage-tracker\": { enabled: true },\n            },\n          },\n        },\n        null,\n        2,\n      ),\n      \"utf8\",\n    );\n\n    const changed = ensureUsageTrackerPluginConfig({ fsModule: fs, openclawDir });\n\n    expect(changed).toBe(true);\n    const next = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    expect(next.plugins.entries[\"usage-tracker\"].hooks).toEqual({\n      allowConversationAccess: true,\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/utils-boolean.test.js",
    "content": "const {\n  isTruthyFlag,\n  parseBooleanValue,\n} = require(\"../../lib/server/utils/boolean\");\n\ndescribe(\"server/utils/boolean\", () => {\n  it(\"detects truthy flags from common string tokens\", () => {\n    expect(isTruthyFlag(\"true\")).toBe(true);\n    expect(isTruthyFlag(\" YES \")).toBe(true);\n    expect(isTruthyFlag(\"on\")).toBe(true);\n    expect(isTruthyFlag(\"1\")).toBe(true);\n    expect(isTruthyFlag(\"false\")).toBe(false);\n    expect(isTruthyFlag(\"0\")).toBe(false);\n    expect(isTruthyFlag(\"\")).toBe(false);\n  });\n\n  it(\"coerces booleans with fallback behavior\", () => {\n    expect(parseBooleanValue(true, false)).toBe(true);\n    expect(parseBooleanValue(false, true)).toBe(false);\n    expect(parseBooleanValue(1, false)).toBe(true);\n    expect(parseBooleanValue(0, true)).toBe(false);\n    expect(parseBooleanValue(\"off\", true)).toBe(false);\n    expect(parseBooleanValue(\"nope\", true)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "tests/server/utils-json.test.js",
    "content": "const {\n  parseJsonSafe,\n  parseJsonObjectFromNoisyOutput,\n} = require(\"../../lib/server/utils/json\");\n\ndescribe(\"server/utils/json\", () => {\n  it(\"parses JSON safely with fallback\", () => {\n    expect(parseJsonSafe('{\"ok\":true}', null)).toEqual({ ok: true });\n    expect(parseJsonSafe(\"not-json\", { ok: false })).toEqual({ ok: false });\n    expect(parseJsonSafe(\"\", { ok: false })).toEqual({ ok: false });\n  });\n\n  it(\"supports trim option for parseJsonSafe\", () => {\n    expect(parseJsonSafe(' \\n {\"count\":2} \\t ', null, { trim: true })).toEqual({\n      count: 2,\n    });\n  });\n\n  it(\"extracts JSON object from noisy output\", () => {\n    expect(\n      parseJsonObjectFromNoisyOutput('prefix\\n{\"ok\":true,\"count\":2}\\nsuffix'),\n    ).toEqual({\n      ok: true,\n      count: 2,\n    });\n    expect(parseJsonObjectFromNoisyOutput(\"no braces\")).toBeNull();\n  });\n});\n"
  },
  {
    "path": "tests/server/utils-shell.test.js",
    "content": "const { quoteShellArg } = require(\"../../lib/server/utils/shell\");\n\ndescribe(\"server/utils/shell\", () => {\n  it(\"quotes with double strategy by default\", () => {\n    const quoted = quoteShellArg('a\"$`\\\\b');\n    expect(quoted).toBe('\"a\\\\\"\\\\$\\\\`\\\\\\\\b\"');\n  });\n\n  it(\"quotes with single strategy when requested\", () => {\n    const quoted = quoteShellArg(\"topic's name\", { strategy: \"single\" });\n    expect(quoted).toBe(\"'topic'\\\"'\\\"'s name'\");\n  });\n\n  it(\"throws for unsupported strategies\", () => {\n    expect(() => quoteShellArg(\"value\", { strategy: \"unknown\" })).toThrow(\n      \"Unsupported shell quote strategy: unknown\",\n    );\n  });\n});\n"
  },
  {
    "path": "tests/server/watchdog-db.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\nconst { DatabaseSync } = require(\"node:sqlite\");\n\nconst loadWatchdogDb = () => {\n  const modulePath = require.resolve(\"../../lib/server/db/watchdog\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nconst sleep = async (ms) =>\n  new Promise((resolve) => {\n    setTimeout(resolve, ms);\n  });\n\nlet currentWatchdogDb = null;\nlet currentDatabase = null;\nlet currentRootDir = \"\";\n\nconst createWatchdogDbContext = (prefix, pruneDays = 30) => {\n  currentRootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));\n  currentWatchdogDb = loadWatchdogDb();\n  const dbResult = currentWatchdogDb.initWatchdogDb({ rootDir: currentRootDir, pruneDays });\n  return {\n    ...currentWatchdogDb,\n    ...dbResult,\n    rootDir: currentRootDir,\n  };\n};\n\ndescribe(\"server/watchdog-db\", () => {\n  afterEach(() => {\n    if (currentDatabase) {\n      currentDatabase.close();\n      currentDatabase = null;\n    }\n    if (currentWatchdogDb?.closeWatchdogDb) {\n      currentWatchdogDb.closeWatchdogDb();\n      currentWatchdogDb = null;\n    }\n    if (currentRootDir) {\n      fs.rmSync(currentRootDir, { recursive: true, force: true });\n      currentRootDir = \"\";\n    }\n  });\n\n  it(\"initializes watchdog.db under root db directory\", () => {\n    const result = createWatchdogDbContext(\"watchdog-db-init-\");\n\n    expect(result.path).toBe(path.join(result.rootDir, \"db\", \"watchdog.db\"));\n    expect(fs.existsSync(result.path)).toBe(true);\n  });\n\n  it(\"returns filtered events up to limit when routine checks are excluded\", async () => {\n    const { insertWatchdogEvent, getRecentEvents } = createWatchdogDbContext(\n      \"watchdog-db-filter-\",\n    );\n\n    insertWatchdogEvent({\n      eventType: \"crash\",\n      source: \"exit_event\",\n      status: \"failed\",\n      details: { code: 1 },\n    });\n    await sleep(2);\n    insertWatchdogEvent({\n      eventType: \"repair\",\n      source: \"crash_loop\",\n      status: \"ok\",\n      details: { started: true },\n    });\n    await sleep(2);\n    insertWatchdogEvent({\n      eventType: \"health_check\",\n      source: \"health_timer\",\n      status: \"ok\",\n      details: { skipped: false },\n    });\n    await sleep(2);\n    insertWatchdogEvent({\n      eventType: \"health_check\",\n      source: \"health_timer\",\n      status: \"ok\",\n      details: { skipped: false },\n    });\n\n    const filtered = getRecentEvents({ limit: 2, includeRoutine: false });\n    const unfiltered = getRecentEvents({ limit: 2, includeRoutine: true });\n\n    expect(filtered).toHaveLength(2);\n    expect(filtered.every((event) => !(event.eventType === \"health_check\" && event.status === \"ok\")))\n      .toBe(true);\n    expect(unfiltered).toHaveLength(2);\n    expect(\n      unfiltered.every((event) => event.eventType === \"health_check\" && event.status === \"ok\"),\n    ).toBe(true);\n  });\n\n  it(\"prunes old events based on retention days\", () => {\n    const { path: dbPath, pruneWatchdogEvents } = createWatchdogDbContext(\n      \"watchdog-db-prune-\",\n      365,\n    );\n    currentDatabase = new DatabaseSync(dbPath);\n    const database = currentDatabase;\n    database\n      .prepare(`\n        INSERT INTO watchdog_events (\n          event_type,\n          source,\n          status,\n          details,\n          correlation_id,\n          created_at\n        ) VALUES (\n          $event_type,\n          $source,\n          $status,\n          $details,\n          $correlation_id,\n          $created_at\n        )\n      `)\n      .run({\n        $event_type: \"crash\",\n        $source: \"exit_event\",\n        $status: \"failed\",\n        $details: \"{}\",\n        $correlation_id: \"\",\n        $created_at: \"2000-01-01T00:00:00.000Z\",\n      });\n    database\n      .prepare(`\n        INSERT INTO watchdog_events (\n          event_type,\n          source,\n          status,\n          details,\n          correlation_id,\n          created_at\n        ) VALUES (\n          $event_type,\n          $source,\n          $status,\n          $details,\n          $correlation_id,\n          $created_at\n        )\n      `)\n      .run({\n        $event_type: \"health_check\",\n        $source: \"health_timer\",\n        $status: \"ok\",\n        $details: \"{}\",\n        $correlation_id: \"\",\n        $created_at: \"2100-01-01T00:00:00.000Z\",\n      });\n\n    const removed = pruneWatchdogEvents(30);\n    const remaining = database\n      .prepare(\"SELECT COUNT(*) AS count FROM watchdog_events\")\n      .get().count;\n\n    expect(removed).toBe(1);\n    expect(remaining).toBe(1);\n  });\n});\n"
  },
  {
    "path": "tests/server/watchdog-notify.test.js",
    "content": "const path = require(\"path\");\n\nconst { createWatchdogNotifier } = require(\"../../lib/server/watchdog-notify\");\n\nconst buildCredentialsFsMock = (entries = {}) => {\n  const credentialsDir = \"/tmp/openclaw/credentials\";\n  const files = new Map(\n    Object.entries(entries).map(([fileName, allowFrom]) => [\n      path.join(credentialsDir, fileName),\n      JSON.stringify({ allowFrom }),\n    ]),\n  );\n\n  return {\n    existsSync: vi.fn((targetPath) => {\n      const normalizedTargetPath = String(targetPath || \"\");\n      return normalizedTargetPath === credentialsDir || files.has(normalizedTargetPath);\n    }),\n    readdirSync: vi.fn((targetPath) => {\n      if (String(targetPath || \"\") !== credentialsDir) return [];\n      return Array.from(files.keys()).map((filePath) => path.basename(filePath));\n    }),\n    readFileSync: vi.fn((targetPath) => {\n      const normalizedTargetPath = String(targetPath || \"\");\n      const value = files.get(normalizedTargetPath);\n      if (value === undefined) {\n        throw new Error(`Unexpected read: ${normalizedTargetPath}`);\n      }\n      return value;\n    }),\n  };\n};\n\nconst buildSlackApiFactory = () => {\n  const clientsByToken = new Map();\n  const countersByToken = new Map();\n  const createSlackApi = vi.fn((getToken) => {\n    const token = typeof getToken === \"function\" ? getToken() : getToken;\n    if (clientsByToken.has(token)) {\n      return clientsByToken.get(token);\n    }\n    const client = {\n      postMessage: vi.fn(async (_userId, _text, _opts = {}) => {\n        const nextCount = Number(countersByToken.get(token) || 0) + 1;\n        countersByToken.set(token, nextCount);\n        return {\n          ts: `${token}-ts-${nextCount}`,\n          channel: `dm-${token}`,\n        };\n      }),\n      addReaction: vi.fn(async () => ({ ok: true })),\n    };\n    clientsByToken.set(token, client);\n    return client;\n  });\n\n  return {\n    createSlackApi,\n    clientsByToken,\n  };\n};\n\ndescribe(\"server/watchdog-notify\", () => {\n  let consoleErrorSpy = null;\n\n  beforeEach(() => {\n    consoleErrorSpy = vi.spyOn(console, \"error\").mockImplementation(() => {});\n  });\n\n  afterEach(() => {\n    consoleErrorSpy.mockRestore();\n  });\n\n  it(\"sends Slack watchdog notifications across default and named accounts with isolated threads\", async () => {\n    const fsMock = buildCredentialsFsMock({\n      \"slack-default-allowFrom.json\": [\"U_SHARED_THREAD\"],\n      \"slack-alerts-allowFrom.json\": [\"U_SHARED_THREAD\"],\n    });\n    const { createSlackApi, clientsByToken } = buildSlackApiFactory();\n    const notifier = createWatchdogNotifier({\n      fsImpl: fsMock,\n      openclawDir: \"/tmp/openclaw\",\n      readEnvFile: () => [\n        { key: \"SLACK_BOT_TOKEN\", value: \"xoxb-default\" },\n        { key: \"SLACK_BOT_TOKEN_ALERTS\", value: \"xoxb-alerts\" },\n      ],\n      createSlackApi,\n    });\n\n    const crashResult = await notifier.notify(\"Crash detected\", {\n      eventType: \"crash\",\n    });\n    const recoveryResult = await notifier.notify(\"Recovered\", {\n      eventType: \"recovery\",\n    });\n\n    expect(crashResult.channels.slack).toEqual({\n      sent: 2,\n      failed: 0,\n      skipped: false,\n      targets: 2,\n    });\n    expect(recoveryResult.channels.slack).toEqual({\n      sent: 2,\n      failed: 0,\n      skipped: false,\n      targets: 2,\n    });\n\n    const defaultClient = clientsByToken.get(\"xoxb-default\");\n    const alertsClient = clientsByToken.get(\"xoxb-alerts\");\n    expect(defaultClient.postMessage.mock.calls[0][2]).toEqual({\n      thread_ts: null,\n      mrkdwn: true,\n    });\n    expect(defaultClient.postMessage.mock.calls[1][2]).toEqual({\n      thread_ts: \"xoxb-default-ts-1\",\n      mrkdwn: true,\n    });\n    expect(alertsClient.postMessage.mock.calls[0][2]).toEqual({\n      thread_ts: null,\n      mrkdwn: true,\n    });\n    expect(alertsClient.postMessage.mock.calls[1][2]).toEqual({\n      thread_ts: \"xoxb-alerts-ts-1\",\n      mrkdwn: true,\n    });\n  });\n\n  it(\"reports partial Slack delivery failure when one account is missing a bot token\", async () => {\n    const fsMock = buildCredentialsFsMock({\n      \"slack-default-allowFrom.json\": [\"U_DEFAULT_OK\"],\n      \"slack-alerts-allowFrom.json\": [\"U_ALERTS_MISSING\"],\n    });\n    const { createSlackApi, clientsByToken } = buildSlackApiFactory();\n    const notifier = createWatchdogNotifier({\n      fsImpl: fsMock,\n      openclawDir: \"/tmp/openclaw\",\n      readEnvFile: () => [{ key: \"SLACK_BOT_TOKEN\", value: \"xoxb-default\" }],\n      createSlackApi,\n    });\n\n    const result = await notifier.notify(\"Health check\", {\n      eventType: \"health\",\n    });\n\n    expect(result.channels.slack).toEqual({\n      sent: 1,\n      failed: 1,\n      skipped: false,\n      targets: 2,\n    });\n    expect(createSlackApi).toHaveBeenCalledTimes(1);\n    expect(Array.from(clientsByToken.keys())).toEqual([\"xoxb-default\"]);\n    expect(consoleErrorSpy).toHaveBeenCalledWith(\n      \"[watchdog] slack notification failed for alerts/U_ALERTS_MISSING: missing SLACK_BOT_TOKEN_ALERTS\",\n    );\n  });\n\n  it(\"delivers whatsapp watchdog notices via clawCmd message send for owner self chat\", async () => {\n    const clawCmd = vi.fn(async () => ({ ok: true, stdout: \"sent\", stderr: \"\" }));\n    const notifier = createWatchdogNotifier({\n      clawCmd,\n      readEnvFile: () => [\n        { key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" },\n      ],\n    });\n\n    const result = await notifier.notify(\"Gateway healthy again\");\n\n    expect(result.ok).toBe(true);\n    expect(result.sent).toBe(1);\n    expect(result.channels.whatsapp).toEqual({\n      sent: 1,\n      failed: 0,\n      skipped: false,\n      targets: 1,\n    });\n    expect(clawCmd).toHaveBeenCalledWith(\n      expect.stringContaining(\"message send --channel whatsapp\"),\n      expect.objectContaining({ quiet: true, timeoutMs: 30000 }),\n    );\n    expect(clawCmd).toHaveBeenCalledWith(\n      expect.stringContaining(\n        '--target \"+15551234567\" --message \"Gateway healthy again\"',\n      ),\n      expect.any(Object),\n    );\n  });\n\n  it(\"counts whatsapp watchdog notices as failed when clawCmd returns ok false\", async () => {\n    const clawCmd = vi.fn(async () => ({\n      ok: false,\n      stdout: \"\",\n      stderr: \"No active WhatsApp Web listener\",\n      code: 1,\n    }));\n    const notifier = createWatchdogNotifier({\n      clawCmd,\n      readEnvFile: () => [\n        { key: \"WHATSAPP_OWNER_NUMBER\", value: \"+15551234567\" },\n      ],\n    });\n\n    const result = await notifier.notify(\"Gateway healthy again\");\n\n    expect(result.ok).toBe(false);\n    expect(result.sent).toBe(0);\n    expect(result.failed).toBe(1);\n    expect(result.channels.whatsapp).toEqual({\n      sent: 0,\n      failed: 1,\n      skipped: false,\n      targets: 1,\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/watchdog.test.js",
    "content": "const { createWatchdog } = require(\"../../lib/server/watchdog\");\n\nconst flushMicrotasks = async () =>\n  new Promise((resolve) => {\n    setImmediate(resolve);\n  });\n\nconst kOriginalAutoRepair = process.env.WATCHDOG_AUTO_REPAIR;\nconst kOriginalNotificationsDisabled = process.env.WATCHDOG_NOTIFICATIONS_DISABLED;\nconst kOriginalFetch = global.fetch;\n\nconst createHarness = ({\n  autoRepair = true,\n  notificationsDisabled = false,\n  clawCmdImpl,\n  resolveSetupUrl = () => \"https://setup.example.com\",\n  resolveGatewayHealthUrl = () => \"http://127.0.0.1:18789/health\",\n  fetchImpl = async () => ({\n    ok: true,\n    status: 200,\n    text: async () => JSON.stringify({ ok: true, status: \"live\" }),\n  }),\n} = {}) => {\n  process.env.WATCHDOG_AUTO_REPAIR = autoRepair ? \"true\" : \"false\";\n  process.env.WATCHDOG_NOTIFICATIONS_DISABLED = notificationsDisabled ? \"true\" : \"false\";\n\n  const insertWatchdogEvent = vi.fn();\n  const clawCmd = vi.fn(\n    clawCmdImpl ||\n      (async () => ({\n        ok: true,\n        stdout: JSON.stringify({ ok: true }),\n      })),\n  );\n  const notifier = { notify: vi.fn(async () => ({ ok: true })) };\n  const launchGatewayProcess = vi.fn(() => ({ pid: 4242 }));\n  const readEnvFile = vi.fn(() => []);\n  const writeEnvFile = vi.fn();\n  const reloadEnv = vi.fn();\n  global.fetch = vi.fn(fetchImpl);\n\n  const watchdog = createWatchdog({\n    clawCmd,\n    launchGatewayProcess,\n    insertWatchdogEvent,\n    notifier,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n    resolveSetupUrl,\n    resolveGatewayHealthUrl,\n  });\n\n  return {\n    watchdog,\n    insertWatchdogEvent,\n    clawCmd,\n    notifier,\n    launchGatewayProcess,\n    readEnvFile,\n    writeEnvFile,\n    reloadEnv,\n  };\n};\n\ndescribe(\"server/watchdog\", () => {\n  afterEach(() => {\n    if (kOriginalAutoRepair == null) {\n      delete process.env.WATCHDOG_AUTO_REPAIR;\n    } else {\n      process.env.WATCHDOG_AUTO_REPAIR = kOriginalAutoRepair;\n    }\n    if (kOriginalNotificationsDisabled == null) {\n      delete process.env.WATCHDOG_NOTIFICATIONS_DISABLED;\n    } else {\n      process.env.WATCHDOG_NOTIFICATIONS_DISABLED = kOriginalNotificationsDisabled;\n    }\n    if (kOriginalFetch == null) {\n      delete global.fetch;\n    } else {\n      global.fetch = kOriginalFetch;\n    }\n    vi.useRealTimers();\n    vi.restoreAllMocks();\n  });\n\n  it(\"logs startup-grace health failures as skipped ok events\", async () => {\n    const { watchdog, insertWatchdogEvent } = createHarness({\n      clawCmdImpl: async (command) => {\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        throw new Error(\"gateway unavailable\");\n      },\n    });\n\n    watchdog.start();\n    await flushMicrotasks();\n\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"health_check\",\n        status: \"ok\",\n        details: expect.objectContaining({\n          skipped: true,\n          startupGraceActive: true,\n        }),\n      }),\n    );\n    watchdog.stop();\n  });\n\n  it(\"retries startup health checks before marking degraded\", async () => {\n    vi.useFakeTimers();\n    let healthChecks = 0;\n    const { watchdog, clawCmd, insertWatchdogEvent } = createHarness({\n      autoRepair: false,\n      clawCmdImpl: async (command) => {\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        healthChecks += 1;\n        if (healthChecks === 1) {\n          throw new Error(\"gateway unavailable\");\n        }\n        return {\n          ok: true,\n          status: 200,\n          text: async () => JSON.stringify({ ok: true, status: \"live\" }),\n        };\n      },\n    });\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now() - 60_000 });\n    await vi.advanceTimersByTimeAsync(0);\n    expect(watchdog.getStatus().health).toBe(\"unknown\");\n\n    await vi.advanceTimersByTimeAsync(5_000);\n\n    expect(clawCmd).not.toHaveBeenCalledWith(\"doctor --fix --yes\", { quiet: true });\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"running\",\n        health: \"healthy\",\n      }),\n    );\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"health_check\",\n        status: \"ok\",\n        details: expect.objectContaining({\n          skipped: true,\n          startupFailureRetryActive: true,\n          startupConsecutiveFailures: 1,\n          startupFailureThreshold: 3,\n        }),\n      }),\n    );\n    watchdog.stop();\n  });\n\n  it(\"uses 5s degraded retries to recover before regular interval\", async () => {\n    vi.useFakeTimers();\n    let healthChecks = 0;\n    const { watchdog } = createHarness({\n      autoRepair: false,\n      clawCmdImpl: async () => {\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        healthChecks += 1;\n        if (healthChecks <= 3) {\n          throw new Error(\"temporarily unavailable\");\n        }\n        return {\n          ok: true,\n          status: 200,\n          text: async () => JSON.stringify({ ok: true, status: \"live\" }),\n        };\n      },\n    });\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now() - 60_000 });\n    await vi.advanceTimersByTimeAsync(10_000);\n    expect(watchdog.getStatus().health).toBe(\"degraded\");\n    expect(healthChecks).toBe(3);\n\n    await vi.advanceTimersByTimeAsync(5_000);\n\n    expect(healthChecks).toBe(4);\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"running\",\n        health: \"healthy\",\n      }),\n    );\n    watchdog.stop();\n  });\n\n  it(\"triggers auto-repair in crash-loop mode when enabled\", async () => {\n    const { watchdog, clawCmd } = createHarness({\n      autoRepair: true,\n      clawCmdImpl: async (command) => {\n        if (command === \"doctor --fix --yes\") return { ok: true, stdout: \"fixed\" };\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        throw new Error(\"still unhealthy\");\n      },\n    });\n\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    await flushMicrotasks();\n    await flushMicrotasks();\n\n    expect(clawCmd).toHaveBeenCalledWith(\"doctor --fix --yes\", { quiet: true });\n  });\n\n  it(\"clears crash-loop lifecycle after a healthy check recovery\", async () => {\n    vi.useFakeTimers();\n    let healthChecks = 0;\n    const { watchdog, insertWatchdogEvent, notifier } = createHarness({\n      autoRepair: false,\n      clawCmdImpl: async (command) => {\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        healthChecks += 1;\n        if (healthChecks === 1) {\n          throw new Error(\"gateway unavailable\");\n        }\n        return {\n          ok: true,\n          status: 200,\n          text: async () => JSON.stringify({ ok: true, status: \"live\" }),\n        };\n      },\n    });\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now() - 60_000 });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"crash_loop\",\n        health: \"unhealthy\",\n      }),\n    );\n\n    await vi.advanceTimersByTimeAsync(120_000);\n\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"running\",\n        health: \"healthy\",\n      }),\n    );\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"recovery\",\n        source: \"health_timer\",\n        status: \"ok\",\n        details: expect.objectContaining({\n          previousLifecycle: \"crash_loop\",\n          health: \"healthy\",\n        }),\n      }),\n    );\n    expect(\n      notifier.notify.mock.calls.some((call) =>\n        String(call?.[0] || \"\").includes(\"🟢 Gateway healthy again\"),\n      ),\n    ).toBe(true);\n    watchdog.stop();\n  });\n\n  it(\"suppresses notifier sends when notifications are disabled\", async () => {\n    const { watchdog, notifier } = createHarness({\n      notificationsDisabled: true,\n      autoRepair: false,\n    });\n\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    await flushMicrotasks();\n\n    expect(notifier.notify).not.toHaveBeenCalled();\n  });\n\n  it(\"suppresses failed health checks during expected restart window\", async () => {\n    const { watchdog, clawCmd, insertWatchdogEvent } = createHarness({\n      autoRepair: true,\n      clawCmdImpl: async () => {\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        throw new Error(\"gateway restarting\");\n      },\n    });\n\n    watchdog.onExpectedRestart();\n    await flushMicrotasks();\n\n    expect(clawCmd).not.toHaveBeenCalledWith(\"doctor --fix --yes\", { quiet: true });\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"health_check\",\n        status: \"ok\",\n        details: expect.objectContaining({\n          skipped: true,\n          expectedRestartActive: true,\n        }),\n      }),\n    );\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"restarting\",\n        health: \"unknown\",\n      }),\n    );\n  });\n\n  it(\"treats non-zero expected exits as crashes\", () => {\n    const { watchdog, insertWatchdogEvent } = createHarness({\n      autoRepair: false,\n    });\n\n    watchdog.onGatewayExit({\n      code: 1,\n      signal: null,\n      expectedExit: true,\n      stderrTail: [\"gateway failed\"],\n    });\n\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"crashed\",\n        health: \"unhealthy\",\n        crashCountInWindow: 1,\n      }),\n    );\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"crash\",\n        source: \"exit_event\",\n        status: \"failed\",\n        details: expect.objectContaining({\n          code: 1,\n          signal: null,\n          stderrTail: [\"gateway failed\"],\n        }),\n      }),\n    );\n  });\n\n  it(\"ignores duplicate-launch port-in-use exits\", () => {\n    const { watchdog, insertWatchdogEvent, launchGatewayProcess } = createHarness({\n      autoRepair: true,\n    });\n\n    watchdog.onGatewayExit({\n      code: 1,\n      signal: null,\n      expectedExit: false,\n      stderrTail: [\n        \"Gateway failed to start: another gateway instance is already listening on ws://127.0.0.1:18789\",\n        \"Port 18789 is already in use.\",\n      ],\n    });\n\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"running\",\n        health: \"unknown\",\n        crashCountInWindow: 0,\n      }),\n    );\n    expect(launchGatewayProcess).not.toHaveBeenCalled();\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"restart\",\n        source: \"exit_event\",\n        status: \"ok\",\n        details: expect.objectContaining({\n          duplicateLaunch: true,\n          code: 1,\n        }),\n      }),\n    );\n  });\n\n  it(\"stops suppressing failures after the expected restart timeout\", async () => {\n    vi.useFakeTimers();\n    const { watchdog, insertWatchdogEvent } = createHarness({\n      autoRepair: false,\n      clawCmdImpl: async () => {\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        throw new Error(\"gateway restarting\");\n      },\n    });\n\n    watchdog.onExpectedRestart();\n    await vi.advanceTimersByTimeAsync(15_000);\n\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        health: \"degraded\",\n      }),\n    );\n    expect(insertWatchdogEvent).toHaveBeenCalledWith(\n      expect.objectContaining({\n        eventType: \"health_check\",\n        status: \"failed\",\n        details: expect.objectContaining({\n          reason: \"gateway restarting\",\n        }),\n      }),\n    );\n  });\n\n  it(\"sends gateway healthy again after deferred auto-repair recovery\", async () => {\n    let healthChecks = 0;\n    const { watchdog, notifier } = createHarness({\n      autoRepair: true,\n      clawCmdImpl: async (command) => {\n        if (command === \"doctor --fix --yes\") return { ok: true, stdout: \"fixed\" };\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        healthChecks += 1;\n        if (healthChecks === 1) {\n          throw new Error(\"not healthy yet\");\n        }\n        return {\n          ok: true,\n          status: 200,\n          text: async () => JSON.stringify({ ok: true, status: \"live\" }),\n        };\n      },\n    });\n\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    await flushMicrotasks();\n    await flushMicrotasks();\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now() });\n    await flushMicrotasks();\n    await flushMicrotasks();\n\n    expect(\n      notifier.notify.mock.calls.some((call) =>\n        String(call?.[0] || \"\").includes(\"🟢 Gateway healthy again\"),\n      ),\n    ).toBe(true);\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        lifecycle: \"running\",\n        health: \"healthy\",\n      }),\n    );\n  });\n\n  it(\"does not repeat auto-repair or notifications while recovery is still pending\", async () => {\n    vi.useFakeTimers();\n    let healthChecks = 0;\n    const { watchdog, clawCmd, notifier } = createHarness({\n      autoRepair: true,\n      clawCmdImpl: async (command) => {\n        if (command === \"doctor --fix --yes\") {\n          return { ok: true, stdout: \"fixed\" };\n        }\n        return { ok: true, stdout: \"\" };\n      },\n      fetchImpl: async () => {\n        healthChecks += 1;\n        throw new Error(\"still unhealthy\");\n      },\n    });\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now() - 60_000 });\n    await vi.advanceTimersByTimeAsync(10_000);\n\n    expect(clawCmd).toHaveBeenCalledTimes(1);\n    expect(clawCmd).toHaveBeenCalledWith(\"doctor --fix --yes\", { quiet: true });\n    expect(\n      notifier.notify.mock.calls.filter((call) =>\n        String(call?.[0] || \"\").includes(\"awaiting health check\"),\n      ),\n    ).toHaveLength(1);\n\n    await vi.advanceTimersByTimeAsync(120_000);\n\n    expect(healthChecks).toBeGreaterThan(3);\n    expect(clawCmd).toHaveBeenCalledTimes(1);\n    expect(\n      notifier.notify.mock.calls.filter((call) =>\n        String(call?.[0] || \"\").includes(\"awaiting health check\"),\n      ),\n    ).toHaveLength(1);\n    expect(watchdog.getStatus()).toEqual(\n      expect.objectContaining({\n        health: \"degraded\",\n      }),\n    );\n  });\n\n  it(\"does not set uptimeStartedAt on start — waits for onGatewayLaunch\", () => {\n    const { watchdog } = createHarness();\n\n    watchdog.start();\n\n    expect(watchdog.getStatus().uptimeStartedAt).toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBe(0);\n    watchdog.stop();\n  });\n\n  it(\"sets uptimeStartedAt when onGatewayLaunch fires\", () => {\n    const { watchdog } = createHarness();\n\n    watchdog.start();\n    const before = Date.now();\n    watchdog.onGatewayLaunch({ startedAt: before, pid: 1234 });\n\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBeGreaterThanOrEqual(0);\n    watchdog.stop();\n  });\n\n  it(\"clears uptimeStartedAt on gateway crash\", () => {\n    const { watchdog } = createHarness({ autoRepair: false });\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now(), pid: 1234 });\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n\n    expect(watchdog.getStatus().uptimeStartedAt).toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBe(0);\n  });\n\n  it(\"clears uptimeStartedAt on expected restart\", () => {\n    const { watchdog } = createHarness();\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now(), pid: 1234 });\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n\n    watchdog.onExpectedRestart();\n\n    expect(watchdog.getStatus().uptimeStartedAt).toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBe(0);\n  });\n\n  it(\"clears uptimeStartedAt on expected exit\", () => {\n    const { watchdog } = createHarness();\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now(), pid: 1234 });\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n\n    watchdog.onGatewayExit({ code: 0, expectedExit: true });\n\n    expect(watchdog.getStatus().uptimeStartedAt).toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBe(0);\n  });\n\n  it(\"preserves uptimeStartedAt on duplicate-launch exit\", () => {\n    const { watchdog } = createHarness();\n\n    const startedAt = Date.now() - 5000;\n    watchdog.onGatewayLaunch({ startedAt, pid: 1234 });\n\n    watchdog.onGatewayExit({\n      code: 1,\n      signal: null,\n      expectedExit: false,\n      stderrTail: [\"another gateway instance is already listening\"],\n    });\n\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBeGreaterThan(0);\n  });\n\n  it(\"clears uptimeStartedAt on stop\", () => {\n    const { watchdog } = createHarness();\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now(), pid: 1234 });\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n\n    watchdog.stop();\n\n    expect(watchdog.getStatus().uptimeStartedAt).toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBe(0);\n  });\n\n  it(\"restores uptimeStartedAt after crash recovery via onGatewayLaunch\", async () => {\n    const { watchdog } = createHarness({ autoRepair: false });\n\n    watchdog.onGatewayLaunch({ startedAt: Date.now() - 10_000, pid: 1234 });\n    watchdog.onGatewayExit({ code: 1, expectedExit: false });\n    expect(watchdog.getStatus().uptimeStartedAt).toBeNull();\n\n    const newStart = Date.now();\n    watchdog.onGatewayLaunch({ startedAt: newStart, pid: 5678 });\n\n    expect(watchdog.getStatus().uptimeStartedAt).not.toBeNull();\n    expect(watchdog.getStatus().uptimeMs).toBeGreaterThanOrEqual(0);\n    watchdog.stop();\n  });\n\n  it(\"writes settings changes to env and updates in-memory status\", () => {\n    const { watchdog, readEnvFile, writeEnvFile, reloadEnv } = createHarness({\n      autoRepair: false,\n      notificationsDisabled: false,\n    });\n    readEnvFile.mockReturnValue([{ key: \"OPENAI_API_KEY\", value: \"x\" }]);\n    reloadEnv.mockImplementation(() => {\n      process.env.WATCHDOG_AUTO_REPAIR = \"true\";\n      process.env.WATCHDOG_NOTIFICATIONS_DISABLED = \"true\";\n    });\n\n    const settings = watchdog.updateSettings({\n      autoRepair: true,\n      notificationsEnabled: false,\n    });\n\n    expect(writeEnvFile).toHaveBeenCalledWith(\n      expect.arrayContaining([\n        expect.objectContaining({ key: \"WATCHDOG_AUTO_REPAIR\", value: \"true\" }),\n        expect.objectContaining({\n          key: \"WATCHDOG_NOTIFICATIONS_DISABLED\",\n          value: \"true\",\n        }),\n      ]),\n    );\n    expect(reloadEnv).toHaveBeenCalledTimes(1);\n    expect(settings).toEqual({\n      autoRepair: true,\n      notificationsEnabled: false,\n    });\n  });\n});\n"
  },
  {
    "path": "tests/server/webhook-middleware.test.js",
    "content": "const http = require(\"http\");\nconst express = require(\"express\");\nconst request = require(\"supertest\");\n\nconst { createWebhookMiddleware } = require(\"../../lib/server/webhook-middleware\");\n\nconst createGatewaySpyServer = async () => {\n  const calls = [];\n  const server = http.createServer((req, res) => {\n    const chunks = [];\n    req.on(\"data\", (chunk) => chunks.push(chunk));\n    req.on(\"end\", () => {\n      calls.push({\n        method: req.method,\n        url: req.url,\n        headers: req.headers,\n        bodyText: Buffer.concat(chunks).toString(\"utf8\"),\n      });\n      res.statusCode = 200;\n      res.setHeader(\"content-type\", \"application/json\");\n      res.end(JSON.stringify({ ok: true }));\n    });\n  });\n\n  await new Promise((resolve) => {\n    server.listen(0, \"127.0.0.1\", resolve);\n  });\n\n  const address = server.address();\n  const gatewayUrl = `http://127.0.0.1:${address.port}`;\n  return { server, calls, gatewayUrl };\n};\n\nconst createHookApp = ({ gatewayUrl, insertRequest = () => {} }) => {\n  const app = express();\n  app.use([\"/hooks\", \"/webhook\"], express.raw({ type: \"*/*\", limit: \"5mb\" }));\n  app.use(\n    createWebhookMiddleware({\n      gatewayUrl,\n      insertRequest,\n      maxPayloadBytes: 1024 * 64,\n    }),\n  );\n  return app;\n};\n\ndescribe(\"server/webhook-middleware\", () => {\n  it(\"maps hook query params into forwarded JSON body\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const app = createHookApp({ gatewayUrl });\n\n    try {\n      const response = await request(app).get(\n        \"/hooks/schwab-oauth?code=AUTH_CODE&session=SESSION_ID\",\n      );\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(calls[0].url).toBe(\"/hooks/schwab-oauth?code=AUTH_CODE&session=SESSION_ID\");\n      expect(calls[0].headers[\"content-type\"]).toContain(\"application/json\");\n      expect(JSON.parse(calls[0].bodyText)).toEqual({\n        code: \"AUTH_CODE\",\n        session: \"SESSION_ID\",\n      });\n    } finally {\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n\n  it(\"keeps explicit JSON body values over query params\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const app = createHookApp({ gatewayUrl });\n\n    try {\n      const response = await request(app)\n        .post(\"/hooks/schwab-oauth?code=AUTH_CODE&session=SESSION_ID\")\n        .set(\"content-type\", \"application/json\")\n        .send(\n          JSON.stringify({\n            code: \"BODY_CODE\",\n            extra: \"from-body\",\n          }),\n        );\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(JSON.parse(calls[0].bodyText)).toEqual({\n        code: \"BODY_CODE\",\n        session: \"SESSION_ID\",\n        extra: \"from-body\",\n      });\n    } finally {\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n\n  it(\"redacts oauth-style secrets in stored payload logs\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const loggedRequests = [];\n    const app = createHookApp({\n      gatewayUrl,\n      insertRequest: (entry) => loggedRequests.push(entry),\n    });\n\n    try {\n      const response = await request(app).get(\n        \"/hooks/schwab-oauth?code=AUTH_CODE&session=SESSION_ID&refresh_token=REFRESH_TOKEN\",\n      );\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(JSON.parse(calls[0].bodyText)).toEqual({\n        code: \"AUTH_CODE\",\n        session: \"SESSION_ID\",\n        refresh_token: \"REFRESH_TOKEN\",\n      });\n\n      expect(loggedRequests).toHaveLength(1);\n      expect(JSON.parse(loggedRequests[0].payload)).toEqual({\n        code: \"[REDACTED]\",\n        session: \"SESSION_ID\",\n        refresh_token: \"[REDACTED]\",\n      });\n    } finally {\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n\n  it(\"moves query token into authorization header without body logging leak\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const loggedRequests = [];\n    const app = createHookApp({\n      gatewayUrl,\n      insertRequest: (entry) => loggedRequests.push(entry),\n    });\n\n    try {\n      const response = await request(app).get(\"/hooks/schwab-oauth?token=SECRET_TOKEN\");\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(calls[0].headers.authorization).toBe(\"Bearer SECRET_TOKEN\");\n      expect(calls[0].url).toBe(\"/hooks/schwab-oauth\");\n      expect(calls[0].bodyText).toBe(\"{}\");\n\n      expect(loggedRequests).toHaveLength(1);\n      expect(loggedRequests[0].headers.authorization).toBeUndefined();\n      expect(loggedRequests[0].payload).toBe(\"{}\");\n    } finally {\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n\n  it(\"drops query token when authorization header already exists\", async () => {\n    const { server, calls, gatewayUrl } = await createGatewaySpyServer();\n    const loggedRequests = [];\n    const app = createHookApp({\n      gatewayUrl,\n      insertRequest: (entry) => loggedRequests.push(entry),\n    });\n\n    try {\n      const response = await request(app)\n        .get(\"/hooks/schwab-oauth?token=SECRET_TOKEN&session=SESSION_ID\")\n        .set(\"authorization\", \"Bearer HEADER_TOKEN\");\n      expect(response.status).toBe(200);\n      expect(calls).toHaveLength(1);\n      expect(calls[0].method).toBe(\"POST\");\n      expect(calls[0].headers.authorization).toBe(\"Bearer HEADER_TOKEN\");\n      expect(calls[0].url).toBe(\"/hooks/schwab-oauth?session=SESSION_ID\");\n      expect(JSON.parse(calls[0].bodyText)).toEqual({\n        session: \"SESSION_ID\",\n      });\n\n      expect(loggedRequests).toHaveLength(1);\n      expect(loggedRequests[0].headers.authorization).toBe(\"[REDACTED]\");\n      expect(JSON.parse(loggedRequests[0].payload)).toEqual({\n        session: \"SESSION_ID\",\n      });\n    } finally {\n      await new Promise((resolve) => server.close(resolve));\n    }\n  });\n});\n"
  },
  {
    "path": "tests/server/webhooks-db.test.js",
    "content": "const fs = require(\"fs\");\nconst os = require(\"os\");\nconst path = require(\"path\");\n\nconst loadWebhooksDb = () => {\n  const modulePath = require.resolve(\"../../lib/server/db/webhooks\");\n  delete require.cache[modulePath];\n  return require(modulePath);\n};\n\nlet currentWebhooksDb = null;\nlet currentRootDir = \"\";\n\nconst createWebhooksDbContext = (prefix) => {\n  currentRootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));\n  currentWebhooksDb = loadWebhooksDb();\n  currentWebhooksDb.initWebhooksDb({ rootDir: currentRootDir });\n  return currentWebhooksDb;\n};\n\ndescribe(\"server/webhooks-db\", () => {\n  afterEach(() => {\n    if (currentWebhooksDb?.closeWebhooksDb) {\n      currentWebhooksDb.closeWebhooksDb();\n      currentWebhooksDb = null;\n    }\n    if (currentRootDir) {\n      fs.rmSync(currentRootDir, { recursive: true, force: true });\n      currentRootDir = \"\";\n    }\n  });\n\n  it(\"creates, rotates, marks usage, and deletes oauth callbacks\", () => {\n    const {\n      createOauthCallback,\n      getOauthCallbackByHook,\n      getOauthCallbackById,\n      rotateOauthCallback,\n      markOauthCallbackUsed,\n      deleteOauthCallback,\n    } = createWebhooksDbContext(\"webhooks-db-oauth-\");\n\n    const created = createOauthCallback({ hookName: \"schwab-oauth\" });\n    expect(created).toBeTruthy();\n    expect(created.hookName).toBe(\"schwab-oauth\");\n    expect(String(created.callbackId || \"\")).toHaveLength(32);\n\n    const byHook = getOauthCallbackByHook(\"schwab-oauth\");\n    expect(byHook?.callbackId).toBe(created.callbackId);\n\n    const byId = getOauthCallbackById(created.callbackId);\n    expect(byId?.hookName).toBe(\"schwab-oauth\");\n\n    const rotated = rotateOauthCallback(\"schwab-oauth\");\n    expect(rotated).toBeTruthy();\n    expect(rotated.callbackId).not.toBe(created.callbackId);\n    expect(rotated.rotatedAt).toBeTruthy();\n    expect(getOauthCallbackById(created.callbackId)).toBeNull();\n\n    const markedRows = markOauthCallbackUsed(rotated.callbackId);\n    expect(markedRows).toBe(1);\n    const afterMarked = getOauthCallbackByHook(\"schwab-oauth\");\n    expect(afterMarked?.lastUsedAt).toBeTruthy();\n\n    const deletedRows = deleteOauthCallback(\"schwab-oauth\");\n    expect(deletedRows).toBe(1);\n    expect(getOauthCallbackByHook(\"schwab-oauth\")).toBeNull();\n  });\n\n  it(\"tracks recent health counts separately from all-time totals\", () => {\n    const {\n      insertRequest,\n      getHookSummaries,\n    } = createWebhooksDbContext(\"webhooks-db-health-\");\n\n    for (let index = 0; index < 30; index += 1) {\n      insertRequest({\n        hookName: \"recent-health\",\n        method: \"POST\",\n        headers: {},\n        payload: `{\"index\":${index}}`,\n        payloadTruncated: false,\n        payloadSize: 12,\n        sourceIp: \"127.0.0.1\",\n        gatewayStatus: index < 5 ? 500 : 200,\n        gatewayBody: \"\",\n      });\n    }\n\n    const summary = getHookSummaries().find(\n      (item) => item.hookName === \"recent-health\",\n    );\n\n    expect(summary).toBeTruthy();\n    expect(summary.totalCount).toBe(30);\n    expect(summary.errorCount).toBe(5);\n    expect(summary.recentTotalCount).toBe(25);\n    expect(summary.recentSuccessCount).toBe(25);\n    expect(summary.recentErrorCount).toBe(0);\n    expect(summary.healthWindowSize).toBe(25);\n  });\n});\n"
  },
  {
    "path": "tests/server/webhooks.test.js",
    "content": "const path = require(\"path\");\n\nconst {\n  createWebhook,\n  getTransformRelativePath,\n  updateWebhookDestination,\n} = require(\"../../lib/server/webhooks\");\n\nconst createMemoryFs = (initialFiles = {}) => {\n  const files = new Map(\n    Object.entries(initialFiles).map(([filePath, contents]) => [\n      filePath,\n      String(contents),\n    ]),\n  );\n\n  return {\n    existsSync: (filePath) => files.has(filePath),\n    readFileSync: (filePath) => {\n      if (!files.has(filePath)) {\n        throw new Error(`File not found: ${filePath}`);\n      }\n      return files.get(filePath);\n    },\n    writeFileSync: (filePath, contents) => {\n      files.set(filePath, String(contents));\n    },\n    mkdirSync: () => {},\n    rmSync: () => {},\n    statSync: (filePath) => {\n      if (!files.has(filePath)) {\n        throw new Error(`File not found: ${filePath}`);\n      }\n      return {\n        birthtime: { toISOString: () => \"2026-03-08T00:00:00.000Z\" },\n        ctime: { toISOString: () => \"2026-03-08T00:00:00.000Z\" },\n      };\n    },\n  };\n};\n\ndescribe(\"server/webhooks\", () => {\n  it(\"writes delivery routing fields onto mapping when destination is provided\", () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      }),\n    });\n\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"gmail-alerts\",\n      destination: {\n        channel: \"telegram\",\n        to: \"-1003709908795:4011\",\n      },\n    });\n    const detail = createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"gmail-alerts-2\",\n      destination: {\n        channel: \"telegram\",\n        to: \"-1003709908795:4011\",\n      },\n    });\n\n    const transformPath = path.join(\n      openclawDir,\n      getTransformRelativePath(\"gmail-alerts\"),\n    );\n    const config = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const mapping = (config?.hooks?.mappings || []).find(\n      (entry) => entry?.match?.path === \"gmail-alerts\",\n    );\n    expect(mapping).toEqual(\n      expect.objectContaining({\n        deliver: true,\n        channel: \"telegram\",\n        to: \"-1003709908795:4011\",\n        agentId: \"main\",\n      }),\n    );\n    const transformSource = fs.readFileSync(transformPath, \"utf8\");\n    expect(transformSource).not.toContain(\"channel:\");\n    expect(transformSource).not.toContain(\"\\n    to:\");\n    expect(detail).toEqual(\n      expect.objectContaining({\n        deliver: true,\n        channel: \"telegram\",\n        to: \"-1003709908795:4011\",\n        agentId: \"main\",\n      }),\n    );\n  });\n\n  it(\"defaults mapping delivery channel to last and falls back to default agent\", () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [{ id: \"main\", default: true }],\n        },\n      }),\n    });\n\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"plain-alerts\",\n    });\n\n    const transformPath = path.join(\n      openclawDir,\n      getTransformRelativePath(\"plain-alerts\"),\n    );\n    const config = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const mapping = (config?.hooks?.mappings || []).find(\n      (entry) => entry?.match?.path === \"plain-alerts\",\n    );\n    expect(mapping).toEqual(\n      expect.objectContaining({\n        deliver: true,\n        channel: \"last\",\n        agentId: \"main\",\n      }),\n    );\n    expect(Object.prototype.hasOwnProperty.call(mapping, \"to\")).toBe(false);\n    const transformSource = fs.readFileSync(transformPath, \"utf8\");\n    expect(transformSource).not.toContain(\"channel:\");\n    expect(transformSource).not.toContain(\"\\n    to:\");\n  });\n\n  it(\"falls back to default agent when destination agentId is unknown\", () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"morpheus\" },\n          ],\n        },\n      }),\n    });\n\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"agent-fallback\",\n      destination: {\n        channel: \"telegram\",\n        to: \"1050\",\n        agentId: \"unknown-agent\",\n      },\n    });\n\n    const config = JSON.parse(fs.readFileSync(configPath, \"utf8\"));\n    const mapping = (config?.hooks?.mappings || []).find(\n      (entry) => entry?.match?.path === \"agent-fallback\",\n    );\n    expect(mapping?.agentId).toBe(\"main\");\n  });\n\n  it(\"updates webhook destination and can reset to default route\", () => {\n    const openclawDir = \"/tmp/openclaw\";\n    const configPath = path.join(openclawDir, \"openclaw.json\");\n    const fs = createMemoryFs({\n      [configPath]: JSON.stringify({\n        agents: {\n          list: [\n            { id: \"main\", default: true },\n            { id: \"alpha\" },\n          ],\n        },\n      }),\n    });\n    createWebhook({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"destination-edit\",\n      destination: {\n        channel: \"direct\",\n        to: \"session-1\",\n        agentId: \"main\",\n      },\n    });\n    const updated = updateWebhookDestination({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"destination-edit\",\n      destination: {\n        channel: \"group\",\n        to: \"session-2\",\n        agentId: \"alpha\",\n      },\n    });\n    expect(updated).toEqual(\n      expect.objectContaining({\n        channel: \"group\",\n        to: \"session-2\",\n        agentId: \"alpha\",\n      }),\n    );\n    const reset = updateWebhookDestination({\n      fs,\n      constants: { OPENCLAW_DIR: openclawDir },\n      name: \"destination-edit\",\n      destination: null,\n    });\n    expect(reset).toEqual(\n      expect.objectContaining({\n        channel: \"last\",\n        agentId: \"alpha\",\n      }),\n    );\n    expect(reset?.to ?? \"\").toBe(\"\");\n  });\n});\n"
  },
  {
    "path": "vitest.config.js",
    "content": "import { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environment: \"node\",\n    include: [\"tests/**/*.test.js\"],\n    restoreMocks: true,\n    clearMocks: true,\n  },\n});\n"
  }
]