diff --git a/.gitignore b/.gitignore
index 32b5585..d589979 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,14 @@ node_modules/
dist/
*.tsbuildinfo
+# Rust / Tauri build artifacts (packages/vesper-desktop — the native shell, DEV-112).
+# Cargo.lock IS committed (this is an application binary — reproducible builds).
+target/
+/packages/vesper-desktop/src-tauri/gen/
+# generated daemon-embed assets + the compiled sidecar binary (DEV-112 Slice 2)
+/packages/vesper-cli/src/generated/
+/packages/vesper-desktop/src-tauri/binaries/
+
# Vesper local runtime directory
.vesper/
diff --git a/README.md b/README.md
index 1d4f2a6..0a7d56b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
Vesper
@@ -12,17 +12,18 @@
-
A local-first runtime for your personal automation agents — that you can actually watch work.
+
A local-first runtime for your personal automation agents — with a native desktop app you actually want to open.
Vesper runs on your machine and hosts small automation **pipelines** (your "agents") under one host
process. It drives **the AI CLI you already pay for** — `claude`, `codex`, `opencode`, or `gemini` —
so it holds **no API keys** and ships **no provider SDKs**. Nothing leaves your machine except the
-calls your own CLI makes. And instead of a cold dashboard, you watch your agents in a little
-pixel-art world: tap one to see, in plain language, what it just did — or to put it to work.
+calls your own CLI makes. You talk to it in a premium dark-glass desktop app: chat with Vesper, watch
+exactly what it's doing as it works, and manage every pipeline, channel, schedule, and permission from
+one window — with a macOS menu-bar popover for a quick glance.
Bring your own CLI — no keys
Orchestrates claude / codex / opencode / gemini over a subprocess. You pay once for your CLI; Vesper adds no per-call billing and stores no LLM credentials. Pick the model per request.
-
Watch your agents work
A pixel-art world (Vesper World) where each agent is a character — busier ones grow, the world livens up with use. Click one for a plain-language result + a big Run button. Built for someone who has never opened a terminal.
+
Chat with Vesper & watch it work
A native dark-glass desktop app: chat with Vesper and it picks the right pipeline, runs it, and streams every step in a live activity rail. A sectioned sidebar manages pipelines, channels, schedules, runtime, permissions, and more — plus a macOS menu-bar popover.
Local-first & private
SQLite storage + OS-keychain secrets, all on your machine. The UI binds to 127.0.0.1 only. No accounts, no cloud, no telemetry.
Capability-sandboxed pipelines
Every agent declares what it may touch — invoke a CLI, read/write storage, touch files — and the host enforces it (deny-by-default) before any side effect.
Self-improving skills
The skill-train engine optimizes a skill's playbook against its own test set (SkillOpt-style: epochs, held-out validation, greedy accept) — using your CLI, never a provider key.
@@ -35,21 +36,30 @@ pixel-art world: tap one to see, in plain language, what it just did — or to p
---
-## Vesper World
+## The app
```sh
vesper daemon start # hosts the runtime + the UI (background)
-vesper ui # opens a browser tab — http://127.0.0.1:4317
+vesper ui # open it in your browser — http://127.0.0.1:4317
```
+Or run the **native desktop app** — a [Tauri](https://tauri.app) shell over the same daemon — from
+`packages/vesper-desktop` (`bun run dev`): a frameless window with a macOS menu-bar (tray) popover, no
+browser involved.
+
+
+
+
+
-
+
-Tap an agent → a plain-language card shows what it last did and lets you run it. The world is a live
-projection of your real runtime (pipelines, runs, schedules) — nothing is faked. It's deliberately
-simple, and built to extend: a planned **Voice** module will let an agent *speak* its result aloud.
-See [docs/ui.md](docs/ui.md).
+Chat with Vesper and it routes your message to the right pipeline and streams the run live in a Vesper
+activity rail. Every section reads your **real** runtime — nothing is faked: **Runtime** (daemon +
+helper-CLI health), **Diagnostics** (CLI probes, recent runs, and the agents running on your machine),
+**Channels**, **Schedule**, **Pipelines**, **Permissions**, **Settings** (theme + config), and more.
+Dark glass is the default; a light and a warm theme ship too. See [docs/ui.md](docs/ui.md).
## Bring your own CLI
@@ -82,7 +92,7 @@ vesper init # create ~/.vesper, initialize storage, detect installed CL
vesper cli list # show each CLI + probe status (ok / not-authenticated / not-installed)
vesper hello # ask your configured CLI to reply — proves orchestration works
vesper daemon start # start the runtime + UI (background)
-vesper ui # open Vesper World in your browser
+vesper ui # open the Vesper app in your browser
```
`vesper hello` is the proof the model works: a fixed prompt to your CLI, reply printed — no
@@ -152,7 +162,7 @@ this list never drifts. Run `vesper --help` for details; see also [doc
invocation if a tool changes its flags. `storage.redactRunSummaries` (opt-in) stores run summaries as
size-only metadata instead of raw CLI output.
-`presence` tunes the live agent view in Vesper World (the running agents it "echoes"). Vesper ships an
+`presence` tunes the live agent view in the **Diagnostics** section (the running agents it "echoes"). Vesper ships an
allowlist for `claude`, `codex`, `opencode`, `gemini`, and `zeroclaw`; `presence.matchers` **adds**
your own without touching code. Each matcher is `{ id, label, kind: "cli" | "app", pattern, exclude? }`,
where `pattern`/`exclude` are regexes matched (case-insensitively) against a process's full command
diff --git a/bun.lock b/bun.lock
index ff5a773..e7a1efb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,6 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@types/bun": "^1.3.14",
+ "@vesper/ui": "workspace:*",
},
},
"packages/pipelines": {
@@ -32,6 +33,13 @@
"name": "@vesper/core",
"version": "0.1.0",
},
+ "packages/vesper-desktop": {
+ "name": "@vesper/desktop",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@tauri-apps/cli": "^2.11.2",
+ },
+ },
"packages/vesper-ui": {
"name": "@vesper/ui",
"version": "0.1.0",
@@ -59,6 +67,30 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
+ "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="],
+
+ "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="],
+
+ "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="],
+
+ "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="],
+
+ "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="],
+
+ "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="],
+
+ "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.2", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="],
+
+ "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="],
+
+ "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="],
+
+ "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="],
+
+ "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="],
+
+ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="],
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
@@ -67,6 +99,8 @@
"@vesper/core": ["@vesper/core@workspace:packages/vesper-core"],
+ "@vesper/desktop": ["@vesper/desktop@workspace:packages/vesper-desktop"],
+
"@vesper/pipelines": ["@vesper/pipelines@workspace:packages/pipelines"],
"@vesper/ui": ["@vesper/ui@workspace:packages/vesper-ui"],
diff --git a/cycle-log.md b/cycle-log.md
index abd2087..efea13b 100644
--- a/cycle-log.md
+++ b/cycle-log.md
@@ -651,3 +651,78 @@ the still-blocked forge sandbox (it executes no LLM-generated code).
- DEFERRED (per spec Out of Scope): applying `fix_proposal`s (software-engineer pipeline); authoring
pipeline CODE (forge, blocked on the sandbox); `NETWORK_FETCH`; a RAG/embedding index over signals;
an elder-surface approval tile; auto-running skill-train on a newly acquired skill.
+
+## Chatbot home + editable pipeline templates (#9 + #4) — SHIPPED
+
+`specs/chatbot-home.md`. The post-onboarding HOME is a simple chatbot; the canvas demotes to a side
+activity panel. Built on the SHIPPED orchestration+trace backbone (consumed, not modified). No Linear
+issue (issue-capped) -> specs/ + this entry + the commit are the record (Rule 11). Built by a
+Backend->Client->Review workflow; the review's 2 real HIGH gaps were then fixed by the lead.
+
+- **Storage (migration `007_chat_home`):** `chat_sessions`, `chat_turns`, `pipeline_templates` + index;
+ 6 synchronous `Store` methods (createSession, appendTurn, listSessions, listTurns, getTemplate,
+ upsertTemplate) mirroring the existing JSON/assert helpers. `chat_turns.run_id` links an assistant
+ turn to the run that produced it (transcript bubble == activity-tree root, same data two ways).
+- **Router pipeline (`packages/pipelines/router/`):** a chat message is a manual `scheduler.run("router",
+ {params})` (the EXISTING run path — no new execution). The handler classifies via `ctx.complete` to ONE
+ label, maps it through a FIXED ALLOWLIST to a registered handler id, and `ctx.spawn`s it; an
+ unmapped/free-form label -> a clarify turn (NO spawn, no dynamic id — preserves no-eval). caps
+ [CLI_INVOKE, WRITE_STORAGE, SPAWN_SUBAGENT].
+- **Routes + WS:** `POST /api/chat`, `GET /api/chat/sessions`, `GET /api/chat/sessions/:id/turns`,
+ `GET /api/pipelines`, `GET|PUT /api/pipelines/:id/template`; a `chat:` WS topic next to the
+ backbone's `agent:` (one socket, UUID-guarded). Client: transcript home + demoted activity
+ panel (reuses the runTree render) + a templates screen; reduced-motion + WCAG-AA honored.
+- **Security:** a minimal out-of-band approval-token module (`vesper-core/src/approval/`, CSPRNG
+ single-use) gates `PUT /template`; `POST /api/approval/request` mints a code and prints it to the daemon
+ TTY (out-of-band — never in the HTTP response, so a local app can mint but not read it). The future
+ `security-hardening.md` adopts this seam. `POST /api/chat` is isLocalRequest-only (deliberate parity
+ with the existing run route, so the canvas Run button still works).
+- **Lead fixes over the workflow output** (2 real HIGHs the review caught): (1) `mint()` had NO production
+ caller -> added the `/api/approval/request` mint path + test, so template editing actually works
+ end-to-end; (2) the router ignored template `default_params` -> it now MERGES the target's editable
+ default_params UNDER the user message (injected via `registerPipelines({getDefaultParams})` -> daemon
+ wires `store.getTemplate`), so an edited template configures its runs (#4). + router/server tests.
+- 724 tests / 0 fail (+ chatbot suite + the 2 fix tests); Biome clean; no NEW tsc errors (same 16
+ pre-existing); no provider SDKs.
+- NOTED (not blocking): `PUT /template` persists prompt/params only — schedule/caps stay editable via
+ `vesper schedule` (the spec's Design-Decisions/Acceptance contradict each other; took the conservative
+ path). Migration `007_chat_home` takes the next free id; the umbrella ledger's planning reservation
+ (007=rag) shifts to 008/009 for rag/eval (gitignored planning doc, reconciled at their build).
+- DEFERRED (per spec Out of Scope): the security-hardening §C token formalization; multi-session history
+ UX; capability editing from the templates UI; token-level streaming.
+
+---
+
+## Desktop shell redesign — premium dark-glass native companion + Vesper World rebuild — SHIPPED
+
+- Specs: `specs/desktop-app-shell.md` + `specs/vesper-world-rebuild.md` (Omar-authorized 2026-06-02; record
+ surface = specs + this log; Linear issue cap active). Reference look: OpenClaw Windows Companion.
+- **Decisions locked (Omar):** premium dark-glass SUPERSEDES the elder-first *visual* framing (Hard rule 14
+ amendment pending on a later sync); primary section name = **Pipelines**; presence/echo MOVES to
+ Diagnostics (not deleted); built shell + rebuilt Chat together as slice 1.
+- **What shipped:** the `@vesper/ui` client is now an app shell — custom draggable titlebar (Cmd+E command
+ search, live status pills off `/api/status`), grouped sidebar, a client-side `SectionRouter`, and a
+ chrome-only theme system (dark default; light/hearth opt-in) that REPLACES the canvas-coupled `WorldTheme`.
+ 14 sections: Chat + Runtime/CLIs/Permissions/Sandbox/Settings/Diagnostics/About (live) + Pipelines/
+ Channels/Schedule (thin) + Skills/Memory/Voice (honest stubs naming their specs).
+- **Vesper World rebuilt:** the pixel-art canvas + machine-wide presence home are RETIRED (controlled
+ `git rm`, recoverable). Chat = transcript + a Vesper-ONLY activity rail (follows the conversation's run
+ tree via the existing `/api/chat` + run-trace APIs; subscribe-before-backfill + de-dupe preserved). No
+ backend rewrite — reused chat/router/sessions/turns verbatim.
+- **Server:** new read-only `/api/status`, `/api/presence`, `/api/runs`; `/api/world` + `snapshot.ts` removed;
+ presence poll kept (feeds `/api/presence` for Diagnostics).
+- **Native:** macOS overlay titlebar (`TitleBarStyle::Overlay` + `hidden_title`, cfg-gated to macOS) so the
+ custom HTML titlebar shows with the traffic lights inset; tray + single-instance from DEV-112 slice 3.
+- **Parallel build:** lead built the backbone + Chat + real sections + server routes; 2 sub-agents built the
+ 6 thin views + the Rust overlay window concurrently (file-disjoint). Net **-890 lines** tracked in vesper-ui.
+- **Gotcha (cost a runtime crash Omar caught):** the browser client is bundled by Bun (which does NOT error
+ on an undefined identifier) and sits OUTSIDE the root tsc program, so a section referenced in the barrel
+ but never imported (`sandboxSection`) only failed at runtime in the browser — green tests + clean bundle
+ missed it. FIX + GUARD: `sections/index.test.ts` imports the barrel and asserts ALL_SECTIONS (14, unique
+ ids, valid shape). Lesson: for the browser client, an import-the-barrel test is the real typecheck.
+- Verified: `biome ci` clean (2 cosmetic warnings), vesper-ui 46 / vesper-cli 104 pass, no new tsc errors in
+ touched files, compiled sidecar serves the new shell end-to-end. No provider SDKs.
+- DEFERRED: privileged config writes from Settings (theme is client-side; default-CLI read-only); full
+ template editing in Pipelines (read-only view); Windows/Linux window chrome (macOS-first per Omar); the
+ one `!important` (reduced-motion) biome warning; the menu-bar popover app + internal-pipelines auto-skills
+ feature (next requests).
diff --git a/docs/imgs/vesper-app.png b/docs/imgs/vesper-app.png
new file mode 100644
index 0000000..03bd1fc
Binary files /dev/null and b/docs/imgs/vesper-app.png differ
diff --git a/docs/imgs/vesper-diagnostics.png b/docs/imgs/vesper-diagnostics.png
new file mode 100644
index 0000000..a33d44d
Binary files /dev/null and b/docs/imgs/vesper-diagnostics.png differ
diff --git a/docs/imgs/vesper-menubar.png b/docs/imgs/vesper-menubar.png
new file mode 100644
index 0000000..09cae74
Binary files /dev/null and b/docs/imgs/vesper-menubar.png differ
diff --git a/docs/imgs/vesper-runtime.png b/docs/imgs/vesper-runtime.png
new file mode 100644
index 0000000..edee1ed
Binary files /dev/null and b/docs/imgs/vesper-runtime.png differ
diff --git a/docs/imgs/vesper-settings.png b/docs/imgs/vesper-settings.png
new file mode 100644
index 0000000..9a60513
Binary files /dev/null and b/docs/imgs/vesper-settings.png differ
diff --git a/package.json b/package.json
index e717e3a..b9dfbf1 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"vesper": "bun packages/vesper-cli/src/index.ts",
"sync:ai": "bun scripts/sync-ai-docs.ts",
"docs:cli": "bun scripts/gen-cli-docs.ts",
+ "build:daemon": "bun scripts/build-daemon.ts",
"prepare": "git config core.hooksPath .githooks || true"
},
"engines": {
@@ -21,6 +22,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
- "@types/bun": "^1.3.14"
+ "@types/bun": "^1.3.14",
+ "@vesper/ui": "workspace:*"
}
}
diff --git a/packages/pipelines/index.ts b/packages/pipelines/index.ts
index c8d13ee..6ca0314 100644
--- a/packages/pipelines/index.ts
+++ b/packages/pipelines/index.ts
@@ -12,6 +12,7 @@ import {
type Capability,
type HandlerRegistry,
type RegisterTaskInput,
+ type RunParams,
type Scheduler,
SchedulerError,
type TaskHandler,
@@ -28,6 +29,13 @@ import {
orchestratorDemoHandler,
orchestratorDemoTaskInput,
} from "./orchestrator-demo/handler.ts";
+import {
+ makeRouterHandler,
+ ROUTE_ALLOWLIST,
+ ROUTER_HANDLER_ID,
+ routerHandler,
+ routerTaskInput,
+} from "./router/handler.ts";
import { SELFTEST_HANDLER_ID, selftestHandler, selftestTaskInput } from "./selftest/handler.ts";
import {
SKILL_TRAIN_HANDLER_ID,
@@ -44,6 +52,10 @@ export {
ORCHESTRATOR_DEMO_HANDLER_ID,
orchestratorDemoHandler,
orchestratorDemoTaskInput,
+ ROUTE_ALLOWLIST,
+ ROUTER_HANDLER_ID,
+ routerHandler,
+ routerTaskInput,
SELFTEST_HANDLER_ID,
SKILL_TRAIN_HANDLER_ID,
selftestHandler,
@@ -71,6 +83,14 @@ export const PIPELINES: readonly PipelineDescriptor[] = [
handler: selftestHandler,
taskInput: selftestTaskInput,
},
+ // The chatbot-home dispatcher: a chat message is a manual run of this pipeline. It
+ // classifies the wish via the CLI and spawns one allowlisted built-in. Adds
+ // CLI_INVOKE + WRITE_STORAGE + SPAWN_SUBAGENT to the host grant union.
+ {
+ handlerId: ROUTER_HANDLER_ID,
+ handler: routerHandler,
+ taskInput: routerTaskInput,
+ },
{
handlerId: SKILL_TRAIN_HANDLER_ID,
handler: skillTrainHandler,
@@ -126,9 +146,29 @@ export function grantedCapabilities(): Capability[] {
* so a daemon restart backfills grants for tasks persisted before per-task grants
* existed. No grant writing happens here; that would duplicate the ceiling check.
*/
-export function registerPipelines(scheduler: Scheduler, registry: HandlerRegistry): void {
+/** Host-injected wiring for built-in pipelines (e.g. the router's template reader). */
+export interface RegisterPipelinesOptions {
+ /**
+ * Resolves a target handler's editable template `default_params` so the `router`
+ * merges them into spawn params (#4). When omitted, the router uses no defaults
+ * (the built-in handler), so non-daemon callers and tests behave unchanged.
+ */
+ readonly getDefaultParams?: (handlerId: string) => RunParams;
+}
+
+export function registerPipelines(
+ scheduler: Scheduler,
+ registry: HandlerRegistry,
+ options: RegisterPipelinesOptions = {},
+): void {
for (const descriptor of PIPELINES) {
- registry.register(descriptor.handlerId, descriptor.handler);
+ // The daemon injects the template reader into the router so edited templates take
+ // effect; every other handler registers as declared.
+ const handler =
+ descriptor.handlerId === ROUTER_HANDLER_ID && options.getDefaultParams !== undefined
+ ? makeRouterHandler({ getDefaultParams: options.getDefaultParams })
+ : descriptor.handler;
+ registry.register(descriptor.handlerId, handler);
// Spawn-only descriptors (no taskInput) register the handler only.
if (descriptor.taskInput === undefined) continue;
diff --git a/packages/pipelines/router/handler.test.ts b/packages/pipelines/router/handler.test.ts
new file mode 100644
index 0000000..6472bad
--- /dev/null
+++ b/packages/pipelines/router/handler.test.ts
@@ -0,0 +1,279 @@
+import { Database } from "bun:sqlite";
+import { describe, expect, test } from "bun:test";
+import { rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type {
+ CompleteResult,
+ PipelineContext,
+ RunOutcome,
+ SubAgentDescriptor,
+ SubAgentHandle,
+} from "@vesper/core";
+import { HandlerRegistry, openStore, Scheduler, type Store } from "@vesper/core";
+import { grantedCapabilities, registerPipelines } from "../index.ts";
+import {
+ makeRouterHandler,
+ ROUTE_ALLOWLIST,
+ ROUTER_HANDLER_ID,
+ routerTaskInput,
+} from "./handler.ts";
+
+// ---------------------------------------------------------------------------
+// Fake PipelineContext — records complete prompts, spawn descriptors, and runs.
+// ---------------------------------------------------------------------------
+
+interface FakeContext {
+ readonly ctx: PipelineContext;
+ readonly completePrompts: string[];
+ readonly spawned: SubAgentDescriptor[];
+ readonly recordedRuns: Array<{ status: string; summary: string }>;
+ readonly progress: string[];
+}
+
+function makeFakeContext(options: {
+ readonly params?: Record;
+ /** Text the fake `complete` returns (the classifier label). */
+ readonly classifyReply?: string;
+ /** Status the spawned child resolves with. */
+ readonly childStatus?: string;
+ /** When true, the spawned child's `done` rejects (handler must tolerate it). */
+ readonly childRejects?: boolean;
+}): FakeContext {
+ const completePrompts: string[] = [];
+ const spawned: SubAgentDescriptor[] = [];
+ const recordedRuns: Array<{ status: string; summary: string }> = [];
+ const progress: string[] = [];
+
+ const ctx: PipelineContext = {
+ task: {
+ id: "router",
+ kind: "manual",
+ schedule_expr: "",
+ handler_id: "router",
+ enabled: true,
+ last_run_at: null,
+ last_error: null,
+ max_runs_per_day: null,
+ max_concurrent: null,
+ max_duration_ms: null,
+ runs_today: 0,
+ runs_today_date: null,
+ attempt_count: 0,
+ next_attempt_at: null,
+ required_capabilities: ["CLI_INVOKE", "WRITE_STORAGE", "SPAWN_SUBAGENT"],
+ },
+ now: new Date(2025, 0, 1),
+ params: options.params ?? {},
+ runId: "router-run",
+ parentRunId: null,
+ async complete(prompt): Promise {
+ completePrompts.push(prompt);
+ const text = options.classifyReply ?? "none";
+ return { text, exit_code: 0, raw_stdout: text, raw_stderr: "", duration_ms: 1 };
+ },
+ recordRun({ status, summary }) {
+ recordedRuns.push({ status, summary });
+ return "router-run";
+ },
+ emitProgress(e) {
+ progress.push(e.message);
+ },
+ spawn(descriptor): SubAgentHandle {
+ spawned.push(descriptor);
+ const outcome: RunOutcome = {
+ taskId: descriptor.handlerId,
+ runId: "child-run",
+ status: options.childStatus ?? "ok",
+ summary: "child done",
+ cli: null,
+ durationMs: 1,
+ };
+ return {
+ runId: "child-run",
+ handlerId: descriptor.handlerId,
+ label: descriptor.label,
+ done: options.childRejects
+ ? Promise.reject(new Error("child failed"))
+ : Promise.resolve(outcome),
+ };
+ },
+ readSignals() {
+ throw new Error("readSignals is not supported in this fake context");
+ },
+ };
+
+ return { ctx, completePrompts, spawned, recordedRuns, progress };
+}
+
+// ---------------------------------------------------------------------------
+// routerHandler — classify + dispatch + no-eval fallback
+// ---------------------------------------------------------------------------
+
+describe("routerHandler", () => {
+ test("an empty message produces a clarify turn with no spawn and no CLI call", async () => {
+ const fake = makeFakeContext({ params: { message: " " } });
+ await makeRouterHandler()(fake.ctx);
+
+ expect(fake.completePrompts).toHaveLength(0);
+ expect(fake.spawned).toHaveLength(0);
+ expect(fake.recordedRuns).toHaveLength(1);
+ expect(fake.recordedRuns[0]?.status).toBe("clarify");
+ });
+
+ test("a mapped label spawns the allowlisted handler id with the message", async () => {
+ const fake = makeFakeContext({
+ params: { message: "run the self test" },
+ classifyReply: "selftest",
+ });
+ await makeRouterHandler()(fake.ctx);
+
+ expect(fake.completePrompts).toHaveLength(1);
+ expect(fake.spawned).toHaveLength(1);
+ expect(fake.spawned[0]?.handlerId).toBe(ROUTE_ALLOWLIST.selftest);
+ expect(fake.spawned[0]?.params?.message).toBe("run the self test");
+ expect(fake.spawned[0]?.capabilities).toEqual(["WRITE_STORAGE"]);
+ expect(fake.recordedRuns[0]?.status).toBe("ok");
+ });
+
+ test("merges the target template default_params UNDER the user message (#4)", async () => {
+ const fake = makeFakeContext({
+ params: { message: "run the self test" },
+ classifyReply: "selftest",
+ });
+ await makeRouterHandler({
+ // The injected reader supplies the target handler's editable default_params;
+ // a template-provided `message` must NOT override the real user message.
+ getDefaultParams: (handlerId) =>
+ handlerId === ROUTE_ALLOWLIST.selftest ? { tone: "concise", message: "IGNORED" } : {},
+ })(fake.ctx);
+
+ expect(fake.spawned).toHaveLength(1);
+ expect(fake.spawned[0]?.params?.tone).toBe("concise");
+ expect(fake.spawned[0]?.params?.message).toBe("run the self test");
+ });
+
+ test("the classifier reply is normalised (case/whitespace/punctuation tolerated)", async () => {
+ const fake = makeFakeContext({ params: { message: "x" }, classifyReply: " SELFTEST.\n" });
+ await makeRouterHandler()(fake.ctx);
+ expect(fake.spawned[0]?.handlerId).toBe(ROUTE_ALLOWLIST.selftest);
+ });
+
+ test("an unmapped label produces a clarify turn and NEVER spawns (no-eval invariant)", async () => {
+ const fake = makeFakeContext({ params: { message: "do my taxes" }, classifyReply: "taxes" });
+ await makeRouterHandler()(fake.ctx);
+
+ expect(fake.spawned).toHaveLength(0);
+ expect(fake.recordedRuns[0]?.status).toBe("clarify");
+ });
+
+ test("a free-form handler-id reply is refused (cannot inject an arbitrary id)", async () => {
+ // The model returns a string that looks like a real handler id but is NOT a label key.
+ const fake = makeFakeContext({
+ params: { message: "x" },
+ classifyReply: "rm -rf; orchestrator-demo",
+ });
+ await makeRouterHandler()(fake.ctx);
+ expect(fake.spawned).toHaveLength(0);
+ expect(fake.recordedRuns[0]?.status).toBe("clarify");
+ });
+
+ test('the literal "none" reply maps to a clarify turn', async () => {
+ const fake = makeFakeContext({ params: { message: "x" }, classifyReply: "none" });
+ await makeRouterHandler()(fake.ctx);
+ expect(fake.spawned).toHaveLength(0);
+ expect(fake.recordedRuns[0]?.status).toBe("clarify");
+ });
+
+ test("a child failure is tolerated — the router records 'partial', not a throw", async () => {
+ const fake = makeFakeContext({
+ params: { message: "x" },
+ classifyReply: "selftest",
+ childRejects: true,
+ });
+ await makeRouterHandler()(fake.ctx);
+ expect(fake.spawned).toHaveLength(1);
+ expect(fake.recordedRuns[0]?.status).toBe("partial");
+ });
+
+ test("a custom allowlist drives dispatch (handler is configurable)", async () => {
+ const fake = makeFakeContext({ params: { message: "x" }, classifyReply: "greet" });
+ await makeRouterHandler({ allowlist: { greet: "selftest" } })(fake.ctx);
+ expect(fake.spawned[0]?.handlerId).toBe("selftest");
+ });
+
+ test("the classify prompt enumerates the allowlist labels and fences the message", async () => {
+ const fake = makeFakeContext({ params: { message: "SECRET" }, classifyReply: "none" });
+ await makeRouterHandler()(fake.ctx);
+ const prompt = fake.completePrompts[0] ?? "";
+ expect(prompt).toContain("selftest");
+ expect(prompt).toContain("orchestrate");
+ expect(prompt).toContain("SECRET");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Registration — the router is installed with the right capabilities.
+// ---------------------------------------------------------------------------
+
+describe("router registration", () => {
+ let path: string;
+ let db: Database;
+ let store: Store;
+
+ function setup(): { registry: HandlerRegistry; scheduler: Scheduler } {
+ path = join(tmpdir(), `vesper-router-${crypto.randomUUID()}.db`);
+ openStore(path).close();
+ db = new Database(path);
+ store = openStore(path);
+ const registry = new HandlerRegistry();
+ const scheduler = new Scheduler({ db, registry, grants: grantedCapabilities() });
+ registerPipelines(scheduler, registry);
+ return { registry, scheduler };
+ }
+
+ function teardown(): void {
+ db.close();
+ store.close();
+ try {
+ rmSync(path, { force: true });
+ rmSync(`${path}-shm`, { force: true });
+ rmSync(`${path}-wal`, { force: true });
+ } catch {
+ // ignore
+ }
+ }
+
+ test("registers a manual router task requiring CLI_INVOKE + WRITE_STORAGE + SPAWN_SUBAGENT", () => {
+ const { scheduler } = setup();
+ try {
+ const task = scheduler.list().find((t) => t.id === "router");
+ expect(task).toBeDefined();
+ expect(task?.kind).toBe("manual");
+ expect(task?.required_capabilities).toContain("CLI_INVOKE");
+ expect(task?.required_capabilities).toContain("WRITE_STORAGE");
+ expect(task?.required_capabilities).toContain("SPAWN_SUBAGENT");
+ } finally {
+ teardown();
+ }
+ });
+
+ test("the router's spawn targets are all registered handlers (allowlist is resolvable)", () => {
+ const { registry } = setup();
+ try {
+ for (const handlerId of Object.values(ROUTE_ALLOWLIST)) {
+ expect(registry.has(handlerId)).toBe(true);
+ }
+ expect(registry.has(ROUTER_HANDLER_ID)).toBe(true);
+ } finally {
+ teardown();
+ }
+ });
+
+ test("the host grant union covers the router task input capabilities", () => {
+ const granted = grantedCapabilities();
+ for (const cap of routerTaskInput.required_capabilities ?? []) {
+ expect(granted).toContain(cap);
+ }
+ });
+});
diff --git a/packages/pipelines/router/handler.ts b/packages/pipelines/router/handler.ts
new file mode 100644
index 0000000..fa2837e
--- /dev/null
+++ b/packages/pipelines/router/handler.ts
@@ -0,0 +1,174 @@
+/**
+ * The `router` pipeline — the chatbot-home dispatcher.
+ *
+ * A chat message is a manual run of THIS pipeline through the existing run path
+ * (`POST /api/chat` -> `scheduler.run("router", { params })`). The handler reads the
+ * user's message from `ctx.params.message`, asks the user's authenticated CLI
+ * (`ctx.complete`, CLI_INVOKE — Hard rule 12, no provider SDK) to CLASSIFY it to ONE
+ * label, maps that label through a FIXED ALLOWLIST to a registered handler id, and
+ * `ctx.spawn`s that pipeline as a sub-agent (the live tree under the transcript turn).
+ *
+ * SAFETY (non-negotiable): the handler id is NEVER taken from the model's free-form
+ * text. The classifier returns a label; only a label present in {@link ROUTE_ALLOWLIST}
+ * resolves to a handler id. An unmapped/unknown/free-form label produces a
+ * clarifying-question turn (a recorded run, NO spawn, NO dynamic handler id) — this
+ * preserves the no-dynamic-eval invariant the scheduler relies on.
+ *
+ * Capabilities: `CLI_INVOKE` (classify), `WRITE_STORAGE` (record + emit trace),
+ * `SPAWN_SUBAGENT` (dispatch). All asserted at the context boundary before any effect.
+ */
+
+import type { Capability, RegisterTaskInput, RunParams, TaskHandler } from "@vesper/core";
+
+/** Allowlisted handler id referenced by the `router` task. */
+export const ROUTER_HANDLER_ID = "router";
+
+/**
+ * The fixed label -> handler-id allowlist. The classifier may ONLY pick a key here;
+ * the value is the registered handler id the router spawns. Anything outside this map
+ * (including a free-form id the model might emit) is refused and becomes a clarifying
+ * turn. Built-in spawn targets only — flagship pipelines extend this map as they land.
+ */
+export const ROUTE_ALLOWLIST: Readonly> = {
+ selftest: "selftest",
+ orchestrate: "orchestrator-demo",
+} as const;
+
+/** The capability the spawned child is granted — `WRITE_STORAGE` only (it records + traces). */
+const CHILD_CAPABILITIES: readonly Capability[] = ["WRITE_STORAGE"];
+
+/** Max characters of the user message embedded in the classify prompt (bound the prompt). */
+const MESSAGE_MAX_LENGTH = 2_000;
+
+/** Read the user message from params; empty string when absent/non-string. */
+function readMessage(params: RunParams): string {
+ const raw = params.message;
+ return typeof raw === "string" ? raw.slice(0, MESSAGE_MAX_LENGTH) : "";
+}
+
+/**
+ * Build the classify prompt: the model must answer with EXACTLY one label from the
+ * allowlist (or the literal `none`). The allowlist is interpolated so the model knows
+ * the closed set; the user's message is fenced so it cannot rewrite the instruction.
+ */
+function buildClassifyPrompt(message: string, labels: readonly string[]): string {
+ return [
+ "You are a strict intent classifier for a local automation runtime.",
+ `Choose EXACTLY ONE label from this closed set: ${labels.join(", ")}, none.`,
+ 'Answer with the single label only — no punctuation, no explanation. Use "none" when',
+ "the request matches no label or is ambiguous.",
+ "",
+ "User request:",
+ "<<<",
+ message,
+ ">>>",
+ ].join("\n");
+}
+
+/**
+ * Normalise the model's reply to a label key: trim, lowercase, and keep only the first
+ * token of word characters. A reply that is not an exact allowlist key resolves to null
+ * (treated as `none`) — the model can never inject an arbitrary handler id this way.
+ */
+function resolveLabel(reply: string, allowlist: Readonly>): string | null {
+ const token =
+ reply
+ .trim()
+ .toLowerCase()
+ .match(/[a-z0-9_-]+/)?.[0] ?? "";
+ return Object.hasOwn(allowlist, token) ? token : null;
+}
+
+/** Dependencies that make the router handler unit-testable; all default to the built-ins. */
+export interface RouterHandlerOptions {
+ /** The label -> handler-id allowlist. Defaults to {@link ROUTE_ALLOWLIST}. */
+ readonly allowlist?: Readonly>;
+ /**
+ * Returns the editable template `default_params` for a target handler id, which the
+ * router MERGES under the user message into the spawn params (so an edited pipeline
+ * template actually affects its runs — #4). Host-injected (the daemon wires it to
+ * `store.getTemplate`); defaults to no defaults when absent (tests / non-daemon).
+ */
+ readonly getDefaultParams?: (handlerId: string) => RunParams;
+}
+
+/**
+ * Build the router handler. The default export {@link routerHandler} uses the built-in
+ * allowlist; tests inject a custom allowlist to assert dispatch + the no-eval fallback.
+ */
+export function makeRouterHandler(options: RouterHandlerOptions = {}): TaskHandler {
+ const allowlist = options.allowlist ?? ROUTE_ALLOWLIST;
+ const getDefaultParams = options.getDefaultParams;
+ const labels = Object.keys(allowlist);
+
+ return async (ctx) => {
+ const message = readMessage(ctx.params);
+
+ if (message.trim().length === 0) {
+ ctx.emitProgress({ kind: "step", message: "empty message — asking for clarification" });
+ ctx.recordRun({
+ status: "clarify",
+ summary: "I did not catch that — could you say what you would like me to do?",
+ });
+ return;
+ }
+
+ ctx.emitProgress({ kind: "step", message: "classifying request" });
+ const result = await ctx.complete(buildClassifyPrompt(message, labels));
+ const label = resolveLabel(result.text, allowlist);
+
+ // No-eval fallback: an unmapped/free-form label NEVER becomes a handler id.
+ if (label === null) {
+ ctx.emitProgress({
+ kind: "step",
+ message: "no matching pipeline — asking for clarification",
+ });
+ ctx.recordRun({
+ status: "clarify",
+ summary:
+ "I am not sure which automation fits that. Could you rephrase, or tell me the task " +
+ "in a few words?",
+ });
+ return;
+ }
+
+ const handlerId = allowlist[label] as string;
+ ctx.emitProgress({
+ kind: "spawn",
+ message: `dispatching to "${handlerId}"`,
+ data: { label, handlerId },
+ });
+
+ // Merge the target's editable template default_params UNDER the user message, so an
+ // edited template configures its runs (#4) without ever overriding the message.
+ const templateParams = getDefaultParams?.(handlerId) ?? {};
+ const handle = ctx.spawn({
+ handlerId,
+ label,
+ params: { ...templateParams, message },
+ capabilities: CHILD_CAPABILITIES,
+ });
+ const childOutcome = await handle.done.catch(() => null);
+
+ ctx.recordRun({
+ status: childOutcome?.status === "ok" ? "ok" : "partial",
+ summary: `routed to ${handlerId} (run ${handle.runId})`,
+ });
+ };
+}
+
+/** The built-in router handler (default allowlist). */
+export const routerHandler: TaskHandler = makeRouterHandler();
+
+/**
+ * Manual task wiring for the `router` pipeline. Requires `CLI_INVOKE` (classify),
+ * `WRITE_STORAGE` (record + emit trace), and `SPAWN_SUBAGENT` (dispatch).
+ */
+export const routerTaskInput: RegisterTaskInput = {
+ id: "router",
+ kind: "manual",
+ schedule_expr: "",
+ handler_id: ROUTER_HANDLER_ID,
+ max_duration_ms: 120_000,
+ required_capabilities: ["CLI_INVOKE", "WRITE_STORAGE", "SPAWN_SUBAGENT"],
+};
diff --git a/packages/vesper-cli/src/commands/daemon-run.ts b/packages/vesper-cli/src/commands/daemon-run.ts
index 1ff23fb..69cbaa8 100644
--- a/packages/vesper-cli/src/commands/daemon-run.ts
+++ b/packages/vesper-cli/src/commands/daemon-run.ts
@@ -1,6 +1,7 @@
import { Database } from "bun:sqlite";
import { mkdir } from "node:fs/promises";
import {
+ ApprovalTokenStore,
DEFAULT_AGENT_MATCHERS,
detectAvailableCLIs,
HandlerRegistry,
@@ -67,21 +68,33 @@ export const daemonRunCommand: Command = {
complete,
redactSummaries: config.storage?.redactRunSummaries === true,
});
- registerPipelines(scheduler, registry);
+ // The UI store is opened first so the router can read editable template
+ // default_params through it (#4) — an edited template then affects its runs.
+ const uiStore = openStore(dbPath());
+ registerPipelines(scheduler, registry, {
+ getDefaultParams: (handlerId) => uiStore.getTemplate(handlerId)?.defaultParams ?? {},
+ });
// Host the Vesper World UI in-process (one runtime): the UI reads this
// scheduler + storage directly and gets live run events off its EventBus.
// Agent-presence detection uses the built-in allowlist plus any matchers
// the user added under `presence.matchers` in config; `presence.pollMs`
// overrides the scan interval.
- const uiStore = openStore(dbPath());
const presenceMatchers = [...DEFAULT_AGENT_MATCHERS, ...(config.presence?.matchers ?? [])];
+ // Out-of-band approval tokens for privileged config mutations (template edits).
+ // In-memory + per-process: a daemon restart invalidates every outstanding code.
+ const approvalTokens = new ApprovalTokenStore();
const ui = await startUiServer({
scheduler,
store: uiStore,
seed: machineFingerprint(),
port: uiPort(),
+ version: "0.1.0",
+ socketPath: socketPath(),
+ defaultCli: config.cli.default ?? null,
+ detectClis: async () => installed.map((name) => ({ name, status: "installed", ok: true })),
detectPresences: presenceDetectorFor(presenceMatchers),
+ approvalTokens,
...(config.presence?.pollMs !== undefined ? { presencePollMs: config.presence.pollMs } : {}),
...(config.ui?.theme !== undefined ? { defaultTheme: config.ui.theme } : {}),
});
diff --git a/packages/vesper-cli/src/compiled-entry.ts b/packages/vesper-cli/src/compiled-entry.ts
new file mode 100644
index 0000000..6cd1865
--- /dev/null
+++ b/packages/vesper-cli/src/compiled-entry.ts
@@ -0,0 +1,23 @@
+#!/usr/bin/env bun
+// Compiled daemon entrypoint for the desktop sidecar (DEV-112 Slice 2).
+//
+// `bun build --compile` bundles this into a single self-contained `vesper-daemon`
+// binary that the Tauri shell spawns. Unlike `vesper daemon run` from source, the
+// compiled binary has no `client/` directory and no runtime bundler (its FS is the
+// virtual `/$bunfs`), so the UI server cannot build its assets on the fly. We instead
+// embed the prebuilt client assets at COMPILE time via `with { type: "text" }` (inlined
+// as string constants) and install them before the daemon starts.
+//
+// The `.txt` files are generated by `scripts/build-daemon.ts` immediately before the
+// compile step; they do not exist in a normal source checkout (see text-imports.d.ts).
+import { setEmbeddedClientAssets } from "@vesper/ui";
+import { registry } from "./commands/index.ts";
+import { dispatch } from "./dispatch.ts";
+import appJs from "./generated/app-js.txt" with { type: "text" };
+import indexHtml from "./generated/index-html.txt" with { type: "text" };
+
+setEmbeddedClientAssets({ indexHtml, appJs });
+
+// Default to `daemon run`; still forward any explicit argv the shell passes through.
+const args = process.argv.slice(2);
+process.exit(await dispatch(registry, args.length > 0 ? args : ["daemon", "run"]));
diff --git a/packages/vesper-cli/src/text-imports.d.ts b/packages/vesper-cli/src/text-imports.d.ts
new file mode 100644
index 0000000..8e1b401
--- /dev/null
+++ b/packages/vesper-cli/src/text-imports.d.ts
@@ -0,0 +1,8 @@
+// Ambient declaration for `import x from "....txt" with { type: "text" }`.
+// The desktop build (scripts/build-daemon.ts) generates the referenced `.txt` files
+// just before `bun build --compile`; this keeps the compiled-entry type-clean in a
+// plain source checkout where those generated files are absent.
+declare module "*.txt" {
+ const content: string;
+ export default content;
+}
diff --git a/packages/vesper-core/src/approval/errors.ts b/packages/vesper-core/src/approval/errors.ts
new file mode 100644
index 0000000..7ced691
--- /dev/null
+++ b/packages/vesper-core/src/approval/errors.ts
@@ -0,0 +1,17 @@
+/** The reason an approval-token operation failed. */
+export type ApprovalErrorReason = "not_found" | "expired" | "already_used";
+
+/**
+ * Thrown by the approval-token store when a token cannot be verified — it was
+ * never minted, has expired, or was already consumed (single-use). Carries a
+ * typed `reason` so a route can map it to the right HTTP status.
+ */
+export class ApprovalError extends Error {
+ readonly reason: ApprovalErrorReason;
+
+ constructor(reason: ApprovalErrorReason, message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = "ApprovalError";
+ this.reason = reason;
+ }
+}
diff --git a/packages/vesper-core/src/approval/index.ts b/packages/vesper-core/src/approval/index.ts
new file mode 100644
index 0000000..080d510
--- /dev/null
+++ b/packages/vesper-core/src/approval/index.ts
@@ -0,0 +1,4 @@
+export type { ApprovalErrorReason } from "./errors.ts";
+export { ApprovalError } from "./errors.ts";
+export type { ApprovalTokenStoreOptions } from "./tokens.ts";
+export { ApprovalTokenStore, DEFAULT_TOKEN_TTL_MS } from "./tokens.ts";
diff --git a/packages/vesper-core/src/approval/tokens.test.ts b/packages/vesper-core/src/approval/tokens.test.ts
new file mode 100644
index 0000000..0932609
--- /dev/null
+++ b/packages/vesper-core/src/approval/tokens.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, test } from "bun:test";
+import { ApprovalError } from "./errors.ts";
+import { ApprovalTokenStore } from "./tokens.ts";
+
+/** A deterministic clock seam whose current value the test controls. */
+function fixedClock(start = 1_000): { now: () => number; advance: (ms: number) => void } {
+ let t = start;
+ return {
+ now: () => t,
+ advance: (ms) => {
+ t += ms;
+ },
+ };
+}
+
+describe("ApprovalTokenStore", () => {
+ test("mint returns a non-empty lowercase-hex code", () => {
+ const store = new ApprovalTokenStore();
+ const code = store.mint();
+ expect(code).toMatch(/^[0-9a-f]+$/);
+ expect(code.length).toBeGreaterThanOrEqual(16);
+ });
+
+ test("mint produces distinct codes (CSPRNG)", () => {
+ const store = new ApprovalTokenStore();
+ const codes = new Set([store.mint(), store.mint(), store.mint()]);
+ expect(codes.size).toBe(3);
+ });
+
+ test("verify succeeds once for a valid code", () => {
+ const store = new ApprovalTokenStore();
+ const code = store.mint();
+ expect(() => store.verify(code)).not.toThrow();
+ });
+
+ test("verify is single-use — a replay throws already_used", () => {
+ const store = new ApprovalTokenStore();
+ const code = store.mint();
+ store.verify(code);
+ try {
+ store.verify(code);
+ throw new Error("expected verify to throw");
+ } catch (err) {
+ expect(err).toBeInstanceOf(ApprovalError);
+ expect((err as ApprovalError).reason).toBe("already_used");
+ }
+ });
+
+ test("verify of an unknown code throws not_found", () => {
+ const store = new ApprovalTokenStore();
+ try {
+ store.verify("deadbeef");
+ throw new Error("expected verify to throw");
+ } catch (err) {
+ expect(err).toBeInstanceOf(ApprovalError);
+ expect((err as ApprovalError).reason).toBe("not_found");
+ }
+ });
+
+ test("verify of an expired code throws expired", () => {
+ const clock = fixedClock();
+ const store = new ApprovalTokenStore({ ttlMs: 100, now: clock.now });
+ const code = store.mint();
+ clock.advance(101);
+ try {
+ store.verify(code);
+ throw new Error("expected verify to throw");
+ } catch (err) {
+ expect(err).toBeInstanceOf(ApprovalError);
+ expect((err as ApprovalError).reason).toBe("expired");
+ }
+ });
+
+ test("a code is valid right up to (but not at) its TTL boundary", () => {
+ const clock = fixedClock();
+ const store = new ApprovalTokenStore({ ttlMs: 100, now: clock.now });
+ const code = store.mint();
+ clock.advance(99);
+ expect(store.isValid(code)).toBe(true);
+ clock.advance(1); // now == expiresAt
+ expect(store.isValid(code)).toBe(false);
+ });
+
+ test("isValid does not consume the code (verify still succeeds afterwards)", () => {
+ const store = new ApprovalTokenStore();
+ const code = store.mint();
+ expect(store.isValid(code)).toBe(true);
+ expect(store.isValid(code)).toBe(true);
+ expect(() => store.verify(code)).not.toThrow();
+ });
+
+ test("isValid is false for unknown/used codes", () => {
+ const store = new ApprovalTokenStore();
+ expect(store.isValid("nope")).toBe(false);
+ const code = store.mint();
+ store.verify(code);
+ expect(store.isValid(code)).toBe(false);
+ });
+
+ test("prune drops expired and used entries (later verify is not_found)", () => {
+ const clock = fixedClock();
+ const store = new ApprovalTokenStore({ ttlMs: 100, now: clock.now });
+ const expiring = store.mint();
+ clock.advance(101);
+ store.prune();
+ try {
+ store.verify(expiring);
+ throw new Error("expected verify to throw");
+ } catch (err) {
+ expect((err as ApprovalError).reason).toBe("not_found");
+ }
+ });
+
+ test("ttl is clamped to a minimum of 1ms", () => {
+ const clock = fixedClock();
+ const store = new ApprovalTokenStore({ ttlMs: 0, now: clock.now });
+ const code = store.mint();
+ // With ttl clamped to >=1, the code is valid at mint time (now < expiresAt).
+ expect(store.isValid(code)).toBe(true);
+ });
+
+ test("injected randomBytes seam is used (deterministic code)", () => {
+ const store = new ApprovalTokenStore({
+ randomBytes: (out) => out.fill(0xab),
+ });
+ const code = store.mint();
+ expect(code).toBe("ab".repeat(code.length / 2));
+ });
+});
diff --git a/packages/vesper-core/src/approval/tokens.ts b/packages/vesper-core/src/approval/tokens.ts
new file mode 100644
index 0000000..74f8783
--- /dev/null
+++ b/packages/vesper-core/src/approval/tokens.ts
@@ -0,0 +1,125 @@
+/**
+ * Out-of-band approval tokens for privileged, out-of-band mutations.
+ *
+ * The daemon mints a short, single-use code (CSPRNG) with a short TTL; a privileged
+ * route (e.g. `PUT /api/pipelines/:id/template`) requires the caller to present a
+ * valid, unexpired, unconsumed code. This is the minimal self-contained module the
+ * future `security-hardening.md` adopts — it adds a SECOND factor over `isLocalRequest`
+ * so a malicious local script cannot silently rewrite a pipeline's config without the
+ * code the daemon surfaced to the operator out-of-band.
+ *
+ * The store is in-memory by design: tokens are ephemeral and per-daemon-process; a
+ * restart invalidates every outstanding code (fail-closed). No token is ever persisted
+ * or logged. `crypto.getRandomValues` is the CSPRNG seam; `() => Date.now()` is the
+ * clock seam (injectable for deterministic tests).
+ */
+
+import { ApprovalError } from "./errors.ts";
+
+/** Default time-to-live for a minted token (5 minutes). */
+export const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1_000;
+
+/** Number of random bytes behind a code; 12 bytes -> 24 lowercase hex chars. */
+const TOKEN_BYTES = 12;
+
+/** A live token entry: when it expires, and whether it was already consumed. */
+interface TokenEntry {
+ readonly expiresAt: number;
+ used: boolean;
+}
+
+/** Options for {@link ApprovalTokenStore}. */
+export interface ApprovalTokenStoreOptions {
+ /** Token lifetime in ms. Defaults to {@link DEFAULT_TOKEN_TTL_MS}. Clamped to >= 1. */
+ readonly ttlMs?: number;
+ /** Clock seam (ms since epoch). Defaults to `Date.now`. Inject for tests. */
+ readonly now?: () => number;
+ /**
+ * CSPRNG seam — fills the given buffer with random bytes. Defaults to
+ * `crypto.getRandomValues`. Inject ONLY for tests; production must use a CSPRNG.
+ */
+ readonly randomBytes?: (out: Uint8Array) => void;
+}
+
+/** Lower-hex encode a byte buffer (no separators). */
+function toHex(bytes: Uint8Array): string {
+ let out = "";
+ for (const b of bytes) {
+ out += b.toString(16).padStart(2, "0");
+ }
+ return out;
+}
+
+/**
+ * In-memory store of single-use, short-TTL approval codes.
+ *
+ * - `mint()` returns a fresh CSPRNG code and records its expiry.
+ * - `verify(code)` consumes the code: it succeeds exactly once for a valid,
+ * unexpired code and throws {@link ApprovalError} otherwise (`not_found`,
+ * `expired`, `already_used`). Verifying marks the code used so a replay fails.
+ */
+export class ApprovalTokenStore {
+ readonly #tokens = new Map();
+ readonly #ttlMs: number;
+ readonly #now: () => number;
+ readonly #randomBytes: (out: Uint8Array) => void;
+
+ constructor(options: ApprovalTokenStoreOptions = {}) {
+ this.#ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_TOKEN_TTL_MS);
+ this.#now = options.now ?? (() => Date.now());
+ this.#randomBytes = options.randomBytes ?? ((out) => crypto.getRandomValues(out));
+ }
+
+ /** Mint a fresh single-use code and return it. The raw code is never persisted to disk. */
+ mint(): string {
+ const buf = new Uint8Array(TOKEN_BYTES);
+ this.#randomBytes(buf);
+ const code = toHex(buf);
+ this.#tokens.set(code, { expiresAt: this.#now() + this.#ttlMs, used: false });
+ return code;
+ }
+
+ /**
+ * Consume `code`. Returns nothing on success (the code is now spent). Throws
+ * {@link ApprovalError}:
+ * - `not_found` when the code was never minted (or was purged after expiry);
+ * - `expired` when the code is past its TTL (it is dropped);
+ * - `already_used` when the code was previously verified (replay).
+ */
+ verify(code: string): void {
+ const entry = this.#tokens.get(code);
+ if (entry === undefined) {
+ throw new ApprovalError("not_found", "approval code is not recognised");
+ }
+ if (this.#now() >= entry.expiresAt) {
+ this.#tokens.delete(code);
+ throw new ApprovalError("expired", "approval code has expired");
+ }
+ if (entry.used) {
+ throw new ApprovalError("already_used", "approval code was already used");
+ }
+ entry.used = true;
+ }
+
+ /**
+ * Non-consuming check used by routes that want a boolean. Returns true iff the
+ * code is valid, unexpired, and unused — but does NOT mark it used. Prefer
+ * {@link verify} on the mutation path so the code is single-use.
+ */
+ isValid(code: string): boolean {
+ const entry = this.#tokens.get(code);
+ if (entry === undefined) return false;
+ if (this.#now() >= entry.expiresAt) return false;
+ return !entry.used;
+ }
+
+ /** Drop expired/used entries so the map does not grow unbounded. */
+ prune(): void {
+ const now = this.#now();
+ for (const [code, entry] of this.#tokens) {
+ if (entry.used || now >= entry.expiresAt) {
+ this.#tokens.delete(code);
+ }
+ }
+ }
+}
diff --git a/packages/vesper-core/src/connections/audit.test.ts b/packages/vesper-core/src/connections/audit.test.ts
new file mode 100644
index 0000000..11e3fa7
--- /dev/null
+++ b/packages/vesper-core/src/connections/audit.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test";
+import type { Store } from "../storage/index.ts";
+import { openStore } from "../storage/index.ts";
+import { recordConnectionEvent, stripSensitive } from "./audit.ts";
+
+function memStore(): Store {
+ const store = openStore(":memory:");
+ store.migrate();
+ return store;
+}
+
+describe("stripSensitive", () => {
+ test("drops token/value/secret/message-body fields, keeps wiring", () => {
+ const cleaned = stripSensitive({
+ channel: "telegram",
+ vaultKey: "telegram_bot_token",
+ token: "123:SECRET",
+ value: "also-secret",
+ text: "a private message body",
+ message: "another body",
+ outcome: "ok",
+ });
+ expect(cleaned).toEqual({
+ channel: "telegram",
+ vaultKey: "telegram_bot_token",
+ outcome: "ok",
+ });
+ expect(cleaned.token).toBeUndefined();
+ expect(cleaned.text).toBeUndefined();
+ });
+});
+
+describe("recordConnectionEvent", () => {
+ test("appends a source:connections event with no secret in the payload", () => {
+ const store = memStore();
+ recordConnectionEvent(store, "connection_connected", {
+ channel: "telegram",
+ vaultKey: "telegram_bot_token",
+ token: "123:SECRET",
+ });
+ const events = store.listEvents({ source: "connections" });
+ expect(events).toHaveLength(1);
+ expect(events[0]?.kind).toBe("connection_connected");
+ expect(events[0]?.payload).toEqual({ channel: "telegram", vaultKey: "telegram_bot_token" });
+ // The serialized row must not contain the secret anywhere.
+ expect(JSON.stringify(events[0])).not.toContain("123:SECRET");
+ store.close();
+ });
+
+ test("records mcp_enabled / mcp_disabled kinds", () => {
+ const store = memStore();
+ recordConnectionEvent(store, "mcp_enabled", { mcp: "linear" });
+ recordConnectionEvent(store, "mcp_disabled", { mcp: "linear" });
+ const kinds = store.listEvents({ source: "connections" }).map((e) => e.kind);
+ expect(kinds).toEqual(["mcp_enabled", "mcp_disabled"]);
+ store.close();
+ });
+});
diff --git a/packages/vesper-core/src/connections/audit.ts b/packages/vesper-core/src/connections/audit.ts
new file mode 100644
index 0000000..918c972
--- /dev/null
+++ b/packages/vesper-core/src/connections/audit.ts
@@ -0,0 +1,62 @@
+/**
+ * Audit helper for connection mutations. Every connection state transition is
+ * recorded on the existing `events` table (source `"connections"`) — NO migration.
+ * This wrapper is the single choke point that strips any secret-bearing field
+ * (the token value, a raw inbound/outbound message body) BEFORE it reaches the
+ * store, so an audit row can never leak a credential or a message (the spec's
+ * "Secret containment" SHALL + the memory protocol's never-write-secrets rule).
+ */
+
+import type { Store } from "../storage/index.ts";
+
+/** The connection mutation kinds recorded to the audit log. */
+export type ConnectionEventKind =
+ | "connection_connected"
+ | "connection_disconnected"
+ | "connection_send_failed"
+ | "mcp_enabled"
+ | "mcp_disabled";
+
+/**
+ * Field names that may carry a secret or a raw message body. They are removed
+ * from any audit payload — only NON-secret wiring (ids, vault KEY NAMES, outcome)
+ * is allowed through. The vault KEY name (`vaultKey`) is explicitly safe; the
+ * VALUE (`token`, `value`, `secret`) is not.
+ */
+const REDACTED_KEYS: ReadonlySet = new Set([
+ "token",
+ "value",
+ "secret",
+ "password",
+ "text",
+ "message",
+ "body",
+]);
+
+/** Drop any secret/message-body field from an audit payload (shallow). */
+export function stripSensitive(
+ payload: Readonly>,
+): Record {
+ const out: Record = {};
+ for (const [key, val] of Object.entries(payload)) {
+ if (REDACTED_KEYS.has(key)) continue;
+ out[key] = val;
+ }
+ return out;
+}
+
+/**
+ * Append a `source: "connections"` audit event with all secret/message-body
+ * fields stripped, returning the new event id.
+ */
+export function recordConnectionEvent(
+ store: Store,
+ kind: ConnectionEventKind,
+ payload: Readonly>,
+): string {
+ return store.appendEvent({
+ source: "connections",
+ kind,
+ payload: stripSensitive(payload),
+ });
+}
diff --git a/packages/vesper-core/src/connections/catalog.test.ts b/packages/vesper-core/src/connections/catalog.test.ts
new file mode 100644
index 0000000..81d72cd
--- /dev/null
+++ b/packages/vesper-core/src/connections/catalog.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test";
+import {
+ CHANNEL_CATALOG,
+ channelById,
+ isChannelId,
+ isMcpId,
+ MCP_CATALOG,
+ mcpById,
+} from "./catalog.ts";
+
+describe("CHANNEL_CATALOG", () => {
+ test("channel ids are unique", () => {
+ const ids = CHANNEL_CATALOG.map((d) => d.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ test("every descriptor has a non-empty allowedHosts and docsUrl", () => {
+ for (const d of CHANNEL_CATALOG) {
+ expect(d.allowedHosts.length).toBeGreaterThan(0);
+ expect(d.docsUrl.length).toBeGreaterThan(0);
+ }
+ });
+
+ test("telegram + discord are ready; whatsapp + signal are deferred", () => {
+ expect(channelById("telegram")?.status).toBe("ready");
+ expect(channelById("discord")?.status).toBe("ready");
+ expect(channelById("whatsapp")?.status).toBe("deferred");
+ expect(channelById("signal")?.status).toBe("deferred");
+ });
+
+ test("telegram declares api.telegram.org and the bot-token vault key", () => {
+ const telegram = channelById("telegram");
+ expect(telegram?.allowedHosts).toEqual(["api.telegram.org"]);
+ expect(telegram?.vaultKeys).toEqual(["telegram_bot_token"]);
+ });
+
+ test("channelById / isChannelId reject unknown ids", () => {
+ expect(channelById("nope")).toBeUndefined();
+ expect(isChannelId("telegram")).toBe(true);
+ expect(isChannelId("nope")).toBe(false);
+ });
+});
+
+describe("MCP_CATALOG", () => {
+ test("has the 10 seed ids, unique", () => {
+ const ids = MCP_CATALOG.map((d) => d.id);
+ expect(ids.length).toBe(10);
+ expect(new Set(ids).size).toBe(10);
+ expect(ids).toEqual([
+ "linear",
+ "notion",
+ "gmail",
+ "google-calendar",
+ "google-drive",
+ "refero",
+ "bigdata",
+ "fmp",
+ "ziprecruiter",
+ "excalidraw",
+ ]);
+ });
+
+ test("every entry declares a docsUrl and non-empty allowedHosts", () => {
+ for (const d of MCP_CATALOG) {
+ expect(d.docsUrl.length).toBeGreaterThan(0);
+ expect(d.allowedHosts.length).toBeGreaterThan(0);
+ }
+ });
+
+ test("mcpById / isMcpId reject unknown ids", () => {
+ expect(mcpById("linear")?.displayName).toBe("Linear");
+ expect(mcpById("nope")).toBeUndefined();
+ expect(isMcpId("notion")).toBe(true);
+ expect(isMcpId("nope")).toBe(false);
+ });
+});
diff --git a/packages/vesper-core/src/connections/catalog.ts b/packages/vesper-core/src/connections/catalog.ts
new file mode 100644
index 0000000..c5a5fcd
--- /dev/null
+++ b/packages/vesper-core/src/connections/catalog.ts
@@ -0,0 +1,162 @@
+/**
+ * Curated, code-reviewed catalogs — the single source of truth for channels and
+ * MCP servers. User input selects an id (a KEY); it never supplies an arbitrary
+ * host or server URL (same trust posture as the agent install catalog).
+ *
+ * Channels: telegram + discord are `ready` (a handler exists / shares the
+ * contract); whatsapp + signal are `deferred` (catalog entry + tutorial only).
+ */
+
+import type { ChannelDescriptor, ChannelId } from "./types.ts";
+
+/** Immutable channel catalog. */
+export const CHANNEL_CATALOG: readonly ChannelDescriptor[] = [
+ {
+ id: "telegram",
+ displayName: "Telegram",
+ transport: "long-poll",
+ allowedHosts: ["api.telegram.org"],
+ vaultKeys: ["telegram_bot_token"],
+ docsUrl: "https://core.telegram.org/bots#how-do-i-create-a-bot",
+ status: "ready",
+ },
+ {
+ id: "discord",
+ displayName: "Discord",
+ transport: "bot-api",
+ allowedHosts: ["discord.com"],
+ vaultKeys: ["discord_bot_token"],
+ docsUrl: "https://discord.com/developers/docs/getting-started",
+ status: "ready",
+ },
+ {
+ id: "whatsapp",
+ displayName: "WhatsApp",
+ transport: "webhook",
+ allowedHosts: ["graph.facebook.com"],
+ vaultKeys: ["whatsapp_access_token"],
+ docsUrl: "https://developers.facebook.com/docs/whatsapp/cloud-api",
+ status: "deferred",
+ },
+ {
+ id: "signal",
+ displayName: "Signal",
+ transport: "local-cli",
+ // Signal runs through a local signal-cli process, not a hosted host. The
+ // localhost REST bridge is the only egress; it is still declared so the
+ // allowlist seam holds (a deferred handler starts nothing in v1).
+ allowedHosts: ["127.0.0.1"],
+ vaultKeys: ["signal_account"],
+ docsUrl: "https://github.com/AsamK/signal-cli",
+ status: "deferred",
+ },
+] as const;
+
+/** Look up a channel descriptor by id, or undefined when not in the catalog. */
+export function channelById(id: string): ChannelDescriptor | undefined {
+ return CHANNEL_CATALOG.find((d) => d.id === id);
+}
+
+/** Type-guard: true iff `id` is a known catalog {@link ChannelId}. */
+export function isChannelId(id: string): id is ChannelId {
+ return CHANNEL_CATALOG.some((d) => d.id === id);
+}
+
+/**
+ * An MCP server catalog entry. The user opts a server in by id; v1 RECORDS the
+ * opt-in + any credential and shows status — it does NOT proxy MCP traffic
+ * (wiring the server into a CLI adapter's own MCP config is the adapter's concern).
+ */
+export interface McpDescriptor {
+ readonly id: string;
+ readonly displayName: string;
+ readonly docsUrl: string;
+ /** Vault KEY names this server needs (NEVER the values); empty when none. */
+ readonly vaultKeys: readonly string[];
+ /** Hosts this server is known to reach (informational in v1; no proxying). */
+ readonly allowedHosts: readonly string[];
+}
+
+/** Immutable MCP catalog — the 10 seed ids observed in the operating environment. */
+export const MCP_CATALOG: readonly McpDescriptor[] = [
+ {
+ id: "linear",
+ displayName: "Linear",
+ docsUrl: "https://linear.app/docs/mcp",
+ vaultKeys: [],
+ allowedHosts: ["mcp.linear.app"],
+ },
+ {
+ id: "notion",
+ displayName: "Notion",
+ docsUrl: "https://developers.notion.com",
+ vaultKeys: [],
+ allowedHosts: ["mcp.notion.com"],
+ },
+ {
+ id: "gmail",
+ displayName: "Gmail",
+ docsUrl: "https://developers.google.com/gmail/api",
+ vaultKeys: [],
+ allowedHosts: ["gmail.googleapis.com"],
+ },
+ {
+ id: "google-calendar",
+ displayName: "Google Calendar",
+ docsUrl: "https://developers.google.com/calendar",
+ vaultKeys: [],
+ allowedHosts: ["www.googleapis.com"],
+ },
+ {
+ id: "google-drive",
+ displayName: "Google Drive",
+ docsUrl: "https://developers.google.com/drive",
+ vaultKeys: [],
+ allowedHosts: ["www.googleapis.com"],
+ },
+ {
+ id: "refero",
+ displayName: "Refero",
+ docsUrl: "https://refero.design",
+ vaultKeys: [],
+ allowedHosts: ["mcp.refero.design"],
+ },
+ {
+ id: "bigdata",
+ displayName: "Bigdata.com",
+ docsUrl: "https://bigdata.com",
+ vaultKeys: [],
+ allowedHosts: ["mcp.bigdata.com"],
+ },
+ {
+ id: "fmp",
+ displayName: "Financial Modeling Prep",
+ docsUrl: "https://site.financialmodelingprep.com/developer/docs",
+ vaultKeys: [],
+ allowedHosts: ["mcp.financialmodelingprep.com"],
+ },
+ {
+ id: "ziprecruiter",
+ displayName: "ZipRecruiter",
+ docsUrl: "https://www.ziprecruiter.com/publishers",
+ vaultKeys: [],
+ allowedHosts: ["api.ziprecruiter.com"],
+ },
+ {
+ id: "excalidraw",
+ displayName: "Excalidraw",
+ docsUrl: "https://docs.excalidraw.com",
+ vaultKeys: [],
+ allowedHosts: ["excalidraw.com"],
+ },
+] as const;
+
+/** Look up an MCP descriptor by id, or undefined when not in the catalog. */
+export function mcpById(id: string): McpDescriptor | undefined {
+ return MCP_CATALOG.find((d) => d.id === id);
+}
+
+/** Type-guard: true iff `id` is a known catalog MCP server id. */
+export function isMcpId(id: string): boolean {
+ return MCP_CATALOG.some((d) => d.id === id);
+}
diff --git a/packages/vesper-core/src/connections/errors.ts b/packages/vesper-core/src/connections/errors.ts
new file mode 100644
index 0000000..3372438
--- /dev/null
+++ b/packages/vesper-core/src/connections/errors.ts
@@ -0,0 +1,27 @@
+/** The reason a connection operation failed. */
+export type ConnectionErrorReason =
+ | "host_not_allowed"
+ | "not_authenticated"
+ | "send_failed"
+ | "receive_failed"
+ | "unknown_channel"
+ | "invalid_response";
+
+/**
+ * Thrown by the connections layer (the allowlisted-fetch seam and channel
+ * handlers) when a network or transport operation is refused. Carries a typed
+ * `reason` so a route or CLI command can map it to the right outcome.
+ *
+ * `host_not_allowed` is the load-bearing invariant: a handler may only egress to
+ * a host its descriptor declares in `allowedHosts` — never an arbitrary host and
+ * never an LLM provider (Hard rule 12).
+ */
+export class ConnectionError extends Error {
+ readonly reason: ConnectionErrorReason;
+
+ constructor(reason: ConnectionErrorReason, message: string, options?: { cause?: unknown }) {
+ super(message, options);
+ this.name = "ConnectionError";
+ this.reason = reason;
+ }
+}
diff --git a/packages/vesper-core/src/connections/fetch.test.ts b/packages/vesper-core/src/connections/fetch.test.ts
new file mode 100644
index 0000000..00d7be7
--- /dev/null
+++ b/packages/vesper-core/src/connections/fetch.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from "bun:test";
+import type { Capability } from "../capabilities/index.ts";
+import { CapabilityError } from "../capabilities/index.ts";
+import { ConnectionError } from "./errors.ts";
+import { allowlistedFetch, type FetchFn } from "./fetch.ts";
+
+const GRANTED: readonly Capability[] = ["NETWORK_FETCH"];
+
+/** A fetch spy that records every URL it is asked to fetch. */
+function spyFetch(): { fn: FetchFn; calls: string[] } {
+ const calls: string[] = [];
+ const fn: FetchFn = async (input) => {
+ calls.push(input);
+ return new Response("{}", { headers: { "content-type": "application/json" } });
+ };
+ return { fn, calls };
+}
+
+describe("allowlistedFetch", () => {
+ test("fetches a host in the allowlist", async () => {
+ const { fn, calls } = spyFetch();
+ const res = await allowlistedFetch({
+ url: "https://api.telegram.org/bot123/getMe",
+ allowedHosts: ["api.telegram.org"],
+ granted: GRANTED,
+ fetchFn: fn,
+ });
+ expect(res.status).toBe(200);
+ expect(calls).toEqual(["https://api.telegram.org/bot123/getMe"]);
+ });
+
+ test("REFUSES a host not in the allowlist and makes NO request", async () => {
+ const { fn, calls } = spyFetch();
+ await expect(
+ allowlistedFetch({
+ url: "https://evil.example.com/steal",
+ allowedHosts: ["api.telegram.org"],
+ granted: GRANTED,
+ fetchFn: fn,
+ }),
+ ).rejects.toMatchObject({
+ name: "ConnectionError",
+ reason: "host_not_allowed",
+ });
+ expect(calls).toEqual([]); // no request was made
+ });
+
+ test("refuses an LLM-provider host (Hard rule 12) — never widened past the allowlist", async () => {
+ const { fn, calls } = spyFetch();
+ await expect(
+ allowlistedFetch({
+ url: "https://api.anthropic.com/v1/messages",
+ allowedHosts: ["api.telegram.org"],
+ granted: GRANTED,
+ fetchFn: fn,
+ }),
+ ).rejects.toBeInstanceOf(ConnectionError);
+ expect(calls).toEqual([]);
+ });
+
+ test("refuses a malformed URL with NO request", async () => {
+ const { fn, calls } = spyFetch();
+ await expect(
+ allowlistedFetch({
+ url: "not a url",
+ allowedHosts: ["api.telegram.org"],
+ granted: GRANTED,
+ fetchFn: fn,
+ }),
+ ).rejects.toMatchObject({ reason: "host_not_allowed" });
+ expect(calls).toEqual([]);
+ });
+
+ test("asserts NETWORK_FETCH before any network work", async () => {
+ const { fn, calls } = spyFetch();
+ await expect(
+ allowlistedFetch({
+ url: "https://api.telegram.org/bot123/getMe",
+ allowedHosts: ["api.telegram.org"],
+ granted: [], // NETWORK_FETCH not granted
+ fetchFn: fn,
+ }),
+ ).rejects.toBeInstanceOf(CapabilityError);
+ expect(calls).toEqual([]);
+ });
+
+ test("host matching is case-insensitive", async () => {
+ const { fn, calls } = spyFetch();
+ await allowlistedFetch({
+ url: "https://API.Telegram.ORG/bot123/getMe",
+ allowedHosts: ["api.telegram.org"],
+ granted: GRANTED,
+ fetchFn: fn,
+ });
+ expect(calls).toHaveLength(1);
+ });
+});
diff --git a/packages/vesper-core/src/connections/fetch.ts b/packages/vesper-core/src/connections/fetch.ts
new file mode 100644
index 0000000..5416822
--- /dev/null
+++ b/packages/vesper-core/src/connections/fetch.ts
@@ -0,0 +1,69 @@
+/**
+ * The single network-egress seam for channel handlers. Every outbound HTTP call a
+ * handler makes routes through {@link allowlistedFetch}; no handler may call the
+ * global `fetch` directly (a test invariant). This is where the two hard
+ * guarantees live: the call asserts `NETWORK_FETCH`, and it refuses any host the
+ * channel descriptor did not declare in `allowedHosts` — so Vesper only ever
+ * reaches the first-party hosts a catalog entry names, never an LLM provider
+ * (Hard rule 12).
+ *
+ * The `fetch` implementation is injected so the test suite fetches to NOTHING.
+ */
+
+import { assertCapabilities, type Capability } from "../capabilities/index.ts";
+import { ConnectionError } from "./errors.ts";
+
+/** A minimal fetch shape — the subset handlers use. Injected for tests. */
+export type FetchFn = (input: string, init?: RequestInit) => Promise;
+
+/** Options for {@link allowlistedFetch}. */
+export interface AllowlistedFetchOptions {
+ /** The target URL. Its host MUST be in {@link allowedHosts}. */
+ readonly url: string;
+ /** Hosts this call is permitted to reach (the descriptor's `allowedHosts`). */
+ readonly allowedHosts: readonly string[];
+ /** Capabilities the handler was granted; MUST include `NETWORK_FETCH`. */
+ readonly granted: readonly Capability[];
+ /** The fetch implementation. Defaults to the global `fetch`; inject for tests. */
+ readonly fetchFn?: FetchFn;
+ /** Standard fetch init (method, headers, body). */
+ readonly init?: RequestInit;
+}
+
+/** Parse `url` and return its lowercase hostname, or null when malformed. */
+function hostnameOf(url: string): string | null {
+ try {
+ return new URL(url).hostname.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Assert `NETWORK_FETCH`, then fetch `url` ONLY if its host is in `allowedHosts`.
+ *
+ * Throws {@link import("../capabilities/index.ts").CapabilityError} ("denied")
+ * before any network work if `NETWORK_FETCH` is not granted; throws
+ * {@link ConnectionError}("host_not_allowed") — and makes NO request — when the
+ * URL is malformed or its host is not allowlisted.
+ */
+export async function allowlistedFetch(options: AllowlistedFetchOptions): Promise {
+ const { url, allowedHosts, granted, init } = options;
+ // Capability gate first: deny before parsing/network if NETWORK_FETCH is absent.
+ assertCapabilities(["NETWORK_FETCH"], granted);
+
+ const host = hostnameOf(url);
+ if (host === null) {
+ throw new ConnectionError("host_not_allowed", `malformed URL refused: ${url}`);
+ }
+ const allowed = allowedHosts.some((h) => h.toLowerCase() === host);
+ if (!allowed) {
+ throw new ConnectionError(
+ "host_not_allowed",
+ `host "${host}" is not in the channel allowlist [${allowedHosts.join(", ")}]`,
+ );
+ }
+
+ const doFetch = options.fetchFn ?? fetch;
+ return doFetch(url, init);
+}
diff --git a/packages/vesper-core/src/connections/index.ts b/packages/vesper-core/src/connections/index.ts
new file mode 100644
index 0000000..2f856df
--- /dev/null
+++ b/packages/vesper-core/src/connections/index.ts
@@ -0,0 +1,35 @@
+// @vesper/core — Connections layer public surface (messaging channels + MCP catalog).
+
+export {
+ type ConnectionEventKind,
+ recordConnectionEvent,
+ stripSensitive,
+} from "./audit.ts";
+export {
+ CHANNEL_CATALOG,
+ channelById,
+ isChannelId,
+ isMcpId,
+ MCP_CATALOG,
+ type McpDescriptor,
+ mcpById,
+} from "./catalog.ts";
+export { ConnectionError, type ConnectionErrorReason } from "./errors.ts";
+export {
+ type AllowlistedFetchOptions,
+ allowlistedFetch,
+ type FetchFn,
+} from "./fetch.ts";
+export { ChannelRegistry } from "./registry.ts";
+export { TelegramHandler, type TelegramHandlerOptions } from "./telegram.ts";
+export type {
+ ChannelDescriptor,
+ ChannelHandler,
+ ChannelId,
+ ChannelStatus,
+ ChannelTransport,
+ ChatSink,
+ InboundMessage,
+ OutboundIntent,
+ Stoppable,
+} from "./types.ts";
diff --git a/packages/vesper-core/src/connections/registry.test.ts b/packages/vesper-core/src/connections/registry.test.ts
new file mode 100644
index 0000000..60af22e
--- /dev/null
+++ b/packages/vesper-core/src/connections/registry.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, test } from "bun:test";
+import { ChannelRegistry } from "./registry.ts";
+import type { ChannelDescriptor, ChannelHandler, ChannelId, ChatSink, Stoppable } from "./types.ts";
+
+function descriptor(id: ChannelId): ChannelDescriptor {
+ return {
+ id,
+ displayName: id,
+ transport: "long-poll",
+ allowedHosts: ["example.com"],
+ vaultKeys: [],
+ docsUrl: "https://example.com",
+ status: "ready",
+ };
+}
+
+/** A fake handler recording start/stop, optionally throwing on receive. */
+function fakeHandler(
+ id: ChannelId,
+ opts: { throwOnReceive?: boolean; onStop?: () => void } = {},
+): { handler: ChannelHandler; started: () => boolean } {
+ let started = false;
+ const handler: ChannelHandler = {
+ descriptor: descriptor(id),
+ authenticate: async () => {},
+ send: async () => {},
+ receive: (_sink: ChatSink): Stoppable => {
+ if (opts.throwOnReceive) throw new Error("boom");
+ started = true;
+ return {
+ stop() {
+ opts.onStop?.();
+ },
+ };
+ },
+ };
+ return { handler, started: () => started };
+}
+
+const sink: ChatSink = async () => {};
+
+describe("ChannelRegistry", () => {
+ test("is empty by default", () => {
+ const reg = new ChannelRegistry();
+ expect(reg.list()).toHaveLength(0);
+ expect(reg.byId("telegram")).toBeUndefined();
+ });
+
+ test("register + byId + list", () => {
+ const { handler } = fakeHandler("telegram");
+ const reg = new ChannelRegistry([handler]);
+ reg.register(fakeHandler("discord").handler);
+ expect(reg.list()).toHaveLength(2);
+ expect(reg.byId("telegram")).toBe(handler);
+ expect(reg.byId("discord")?.descriptor.id).toBe("discord");
+ });
+
+ test("startAll starts every registered handler", () => {
+ const a = fakeHandler("telegram");
+ const b = fakeHandler("discord");
+ const reg = new ChannelRegistry([a.handler, b.handler]);
+ reg.startAll(sink);
+ expect(a.started()).toBe(true);
+ expect(b.started()).toBe(true);
+ });
+
+ test("isolates a throwing handler so the others still start", () => {
+ const bad = fakeHandler("telegram", { throwOnReceive: true });
+ const good = fakeHandler("discord");
+ const reg = new ChannelRegistry([bad.handler, good.handler]);
+ const handle = reg.startAll(sink); // must not throw
+ expect(good.started()).toBe(true);
+ handle.stop();
+ });
+
+ test("startAll's handle stops every started loop (idempotent)", () => {
+ let stops = 0;
+ const a = fakeHandler("telegram", { onStop: () => stops++ });
+ const b = fakeHandler("discord", { onStop: () => stops++ });
+ const reg = new ChannelRegistry([a.handler, b.handler]);
+ const handle = reg.startAll(sink);
+ handle.stop();
+ handle.stop(); // idempotent — does not double-stop
+ expect(stops).toBe(2);
+ });
+});
diff --git a/packages/vesper-core/src/connections/registry.ts b/packages/vesper-core/src/connections/registry.ts
new file mode 100644
index 0000000..fff176a
--- /dev/null
+++ b/packages/vesper-core/src/connections/registry.ts
@@ -0,0 +1,62 @@
+/**
+ * Holds the registered {@link ChannelHandler}s and starts their inbound loops.
+ * Modeled exactly on the shipped `ModuleRegistry`: per-handler failure isolation
+ * (one handler throwing during `startAll` never breaks the others), empty by
+ * default. Only catalog entries with a stored credential AND
+ * `connections..enabled === true` are registered by the daemon.
+ */
+
+import type { ChannelHandler, ChannelId, ChatSink, Stoppable } from "./types.ts";
+
+export class ChannelRegistry {
+ readonly #handlers: ChannelHandler[] = [];
+
+ constructor(handlers: readonly ChannelHandler[] = []) {
+ this.#handlers.push(...handlers);
+ }
+
+ /** Register a handler. A second handler for the same channel id replaces nothing — registration is additive; the daemon registers at most one per channel. */
+ register(handler: ChannelHandler): void {
+ this.#handlers.push(handler);
+ }
+
+ list(): readonly ChannelHandler[] {
+ return this.#handlers;
+ }
+
+ byId(id: ChannelId): ChannelHandler | undefined {
+ return this.#handlers.find((h) => h.descriptor.id === id);
+ }
+
+ /**
+ * Start every registered handler's inbound loop, wiring each to `sink`. A
+ * handler that throws synchronously while starting is isolated (logged-by-
+ * swallowing) so one misbehaving channel cannot stop the others — the same
+ * failure-isolation contract as `ModuleRegistry.dispatchRunCompleted`. Returns
+ * a single {@link Stoppable} that stops every started loop (idempotent).
+ */
+ startAll(sink: ChatSink): Stoppable {
+ const handles: Stoppable[] = [];
+ for (const handler of this.#handlers) {
+ try {
+ handles.push(handler.receive(sink));
+ } catch {
+ // A misbehaving handler must not prevent the others from receiving.
+ }
+ }
+ let stopped = false;
+ return {
+ stop() {
+ if (stopped) return;
+ stopped = true;
+ for (const h of handles) {
+ try {
+ h.stop();
+ } catch {
+ // Best-effort teardown; one handler's failure must not block the rest.
+ }
+ }
+ },
+ };
+ }
+}
diff --git a/packages/vesper-core/src/connections/telegram.test.ts b/packages/vesper-core/src/connections/telegram.test.ts
new file mode 100644
index 0000000..b400829
--- /dev/null
+++ b/packages/vesper-core/src/connections/telegram.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, test } from "bun:test";
+import type { Capability } from "../capabilities/index.ts";
+import { CapabilityError } from "../capabilities/index.ts";
+import type { Vault } from "../vault/index.ts";
+import { ConnectionError } from "./errors.ts";
+import type { FetchFn } from "./fetch.ts";
+import { TelegramHandler } from "./telegram.ts";
+import type { ChatSink, InboundMessage } from "./types.ts";
+
+const GRANTED: readonly Capability[] = ["NETWORK_FETCH", "READ_VAULT"];
+
+/** An in-memory vault stub for tests (the suite never touches the Keychain). */
+function fakeVault(entries: Record): Vault {
+ return {
+ async get(key) {
+ const v = entries[key];
+ if (v === undefined) throw new Error(`no such key ${key}`);
+ return v;
+ },
+ async set() {},
+ async delete() {},
+ async list() {
+ return Object.keys(entries).sort();
+ },
+ };
+}
+
+/** A scripted fetch: map a method name (from the URL path) to a JSON response. */
+function scriptedFetch(responder: (method: string, body: unknown) => unknown): {
+ fn: FetchFn;
+ calls: { url: string; body: unknown }[];
+} {
+ const calls: { url: string; body: unknown }[] = [];
+ const fn: FetchFn = async (input, init) => {
+ const body = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined;
+ calls.push({ url: input, body });
+ const method = input.split("/").pop() ?? "";
+ const result = responder(method, body);
+ return new Response(JSON.stringify(result), {
+ headers: { "content-type": "application/json" },
+ });
+ };
+ return { fn, calls };
+}
+
+describe("TelegramHandler", () => {
+ test("authenticate loads the token from the vault and verifies via getMe", async () => {
+ const { fn, calls } = scriptedFetch((method) =>
+ method === "getMe"
+ ? { ok: true, result: { id: 1, is_bot: true, username: "vesperbot" } }
+ : { ok: true },
+ );
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await handler.authenticate(fakeVault({ telegram_bot_token: "123:ABC" }));
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toBe("https://api.telegram.org/bot123:ABC/getMe");
+ });
+
+ test("authenticate throws when getMe says not-a-bot", async () => {
+ const { fn } = scriptedFetch(() => ({ ok: true, result: { id: 1, is_bot: false } }));
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await expect(
+ handler.authenticate(fakeVault({ telegram_bot_token: "123:ABC" })),
+ ).rejects.toBeInstanceOf(ConnectionError);
+ });
+
+ test("authenticate surfaces a Bot API error envelope", async () => {
+ const { fn } = scriptedFetch(() => ({ ok: false, description: "Unauthorized" }));
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await expect(
+ handler.authenticate(fakeVault({ telegram_bot_token: "bad" })),
+ ).rejects.toMatchObject({ reason: "invalid_response" });
+ });
+
+ test("send posts a sendMessage with chat_id + text", async () => {
+ const { fn, calls } = scriptedFetch((method) =>
+ method === "getMe" ? { ok: true, result: { id: 1, is_bot: true } } : { ok: true, result: {} },
+ );
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await handler.authenticate(fakeVault({ telegram_bot_token: "t" }));
+ await handler.send({ kind: "reply", chatId: "42", text: "hello" });
+ const sendCall = calls.find((c) => c.url.endsWith("/sendMessage"));
+ expect(sendCall?.body).toEqual({ chat_id: "42", text: "hello" });
+ });
+
+ test("send before authenticate throws not_authenticated", async () => {
+ const { fn } = scriptedFetch(() => ({ ok: true }));
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await expect(handler.send({ kind: "reply", chatId: "1", text: "x" })).rejects.toMatchObject({
+ reason: "not_authenticated",
+ });
+ });
+
+ test("send asserts NETWORK_FETCH (denied when not granted)", async () => {
+ const { fn } = scriptedFetch((method) =>
+ method === "getMe" ? { ok: true, result: { id: 1, is_bot: true } } : { ok: true },
+ );
+ // Authenticate with full grant, then a fresh handler with no NETWORK_FETCH for send.
+ const denied = new TelegramHandler({ granted: ["READ_VAULT"], fetchFn: fn });
+ // Manually mark authenticated is not possible (private); instead authenticate fails first.
+ await expect(
+ denied.authenticate(fakeVault({ telegram_bot_token: "t" })),
+ ).rejects.toBeInstanceOf(CapabilityError);
+ });
+
+ test("receive long-polls getUpdates and feeds text messages to the sink", async () => {
+ let polls = 0;
+ const { fn } = scriptedFetch((method) => {
+ if (method === "getMe") return { ok: true, result: { id: 1, is_bot: true } };
+ if (method === "getUpdates") {
+ polls++;
+ if (polls === 1) {
+ return {
+ ok: true,
+ result: [
+ {
+ update_id: 10,
+ message: {
+ message_id: 1,
+ text: "hi bot",
+ chat: { id: 99 },
+ from: { id: 7, username: "omar" },
+ date: 1700,
+ },
+ },
+ ],
+ };
+ }
+ return { ok: true, result: [] }; // subsequent polls are empty
+ }
+ return { ok: true, result: {} };
+ });
+
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await handler.authenticate(fakeVault({ telegram_bot_token: "t" }));
+
+ const received: InboundMessage[] = [];
+ const sink: ChatSink = async (m) => void received.push(m);
+ const handle = handler.receive(sink);
+
+ // Let the loop run a couple of iterations, then stop.
+ await new Promise((r) => setTimeout(r, 50));
+ handle.stop();
+
+ expect(received).toHaveLength(1);
+ expect(received[0]).toEqual({
+ channel: "telegram",
+ chatId: "99",
+ from: "omar",
+ text: "hi bot",
+ ts: 1700 * 1000,
+ });
+ });
+
+ test("receive isolates a failing sink (ingress survives)", async () => {
+ let polls = 0;
+ const { fn } = scriptedFetch((method) => {
+ if (method === "getMe") return { ok: true, result: { id: 1, is_bot: true } };
+ if (method === "getUpdates") {
+ polls++;
+ if (polls === 1) {
+ return {
+ ok: true,
+ result: [
+ { update_id: 1, message: { message_id: 1, text: "x", chat: { id: 1 }, date: 1 } },
+ ],
+ };
+ }
+ return { ok: true, result: [] };
+ }
+ return { ok: true, result: {} };
+ });
+ const handler = new TelegramHandler({ granted: GRANTED, fetchFn: fn });
+ await handler.authenticate(fakeVault({ telegram_bot_token: "t" }));
+ const handle = handler.receive(async () => {
+ throw new Error("chatbot down");
+ });
+ await new Promise((r) => setTimeout(r, 30));
+ handle.stop(); // did not crash the loop
+ expect(polls).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/vesper-core/src/connections/telegram.ts b/packages/vesper-core/src/connections/telegram.ts
new file mode 100644
index 0000000..ff3d40c
--- /dev/null
+++ b/packages/vesper-core/src/connections/telegram.ts
@@ -0,0 +1,204 @@
+/**
+ * The Telegram channel handler — the only handler BUILT in v1. It is pure
+ * transport over the Bot API: `authenticate` loads the bot token from the vault
+ * and verifies it with `getMe`; `send` posts a `sendMessage`; `receive` runs a
+ * long-poll `getUpdates` loop (no public URL needed — works behind NAT) and feeds
+ * each message into the {@link ChatSink}. EVERY HTTP call goes through the injected
+ * {@link allowlistedFetch} seam, so the suite fetches to nothing and a handler can
+ * never reach a host outside its descriptor's allowlist (Hard rule 12).
+ */
+
+import type { Capability } from "../capabilities/index.ts";
+import type { Vault } from "../vault/index.ts";
+import { channelById } from "./catalog.ts";
+import { ConnectionError } from "./errors.ts";
+import { allowlistedFetch, type FetchFn } from "./fetch.ts";
+import type {
+ ChannelDescriptor,
+ ChannelHandler,
+ ChatSink,
+ InboundMessage,
+ OutboundIntent,
+ Stoppable,
+} from "./types.ts";
+
+/** The Telegram catalog descriptor (non-null — telegram is a built-in catalog id). */
+const TELEGRAM_DESCRIPTOR = channelById("telegram") as ChannelDescriptor;
+
+/** Long-poll timeout (seconds) passed to `getUpdates`; the server holds the request open. */
+const LONG_POLL_TIMEOUT_S = 25;
+
+/** Options for {@link TelegramHandler}. */
+export interface TelegramHandlerOptions {
+ /** Capabilities the handler was granted; MUST include NETWORK_FETCH (+ READ_VAULT). */
+ readonly granted: readonly Capability[];
+ /** The fetch implementation — injected so the suite fetches to nothing. */
+ readonly fetchFn?: FetchFn;
+ /** Vault KEY the bot token is stored under. Defaults to `telegram_bot_token`. */
+ readonly vaultKey?: string;
+ /** Hosts the handler may reach; defaults to the descriptor allowlist (narrowed, never widened upstream). */
+ readonly allowedHosts?: readonly string[];
+}
+
+/** A Telegram `User` (the `getMe` result we care about). */
+interface TelegramUser {
+ readonly id: number;
+ readonly is_bot: boolean;
+ readonly username?: string;
+}
+
+/** A Telegram `Message` envelope (the subset `getUpdates` hands us). */
+interface TelegramMessage {
+ readonly message_id: number;
+ readonly text?: string;
+ readonly chat: { readonly id: number };
+ readonly from?: { readonly id: number; readonly username?: string };
+ readonly date: number;
+}
+
+/** A Telegram `Update` row. */
+interface TelegramUpdate {
+ readonly update_id: number;
+ readonly message?: TelegramMessage;
+}
+
+/** The standard `{ ok, result }` Bot API envelope. */
+interface TelegramResponse {
+ readonly ok: boolean;
+ readonly result?: T;
+ readonly description?: string;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export class TelegramHandler implements ChannelHandler {
+ readonly descriptor: ChannelDescriptor = TELEGRAM_DESCRIPTOR;
+ readonly #granted: readonly Capability[];
+ readonly #fetchFn: FetchFn | undefined;
+ readonly #vaultKey: string;
+ readonly #allowedHosts: readonly string[];
+ #token: string | null = null;
+
+ constructor(options: TelegramHandlerOptions) {
+ this.#granted = options.granted;
+ this.#fetchFn = options.fetchFn;
+ this.#vaultKey = options.vaultKey ?? "telegram_bot_token";
+ this.#allowedHosts = options.allowedHosts ?? this.descriptor.allowedHosts;
+ }
+
+ /** Build a Bot API method URL for the loaded token. */
+ #methodUrl(method: string): string {
+ return `https://api.telegram.org/bot${this.#token}/${method}`;
+ }
+
+ /** Call a Bot API method through the allowlisted-fetch seam and return its `result`. */
+ async #call(method: string, body?: unknown): Promise {
+ if (this.#token === null) {
+ throw new ConnectionError("not_authenticated", "telegram handler is not authenticated");
+ }
+ const init: RequestInit =
+ body === undefined
+ ? { method: "GET" }
+ : {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ };
+ const res = await allowlistedFetch({
+ url: this.#methodUrl(method),
+ allowedHosts: this.#allowedHosts,
+ granted: this.#granted,
+ ...(this.#fetchFn !== undefined ? { fetchFn: this.#fetchFn } : {}),
+ init,
+ });
+ let parsed: unknown;
+ try {
+ parsed = await res.json();
+ } catch (cause) {
+ throw new ConnectionError("invalid_response", `telegram ${method} returned non-JSON`, {
+ cause,
+ });
+ }
+ if (!isRecord(parsed) || parsed.ok !== true) {
+ const description =
+ isRecord(parsed) && typeof parsed.description === "string"
+ ? parsed.description
+ : `status ${res.status}`;
+ throw new ConnectionError("invalid_response", `telegram ${method} failed: ${description}`);
+ }
+ return (parsed as TelegramResponse).result as T;
+ }
+
+ /** Load the bot token from the vault and verify it with `getMe`. */
+ async authenticate(vault: Vault): Promise {
+ this.#token = await vault.get(this.#vaultKey);
+ const me = await this.#call("getMe");
+ if (!me.is_bot) {
+ throw new ConnectionError("not_authenticated", "telegram getMe did not return a bot");
+ }
+ }
+
+ /** Deliver an outbound intent via `sendMessage`. */
+ async send(intent: OutboundIntent): Promise {
+ await this.#call("sendMessage", { chat_id: intent.chatId, text: intent.text });
+ }
+
+ /**
+ * Start a long-poll `getUpdates` loop, handing each text message to `sink`.
+ * Returns a {@link Stoppable}; `stop()` halts the loop after the in-flight poll
+ * settles. A failed poll is isolated (the loop continues) so a transient error
+ * does not kill ingress.
+ */
+ receive(sink: ChatSink): Stoppable {
+ let running = true;
+ let offset = 0;
+
+ const loop = async (): Promise => {
+ while (running) {
+ // Yield to the event loop each iteration so a cooperative stop() and any
+ // pending timers actually run. Without this, an instantly-resolving poll
+ // (a mock, or a server that ignores the long-poll timeout) re-arms purely on
+ // the microtask queue, starving macrotasks — the loop never sees running=false
+ // and the process never quiesces.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ let updates: TelegramUpdate[];
+ try {
+ updates = await this.#call("getUpdates", {
+ offset,
+ timeout: LONG_POLL_TIMEOUT_S,
+ });
+ } catch {
+ // Isolate a transient poll failure; yield, then retry while running.
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
+ continue;
+ }
+ for (const update of updates) {
+ offset = Math.max(offset, update.update_id + 1);
+ const msg = update.message;
+ if (msg?.text === undefined) continue;
+ const inbound: InboundMessage = {
+ channel: "telegram",
+ chatId: String(msg.chat.id),
+ from: msg.from?.username ?? String(msg.from?.id ?? "unknown"),
+ text: msg.text,
+ ts: msg.date * 1_000,
+ };
+ try {
+ await sink(inbound);
+ } catch {
+ // A sink failure (e.g. chatbot down) must not stop ingress.
+ }
+ }
+ }
+ };
+
+ void loop();
+ return {
+ stop() {
+ running = false;
+ },
+ };
+ }
+}
diff --git a/packages/vesper-core/src/connections/types.ts b/packages/vesper-core/src/connections/types.ts
new file mode 100644
index 0000000..911a07b
--- /dev/null
+++ b/packages/vesper-core/src/connections/types.ts
@@ -0,0 +1,84 @@
+/**
+ * Public types for the Connections layer — pluggable messaging-channel handlers
+ * modeled exactly on the shipped `UiModule` + `ModuleRegistry` seam.
+ *
+ * A handler is PURE TRANSPORT: it authenticates, sends an outbound intent, and
+ * receives inbound messages. It NEVER reasons — the chatbot/pipeline is the brain
+ * (Hard rule 12). Outbound is the only network egress and is `NETWORK_FETCH`-gated
+ * through the descriptor's host allowlist (see `fetch.ts`).
+ */
+
+import type { Vault } from "../vault/index.ts";
+
+/** The messaging channels Vesper knows about. Catalog-only; no arbitrary channels. */
+export type ChannelId = "telegram" | "discord" | "whatsapp" | "signal";
+
+/** Whether a channel is BUILT in v1 or declared-but-deferred (catalog + tutorial only). */
+export type ChannelStatus = "ready" | "deferred";
+
+/** The transport a channel handler uses to reach its service. */
+export type ChannelTransport = "long-poll" | "webhook" | "bot-api" | "local-cli";
+
+/** An outbound message the chatbot asks a handler to deliver. */
+export interface OutboundIntent {
+ readonly kind: "reply" | "notify";
+ /** Channel-native conversation id (e.g. a Telegram chat id). */
+ readonly chatId: string;
+ readonly text: string;
+}
+
+/** An inbound message a handler received and hands to the {@link ChatSink}. */
+export interface InboundMessage {
+ readonly channel: ChannelId;
+ /** Channel-native conversation id the reply is routed back to. */
+ readonly chatId: string;
+ /** Channel-native sender identity (id/username); used for audit, never as auth. */
+ readonly from: string;
+ readonly text: string;
+ /** Unix timestamp in milliseconds the message arrived. */
+ readonly ts: number;
+}
+
+/**
+ * A {@link CHANNEL_CATALOG} entry — the single source of truth for a channel.
+ * Mirrors the curated-CATALOG-constant pattern: user input selects an id (a KEY),
+ * never an arbitrary host or URL.
+ */
+export interface ChannelDescriptor {
+ readonly id: ChannelId;
+ readonly displayName: string;
+ readonly transport: ChannelTransport;
+ /** Host-allowlist seam for `NETWORK_FETCH` (e.g. `["api.telegram.org"]`). Non-empty for ready channels. */
+ readonly allowedHosts: readonly string[];
+ /** Vault KEY names this channel needs (NEVER the values). */
+ readonly vaultKeys: readonly string[];
+ /** The per-channel setup-tutorial anchor (#12). */
+ readonly docsUrl: string;
+ readonly status: ChannelStatus;
+}
+
+/** Forwards an inbound message into the chatbot. The v1 impl POSTs to `/api/chat`. */
+export type ChatSink = (message: InboundMessage) => Promise;
+
+/**
+ * A stop handle for a long-running `receive` loop. Idempotent: calling `stop`
+ * more than once is a no-op. Mirrors the Bun-idiomatic "Stoppable" shape used by
+ * the UI server handle.
+ */
+export interface Stoppable {
+ stop(): void;
+}
+
+/**
+ * A pluggable channel handler — the {@link import("../../../vesper-ui/src/modules/types.ts").UiModule}
+ * analogue. Telegram is the only handler BUILT in v1; Discord shares this contract.
+ */
+export interface ChannelHandler {
+ readonly descriptor: ChannelDescriptor;
+ /** Load the credential from the vault (READ_VAULT) and verify it (e.g. getMe). */
+ authenticate(vault: Vault): Promise;
+ /** Deliver an outbound intent. NETWORK_FETCH, host-allowlisted to the descriptor. */
+ send(intent: OutboundIntent): Promise;
+ /** Start the inbound loop feeding `sink`; returns a stop handle. */
+ receive(sink: ChatSink): Stoppable;
+}
diff --git a/packages/vesper-core/src/index.ts b/packages/vesper-core/src/index.ts
index 398b9f8..46cd705 100644
--- a/packages/vesper-core/src/index.ts
+++ b/packages/vesper-core/src/index.ts
@@ -1,9 +1,11 @@
// @vesper/core — host runtime public surface.
// Modules are re-exported here as they land through the Foundation feature loop.
+export * from "./approval/index.ts";
export * from "./auto-evolve/index.ts";
export * from "./capabilities/index.ts";
export * from "./cli/index.ts";
+export * from "./connections/index.ts";
export { VesperError } from "./errors.ts";
export * from "./ipc/index.ts";
export * from "./presence/index.ts";
diff --git a/packages/vesper-core/src/scheduler/context.test.ts b/packages/vesper-core/src/scheduler/context.test.ts
index 8b97bf0..961e764 100644
--- a/packages/vesper-core/src/scheduler/context.test.ts
+++ b/packages/vesper-core/src/scheduler/context.test.ts
@@ -89,6 +89,24 @@ function makeStore(): {
getTaskGrant() {
return null;
},
+ // Chat-home Store methods (migration 007): unused by the context double, stubbed
+ // so the mock still satisfies the widened Store interface.
+ createSession() {
+ return "session-id";
+ },
+ appendTurn() {
+ return "turn-id";
+ },
+ listSessions() {
+ return [];
+ },
+ listTurns() {
+ return [];
+ },
+ getTemplate() {
+ return null;
+ },
+ upsertTemplate() {},
close() {},
};
return { store, finished, events };
diff --git a/packages/vesper-core/src/storage/index.ts b/packages/vesper-core/src/storage/index.ts
index 278353b..581ab65 100644
--- a/packages/vesper-core/src/storage/index.ts
+++ b/packages/vesper-core/src/storage/index.ts
@@ -4,11 +4,18 @@ export { openStore } from "./store.ts";
export type {
AppendEventInput,
AppendRunEventInput,
+ AppendTurnInput,
+ ChatSessionRow,
+ ChatTurnRole,
+ ChatTurnRow,
+ CreateSessionInput,
EventRow,
FinishRunInput,
ListEventsOptions,
ListRunEventsOptions,
ListRunsOptions,
+ ListTurnsOptions,
+ PipelineTemplateRow,
RecordRunInput,
RunEventKind,
RunEventRow,
@@ -18,4 +25,5 @@ export type {
Store,
TaskGrant,
UpsertTaskGrantInput,
+ UpsertTemplateInput,
} from "./types.ts";
diff --git a/packages/vesper-core/src/storage/migrations.ts b/packages/vesper-core/src/storage/migrations.ts
index 7e981a6..51340d5 100644
--- a/packages/vesper-core/src/storage/migrations.ts
+++ b/packages/vesper-core/src/storage/migrations.ts
@@ -108,4 +108,34 @@ export const MIGRATIONS: readonly Migration[] = [
CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, ts);
`,
},
+ {
+ // The chatbot-home surface: a chat session + transcript model and per-pipeline
+ // editable templates. Each assistant turn carries the `run_id` of the router run
+ // that produced it, so a transcript bubble and the live activity tree are the same
+ // data viewed two ways. Forward-only; appended AFTER 006. The `events` table stays
+ // the durable audit trail (every chat/template mutation also writes an event there).
+ id: "007_chat_home",
+ sql: `
+ CREATE TABLE IF NOT EXISTS chat_sessions (
+ id TEXT PRIMARY KEY NOT NULL,
+ ts INTEGER NOT NULL,
+ title TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS chat_turns (
+ id TEXT PRIMARY KEY NOT NULL,
+ session_id TEXT NOT NULL,
+ ts INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ text TEXT NOT NULL,
+ run_id TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_chat_turns_session ON chat_turns(session_id, ts);
+ CREATE TABLE IF NOT EXISTS pipeline_templates (
+ handler_id TEXT PRIMARY KEY NOT NULL,
+ prompt TEXT NOT NULL DEFAULT '',
+ default_params TEXT NOT NULL DEFAULT '{}',
+ updated_at INTEGER NOT NULL
+ );
+ `,
+ },
];
diff --git a/packages/vesper-core/src/storage/store.test.ts b/packages/vesper-core/src/storage/store.test.ts
index 01cbf8b..19b827a 100644
--- a/packages/vesper-core/src/storage/store.test.ts
+++ b/packages/vesper-core/src/storage/store.test.ts
@@ -889,3 +889,219 @@ describe("listRuns parentRunId filter and runTree", () => {
expect(store.runTree("does-not-exist")).toBeNull();
});
});
+
+// ---------------------------------------------------------------------------
+// Chat home (migration 007_chat_home)
+// ---------------------------------------------------------------------------
+
+describe("chat sessions and turns", () => {
+ let path: string;
+ let store: Store;
+
+ beforeEach(() => {
+ path = tempDbPath();
+ store = openStore(path);
+ });
+
+ afterEach(() => {
+ store.close();
+ try {
+ rmSync(path, { force: true });
+ rmSync(`${path}-shm`, { force: true });
+ rmSync(`${path}-wal`, { force: true });
+ } catch {
+ // ignore
+ }
+ });
+
+ test("createSession returns a generated id and listSessions reads it back", () => {
+ const id = store.createSession({ title: "first wish" });
+ expect(typeof id).toBe("string");
+ expect(id.length).toBeGreaterThan(0);
+
+ const sessions = store.listSessions();
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0]?.id).toBe(id);
+ expect(sessions[0]?.title).toBe("first wish");
+ expect(typeof sessions[0]?.ts).toBe("number");
+ });
+
+ test("createSession honors a supplied id", () => {
+ const id = store.createSession({ id: "11111111-1111-4111-8111-111111111111", title: "x" });
+ expect(id).toBe("11111111-1111-4111-8111-111111111111");
+ });
+
+ test("listSessions is newest-first", () => {
+ const a = store.createSession({ title: "a" });
+ const b = store.createSession({ title: "b" });
+ const ids = store.listSessions().map((s) => s.id);
+ // b was created after a, so it sorts first (ts DESC).
+ expect(ids[0]).toBe(b);
+ expect(ids).toContain(a);
+ });
+
+ test("appendTurn persists user and assistant turns; listTurns is oldest-first", () => {
+ const session = store.createSession({ title: "t" });
+ const userTurn = store.appendTurn({ sessionId: session, role: "user", text: "do a thing" });
+ const asstTurn = store.appendTurn({
+ sessionId: session,
+ role: "assistant",
+ text: "on it",
+ runId: "22222222-2222-4222-8222-222222222222",
+ });
+
+ const turns = store.listTurns({ sessionId: session });
+ expect(turns.map((t) => t.id)).toEqual([userTurn, asstTurn]);
+ expect(turns[0]?.role).toBe("user");
+ expect(turns[0]?.runId).toBeNull();
+ expect(turns[1]?.role).toBe("assistant");
+ expect(turns[1]?.runId).toBe("22222222-2222-4222-8222-222222222222");
+ });
+
+ test("listTurns filters by afterTs and respects limit", () => {
+ const session = store.createSession({ title: "t" });
+ store.appendTurn({ sessionId: session, role: "user", text: "one" });
+ const all = store.listTurns({ sessionId: session });
+ const firstTs = all[0]?.ts ?? 0;
+
+ // afterTs strictly greater — the only turn (ts == firstTs) is excluded.
+ expect(store.listTurns({ sessionId: session, afterTs: firstTs })).toHaveLength(0);
+
+ store.appendTurn({ sessionId: session, role: "assistant", text: "two" });
+ store.appendTurn({ sessionId: session, role: "assistant", text: "three" });
+ expect(store.listTurns({ sessionId: session, limit: 1 })).toHaveLength(1);
+ });
+
+ test("listTurns scopes to its session only", () => {
+ const s1 = store.createSession({ title: "s1" });
+ const s2 = store.createSession({ title: "s2" });
+ store.appendTurn({ sessionId: s1, role: "user", text: "a" });
+ store.appendTurn({ sessionId: s2, role: "user", text: "b" });
+ expect(store.listTurns({ sessionId: s1 })).toHaveLength(1);
+ expect(store.listTurns({ sessionId: s1 })[0]?.text).toBe("a");
+ });
+
+ test("turns survive reopen (durable transcript)", () => {
+ const session = store.createSession({ title: "t" });
+ store.appendTurn({ sessionId: session, role: "user", text: "persisted" });
+ store.close();
+
+ const reopened = openStore(path);
+ const turns = reopened.listTurns({ sessionId: session });
+ reopened.close();
+ expect(turns).toHaveLength(1);
+ expect(turns[0]?.text).toBe("persisted");
+ });
+
+ test("a corrupted role column is rejected on read (corruption guard)", () => {
+ const session = store.createSession({ title: "t" });
+ // Write a row with an out-of-allowlist role directly.
+ const db = new Database(path);
+ db.run(
+ "INSERT INTO chat_turns (id, session_id, ts, role, text, run_id) VALUES ('bad', ?, 1, 'system', 'x', NULL)",
+ [session],
+ );
+ db.close();
+ expect(() => store.listTurns({ sessionId: session })).toThrow(StorageError);
+ });
+});
+
+describe("pipeline_templates round-trip", () => {
+ let path: string;
+ let store: Store;
+
+ beforeEach(() => {
+ path = tempDbPath();
+ store = openStore(path);
+ });
+
+ afterEach(() => {
+ store.close();
+ try {
+ rmSync(path, { force: true });
+ rmSync(`${path}-shm`, { force: true });
+ rmSync(`${path}-wal`, { force: true });
+ } catch {
+ // ignore
+ }
+ });
+
+ test("getTemplate returns null before any upsert", () => {
+ expect(store.getTemplate("router")).toBeNull();
+ });
+
+ test("upsertTemplate then getTemplate round-trips prompt + default params", () => {
+ store.upsertTemplate({
+ handlerId: "router",
+ prompt: "classify strictly",
+ defaultParams: { tone: "warm", retries: 2 },
+ });
+ const t = store.getTemplate("router");
+ expect(t).not.toBeNull();
+ expect(t?.handlerId).toBe("router");
+ expect(t?.prompt).toBe("classify strictly");
+ expect(t?.defaultParams).toEqual({ tone: "warm", retries: 2 });
+ expect(typeof t?.updatedAt).toBe("number");
+ });
+
+ test("upsertTemplate updates an existing row (ON CONFLICT)", () => {
+ store.upsertTemplate({ handlerId: "router", prompt: "v1", defaultParams: {} });
+ store.upsertTemplate({ handlerId: "router", prompt: "v2", defaultParams: { a: 1 } });
+ const t = store.getTemplate("router");
+ expect(t?.prompt).toBe("v2");
+ expect(t?.defaultParams).toEqual({ a: 1 });
+ });
+
+ test("template survives reopen", () => {
+ store.upsertTemplate({ handlerId: "router", prompt: "kept", defaultParams: {} });
+ store.close();
+ const reopened = openStore(path);
+ expect(reopened.getTemplate("router")?.prompt).toBe("kept");
+ reopened.close();
+ });
+});
+
+describe("migration 007 — chat home", () => {
+ let path: string;
+
+ beforeEach(() => {
+ path = tempDbPath();
+ });
+
+ afterEach(() => {
+ try {
+ rmSync(path, { force: true });
+ rmSync(`${path}-shm`, { force: true });
+ rmSync(`${path}-wal`, { force: true });
+ } catch {
+ // ignore
+ }
+ });
+
+ test("schema_migrations records 007 and reopen is idempotent (chat tables queryable)", () => {
+ const first = openStore(path);
+ first.close();
+ const second = openStore(path);
+ second.close();
+
+ const db = new Database(path, { readonly: true });
+ const ids = db
+ .query<{ id: string }, []>("SELECT id FROM schema_migrations")
+ .all()
+ .map((r) => r.id);
+ expect(() => db.query("SELECT count(*) FROM chat_sessions").get()).not.toThrow();
+ expect(() => db.query("SELECT count(*) FROM chat_turns").get()).not.toThrow();
+ expect(() => db.query("SELECT count(*) FROM pipeline_templates").get()).not.toThrow();
+ db.close();
+
+ expect(ids).toContain("007_chat_home");
+ expect(ids.filter((id) => id === "007_chat_home")).toHaveLength(1);
+ });
+
+ test("007 is sequenced AFTER 006 (forward-only ordering)", () => {
+ const idx006 = MIGRATIONS.findIndex((m) => m.id.startsWith("006"));
+ const idx007 = MIGRATIONS.findIndex((m) => m.id === "007_chat_home");
+ expect(idx006).toBeGreaterThanOrEqual(0);
+ expect(idx007).toBeGreaterThan(idx006);
+ });
+});
diff --git a/packages/vesper-core/src/storage/store.ts b/packages/vesper-core/src/storage/store.ts
index 686d339..686b23b 100644
--- a/packages/vesper-core/src/storage/store.ts
+++ b/packages/vesper-core/src/storage/store.ts
@@ -6,11 +6,18 @@ import { MIGRATIONS } from "./migrations.ts";
import type {
AppendEventInput,
AppendRunEventInput,
+ AppendTurnInput,
+ ChatSessionRow,
+ ChatTurnRole,
+ ChatTurnRow,
+ CreateSessionInput,
EventRow,
FinishRunInput,
ListEventsOptions,
ListRunEventsOptions,
ListRunsOptions,
+ ListTurnsOptions,
+ PipelineTemplateRow,
RecordRunInput,
RunEventKind,
RunEventRow,
@@ -20,6 +27,7 @@ import type {
Store,
TaskGrant,
UpsertTaskGrantInput,
+ UpsertTemplateInput,
} from "./types.ts";
/**
@@ -68,6 +76,31 @@ interface RawTaskGrantRow {
granted_by: unknown;
}
+/** Raw shape returned for the `chat_sessions` table. */
+interface RawChatSessionRow {
+ id: unknown;
+ ts: unknown;
+ title: unknown;
+}
+
+/** Raw shape returned for the `chat_turns` table. */
+interface RawChatTurnRow {
+ id: unknown;
+ session_id: unknown;
+ ts: unknown;
+ role: unknown;
+ text: unknown;
+ run_id: unknown;
+}
+
+/** Raw shape returned for the `pipeline_templates` table. */
+interface RawPipelineTemplateRow {
+ handler_id: unknown;
+ prompt: unknown;
+ default_params: unknown;
+ updated_at: unknown;
+}
+
function assertString(value: unknown, column: string): string {
if (typeof value !== "string") {
throw new StorageError(
@@ -192,6 +225,43 @@ function toTaskGrant(raw: RawTaskGrantRow): TaskGrant {
};
}
+function toChatSessionRow(raw: RawChatSessionRow): ChatSessionRow {
+ return {
+ id: assertString(raw.id, "id"),
+ ts: assertNumber(raw.ts, "ts"),
+ title: assertString(raw.title, "title"),
+ };
+}
+
+/** Narrow a `chat_turns.role` column to the allowlisted union (corruption guard). */
+function assertChatTurnRole(value: unknown, column: string): ChatTurnRole {
+ const str = assertString(value, column);
+ if (str !== "user" && str !== "assistant") {
+ throw new StorageError("query_failed", `unrecognised chat turn role "${str}"`);
+ }
+ return str;
+}
+
+function toChatTurnRow(raw: RawChatTurnRow): ChatTurnRow {
+ return {
+ id: assertString(raw.id, "id"),
+ sessionId: assertString(raw.session_id, "session_id"),
+ ts: assertNumber(raw.ts, "ts"),
+ role: assertChatTurnRole(raw.role, "role"),
+ text: assertString(raw.text, "text"),
+ runId: assertStringOrNull(raw.run_id, "run_id"),
+ };
+}
+
+function toPipelineTemplateRow(raw: RawPipelineTemplateRow): PipelineTemplateRow {
+ return {
+ handlerId: assertString(raw.handler_id, "handler_id"),
+ prompt: assertString(raw.prompt, "prompt"),
+ defaultParams: parsePayload(raw.default_params, "default_params"),
+ updatedAt: assertNumber(raw.updated_at, "updated_at"),
+ };
+}
+
/** {@link Store} backed by a `bun:sqlite` database. */
export class SqliteStore implements Store {
readonly #db: Database;
@@ -518,6 +588,118 @@ export class SqliteStore implements Store {
}
}
+ // -------------------------------------------------------------------------
+ // Chat home (migration 007_chat_home)
+ // -------------------------------------------------------------------------
+
+ createSession(input: CreateSessionInput): string {
+ const id = input.id ?? crypto.randomUUID();
+ const ts = Date.now();
+ try {
+ this.#db
+ .query(
+ "INSERT INTO chat_sessions (id, ts, title) VALUES (?, ?, ?)",
+ )
+ .run(id, ts, input.title);
+ } catch (cause) {
+ throw new StorageError("query_failed", "failed to create chat session", { cause });
+ }
+ return id;
+ }
+
+ appendTurn(input: AppendTurnInput): string {
+ const id = crypto.randomUUID();
+ const ts = Date.now();
+ const runId = input.runId ?? null;
+ try {
+ this.#db
+ .query(
+ "INSERT INTO chat_turns (id, session_id, ts, role, text, run_id) VALUES (?, ?, ?, ?, ?, ?)",
+ )
+ .run(id, input.sessionId, ts, input.role, input.text, runId);
+ } catch (cause) {
+ throw new StorageError("query_failed", "failed to append chat turn", { cause });
+ }
+ return id;
+ }
+
+ listSessions(): ChatSessionRow[] {
+ try {
+ // `rowid DESC` breaks ts ties by insertion order so two sessions created in
+ // the same millisecond still sort newest-first deterministically.
+ const rows = this.#db
+ .query(
+ "SELECT id, ts, title FROM chat_sessions ORDER BY ts DESC, rowid DESC",
+ )
+ .all();
+ return rows.map(toChatSessionRow);
+ } catch (cause) {
+ if (cause instanceof StorageError) throw cause;
+ throw new StorageError("query_failed", "failed to list chat sessions", { cause });
+ }
+ }
+
+ listTurns(options: ListTurnsOptions): ChatTurnRow[] {
+ try {
+ const conditions: string[] = ["session_id = ?"];
+ const params: (string | number)[] = [options.sessionId];
+
+ if (options.afterTs !== undefined) {
+ conditions.push("ts > ?");
+ params.push(options.afterTs);
+ }
+
+ const where = ` WHERE ${conditions.join(" AND ")}`;
+ const limitClause = options.limit !== undefined ? " LIMIT ?" : "";
+ if (options.limit !== undefined) {
+ params.push(options.limit);
+ }
+
+ // `rowid ASC` breaks ts ties by insertion order so turns appended in the same
+ // millisecond still read back oldest-first (user before assistant).
+ const sql = `SELECT id, session_id, ts, role, text, run_id FROM chat_turns${where} ORDER BY ts ASC, rowid ASC${limitClause}`;
+ const rows = this.#db.query(sql).all(...params);
+ return rows.map(toChatTurnRow);
+ } catch (cause) {
+ if (cause instanceof StorageError) throw cause;
+ throw new StorageError("query_failed", "failed to list chat turns", { cause });
+ }
+ }
+
+ getTemplate(handlerId: string): PipelineTemplateRow | null {
+ try {
+ const row = this.#db
+ .query(
+ `SELECT handler_id, prompt, default_params, updated_at
+ FROM pipeline_templates WHERE handler_id = ?`,
+ )
+ .get(handlerId);
+ return row !== null ? toPipelineTemplateRow(row) : null;
+ } catch (cause) {
+ if (cause instanceof StorageError) throw cause;
+ throw new StorageError("query_failed", "failed to read pipeline template", { cause });
+ }
+ }
+
+ upsertTemplate(input: UpsertTemplateInput): void {
+ const updatedAt = Date.now();
+ const defaultParamsJson = JSON.stringify(input.defaultParams);
+ try {
+ this.#db
+ .query(
+ `INSERT INTO pipeline_templates (handler_id, prompt, default_params, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(handler_id) DO UPDATE SET
+ prompt = excluded.prompt,
+ default_params = excluded.default_params,
+ updated_at = excluded.updated_at`,
+ )
+ .run(input.handlerId, input.prompt, defaultParamsJson, updatedAt);
+ } catch (cause) {
+ throw new StorageError("query_failed", "failed to upsert pipeline template", { cause });
+ }
+ }
+
close(): void {
this.#db.close();
}
diff --git a/packages/vesper-core/src/storage/types.ts b/packages/vesper-core/src/storage/types.ts
index d90230f..f5fec41 100644
--- a/packages/vesper-core/src/storage/types.ts
+++ b/packages/vesper-core/src/storage/types.ts
@@ -128,6 +128,80 @@ export interface UpsertTaskGrantInput {
readonly granted_at?: number;
}
+// ---------------------------------------------------------------------------
+// Chat home (migration 007_chat_home)
+// ---------------------------------------------------------------------------
+
+/** A row from the `chat_sessions` table — one conversation thread. */
+export interface ChatSessionRow {
+ readonly id: string;
+ /** Unix timestamp in milliseconds the session was created. */
+ readonly ts: number;
+ readonly title: string;
+}
+
+/** The role of a {@link ChatTurnRow}. */
+export type ChatTurnRole = "user" | "assistant";
+
+/**
+ * A row from the `chat_turns` table — a single transcript bubble. An assistant
+ * turn carries the `runId` of the router run that produced it, so the same row
+ * renders both as a transcript bubble and as the root of the live activity tree.
+ */
+export interface ChatTurnRow {
+ readonly id: string;
+ readonly sessionId: string;
+ /** Unix timestamp in milliseconds the turn was appended. */
+ readonly ts: number;
+ readonly role: ChatTurnRole;
+ readonly text: string;
+ /** The `runs` row id this assistant turn started, or null (user turns). */
+ readonly runId: string | null;
+}
+
+/** A row from the `pipeline_templates` table — a pipeline's editable prompt + params. */
+export interface PipelineTemplateRow {
+ readonly handlerId: string;
+ readonly prompt: string;
+ /** The deserialized default-params object the router merges into spawn params. */
+ readonly defaultParams: Record;
+ /** Unix timestamp in milliseconds the template was last written. */
+ readonly updatedAt: number;
+}
+
+/** Input for {@link Store.createSession}. `id`/`ts` are generated when omitted. */
+export interface CreateSessionInput {
+ /** Pre-allocated session id (UUID); a fresh one is generated when omitted. */
+ readonly id?: string;
+ readonly title: string;
+}
+
+/** Input for {@link Store.appendTurn}. */
+export interface AppendTurnInput {
+ readonly sessionId: string;
+ readonly role: ChatTurnRole;
+ readonly text: string;
+ /** The `runs` row this turn started (assistant turns); omitted/null for user turns. */
+ readonly runId?: string | null;
+}
+
+/** Filters for {@link Store.listTurns}. */
+export interface ListTurnsOptions {
+ readonly sessionId: string;
+ /** Return only turns strictly after this timestamp (ts > afterTs). */
+ readonly afterTs?: number;
+ /** Maximum number of rows to return (default: unlimited). */
+ readonly limit?: number;
+}
+
+/** Input for {@link Store.upsertTemplate}. */
+export interface UpsertTemplateInput {
+ readonly handlerId: string;
+ readonly prompt: string;
+ /** Default-params object; serialized to JSON on write. */
+ readonly defaultParams: Record;
+}
+
/** Optional filters for {@link Store.listEvents}. */
export interface ListEventsOptions {
/** Return only events with this source. */
@@ -233,6 +307,28 @@ export interface Store {
*/
getTaskGrant(handlerId: string, contentHash?: string): TaskGrant | null;
+ // -------------------------------------------------------------------------
+ // Chat home (migration 007_chat_home)
+ // -------------------------------------------------------------------------
+
+ /** Create a chat session and return its generated (or supplied) id. */
+ createSession(input: CreateSessionInput): string;
+
+ /** Append a transcript turn and return its generated id. */
+ appendTurn(input: AppendTurnInput): string;
+
+ /** List chat sessions newest-first (most recent activity at the top). */
+ listSessions(): ChatSessionRow[];
+
+ /** List a session's turns oldest-first, optionally filtered by `afterTs`/`limit`. */
+ listTurns(options: ListTurnsOptions): ChatTurnRow[];
+
+ /** Return the editable template for `handlerId`, or null if none was saved yet. */
+ getTemplate(handlerId: string): PipelineTemplateRow | null;
+
+ /** Insert or update a pipeline's editable template (prompt + default params). */
+ upsertTemplate(input: UpsertTemplateInput): void;
+
/**
* Close the underlying database connection. After this call the store must not be used.
*/
diff --git a/packages/vesper-desktop/README.md b/packages/vesper-desktop/README.md
new file mode 100644
index 0000000..d94f0dc
--- /dev/null
+++ b/packages/vesper-desktop/README.md
@@ -0,0 +1,48 @@
+# @vesper/desktop
+
+Native desktop shell for Vesper — a deliberately thin [Tauri 2](https://tauri.app) window
+whose WebView loads the Bun daemon's "Vesper World" UI. The host runtime stays Bun; this
+package adds no business logic. See `specs/tauri-migration.md` and Linear DEV-112.
+
+## Why this exists
+
+For the elder-first Desktop target, "open a browser to `localhost:4317`" is a UX wall.
+This shell makes Vesper a double-click native app while reusing the exact same web UI the
+daemon already serves (`@vesper/ui`). No Rust in the host — only in this shell.
+
+## Slice 1 (current)
+
+A native window pointed at `http://127.0.0.1:4317`. The daemon is started manually for now;
+auto-starting it as a bundled sidecar is Slice 2.
+
+### Run it
+
+```sh
+# 1. Toolchain (one-time): Rust + the Tauri CLI.
+# rustup is the supported installer; the Tauri CLI is a devDependency here.
+
+# 2. Start the Bun daemon (hosts Vesper World on 127.0.0.1:4317):
+bun run vesper daemon start # from the repo root
+
+# 3. Launch the native shell (from this package):
+bun run dev # = tauri dev
+```
+
+A native window opens showing Vesper World — click a creature, inspect, Run; live updates
+arrive over the same WebSocket the browser uses. No browser involved.
+
+### Build a bundle
+
+```sh
+bun run build # = tauri build -> .app / .dmg (macOS)
+```
+
+## Layout
+
+- `src-tauri/` — the Rust core (window config + entrypoint). Thin by design.
+- `src/index.html` — Slice 1 fallback page (becomes the Slice 2 boot splash).
+
+## Not yet (later slices)
+
+Sidecar auto-start + health-wait + attach-to-running-daemon (Slice 2); tray / menu /
+notifications (Slice 3); signed installers + auto-updater + CI (Slice 4).
diff --git a/packages/vesper-desktop/package.json b/packages/vesper-desktop/package.json
new file mode 100644
index 0000000..7d90729
--- /dev/null
+++ b/packages/vesper-desktop/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@vesper/desktop",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Native desktop shell for Vesper — a thin Tauri 2 window over the Bun-hosted Vesper World. DEV-112 Slice 1.",
+ "scripts": {
+ "dev": "tauri dev",
+ "build": "tauri build",
+ "tauri": "tauri"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^2.11.2"
+ }
+}
diff --git a/packages/vesper-desktop/src-tauri/Cargo.lock b/packages/vesper-desktop/src-tauri/Cargo.lock
new file mode 100644
index 0000000..e41b582
--- /dev/null
+++ b/packages/vesper-desktop/src-tauri/Cargo.lock
@@ -0,0 +1,5220 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.11.1",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
+dependencies = [
+ "serde",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.11.1",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.11.1",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ctor"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dom_query"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
+dependencies = [
+ "bit-set",
+ "cssparser",
+ "foldhash 0.2.0",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dtor"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 1.1.2+spec-1.1.0",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.11.1",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "http"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
+dependencies = [
+ "byteorder",
+ "png 0.17.16",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.11.1",
+ "serde",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
+
+[[package]]
+name = "markup5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "muda"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.11.1",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.11.1",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.11.1",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.11.1",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "open"
+version = "5.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
+dependencies = [
+ "dunce",
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.11.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.12+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.11.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "selectors"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
+dependencies = [
+ "bitflags 2.11.1",
+ "cssparser",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shared_child"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
+dependencies = [
+ "libc",
+ "sigchld",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "sigchld"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
+dependencies = [
+ "libc",
+ "os_pipe",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "swift-rs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.35.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dbus",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "once_cell",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
+dependencies = [
+ "base64 0.22.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png 0.17.16",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.117",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
+dependencies = [
+ "anyhow",
+ "glob",
+ "plist",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-plugin-shell"
+version = "2.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "open",
+ "os_pipe",
+ "regex",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "shared_child",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "tokio",
+]
+
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "thiserror 2.0.18",
+ "tracing",
+ "windows-sys 0.60.2",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dom_query",
+ "dunce",
+ "glob",
+ "http",
+ "infer",
+ "json-patch",
+ "log",
+ "memchr",
+ "phf",
+ "plist",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.18",
+ "toml 1.1.2+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 1.1.2+spec-1.1.0",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
+dependencies = [
+ "new_debug_unreachable",
+ "utf-8",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.11.1",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unic-char-property"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
+dependencies = [
+ "unic-char-range",
+]
+
+[[package]]
+name = "unic-char-range"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
+
+[[package]]
+name = "unic-common"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+
+[[package]]
+name = "unic-ucd-ident"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
+dependencies = [
+ "unic-char-property",
+ "unic-char-range",
+ "unic-ucd-version",
+]
+
+[[package]]
+name = "unic-ucd-version"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
+dependencies = [
+ "unic-common",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlpattern"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
+dependencies = [
+ "regex",
+ "serde",
+ "unic-ucd-ident",
+ "url",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vesper-desktop"
+version = "0.1.0"
+dependencies = [
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-shell",
+ "tauri-plugin-single-instance",
+]
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.72"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.14.0",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.11.1",
+ "hashbrown 0.15.5",
+ "indexmap 2.14.0",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-implement",
+ "windows-interface",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
+dependencies = [
+ "thiserror 2.0.18",
+ "windows",
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "windows-sys 0.59.0",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "indexmap 2.14.0",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.11.1",
+ "indexmap 2.14.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.14.0",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wry"
+version = "0.55.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.3",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
+dependencies = [
+ "serde",
+ "winnow 1.0.3",
+ "zvariant",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.3",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 1.0.3",
+]
diff --git a/packages/vesper-desktop/src-tauri/Cargo.toml b/packages/vesper-desktop/src-tauri/Cargo.toml
new file mode 100644
index 0000000..246f070
--- /dev/null
+++ b/packages/vesper-desktop/src-tauri/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "vesper-desktop"
+version = "0.1.0"
+edition = "2021"
+description = "Native desktop shell for Vesper (thin Tauri window over the Bun daemon)."
+authors = ["ogarciarevett"]
+# Pin a recent-enough toolchain; CI provisions Rust for the desktop package only.
+rust-version = "1.77"
+
+# The compiled shell binary. The Bun host stays Bun — see specs/tauri-migration.md.
+[[bin]]
+name = "vesper-desktop"
+path = "src/main.rs"
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+tauri = { version = "2", features = ["tray-icon"] }
+tauri-plugin-shell = "2"
+tauri-plugin-single-instance = "2"
+
+# Smaller, faster release binaries for distribution.
+[profile.release]
+codegen-units = 1
+lto = true
+opt-level = "s"
+panic = "abort"
+strip = true
diff --git a/packages/vesper-desktop/src-tauri/build.rs b/packages/vesper-desktop/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/packages/vesper-desktop/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/packages/vesper-desktop/src-tauri/capabilities/default.json b/packages/vesper-desktop/src-tauri/capabilities/default.json
new file mode 100644
index 0000000..b8c703b
--- /dev/null
+++ b/packages/vesper-desktop/src-tauri/capabilities/default.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "default",
+ "description": "Core permissions for the main and panel windows. Both load the local Vesper World over HTTP; the panel popover calls the app's custom commands (open_main, quit_app) over the Tauri IPC bridge, which custom commands allow by default under core defaults.",
+ "windows": ["main", "panel"],
+ "permissions": ["core:default"]
+}
diff --git a/packages/vesper-desktop/src-tauri/icons/128x128.png b/packages/vesper-desktop/src-tauri/icons/128x128.png
new file mode 100644
index 0000000..1c1de89
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/128x128.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/128x128@2x.png b/packages/vesper-desktop/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000..8265f65
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/128x128@2x.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/32x32.png b/packages/vesper-desktop/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..687b884
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/32x32.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/64x64.png b/packages/vesper-desktop/src-tauri/icons/64x64.png
new file mode 100644
index 0000000..e8d2239
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/64x64.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square107x107Logo.png b/packages/vesper-desktop/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 0000000..3378204
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square107x107Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square142x142Logo.png b/packages/vesper-desktop/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 0000000..90ebdc7
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square142x142Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square150x150Logo.png b/packages/vesper-desktop/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 0000000..f535fac
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square150x150Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square284x284Logo.png b/packages/vesper-desktop/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 0000000..98edde4
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square284x284Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square30x30Logo.png b/packages/vesper-desktop/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 0000000..bf6213f
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square30x30Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square310x310Logo.png b/packages/vesper-desktop/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 0000000..93874be
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square310x310Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square44x44Logo.png b/packages/vesper-desktop/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 0000000..2086177
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square44x44Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square71x71Logo.png b/packages/vesper-desktop/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 0000000..2c52f0c
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square71x71Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/Square89x89Logo.png b/packages/vesper-desktop/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 0000000..f35bf6a
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/Square89x89Logo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/StoreLogo.png b/packages/vesper-desktop/src-tauri/icons/StoreLogo.png
new file mode 100644
index 0000000..a363b75
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/StoreLogo.png differ
diff --git a/packages/vesper-desktop/src-tauri/icons/icon.icns b/packages/vesper-desktop/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..b26aabc
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/icon.icns differ
diff --git a/packages/vesper-desktop/src-tauri/icons/icon.ico b/packages/vesper-desktop/src-tauri/icons/icon.ico
new file mode 100644
index 0000000..1e30046
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/icon.ico differ
diff --git a/packages/vesper-desktop/src-tauri/icons/icon.png b/packages/vesper-desktop/src-tauri/icons/icon.png
new file mode 100644
index 0000000..b106d1a
Binary files /dev/null and b/packages/vesper-desktop/src-tauri/icons/icon.png differ
diff --git a/packages/vesper-desktop/src-tauri/src/main.rs b/packages/vesper-desktop/src-tauri/src/main.rs
new file mode 100644
index 0000000..2232c50
--- /dev/null
+++ b/packages/vesper-desktop/src-tauri/src/main.rs
@@ -0,0 +1,254 @@
+// Vesper desktop shell — DEV-112 slices 2-3.
+//
+// A thin native window over the Bun daemon. The Rust core holds no business logic; it:
+// 1. spawns the compiled `vesper-daemon` sidecar (serves Vesper World on 127.0.0.1:4317),
+// 2. waits for that port to accept connections,
+// 3. opens the window onto it,
+// 4. stops the sidecar on exit,
+// plus native chrome (slice 3): a system tray (Show/Quit) and single-instance focus.
+//
+// Attach-if-already-running is free: if a daemon is already up (e.g. `vesper daemon
+// start`), the sidecar's own single-instance guard makes it exit immediately, the window
+// attaches to the running daemon, and on quit we only kill OUR child — never someone
+// else's daemon.
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use std::net::{SocketAddr, TcpStream};
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+use tauri::menu::{Menu, MenuItem};
+use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
+use tauri::{Manager, PhysicalPosition, WebviewUrl, WebviewWindowBuilder, WindowEvent};
+use tauri_plugin_shell::process::CommandChild;
+use tauri_plugin_shell::ShellExt;
+
+/// Localhost address the daemon serves Vesper World on (matches `uiPort()`'s 4317 default).
+const UI_ADDR: &str = "127.0.0.1:4317";
+/// URL of the compact menu-bar popover UI (built by the web team, served by the daemon).
+const PANEL_URL: &str = "http://127.0.0.1:4317/?panel=1";
+/// Borderless popover size (logical pixels) — a quick-glance panel under the tray icon.
+const PANEL_WIDTH: f64 = 380.0;
+const PANEL_HEIGHT: f64 = 480.0;
+/// Gap (physical-ish, scaled at use) between the menu bar / tray icon and the panel top.
+const PANEL_GAP: f64 = 6.0;
+/// Max time to wait for the daemon to come up before opening the window anyway.
+const HEALTH_TIMEOUT: Duration = Duration::from_secs(30);
+/// Poll interval while waiting for the daemon's port.
+const HEALTH_POLL: Duration = Duration::from_millis(250);
+
+/// Holds the spawned sidecar so it can be stopped on exit. `None` when we attached to an
+/// already-running daemon (the spawned child exited via the single-instance guard).
+struct Sidecar(Mutex