diff --git a/.agents/skills/coven-cave/SKILL.md b/.agents/skills/coven-cave/SKILL.md new file mode 100644 index 0000000000..75825c9b7f --- /dev/null +++ b/.agents/skills/coven-cave/SKILL.md @@ -0,0 +1,203 @@ +--- +name: coven-cave +description: > + Default mandatory workspace discipline for all repository and development work in + OpenCoven Coven Cave (github.com/OpenCoven/coven-cave). Always activate when the + current working directory or target repository is Coven Cave, even if the user's + prompt does not name the project. Use for planning, status and diff inspection, Git + operations, setup, coding, reviewing, debugging, testing, building, running, release + work, and any task touching coven-cave / CovenCave / OpenCoven cave, ~/.coven, + COVEN_HOME, or the Cave Tauri/Next app. Enforces isolated-home VFS: never let the app + or tooling read the real user home. Triggers include a coven-cave path or remote, + Coven Cave, OpenCoven cave, "isolated cave", dev:app, pnpm dev for cave, ~/.coven, + COVEN_HOME, Tauri cave shell, and /coven-cave. This is the way. +--- + +# Coven Cave — isolated workspace (this is the way) + +When the current checkout or target repository is Coven Cave, **load this skill by +default and follow it before any other repository or development work**. The user does +not need to mention Coven Cave or invoke the skill explicitly. + +Coven Cave persists state under `~/.coven` / `os.homedir()` unless redirected. On this +machine the contract is: **development never reads or writes the real user home**. +Use the isolated tree + env-level fake home instead of a kernel VFS. + +## Detect that you are in coven-cave + +Any of these → this skill owns the session: + +- `cwd` or a parent is a `coven-cave` checkout (look for `src-tauri/`, `package.json` + name `coven-cave`, or `ai.opencoven.cave`) +- git remote contains `OpenCoven/coven-cave` +- User asks to run, fix, review, or develop Cave / CovenCave / coven-cave +- Any plan would touch `~/.coven`, `COVEN_HOME`, or install Cave tools into real `~` + +If you are **not** in that repo and the user did not ask for Cave, do not load this +workflow. + +## Non-negotiable rules + +1. **Never** run `pnpm dev`, `pnpm dev:app`, `scripts/dev-app.sh`, Tauri, or tests + against the real `$HOME` / real `~/.coven`. +2. **Always** use the isolation launcher so `HOME`, `COVEN_HOME`, `COVEN_CAVE_HOME`, + `XDG_*`, `CARGO_HOME`, and `CARGO_TARGET_DIR` sit under the isolated tree. +3. Prefer the existing isolated checkout over cloning into `~/…`. +4. If isolation is missing, recreate it with the skill script — do not “just use” + the real home for speed. +5. Real `~/.coven` may already exist from a production install. Treat it as **off + limits** unless the user explicitly asks to inspect production state. + +## Canonical isolated root + +| Item | Path | +|------|------| +| Checkout | `/tmp/coven-cave-isolated` | +| Fake home | `/tmp/coven-cave-isolated/.isolated-home` | +| `$COVEN_HOME` | `…/.isolated-home/.coven` | +| `$COVEN_CAVE_HOME` | `…/.isolated-home/.coven/cave` | +| Cargo home | `…/.isolated-home/.cargo` | +| Cargo target | `…/src-tauri/target` | +| Launcher | `…/dev-isolated.sh` | +| Toolchain | `…/.bin` (node + pnpm, auto-downloaded) | + +Override root with `COVEN_CAVE_ISOLATED_ROOT` if needed; keep it **outside** the +real user home. + +## Bootstrap (if missing) + +Resolve this skill’s directory (folder containing this `SKILL.md`), then: + +```bash +bash "/scripts/ensure-isolated.sh" +``` + +That clones `https://github.com/OpenCoven/coven-cave` into the isolated root if +needed, seeds empty cave state, installs `dev-isolated.sh`, provisions the +Cave-managed Node/npm lane plus the reviewed Coven, Claude Code, Codex, Copilot, +and OpenClaw packages, and optionally seeds the cargo registry **once** from the +host (runtime still never points at real `~`). + +## Every command goes through the launcher + +```bash +ROOT=/tmp/coven-cave-isolated # or $COVEN_CAVE_ISOLATED_ROOT +cd "$ROOT" + +./dev-isolated.sh pnpm install +./dev-isolated.sh env PORT=3011 pnpm dev +./dev-isolated.sh env PORT=3011 bash scripts/dev-app.sh # Tauri shell +./dev-isolated.sh pnpm typecheck +./dev-isolated.sh pnpm test:app +``` + +`dev-isolated.sh` **exits non-zero** if `HOME` or `CARGO_HOME` still resolve under +the real user home. Do not bypass it. + +## Self-contained toolchain (`$ROOT/.bin`) + +The launcher does **not** rely on host homebrew, nvm, or corepack. On every run +it ensures a pinned toolchain under `$ROOT/.bin` and puts it first on `PATH`: + +- **node** — version from the checkout's `.nvmrc`, official tarball extracted + to `.bin/toolchain/`, with `node`/`npm`/`npx`/`corepack` symlinked into `.bin`. +- **pnpm** — standalone binary (no node needed to boot it), version from + `package.json` `"packageManager"`. +- **rust** — NOT downloaded by default (large). If `cargo` is missing the + launcher warns; run once with `COVEN_CAVE_ENSURE_RUST=1` to install rustup + into the isolated `CARGO_HOME`/`RUSTUP_HOME`. A host cargo (e.g. homebrew's) + is used as fallback when present. + +Provisioning is idempotent (fast no-op when versions match) and serialized via +`.bin/.provision.lock` (mkdir-atomic), so concurrent sessions can't corrupt a +download in flight — if the lock is stale (>5 min), the launcher says so; +remove the dir by hand. This means "pnpm/node: command not found" can no +longer happen from GUI-spawned shells, cron, or bare-PATH contexts: the +launcher bootstraps what it needs. Delete `.bin/` to force a re-download. + +## Cave-managed runtime tools + +The app's runtime lane is separate from the launcher toolchain. Run the +idempotent setup directly when only these capabilities need repair: + +```bash +./dev-isolated.sh pnpm dev:setup:tools +``` + +`scripts/setup-isolated-dev-tools.ts` installs Cave-managed Node/npm and the +exact reviewed versions of Coven CLI, Claude Code, Codex, GitHub Copilot CLI, +and OpenClaw from `src/lib/onboarding-prerequisites.ts`. It verifies registry +integrity before each missing or mismatched package install and verifies every +launcher afterward. Authentication remains an explicit per-user step. + +## Dev server + Tauri + +- **Web only:** `./dev-isolated.sh env PORT=3011 pnpm dev` → `http://127.0.0.1:3011` +- **Native shell:** same `PORT`, then `bash scripts/dev-app.sh` (or `pnpm dev:app`). + The wrapper reuses a healthy server on that port; first cargo build is slow. +- Whisper runtime is bundled by `scripts/whisper-runtime-bundle.sh` on first + `dev-app` (lives under `src-tauri/resources/whisper`). +- Logs: `.isolated-home/Library/Logs/ai.opencoven.cave/CovenCave.log` +- App data: `.isolated-home/Library/Application Support/ai.opencoven.cave/` + +Pin a free port if 3011 is taken; stay consistent so web + Tauri share one origin. + +## Isolation model (why this works) + +App code honors: + +- `COVEN_HOME` / `COVEN_CAVE_HOME` (see `src/lib/coven-paths.ts`) +- `os.homedir()` for some paths that still join `~/.coven` without the env vars + +So isolation must set **both** explicit coven env vars **and** fake `HOME`. +Playwright e2e in-repo already uses temp `COVEN_HOME`; this skill is the same +idea for interactive/dev work, plus full home/XDG/cargo fencing. + +Not a FUSE VFS — env-root sandbox. Good enough when every process is started +through `dev-isolated.sh`. + +## Repo workflow (from upstream, still apply) + +While isolated for FS, still follow Cave’s engineering norms from the checkout’s +`AGENTS.md` / `CLAUDE.md`: + +- Branch from current `origin/main`; short-lived PR branches / worktrees +- Prefer managed worktrees via `pnpm beads:worktrees:create` when Beads is in play +- Verify with the suite that matches the change (`typecheck`, `test:app`, `test:api`, …) + +Run those commands **through** `./dev-isolated.sh` so beads/tests cannot write +real home by accident. + +## Quick health checks + +```bash +# Launcher points at fake home +./dev-isolated.sh node -e "const os=require('os'); console.log(os.homedir())" +# must print .../coven-cave-isolated/.isolated-home + +# Toolchain is self-contained (no host homebrew/nvm involved) +./dev-isolated.sh sh -c 'command -v node pnpm' +# both must print paths under $ROOT/.bin/ + +# Onboarding sees isolated coven home +curl -sS http://127.0.0.1:3011/api/onboarding/status | head -c 400 + +# Real production state untouched (mtime should not jump because of your session) +stat -f '%Sm %N' "$HOME/.coven" 2>/dev/null || true +``` + +## Do not + +- `cd` into a checkout under the real home and run bare `pnpm dev` +- Export `COVEN_HOME=~/.coven` “just this once” +- Point `CARGO_HOME` at `~/.cargo` for the running app (one-time rsync seed into + the isolated cargo dir is fine; live env is not) +- Commit the isolated tree under `/tmp` as if it were the user’s product clone + unless they asked + +## References + +- `references/layout.md` — path map and env vars +- `scripts/dev-isolated.sh` — launcher source of truth +- `scripts/ensure-isolated.sh` — bootstrap / repair +- Upstream: https://github.com/OpenCoven/coven-cave diff --git a/.agents/skills/coven-cave/agents/openai.yaml b/.agents/skills/coven-cave/agents/openai.yaml new file mode 100644 index 0000000000..ccc28a7d05 --- /dev/null +++ b/.agents/skills/coven-cave/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Coven Cave" + short_description: "Keep Coven Cave development isolated" + default_prompt: "Use $coven-cave to work safely in the Coven Cave repository." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/coven-cave/references/layout.md b/.agents/skills/coven-cave/references/layout.md new file mode 100644 index 0000000000..7710629649 --- /dev/null +++ b/.agents/skills/coven-cave/references/layout.md @@ -0,0 +1,75 @@ +# Isolated coven-cave layout + +Default root: `/tmp/coven-cave-isolated` (`$COVEN_CAVE_ISOLATED_ROOT`). + +## Tree + +``` +$ROOT/ + .git/ # clone of OpenCoven/coven-cave + node_modules/ + src/ src-tauri/ scripts/ … + dev-isolated.sh # required entrypoint for all commands + .bin/ # self-contained toolchain, first on PATH + node → toolchain/node-v…/bin/node + npm npx corepack # symlinks into the node dist + pnpm # standalone binary (packageManager pin) + toolchain/node-v…/ # extracted node dist (.nvmrc pin) + .provision.lock/ # transient; serializes concurrent provisioning + .pnpm-home/ + .isolated-home/ # fake $HOME + .coven/ # $COVEN_HOME + cave/ # $COVEN_CAVE_HOME + familiars.toml + memory/ prompts/ skills/ adapters/ workspaces/ + .cargo/ # $CARGO_HOME + .pnpm-store/ + .npm/ + .config/ .local/ .cache/ # XDG_* + Library/ + Application Support/OpenCoven/CovenCave/toolchains/ + node/v…/ # Cave-managed Node/npm runtime + npm/ # reviewed global CLI packages + launchers + Application Support/ai.opencoven.cave/ + Logs/ai.opencoven.cave/ + src-tauri/target/ # $CARGO_TARGET_DIR +``` + +## Environment (set by `dev-isolated.sh`) + +| Variable | Value | +|----------|--------| +| `HOME` | `$ROOT/.isolated-home` | +| `COVEN_HOME` | `$HOME/.coven` | +| `COVEN_CAVE_HOME` | `$HOME/.coven/cave` | +| `XDG_CONFIG_HOME` | `$HOME/.config` | +| `XDG_DATA_HOME` | `$HOME/.local/share` | +| `XDG_STATE_HOME` | `$HOME/.local/state` | +| `XDG_CACHE_HOME` | `$HOME/.cache` | +| `CARGO_HOME` | `$HOME/.cargo` | +| `CARGO_TARGET_DIR` | `$ROOT/src-tauri/target` | +| `PNPM_STORE_PATH` | `$HOME/.pnpm-store` | +| `npm_config_cache` | `$HOME/.npm` | +| `npm_config_userconfig` | `$HOME/.npmrc` | +| `PATH` | `$ROOT/.bin` first, then `$CARGO_HOME/bin`, `$PNPM_HOME`, system dirs; homebrew last (fallback only) | + +## Toolchain pins + +- node — `.nvmrc` in the checkout (bare major resolves to newest release). +- pnpm — `"packageManager"` in the checkout's `package.json`. +- rust — not auto-installed; `COVEN_CAVE_ENSURE_RUST=1` installs rustup into + the isolated `CARGO_HOME`/`RUSTUP_HOME`; a host cargo is used as fallback. +- Cave-managed runtime tools — `scripts/setup-isolated-dev-tools.ts` reads the + exact Node and npm package pins from `src/lib/onboarding-prerequisites.ts`. + +## Why both `HOME` and `COVEN_HOME` + +- `src/lib/coven-paths.ts` prefers `COVEN_HOME` / `COVEN_CAVE_HOME`. +- Some routes still use `homedir() + "/.coven"` (e.g. onboarding status, + github-subscriptions). Fake `HOME` covers those. + +## Ports + +Preferred isolated web port: **3011**. Reuse it for Tauri `devUrl` so the shell +and Next share one origin. `scripts/dev-app.sh` also auto-picks `3000..3010` if +`PORT` is unset — pin `PORT` when the isolated server is already up. diff --git a/.agents/skills/coven-cave/scripts/dev-isolated.sh b/.agents/skills/coven-cave/scripts/dev-isolated.sh new file mode 100755 index 0000000000..72f2987f8f --- /dev/null +++ b/.agents/skills/coven-cave/scripts/dev-isolated.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Isolated dev launcher for coven-cave — never touches the real $HOME. +# Self-contained toolchain: node + pnpm are downloaded into $ROOT/.bin on +# first run (pinned by .nvmrc / package.json "packageManager"), so the +# launcher works from GUI-spawned shells, cron, CI, or any host without +# homebrew/nvm on PATH. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +export COVEN_CAVE_ISOLATED_ROOT="$ROOT" +export HOME="$ROOT/.isolated-home" +export USERPROFILE="$HOME" +export HOMEDRIVE="" +export HOMEPATH="" +export COVEN_HOME="$HOME/.coven" +export COVEN_CAVE_HOME="$HOME/.coven/cave" +export XDG_CONFIG_HOME="$HOME/.config" +export XDG_DATA_HOME="$HOME/.local/share" +export XDG_STATE_HOME="$HOME/.local/state" +export XDG_CACHE_HOME="$HOME/.cache" +export PNPM_HOME="$ROOT/.pnpm-home" +export npm_config_cache="$HOME/.npm" +export npm_config_userconfig="$HOME/.npmrc" +export npm_config_globalconfig="$HOME/.npmrc-global" +export PNPM_STORE_PATH="$HOME/.pnpm-store" +export CARGO_HOME="$HOME/.cargo" +export RUSTUP_HOME="$HOME/.rustup" +export CARGO_TARGET_DIR="$ROOT/src-tauri/target" +# macOS app support paths under fake home +export TMPDIR="$HOME/tmp" +unset CLAUDE_CONFIG_DIR 2>/dev/null || true + +BIN="$ROOT/.bin" +TOOLCHAIN="$BIN/toolchain" +# $BIN first so the pinned toolchain always wins; $CARGO_HOME/bin picks up an +# isolated rustup install; homebrew/usr-local sit at the END as fallback only — +# nothing here requires them. +export PATH="$BIN:$CARGO_HOME/bin:$PNPM_HOME:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:/opt/homebrew/sbin" + +cd "$ROOT" +mkdir -p \ + "$COVEN_HOME" "$COVEN_CAVE_HOME" \ + "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" \ + "$PNPM_STORE_PATH" "$npm_config_cache" "$PNPM_HOME" \ + "$CARGO_HOME" "$CARGO_TARGET_DIR" "$TMPDIR" \ + "$BIN" "$TOOLCHAIN" \ + "$HOME/Library/Application Support" \ + "$HOME/Library/Caches" \ + "$HOME/Library/Logs" \ + "$HOME/Library/Preferences" \ + "$HOME/Library/WebKit" + +fetch() { # fetch + local partial + partial="$(mktemp "$2.part.XXXXXX")" + curl -fsSL --retry 3 --proto '=https' -o "$partial" "$1" \ + || { echo "[isolated] FATAL: download failed: $1" >&2; rm -f "$partial"; exit 1; } + mv -f "$partial" "$2" +} + +ensure_toolchain() { + local node_os pnpm_os arch pnpm_arch + case "$(uname -s)" in + Darwin) node_os="darwin"; pnpm_os="macos" ;; + Linux) node_os="linux"; pnpm_os="linux" ;; + *) echo "[isolated] FATAL: unsupported OS: $(uname -s)" >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64|aarch64) arch="arm64"; pnpm_arch="arm64" ;; + x86_64|amd64) arch="x64"; pnpm_arch="x64" ;; + *) echo "[isolated] FATAL: unsupported arch: $(uname -m)" >&2; exit 1 ;; + esac + + # --- node: pinned by .nvmrc --- + local node_version="24.18.0" + if [ -f "$ROOT/.nvmrc" ]; then + node_version="$(head -1 "$ROOT/.nvmrc" | tr -d '[:space:]' | sed 's/^v//')" + fi + if ! printf '%s' "$node_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + # partial pin (e.g. "24") — resolve newest matching release + local major="${node_version%%.*}" resolved + resolved="$(curl -fsSL https://nodejs.org/dist/index.json \ + | grep -oE "\"v$major\.[0-9]+\.[0-9]+\"" | head -1 | tr -d '"' | sed 's/^v//')" || resolved="" + [ -n "$resolved" ] || { echo "[isolated] FATAL: cannot resolve node $node_version.x" >&2; exit 1; } + node_version="$resolved" + fi + if [ "$("$BIN/node" --version 2>/dev/null || true)" != "v$node_version" ]; then + local ndir="node-v$node_version-$node_os-$arch" + if [ ! -x "$TOOLCHAIN/$ndir/bin/node" ]; then + echo "[isolated] fetching node v$node_version → .bin/toolchain/$ndir" + fetch "https://nodejs.org/dist/v$node_version/$ndir.tar.gz" "$TOOLCHAIN/$ndir.tar.gz" + tar -xzf "$TOOLCHAIN/$ndir.tar.gz" -C "$TOOLCHAIN" + rm -f "$TOOLCHAIN/$ndir.tar.gz" + fi + ln -sfn "$TOOLCHAIN/$ndir/bin/node" "$BIN/node" + ln -sfn "$TOOLCHAIN/$ndir/bin/npm" "$BIN/npm" + ln -sfn "$TOOLCHAIN/$ndir/bin/npx" "$BIN/npx" + [ -e "$TOOLCHAIN/$ndir/bin/corepack" ] && ln -sfn "$TOOLCHAIN/$ndir/bin/corepack" "$BIN/corepack" + fi + + # --- pnpm: standalone binary, pinned by package.json "packageManager" --- + local pnpm_version="" + if [ -f "$ROOT/package.json" ]; then + pnpm_version="$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"pnpm@\([0-9][0-9.]*\).*/\1/p' "$ROOT/package.json" | head -1)" + fi + [ -n "$pnpm_version" ] || pnpm_version="10.34.0" + # --version from / so pnpm reports the binary's own version instead of + # self-switching to the repo's packageManager pin (a network fetch). + if [ "$(cd / && "$BIN/pnpm" --version 2>/dev/null | tail -1 || true)" != "$pnpm_version" ]; then + echo "[isolated] fetching pnpm v$pnpm_version → .bin/pnpm" + fetch "https://github.com/pnpm/pnpm/releases/download/v$pnpm_version/pnpm-$pnpm_os-$pnpm_arch" "$BIN/pnpm" + chmod +x "$BIN/pnpm" + fi + + # --- rust: opt-in (large download); installs into the ISOLATED cargo/rustup homes --- + if ! command -v cargo >/dev/null 2>&1; then + if [ "${COVEN_CAVE_ENSURE_RUST:-0}" = "1" ]; then + echo "[isolated] installing rust (rustup) into $RUSTUP_HOME / $CARGO_HOME" + fetch "https://sh.rustup.rs" "$BIN/rustup-init.sh" + sh "$BIN/rustup-init.sh" -y --no-modify-path --profile minimal --default-toolchain stable + rm -f "$BIN/rustup-init.sh" + else + echo "[isolated] WARN: cargo not found — Tauri dev needs it. Re-run with COVEN_CAVE_ENSURE_RUST=1 to install rustup into the isolated home." >&2 + fi + fi +} + +# Concurrent sessions are the norm in this repo — serialize provisioning so +# two launchers can't clobber each other's downloads (mkdir is atomic). +LOCKDIR="$BIN/.provision.lock" +waited=0 +until mkdir "$LOCKDIR" 2>/dev/null; do + if [ "$waited" -eq 0 ]; then + echo "[isolated] waiting for concurrent toolchain provisioning ($LOCKDIR)…" + fi + waited=$((waited + 1)) + if [ "$waited" -gt 300 ]; then + echo "[isolated] FATAL: provisioning lock held >5m — remove $LOCKDIR if stale" >&2 + exit 1 + fi + sleep 1 +done +trap 'rmdir "$LOCKDIR" 2>/dev/null || true' EXIT INT TERM +ensure_toolchain +rmdir "$LOCKDIR" 2>/dev/null || true +trap - EXIT INT TERM + +echo "[isolated] HOME=$HOME" +echo "[isolated] COVEN_HOME=$COVEN_HOME" +echo "[isolated] COVEN_CAVE_HOME=$COVEN_CAVE_HOME" +echo "[isolated] CARGO_HOME=$CARGO_HOME" +echo "[isolated] CARGO_TARGET_DIR=$CARGO_TARGET_DIR" +echo "[isolated] BIN=$BIN" +echo "[isolated] cwd=$(pwd)" +echo "[isolated] node=$(command -v node) ($(node --version 2>/dev/null || echo missing))" +echo "[isolated] pnpm=$(command -v pnpm) ($(pnpm --version 2>/dev/null || echo missing))" +echo "[isolated] cargo=$(command -v cargo) ($(cargo --version 2>/dev/null || echo missing))" + +REAL_HOME="$(dscl . -read /Users/"$(whoami)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" || REAL_HOME="/Users/$(whoami)" +if [ "$HOME" = "$REAL_HOME" ] || [ "$HOME" = "/Users/$(whoami)" ]; then + echo "[isolated] FATAL: HOME still points at real home: $HOME" >&2 + exit 1 +fi +if [[ "$CARGO_HOME" == "$REAL_HOME"/* ]]; then + echo "[isolated] FATAL: CARGO_HOME under real home: $CARGO_HOME" >&2 + exit 1 +fi +if [[ "$RUSTUP_HOME" == "$REAL_HOME"/* ]]; then + echo "[isolated] FATAL: RUSTUP_HOME under real home: $RUSTUP_HOME" >&2 + exit 1 +fi +if command -v node >/dev/null 2>&1 && [[ "$(command -v node)" != "$BIN/"* ]]; then + echo "[isolated] WARN: node resolves outside .bin: $(command -v node)" >&2 +fi + +exec "$@" diff --git a/.agents/skills/coven-cave/scripts/ensure-isolated.sh b/.agents/skills/coven-cave/scripts/ensure-isolated.sh new file mode 100755 index 0000000000..af92d3d344 --- /dev/null +++ b/.agents/skills/coven-cave/scripts/ensure-isolated.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Ensure an isolated coven-cave workspace exists. Never uses the real $HOME for app state. +set -euo pipefail +ROOT="${COVEN_CAVE_ISOLATED_ROOT:-/tmp/coven-cave-isolated}" +REPO_URL="${COVEN_CAVE_REPO_URL:-https://github.com/OpenCoven/coven-cave}" +SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +if [ ! -d "$ROOT/.git" ]; then + echo "[coven-cave] cloning $REPO_URL → $ROOT" + git clone "$REPO_URL" "$ROOT" +fi + +mkdir -p \ + "$ROOT/.isolated-home/.coven/cave" \ + "$ROOT/.isolated-home/.local/state" \ + "$ROOT/.isolated-home/.local/share" \ + "$ROOT/.isolated-home/.config" \ + "$ROOT/.isolated-home/.cache" \ + "$ROOT/.isolated-home/.pnpm-store" \ + "$ROOT/.isolated-home/.npm" \ + "$ROOT/.isolated-home/.cargo" \ + "$ROOT/.isolated-home/Library/Application Support" \ + "$ROOT/.isolated-home/Library/Caches" \ + "$ROOT/.isolated-home/Library/Logs" \ + "$ROOT/.isolated-home/Library/Preferences" \ + "$ROOT/.isolated-home/tmp" \ + "$ROOT/.pnpm-home" \ + "$ROOT/.bin" \ + "$ROOT/src-tauri/target" + +# Install / refresh the isolation launcher from this skill +cp "$SKILL_DIR/scripts/dev-isolated.sh" "$ROOT/dev-isolated.sh" +chmod +x "$ROOT/dev-isolated.sh" + +# Provision the self-contained toolchain (node + pnpm → $ROOT/.bin) now so the +# first real dev command doesn't pay the download mid-task. +"$ROOT/dev-isolated.sh" true + +# Provision the Cave-owned Node/npm lane and reviewed runtime CLIs used by the +# app inside this fake home. The setup program is idempotent and reads its exact +# versions and integrity values from the onboarding prerequisite manifest. +"$ROOT/dev-isolated.sh" node --experimental-strip-types scripts/setup-isolated-dev-tools.ts + +# Minimal seed state (empty familiars; onboarding free to run) +if [ ! -f "$ROOT/.isolated-home/.coven/familiars.toml" ]; then + printf '# isolated dev familiars registry\n' > "$ROOT/.isolated-home/.coven/familiars.toml" +fi +if [ ! -f "$ROOT/.isolated-home/.coven/cave/config.json" ]; then + cat > "$ROOT/.isolated-home/.coven/cave/config.json" <<'JSON' +{ + "version": 1, + "profile": { "displayName": "Isolated Dev" }, + "onboarding": { "dismissed": true } +} +JSON +fi + +# Seed cargo registry once from the real user cache if present (one-time host seed; +# runtime never points CARGO_HOME at real ~). +if [ -d "${REAL_CARGO_HOME:-$HOME/.cargo}/registry" ] && [ ! -d "$ROOT/.isolated-home/.cargo/registry" ]; then + # Only seed when our isolation HOME is not already the process HOME + REAL_HOME_PROBE="$(dscl . -read /Users/"$(whoami)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" || REAL_HOME_PROBE="/Users/$(whoami)" + if [ -d "$REAL_HOME_PROBE/.cargo/registry" ]; then + echo "[coven-cave] seeding isolated cargo registry from host (one-time)" + rsync -a "$REAL_HOME_PROBE/.cargo/registry" "$ROOT/.isolated-home/.cargo/" || true + [ -d "$REAL_HOME_PROBE/.cargo/git" ] && rsync -a "$REAL_HOME_PROBE/.cargo/git" "$ROOT/.isolated-home/.cargo/" || true + fi +fi + +echo "[coven-cave] ready: $ROOT" +echo "[coven-cave] run via: $ROOT/dev-isolated.sh " +echo "$ROOT" diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 305d62785b..3bb1b7c75f 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -1089,3 +1089,4 @@ {"id":"int-01d27f504ff5922eb0cd89703b5830e0","kind":"field_change","created_at":"2026-08-03T22:01:18.3227Z","actor":"Val Alexander","issue_id":"cave-2qyqu","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #4298 (landed as 81ea2d5b31). Select All in the session picker now covers only visible sessions via visibleLocalThreads, derived from entries so it cannot drift from what renders; bulk actions still operate on the explicit selection. Also mirrored /auto into the iOS slash catalog as .desktopOnly, unbreaking ios-slash-commands on main."}} {"id":"int-f96ee9a954c81e3c21296e47b45bbed2","kind":"field_change","created_at":"2026-08-03T22:05:28.47423Z","actor":"Val Alexander","issue_id":"cave-2i9dq","extra":{"field":"assignee","new_value":"Val Alexander","old_value":""}} {"id":"int-80d43de3534a7839b71c4963802514d5","kind":"field_change","created_at":"2026-08-03T22:09:12.700068Z","actor":"Val Alexander","issue_id":"cave-m1qd0","extra":{"field":"status","new_value":"blocked","old_value":"in_progress"}} +{"id":"int-ace6a979c6318ecf25a0d0f290ce7bad","kind":"field_change","created_at":"2026-08-04T02:39:12.347907Z","actor":"lou","issue_id":"cave-3g5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and verified distinct ocd development identity, bundle ID, icon geometry, and native app wrapper while preserving production branding."}} diff --git a/package.json b/package.json index 472378d90e..c74e3f7d66 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ }, "scripts": { "dev": "node --experimental-strip-types server.ts", + "dev:setup:tools": "node --experimental-strip-types scripts/setup-isolated-dev-tools.ts", "dev:app": "bash scripts/dev-app.sh", + "dev:icons": "node scripts/generate-dev-app-icons.mjs", "mobile:ios:sim": "bash scripts/ios-simulator.sh", "uninstall:app": "bash scripts/uninstall-app.sh", "mobile:tailscale": "bash scripts/mobile-tailscale.sh start", diff --git a/scripts/dev-app.sh b/scripts/dev-app.sh index 1b5a1bfaa7..b5136ba0f7 100755 --- a/scripts/dev-app.sh +++ b/scripts/dev-app.sh @@ -165,7 +165,23 @@ fi # must never start another one. This makes initial startup fail in the terminal # rather than presenting a black native window. cat >"$TAURI_OVERRIDE_CONFIG" <&2 + exit 1 + fi + runner_variable="CARGO_TARGET_$(printf '%s' "$rust_host" | tr '[:lower:]-' '[:upper:]_')_RUNNER" + export "${runner_variable}=$PWD/scripts/tauri-dev-macos-runner.sh" + echo "[dev:app] macOS identity: ocd (ai.opencoven.cave.dev)" +fi + pnpm exec tauri dev --config "$TAURI_OVERRIDE_CONFIG" "$@" & tauri_pid=$! diff --git a/scripts/dev-app.test.mjs b/scripts/dev-app.test.mjs index 7097dbd378..3f607776e1 100644 --- a/scripts/dev-app.test.mjs +++ b/scripts/dev-app.test.mjs @@ -30,10 +30,80 @@ assert.match( ); assert.match( source, - /"beforeDevCommand":null,"devUrl":"\$\{dev_url\}"/, + /"beforeDevCommand": null,[\s\S]*?"devUrl": "\$\{dev_url\}"/, "Tauri must not launch a second server after the launcher has verified the first root document", ); +assert.match( + source, + /"productName": "ocd"/, + "the development shell must have an unmistakable application name", +); +assert.match( + source, + /"identifier": "ai\.opencoven\.cave\.dev"/, + "the development shell must use a distinct OS and WebView data identity", +); +for (const icon of ["32x32.png", "128x128.png", "128x128@2x.png", "icon.icns", "icon.ico"]) { + assert.match( + source, + new RegExp(`"icons/dev/${icon.replace(".", "\\.")}"`), + `the development override must select ${icon}`, + ); + assert.ok( + readFileSync(new URL(`../src-tauri/icons/dev/${icon}`, import.meta.url)).length > 0, + `${icon} must be a non-empty development icon asset`, + ); +} +assert.notDeepEqual( + readFileSync(new URL("../src-tauri/icons/dev/128x128.png", import.meta.url)), + readFileSync(new URL("../src-tauri/icons/128x128.png", import.meta.url)), + "the development icon must remain visually distinct from the production icon", +); + +const productionConfig = JSON.parse( + readFileSync(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8"), +); +assert.equal(productionConfig.productName, "CovenCave"); +assert.equal(productionConfig.identifier, "ai.opencoven.cave"); +assert.ok( + productionConfig.bundle.icon.every((icon) => !icon.includes("icons/dev/")), + "production bundles must keep the production icon set", +); + +const nativeWindows = ["tauri_setup.rs", "window_geometry.rs"] + .map((file) => readFileSync(new URL(`../src-tauri/src/${file}`, import.meta.url), "utf8")) + .join("\n"); +assert.doesNotMatch( + nativeWindows, + /\.title\("CovenCave(?: Quick Chat| Notch)?"\)/, + "native window titles must follow the configured product name in both development and production", +); +assert.match( + nativeWindows, + /app\.config\(\)\.product_name/, + "native window titles must be derived from Tauri's active product configuration", +); + +assert.match( + source, + /if \[ "\$\(uname -s\)" = "Darwin" \]; then[\s\S]*?CARGO_TARGET_[\s\S]*?_RUNNER[\s\S]*?tauri-dev-macos-runner\.sh[\s\S]*?pnpm exec tauri dev/, + "macOS development must launch through the app-bundle runner instead of a generic raw binary", +); +const macosRunner = readFileSync( + new URL("./tauri-dev-macos-runner.sh", import.meta.url), + "utf8", +); +assert.match(macosRunner, /ocd\.app/); +assert.match(macosRunner, /ocd<\/string>/); +assert.match(macosRunner, /ai\.opencoven\.cave\.dev<\/string>/); +assert.match(macosRunner, /icons\/dev\/icon\.icns/); +assert.match( + macosRunner, + /exec "\$bundle_executable" "\$@"/, + "the runner must preserve Cargo's child-process lifecycle for Tauri's watcher and teardown", +); + assert.match( source, /if \[ -n "\$\{COVEN_CAVE_AUTH_TOKEN:-\}" \]; then[\s\S]*?encodeURIComponent\(process\.env\.COVEN_CAVE_AUTH_TOKEN\)[\s\S]*?dev_url\+="#covenCaveToken=\$\{sidecar_token_fragment\}"/, @@ -41,7 +111,7 @@ assert.match( ); assert.match( source, - /"devUrl":"\$\{dev_url\}"/, + /"devUrl": "\$\{dev_url\}"/, "both launcher paths must use the token-bearing dev URL", ); @@ -77,7 +147,7 @@ assert.match( ); assert.match( source, - /initial_timeout_ms=\$\(\(DEV_SERVER_GRACE_SECONDS \* 1000\)\)[\s\S]*?origin_is_ready "\$dev_port" "\$initial_timeout_ms"[\s\S]*?desktop shell was not opened[\s\S]*?beforeDevCommand":null[\s\S]*?pnpm exec tauri dev/, + /initial_timeout_ms=\$\(\(DEV_SERVER_GRACE_SECONDS \* 1000\)\)[\s\S]*?origin_is_ready "\$dev_port" "\$initial_timeout_ms"[\s\S]*?desktop shell was not opened[\s\S]*?beforeDevCommand": null[\s\S]*?pnpm exec tauri dev/, "the launcher must validate the root document before opening Tauri, avoiding an initial black window", ); assert.match( diff --git a/scripts/generate-dev-app-icons.mjs b/scripts/generate-dev-app-icons.mjs new file mode 100644 index 0000000000..7c434e5f26 --- /dev/null +++ b/scripts/generate-dev-app-icons.mjs @@ -0,0 +1,111 @@ +import { spawnSync } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import sharp from "sharp"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const iconRoot = join(repoRoot, "src-tauri", "icons"); +const outputRoot = join(iconRoot, "dev"); +const sourceIcon = join(iconRoot, "icon-source-1024.png"); +const generatedFiles = [ + "32x32.png", + "128x128.png", + "128x128@2x.png", + "icon.icns", + "icon.ico", +]; + +const canvasSize = 1024; +const artworkSize = 824; +const artworkInset = (canvasSize - artworkSize) / 2; + +function superellipsePath(size, exponent = 5, segments = 256) { + const radius = size / 2; + const points = []; + for (let index = 0; index <= segments; index += 1) { + const angle = (index / segments) * Math.PI * 2; + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + const x = radius + radius * Math.sign(cosine) * Math.abs(cosine) ** (2 / exponent); + const y = radius + radius * Math.sign(sine) * Math.abs(sine) ** (2 / exponent); + points.push(`${index === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`); + } + return `${points.join(" ")} Z`; +} + +// Map the production icon's black background to OpenCoven lavender while +// preserving its white mark and grayscale antialiasing. +const background = [0x68, 0x59, 0xac]; +const scale = background.map((channel) => (255 - channel) / 255); +const mask = Buffer.from( + ``, +); +const temporaryRoot = await mkdtemp(join(tmpdir(), "coven-cave-dev-icons-")); +const tintedSource = join(temporaryRoot, "icon-source-1024.png"); +const generatedRoot = join(temporaryRoot, "generated"); + +try { + await sharp(sourceIcon) + .linear(scale, background) + .resize(artworkSize, artworkSize) + .composite([{ input: mask, blend: "dest-in" }]) + .extend({ + top: artworkInset, + bottom: artworkInset, + left: artworkInset, + right: artworkInset, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toFile(tintedSource); + + let outputIsCurrent = false; + try { + const [nextSource, existingSource, ...existingIcons] = await Promise.all([ + readFile(tintedSource), + readFile(join(outputRoot, "icon-source-1024.png")), + ...generatedFiles.map((file) => readFile(join(outputRoot, file))), + ]); + outputIsCurrent = + nextSource.equals(existingSource) && + existingIcons.every((icon) => icon.length > 0); + } catch { + outputIsCurrent = false; + } + + if (outputIsCurrent) { + console.log(`Development app icons are up to date in ${outputRoot}`); + } else { + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const result = spawnSync( + pnpm, + ["exec", "tauri", "icon", tintedSource, "--output", generatedRoot], + { cwd: repoRoot, encoding: "utf8" }, + ); + if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exitCode = result.status ?? 1; + throw new Error("Tauri could not generate the development icon set"); + } + + await mkdir(outputRoot, { recursive: true }); + await copyFile(tintedSource, join(outputRoot, "icon-source-1024.png")); + await Promise.all( + generatedFiles.map((file) => + copyFile(join(generatedRoot, file), join(outputRoot, file)), + ), + ); + console.log(`Generated development app icons in ${outputRoot}`); + } +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 065817ab41..7a43a1e194 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -115,6 +115,8 @@ export const SUITES = { "src/components/home/use-home-model-state.test.ts", "src/lib/model-selection-mutation-queue.test.ts", "src/lib/perf/web-vitals-format.test.ts", + "src/lib/perf/system-performance-format.test.ts", + "src/components/perf/perf-overlay-contract.test.ts", "src/lib/app-version.test.ts", "src/lib/endpoint-validators.test.ts", "src/lib/x-api.test.ts", @@ -884,6 +886,7 @@ export const SUITES = { "src/lib/onboarding-gate.test.ts", "src/lib/onboarding-prerequisites.test.ts", "src/lib/server/managed-node-toolchain.test.ts", + "scripts/setup-isolated-dev-tools.test.ts", "src/lib/onboarding-install-queue.test.ts", "src/lib/onboarding-setup-failure.test.ts", "src/app/onboarding-install-route.test.ts", diff --git a/scripts/setup-isolated-dev-tools.test.ts b/scripts/setup-isolated-dev-tools.test.ts new file mode 100644 index 0000000000..6ab22bc263 --- /dev/null +++ b/scripts/setup-isolated-dev-tools.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + assertIsolatedDevEnvironment, + globalPackageManifestPath, + packagesNeedingInstall, + reviewedDevPackages, +} from "./setup-isolated-dev-tools.ts"; + +const testRoot = await mkdtemp(path.join(tmpdir(), "coven-dev-setup-test-")); +try { + const testHome = path.join(testRoot, ".isolated-home"); + assert.equal( + assertIsolatedDevEnvironment({ + home: testHome, + isolatedRoot: testRoot, + covenHome: path.join(testHome, ".coven"), + caveHome: path.join(testHome, ".coven", "cave"), + }), + path.resolve(testRoot), + ); + assert.throws( + () => assertIsolatedDevEnvironment({ + home: tmpdir(), + isolatedRoot: testRoot, + covenHome: path.join(testHome, ".coven"), + caveHome: path.join(testHome, ".coven", "cave"), + }), + /HOME must be the isolated dev home/, + ); + + const packages = reviewedDevPackages(); + assert.deepEqual( + packages.map((entry) => entry.packageName), + ["@opencoven/cli", "@anthropic-ai/claude-code", "@openai/codex", "@github/copilot", "openclaw"], + ); + assert.ok(packages.every((entry) => /^\d+\.\d+\.\d+/.test(entry.version))); + assert.ok(packages.every((entry) => entry.integrity.startsWith("sha512-"))); + + const paths = { + platform: "linux" as const, + npmPrefix: path.join(testRoot, "toolchains", "npm"), + }; + const first = packages[0]!; + const manifestPath = globalPackageManifestPath(paths, first.packageName); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, JSON.stringify({ version: first.version }), "utf8"); + + const pending = await packagesNeedingInstall(paths, packages); + assert.deepEqual( + pending.map((entry) => entry.packageName), + packages.slice(1).map((entry) => entry.packageName), + "exactly pinned packages must be no-ops while missing packages remain pending", + ); + + await writeFile(manifestPath, JSON.stringify({ version: "0.0.0" }), "utf8"); + assert.equal( + (await packagesNeedingInstall(paths, packages))[0]?.packageName, + first.packageName, + "a mismatched installed version must be repaired", + ); + + const ensureSource = await readFile( + new URL("../.agents/skills/coven-cave/scripts/ensure-isolated.sh", import.meta.url), + "utf8", + ); + assert.match( + ensureSource, + /dev-isolated\.sh" node --experimental-strip-types scripts\/setup-isolated-dev-tools\.ts/, + "the isolated workspace bootstrap must run the reviewed dev-tool setup", + ); + const launcherSource = await readFile( + new URL("../.agents/skills/coven-cave/scripts/dev-isolated.sh", import.meta.url), + "utf8", + ); + assert.match( + launcherSource, + /export COVEN_CAVE_ISOLATED_ROOT="\$ROOT"/, + "the isolation launcher must identify its root for the setup safety guard", + ); +} finally { + await rm(testRoot, { recursive: true, force: true }); +} + +console.log("setup-isolated-dev-tools: ok"); diff --git a/scripts/setup-isolated-dev-tools.ts b/scripts/setup-isolated-dev-tools.ts new file mode 100644 index 0000000000..6e7ec07b63 --- /dev/null +++ b/scripts/setup-isolated-dev-tools.ts @@ -0,0 +1,256 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + prerequisiteById, + type NpmPackage, + type PrerequisiteId, +} from "../src/lib/onboarding-prerequisites.ts"; +import { + installManagedNodeToolchain, + managedNodeSpawnEnv, + managedNpmLaunch, + probeManagedNodeToolchain, + type ManagedNodePaths, + type ManagedNodeProbe, +} from "../src/lib/server/managed-node-toolchain.ts"; + +const REVIEWED_PACKAGE_IDS = [ + "coven-cli", + "runtime-claude", + "runtime-codex", + "runtime-copilot", + "runtime-openclaw", +] as const satisfies readonly PrerequisiteId[]; + +type ReviewedPackage = NpmPackage & { + id: (typeof REVIEWED_PACKAGE_IDS)[number]; + label: string; +}; + +type IsolationEnvironment = { + home: string; + isolatedRoot: string | undefined; + covenHome: string | undefined; + caveHome: string | undefined; +}; + +export function assertIsolatedDevEnvironment(environment: IsolationEnvironment): string { + if (!environment.isolatedRoot) { + throw new Error("COVEN_CAVE_ISOLATED_ROOT is missing; run this script through dev-isolated.sh"); + } + const isolatedRoot = path.resolve(environment.isolatedRoot); + const expectedHome = path.join(isolatedRoot, ".isolated-home"); + if (path.resolve(environment.home) !== expectedHome) { + throw new Error(`HOME must be the isolated dev home: ${expectedHome}`); + } + if (path.resolve(environment.covenHome ?? "") !== path.join(expectedHome, ".coven")) { + throw new Error("COVEN_HOME must be inside the isolated dev home"); + } + if (path.resolve(environment.caveHome ?? "") !== path.join(expectedHome, ".coven", "cave")) { + throw new Error("COVEN_CAVE_HOME must be inside the isolated dev home"); + } + return isolatedRoot; +} + +export function reviewedDevPackages(): ReviewedPackage[] { + return REVIEWED_PACKAGE_IDS.map((id) => { + const definition = prerequisiteById(id); + if (definition.install.kind !== "managed-npm") { + throw new Error(`${id} is not configured for the managed npm lane`); + } + return { id, label: definition.label, ...definition.install.package }; + }); +} + +export function globalPackageManifestPath( + paths: Pick, + packageName: string, +): string { + const modulesRoot = paths.platform === "win32" + ? path.join(paths.npmPrefix, "node_modules") + : path.join(paths.npmPrefix, "lib", "node_modules"); + return path.join(modulesRoot, ...packageName.split("/"), "package.json"); +} + +export async function installedPackageVersion( + paths: Pick, + packageName: string, +): Promise { + try { + const manifest = JSON.parse(await readFile(globalPackageManifestPath(paths, packageName), "utf8")); + return typeof manifest.version === "string" ? manifest.version : null; + } catch { + return null; + } +} + +export async function packagesNeedingInstall( + paths: Pick, + packages = reviewedDevPackages(), +): Promise { + const versions = await Promise.all( + packages.map((entry) => installedPackageVersion(paths, entry.packageName)), + ); + return packages.filter((entry, index) => versions[index] !== entry.version); +} + +function managedEnvironment(paths: ManagedNodePaths): NodeJS.ProcessEnv { + const environment = managedNodeSpawnEnv(process.env, paths); + if (!environment) throw new Error("managed Node/npm is unavailable on this platform"); + return { + ...environment, + NPM_CONFIG_AUDIT: "false", + NPM_CONFIG_FUND: "false", + NPM_CONFIG_UPDATE_NOTIFIER: "false", + }; +} + +async function runVisible( + command: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, +): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false, stdio: "inherit" }); + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`command exited with ${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}`)); + }); + }); +} + +async function capture( + command: string, + args: string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(stderr.trim() || `command exited with ${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}`)); + }); + }); +} + +async function waitForManagedNode(result: ManagedNodeProbe): Promise> { + let current = result; + for (let attempt = 0; attempt < 4 && current.status !== "ready"; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))); + current = await probeManagedNodeToolchain(); + } + if (current.status !== "ready") { + const detail = current.status === "unusable" ? current.detail : `probe returned ${current.status}`; + throw new Error(`Cave-managed Node.js/npm verification failed: ${detail}`); + } + return current; +} + +async function ensureManagedNode(verifyOnly: boolean): Promise> { + const existing = await probeManagedNodeToolchain(); + if (existing.status === "ready") return existing; + if (verifyOnly) throw new Error(`Cave-managed Node.js/npm is not ready (${existing.status})`); + const installed = await installManagedNodeToolchain({ + onProgress: (line) => console.log(`[dev-setup] ${line}`), + }); + return waitForManagedNode(installed); +} + +async function verifyPublishedIntegrity( + paths: ManagedNodePaths, + entry: ReviewedPackage, +): Promise { + const launch = managedNpmLaunch(paths); + if (!launch) throw new Error("managed npm launcher is unavailable"); + const raw = await capture( + launch.command, + [...launch.args, "view", `${entry.packageName}@${entry.version}`, "dist.integrity", "--json"], + { cwd: homedir(), env: managedEnvironment(paths) }, + ); + const published = JSON.parse(raw); + if (published !== entry.integrity) { + throw new Error(`${entry.packageName}@${entry.version} does not match the reviewed integrity`); + } +} + +async function installPackages(paths: ManagedNodePaths, pending: ReviewedPackage[]): Promise { + const launch = managedNpmLaunch(paths); + if (!launch) throw new Error("managed npm launcher is unavailable"); + for (const entry of pending) { + console.log(`[dev-setup] Verifying ${entry.packageName}@${entry.version}…`); + await verifyPublishedIntegrity(paths, entry); + } + console.log(`[dev-setup] Installing ${pending.map((entry) => entry.label).join(", ")}…`); + await runVisible( + launch.command, + [ + ...launch.args, + "install", + "--global", + "--no-audit", + "--no-fund", + ...pending.map((entry) => `${entry.packageName}@${entry.version}`), + ], + { cwd: homedir(), env: managedEnvironment(paths) }, + ); +} + +async function verifyBinary(paths: ManagedNodePaths, entry: ReviewedPackage): Promise { + const suffix = paths.platform === "win32" ? ".cmd" : ""; + const binary = path.join(paths.npmBin, `${entry.binary}${suffix}`); + await access(binary, paths.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return capture(binary, ["--version"], { cwd: homedir(), env: managedEnvironment(paths) }); +} + +export async function main(args = process.argv.slice(2)): Promise { + const unknown = args.filter((arg) => arg !== "--verify-only"); + if (unknown.length > 0) throw new Error(`unknown option: ${unknown[0]}`); + const verifyOnly = args.includes("--verify-only"); + const isolatedRoot = assertIsolatedDevEnvironment({ + home: homedir(), + isolatedRoot: process.env.COVEN_CAVE_ISOLATED_ROOT, + covenHome: process.env.COVEN_HOME, + caveHome: process.env.COVEN_CAVE_HOME, + }); + console.log(`[dev-setup] Isolated root: ${isolatedRoot}`); + + const managedNode = await ensureManagedNode(verifyOnly); + console.log(`[dev-setup] Node.js ${managedNode.version} and npm are ready.`); + + const packages = reviewedDevPackages(); + const pending = await packagesNeedingInstall(managedNode.paths, packages); + if (pending.length > 0 && verifyOnly) { + throw new Error(`reviewed packages are missing or mismatched: ${pending.map((entry) => entry.packageName).join(", ")}`); + } + if (pending.length > 0) await installPackages(managedNode.paths, pending); + else console.log("[dev-setup] Reviewed CLI packages already match the manifest."); + + const mismatched = await packagesNeedingInstall(managedNode.paths, packages); + if (mismatched.length > 0) { + throw new Error(`package verification failed: ${mismatched.map((entry) => entry.packageName).join(", ")}`); + } + for (const entry of packages) { + const versionOutput = await verifyBinary(managedNode.paths, entry); + console.log(`[dev-setup] ${entry.label}: ${versionOutput.split(/\r?\n/)[0]}`); + } +} + +const entryPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (entryPath === import.meta.url) { + main().catch((error) => { + console.error(`[dev-setup] FATAL: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/tauri-dev-macos-runner.sh b/scripts/tauri-dev-macos-runner.sh new file mode 100755 index 0000000000..bebc523df5 --- /dev/null +++ b/scripts/tauri-dev-macos-runner.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Give the raw `cargo run` executable a real macOS development identity. + +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "[dev:app] ERROR: macOS runner expected the compiled executable" >&2 + exit 1 +fi + +binary="$1" +shift +case "$binary" in + /*) ;; + *) binary="$PWD/$binary" ;; +esac + +binary_dir="$(cd "$(dirname "$binary")" && pwd)" +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +bundle_dir="$binary_dir/ocd.app" +contents_dir="$bundle_dir/Contents" +macos_dir="$contents_dir/MacOS" +resources_dir="$contents_dir/Resources" +bundle_executable="$macos_dir/ocd" + +mkdir -p "$macos_dir" "$resources_dir" +cp "$binary" "$bundle_executable" +chmod +x "$bundle_executable" +cp "$repo_root/src-tauri/icons/dev/icon.icns" "$resources_dir/icon.icns" + +cat >"$contents_dir/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ocd + CFBundleExecutable + ocd + CFBundleIconFile + icon.icns + CFBundleIdentifier + ai.opencoven.cave.dev + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ocd + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.developer-tools + NSHighResolutionCapable + + + +PLIST + +/usr/bin/plutil -lint "$contents_dir/Info.plist" >/dev/null +exec "$bundle_executable" "$@" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5957ea1c32..b621b58d08 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -96,6 +96,7 @@ dependencies = [ "serde_json", "sha2", "signal-hook-registry", + "sysinfo", "tar", "tauri", "tauri-build", @@ -2435,6 +2436,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2684,6 +2694,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4145,6 +4165,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-deps" version = "6.2.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8faf45406..92779cb1d5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,11 @@ once_cell = "1" parking_lot = "0.12" rand = "0.8" tauri-plugin-os = "2" +# The development performance monitor samples only CPU and RAM. Disable the +# default disk/network/component collectors so the monitor does not become a +# meaningful source of the load it is measuring. The Apple Store feature keeps +# the same implementation valid for debug iOS builds. +sysinfo = { version = "=0.35.2", default-features = false, features = ["system", "apple-app-store"] } # Desktop-only deps. `portable-pty` cannot build on iOS/Android (no # fork, no /dev/ptmx); the bottom-terminal surface gates its frontend diff --git a/src-tauri/capabilities/loopback-dev-performance.json b/src-tauri/capabilities/loopback-dev-performance.json new file mode 100644 index 0000000000..827b5fcb93 --- /dev/null +++ b/src-tauri/capabilities/loopback-dev-performance.json @@ -0,0 +1,23 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "loopback-dev-performance", + "description": "allows only the trusted main loopback webview to sample debug performance metrics", + "platforms": [ + "linux", + "macOS", + "windows" + ], + "webviews": [ + "main" + ], + "remote": { + "urls": [ + "http://localhost:*/*", + "http://127.0.0.1:*/*", + "http://[\\:\\:1]:*/*" + ] + }, + "permissions": [ + "allow-dev-performance-snapshot" + ] +} diff --git a/src-tauri/capabilities/mobile-tailscale.json b/src-tauri/capabilities/mobile-tailscale.json index 937f3314eb..5b3ac6a40c 100644 --- a/src-tauri/capabilities/mobile-tailscale.json +++ b/src-tauri/capabilities/mobile-tailscale.json @@ -7,6 +7,7 @@ "windows": ["main"], "permissions": [ "core:default", - "notification:default" + "notification:default", + "allow-dev-performance-snapshot" ] } diff --git a/src-tauri/icons/dev/128x128.png b/src-tauri/icons/dev/128x128.png new file mode 100644 index 0000000000..89e1f50c7d Binary files /dev/null and b/src-tauri/icons/dev/128x128.png differ diff --git a/src-tauri/icons/dev/128x128@2x.png b/src-tauri/icons/dev/128x128@2x.png new file mode 100644 index 0000000000..e0fb18d44e Binary files /dev/null and b/src-tauri/icons/dev/128x128@2x.png differ diff --git a/src-tauri/icons/dev/32x32.png b/src-tauri/icons/dev/32x32.png new file mode 100644 index 0000000000..b00d198d47 Binary files /dev/null and b/src-tauri/icons/dev/32x32.png differ diff --git a/src-tauri/icons/dev/icon-source-1024.png b/src-tauri/icons/dev/icon-source-1024.png new file mode 100644 index 0000000000..1ff7e816e4 Binary files /dev/null and b/src-tauri/icons/dev/icon-source-1024.png differ diff --git a/src-tauri/icons/dev/icon.icns b/src-tauri/icons/dev/icon.icns new file mode 100644 index 0000000000..5089810868 Binary files /dev/null and b/src-tauri/icons/dev/icon.icns differ diff --git a/src-tauri/icons/dev/icon.ico b/src-tauri/icons/dev/icon.ico new file mode 100644 index 0000000000..a313524987 Binary files /dev/null and b/src-tauri/icons/dev/icon.ico differ diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index f1d0eca388..08c86cfdc4 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -26,4 +26,5 @@ permissions = [ "allow-speech-stt-stop", "allow-desktop-reachability-status", "allow-desktop-reachability-configure", + "allow-dev-performance-snapshot", ] diff --git a/src-tauri/permissions/dev-performance.toml b/src-tauri/permissions/dev-performance.toml new file mode 100644 index 0000000000..88ce306d35 --- /dev/null +++ b/src-tauri/permissions/dev-performance.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-dev-performance-snapshot" +description = "Allows the trusted development UI to sample system CPU and memory metrics." +commands.allow = ["dev_performance_snapshot"] diff --git a/src-tauri/src/dev_performance.rs b/src-tauri/src/dev_performance.rs new file mode 100644 index 0000000000..54736dc9c7 --- /dev/null +++ b/src-tauri/src/dev_performance.rs @@ -0,0 +1,97 @@ +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; +use sysinfo::System; + +const POWER_IMPACT_SMOOTHING: f32 = 0.35; + +static SAMPLER: Lazy> = + Lazy::new(|| Mutex::new(DevPerformanceSampler::new())); + +struct DevPerformanceSampler { + system: System, + power_impact_percent: Option, +} + +impl DevPerformanceSampler { + fn new() -> Self { + let mut system = System::new(); + system.refresh_cpu_usage(); + system.refresh_memory(); + Self { + system, + power_impact_percent: None, + } + } + + fn sample(&mut self) -> Result { + if !sysinfo::IS_SUPPORTED_SYSTEM { + return Err("system performance metrics are unavailable on this platform".into()); + } + + self.system.refresh_cpu_usage(); + self.system.refresh_memory(); + + let cpu_percent = self.system.global_cpu_usage().clamp(0.0, 100.0); + let memory_used_bytes = self.system.used_memory(); + let memory_total_bytes = self.system.total_memory(); + if memory_total_bytes == 0 { + return Err("system memory metrics are unavailable on this platform".into()); + } + + let power_impact_percent = smooth_power_impact(self.power_impact_percent, cpu_percent); + self.power_impact_percent = Some(power_impact_percent); + + Ok(DevPerformanceSnapshot { + cpu_percent, + memory_used_bytes, + memory_total_bytes, + power_impact_percent, + sampled_at_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + }) + } +} + +fn smooth_power_impact(previous: Option, cpu_percent: f32) -> f32 { + let current = cpu_percent.clamp(0.0, 100.0); + previous + .map(|value| value * (1.0 - POWER_IMPACT_SMOOTHING) + current * POWER_IMPACT_SMOOTHING) + .unwrap_or(current) + .clamp(0.0, 100.0) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DevPerformanceSnapshot { + cpu_percent: f32, + memory_used_bytes: u64, + memory_total_bytes: u64, + power_impact_percent: f32, + sampled_at_ms: u64, +} + +#[tauri::command] +pub(crate) fn dev_performance_snapshot() -> Result { + SAMPLER.lock().sample() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_impact_starts_at_the_current_sample() { + assert_eq!(smooth_power_impact(None, 42.0), 42.0); + } + + #[test] + fn power_impact_smooths_and_clamps_cpu_spikes() { + assert_eq!(smooth_power_impact(Some(20.0), 60.0), 34.0); + assert_eq!(smooth_power_impact(Some(100.0), 140.0), 100.0); + assert_eq!(smooth_power_impact(Some(0.0), -20.0), 0.0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f3d2b163ce..0f88ae7c26 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,6 +49,8 @@ pub mod browser; mod discord_presence; #[cfg(desktop)] mod desktop_reachability; +#[cfg(debug_assertions)] +mod dev_performance; #[cfg(desktop)] mod platform_lifecycle; #[cfg(all(desktop, target_os = "macos"))] diff --git a/src-tauri/src/tauri_setup.rs b/src-tauri/src/tauri_setup.rs index c0db11ea11..0d69c70aa9 100644 --- a/src-tauri/src/tauri_setup.rs +++ b/src-tauri/src/tauri_setup.rs @@ -161,7 +161,11 @@ pub fn run() { #[cfg(mobile)] { builder - .invoke_handler(tauri::generate_handler![webview_probe_report]) + .invoke_handler(tauri::generate_handler![ + webview_probe_report, + #[cfg(debug_assertions)] + dev_performance::dev_performance_snapshot, + ]) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -213,12 +217,17 @@ pub fn run() { tauri::WebviewUrl::App("index.html".into()) }; + let product_name = app + .config() + .product_name + .clone() + .unwrap_or_else(|| "CovenCave".to_string()); tauri::WebviewWindowBuilder::new( app, "main", webview_url, ) - .title("CovenCave") + .title(product_name) .build()?; Ok(()) @@ -246,6 +255,8 @@ pub fn run() { pty::pty_snapshot, pty::pty_diagnose, webview_probe_report, + #[cfg(debug_assertions)] + dev_performance::dev_performance_snapshot, browser::browser_commands::browser_navigate, browser::browser_commands::browser_set_bounds, browser::browser_commands::browser_hide, @@ -287,6 +298,12 @@ pub fn run() { #[cfg(desktop)] builder .setup(move |app| { + let product_name = app + .config() + .product_name + .clone() + .unwrap_or_else(|| "CovenCave".to_string()); + // The updater's Windows pre-exit path clears the application // resource table after validating the package and before starting // msiexec. Dropping this guard stops/reaps the sidecar even though @@ -342,7 +359,7 @@ pub fn run() { #[cfg(target_os = "windows")] { WebviewWindowBuilder::new(app, "main", WebviewUrl::App("startup.html".into())) - .title("CovenCave") + .title(product_name.clone()) .inner_size(1320.0, 820.0) .min_inner_size(960.0, 600.0) .resizable(true) @@ -372,7 +389,7 @@ pub fn run() { remember_main_startup_url(&main_url); let mut main_window = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(main_url)) - .title("CovenCave") + .title(product_name.clone()) .inner_size(1320.0, 820.0) .min_inner_size(960.0, 600.0) .resizable(true) diff --git a/src-tauri/src/window_geometry.rs b/src-tauri/src/window_geometry.rs index c05cfd9b82..3a85da7c2e 100644 --- a/src-tauri/src/window_geometry.rs +++ b/src-tauri/src/window_geometry.rs @@ -198,12 +198,14 @@ pub(super) fn show_quick_chat_window(app: &tauri::AppHandle, quick_chat_url: &Ur }; let (x, y) = quick_chat_position(app); + let product_name = app.config().product_name.as_deref().unwrap_or("CovenCave"); + let title = format!("{product_name} Quick Chat"); let builder = WebviewWindowBuilder::new( app, QUICK_CHAT_WINDOW_LABEL, WebviewUrl::External(quick_chat_url.clone()), ) - .title("CovenCave Quick Chat") + .title(title) .inner_size(QUICK_CHAT_WIDTH, QUICK_CHAT_HEIGHT) .min_inner_size(340.0, 420.0) // Resizable since the window holds multiple chats now — the min size @@ -572,12 +574,14 @@ pub(super) fn show_notch_window(app: &tauri::AppHandle, notch_url: &Url) { let (width, height) = notch_collapsed_size(&config, strip_height); let (x, y) = notch_position(app, width); + let product_name = app.config().product_name.as_deref().unwrap_or("CovenCave"); + let title = format!("{product_name} Notch"); let builder = WebviewWindowBuilder::new( app, NOTCH_WINDOW_LABEL, WebviewUrl::External(notch_url.clone()), ) - .title("CovenCave Notch") + .title(title) .inner_size(width, height) // The shell resizes it between the two fixed states; user resize would // fight the collapse animation. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 727b6c92a0..9c5e3c886a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,8 +20,6 @@ import { ConfirmProvider } from "@/components/ui/confirm-dialog"; import { PwaRegister } from "@/components/pwa-register"; import { DevCacheResetScript } from "@/components/dev-cache-reset-script"; import { DevShellRecovery } from "@/components/dev-shell-recovery"; -import { WebVitalsReporter } from "@/components/perf/web-vitals-reporter"; -import { PerfOverlay } from "@/components/perf/perf-overlay"; import { PreferencesBootstrapController } from "@/components/preferences-bootstrap-controller"; import { DaemonReleaseAlignmentTrigger } from "@/components/update-available"; import { createDefaultPreferences } from "@/lib/preferences-schema"; @@ -59,11 +57,16 @@ export const viewport: Viewport = { export const dynamic = "force-dynamic"; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const DevelopmentPerformanceTools = + process.env.NODE_ENV === "development" + ? (await import("@/components/perf/development-performance-tools")) + .DevelopmentPerformanceTools + : null; // First shell delivery must never enter the reconciled preference store. The // uninitialized snapshot is paint-only: ThemeScript may combine it with this // origin's compatibility cache, while PreferencesBootstrapController fetches @@ -102,8 +105,7 @@ export default function RootLayout({ - - + {DevelopmentPerformanceTools ? : null} {children} diff --git a/src/components/perf/development-performance-tools.tsx b/src/components/perf/development-performance-tools.tsx new file mode 100644 index 0000000000..387471069c --- /dev/null +++ b/src/components/perf/development-performance-tools.tsx @@ -0,0 +1,11 @@ +import { PerfOverlay } from "@/components/perf/perf-overlay"; +import { WebVitalsReporter } from "@/components/perf/web-vitals-reporter"; + +export function DevelopmentPerformanceTools() { + return ( + <> + + + + ); +} diff --git a/src/components/perf/perf-overlay-contract.test.ts b/src/components/perf/perf-overlay-contract.test.ts new file mode 100644 index 0000000000..0d05ba36b9 --- /dev/null +++ b/src/components/perf/perf-overlay-contract.test.ts @@ -0,0 +1,39 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +const read = (path) => readFileSync(new URL(`../../../${path}`, import.meta.url), "utf8"); + +test("the frontend monitor is mounted only in development", () => { + const layout = read("src/app/layout.tsx"); + assert.match( + layout, + /process\.env\.NODE_ENV\s*===\s*"development"\s*\?\s*\(await import\("@\/components\/perf\/development-performance-tools"\)\)/, + ); + assert.match( + layout, + /DevelopmentPerformanceTools\s*\?\s*\s*:\s*null/, + ); + assert.doesNotMatch(layout, /^import .*perf\//m, "production must not statically import monitor code"); +}); + +test("the native command is absent from release command registries", () => { + const lib = read("src-tauri/src/lib.rs"); + const setup = read("src-tauri/src/tauri_setup.rs"); + assert.match(lib, /#\[cfg\(debug_assertions\)\]\s*mod dev_performance;/); + assert.equal( + setup.match(/#\[cfg\(debug_assertions\)\]\s*dev_performance::dev_performance_snapshot/g)?.length, + 2, + "desktop and mobile command registrations must both be debug-only", + ); +}); + +test("dismissal stops native polling and the render layer stays headless", () => { + const overlay = read("src/components/perf/perf-overlay.tsx"); + assert.match(overlay, /enabled:\s*!dismissed\s*&&\s*nativeMetricsAvailable/); + assert.doesNotMatch(overlay, /\buseEffect\s*\(/); + assert.doesNotMatch(overlay, /onClick=\{\(\)\s*=>/); +}); + +console.log("perf-overlay-contract.test.ts: ok"); diff --git a/src/components/perf/perf-overlay.tsx b/src/components/perf/perf-overlay.tsx index f56d686208..c5c7a0c876 100644 --- a/src/components/perf/perf-overlay.tsx +++ b/src/components/perf/perf-overlay.tsx @@ -1,46 +1,77 @@ "use client"; -// Display-only perf HUD. Hidden by default; enable with `?perf=1` in the URL or -// `localStorage.setItem("cave:perf-overlay", "1")`. Shows live Web Vitals -// (colored by rating) and the most recent custom perf measures (markStart/ -// markEnd). pointer-events-none so it never steals clicks from the app. - -import { useEffect, useState } from "react"; -import { formatWebVital, type WebVitalRating } from "@/lib/perf/web-vitals-format"; -import { getPerfMeasures, type PerfMeasure } from "@/lib/perf/marks"; +import { useCallback, useRef, useState } from "react"; import type { CaveVital } from "@/components/perf/web-vitals-reporter"; +import { useAnnouncer } from "@/components/ui/live-region"; +import { Icon } from "@/lib/icon"; +import { getPerfMeasures, type PerfMeasure } from "@/lib/perf/marks"; +import { + formatMemoryUsage, + formatPercent, + formatPowerImpact, + performanceTone, + type SystemPerformanceSnapshot, +} from "@/lib/perf/system-performance-format"; +import { formatWebVital, type WebVitalRating } from "@/lib/perf/web-vitals-format"; +import { useMountEffect } from "@/lib/use-mount-effect"; +import { usePausablePoll } from "@/lib/use-pausable-poll"; +import { useTauriPlatform } from "@/lib/tauri-platform"; + +import "@/styles/perf-overlay.css"; -const RATING_COLOR: Record = { - good: "var(--color-success)", - "needs-improvement": "var(--color-warning)", - poor: "var(--color-danger)", - unknown: "var(--text-muted)", +type SystemMetricState = + | { status: "sampling"; snapshot: null } + | { status: "ready"; snapshot: SystemPerformanceSnapshot } + | { status: "error"; snapshot: null }; + +type MetricRow = { + label: string; + value: string; + tone: WebVitalRating; + title?: string; }; -function enabledFromEnv(): boolean { - if (typeof window === "undefined") return false; - try { - const q = new URLSearchParams(window.location.search); - if (q.get("perf") === "1") return true; - return window.localStorage.getItem("cave:perf-overlay") === "1"; - } catch { - return false; - } +const SYSTEM_POLL_MS = 1_500; + +async function requestSystemSnapshot(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("dev_performance_snapshot"); } -export function PerfOverlay() { - const [enabled, setEnabled] = useState(false); +function usePerfOverlay() { + const { announce } = useAnnouncer(); + const platform = useTauriPlatform(); + const [dismissed, setDismissed] = useState(false); + const [systemMetric, setSystemMetric] = useState({ + status: "sampling", + snapshot: null, + }); const [vitals, setVitals] = useState>({}); const [measures, setMeasures] = useState([]); + const systemRequestPending = useRef(false); - // Gate read happens post-mount so SSR markup stays empty (no hydration drift). - useEffect(() => { - setEnabled(enabledFromEnv()); + const nativeMetricsAvailable = + platform === "desktop" || platform === "ios" || platform === "android"; + + const sampleSystem = useCallback(async () => { + if (systemRequestPending.current) return; + systemRequestPending.current = true; + try { + const snapshot = await requestSystemSnapshot(); + setSystemMetric({ status: "ready", snapshot }); + } catch { + setSystemMetric({ status: "error", snapshot: null }); + } finally { + systemRequestPending.current = false; + } }, []); - useEffect(() => { - if (!enabled) return; - setVitals(window.__caveVitals ?? {}); + usePausablePoll(sampleSystem, SYSTEM_POLL_MS, { + enabled: !dismissed && nativeMetricsAvailable, + }); + + useMountEffect(() => { + setVitals({ ...(window.__caveVitals ?? {}) }); setMeasures([...getPerfMeasures()]); const onVital = () => setVitals({ ...(window.__caveVitals ?? {}) }); const onMeasure = () => setMeasures([...getPerfMeasures()]); @@ -50,38 +81,134 @@ export function PerfOverlay() { window.removeEventListener("cave:web-vital", onVital as EventListener); window.removeEventListener("cave:perf-measure", onMeasure as EventListener); }; - }, [enabled]); + }); - if (!enabled) return null; + const dismiss = useCallback(() => { + setDismissed(true); + announce("Performance monitor dismissed."); + }, [announce]); - const vitalRows = Object.values(vitals).sort((a, b) => a.name.localeCompare(b.name)); + const snapshot = systemMetric.snapshot; + const memoryPercent = snapshot + ? (snapshot.memoryUsedBytes / snapshot.memoryTotalBytes) * 100 + : 0; + const systemRows: MetricRow[] = snapshot + ? [ + { + label: "CPU", + value: formatPercent(snapshot.cpuPercent), + tone: performanceTone(snapshot.cpuPercent), + title: "System-wide CPU utilization", + }, + { + label: "Memory", + value: formatMemoryUsage(snapshot.memoryUsedBytes, snapshot.memoryTotalBytes), + tone: performanceTone(memoryPercent), + title: "System memory in use", + }, + { + label: "Power", + value: formatPowerImpact(snapshot.powerImpactPercent), + tone: performanceTone(snapshot.powerImpactPercent), + title: "Estimated impact, smoothed from system CPU utilization", + }, + ] + : [ + { label: "CPU", value: "—", tone: "unknown" }, + { label: "Memory", value: "—", tone: "unknown" }, + { label: "Power", value: "—", tone: "unknown" }, + ]; + + const systemStatus = + platform === "unknown" + ? "Finding native metrics…" + : platform === "browser" + ? "System metrics need the native app shell." + : systemMetric.status === "sampling" + ? "Sampling system metrics…" + : systemMetric.status === "error" + ? "Couldn’t load system metrics." + : null; + + return { + visible: !dismissed, + dismissButtonProps: { + type: "button" as const, + onClick: dismiss, + "aria-label": "Dismiss performance monitor", + title: "Dismiss performance monitor", + }, + systemRows, + systemStatus, + vitalRows: Object.values(vitals).sort((a, b) => a.name.localeCompare(b.name)), + recentMeasures: measures.slice(-4), + }; +} + +export function PerfOverlay() { + const { + visible, + dismissButtonProps, + systemRows, + systemStatus, + vitalRows, + recentMeasures, + } = usePerfOverlay(); + + if (!visible) return null; return ( -
-
PERF
- {vitalRows.length === 0 ? ( -
waiting for vitals…
- ) : ( - vitalRows.map((v) => ( -
- {v.name} - {formatWebVital(v.name, v.value)} + ); } diff --git a/src/components/shell-edge-rails.test.ts b/src/components/shell-edge-rails.test.ts index a57aaa1ac7..4747d8463c 100644 --- a/src/components/shell-edge-rails.test.ts +++ b/src/components/shell-edge-rails.test.ts @@ -313,11 +313,42 @@ assert.doesNotMatch( { const layout = readFileSync(new URL("../app/layout.tsx", import.meta.url), "utf8"); const marker = readFileSync(new URL("./tauri-titlebar-marker.tsx", import.meta.url), "utf8"); + const tauriPlatform = readFileSync(new URL("../lib/tauri-platform.ts", import.meta.url), "utf8"); assert.match(layout, //, "the root layout owns the titlebar marker"); assert.match( marker, - /isMacDesktopShell\(\)/, - "the marker uses the shared macOS-desktop-shell detection", + /if \(!isTauriDesktopShell\(\)\) return;/, + "the marker is limited to desktop Tauri shells", + ); + assert.match( + marker, + /if \(isMacDesktopShell\(\)\) root\.dataset\.tauriTitlebar = "";/, + "only macOS desktop shells publish the overlay-titlebar marker", + ); + assert.match( + tauriPlatform, + /export function isTauriDesktopShell\(\): boolean \{[\s\S]{0,180}?if \(!isTauri\(\)\) return false;[\s\S]{0,180}?iPhone\|iPad\|iPod\|Android/, + "the synchronous desktop detector excludes browsers and Tauri mobile shells", + ); + assert.match( + marker, + /const IS_DEVELOPMENT = process\.env\.NODE_ENV === "development";/, + "the native development marker is derived from the build environment", + ); + assert.match( + marker, + /if \(IS_DEVELOPMENT\) root\.dataset\.caveDevelopment = "";/, + "development shells publish the frame marker on the root element", + ); + assert.match( + marker, + /useMountEffect\(/, + "the root marker uses the mount-only external synchronization hook", + ); + assert.doesNotMatch( + marker, + /\buseEffect\(/, + "the root marker does not call useEffect directly", ); assert.doesNotMatch( shell, @@ -358,6 +389,11 @@ assert.doesNotMatch( /:root\[data-tauri-titlebar\] \.shell-top \{[\s\S]{0,340}?backdrop-filter: blur\(var\(--glass-blur\)\) saturate\(var\(--glass-saturate\)\);/, "the native shell titlebar carries subtle glass", ); + assert.match( + css, + /:root\[data-cave-development\] body::after \{[\s\S]{0,520}?pointer-events: none;[\s\S]{0,240}?border: calc\(var\(--ring-width\) \* 1\.5\) solid[\s\S]{0,180}?var\(--accent-presence\)[\s\S]{0,240}?var\(--radius-control\)/, + "every desktop development shell carries a pointer-inert, token-derived app frame", + ); const dashboardCss = readFileSync(new URL("../styles/dashboard.css", import.meta.url), "utf8"); assert.match( dashboardCss, diff --git a/src/components/tauri-titlebar-marker.tsx b/src/components/tauri-titlebar-marker.tsx index b686f3418e..9968cef041 100644 --- a/src/components/tauri-titlebar-marker.tsx +++ b/src/components/tauri-titlebar-marker.tsx @@ -13,17 +13,27 @@ * Mounted once in the root layout; the overlay title bar is a property of * the window, not of a route, so the marker is never removed. globals.css * (and dashboard.css) key their traffic-light insets and titlebar glass off - * `:root[data-tauri-titlebar]`. Browser, Windows, Linux, and Tauri-mobile - * never set it. + * `:root[data-tauri-titlebar]`. Every desktop development shell also publishes + * a dedicated marker for its in-app frame treatment. Browser, Tauri-mobile, + * and production builds never receive the development marker. */ -import { useEffect } from "react"; -import { isMacDesktopShell } from "@/lib/tauri-platform"; +import { isMacDesktopShell, isTauriDesktopShell } from "@/lib/tauri-platform"; +import { useMountEffect } from "@/lib/use-mount-effect"; + +const IS_DEVELOPMENT = process.env.NODE_ENV === "development"; + +function useTauriTitlebarMarker() { + useMountEffect(() => { + if (!isTauriDesktopShell()) return; + + const root = document.documentElement; + if (isMacDesktopShell()) root.dataset.tauriTitlebar = ""; + if (IS_DEVELOPMENT) root.dataset.caveDevelopment = ""; + }); +} export function TauriTitlebarMarker() { - useEffect(() => { - if (!isMacDesktopShell()) return; - document.documentElement.dataset.tauriTitlebar = ""; - }, []); + useTauriTitlebarMarker(); return null; } diff --git a/src/lib/design-token-drift.test.ts b/src/lib/design-token-drift.test.ts index 69a6e7a495..cbbcbdee9c 100644 --- a/src/lib/design-token-drift.test.ts +++ b/src/lib/design-token-drift.test.ts @@ -71,7 +71,7 @@ const BASELINES = { offScaleSpacingPx: 1654, // +1: live call transcript (cave-zr9dx) — the spoken-word highlight's `padding: 0 2px`. The tints the words the familiar is voicing INSIDE a turn bubble that is already tinted, so the run needs a hair of inline padding or the tint touches the glyphs on both sides; --space-1 (4px) at that scale reads as a gap in the sentence, which is the one thing a mid-sentence highlight must not do. Same micro-mark family the Chart Room, Weaves, Review Deck and GitHub-composer handoffs banked. Every other spacing value the call overlay added — 5 gaps, 3 paddings and the reply form's stack — snapped to --space-1/-2/-3 in this PR, so the sheet went +1 rather than the +11 it started at. // -65: banked — the familiar-analytics workbench rebuild (cave-l4ttp) snapped the mock's 5/6/7/9/10/11/13/14/15/18/22/26px paddings and gaps to --space-1..-6 across the whole new sheet, and retiring the old page's section/KPI/hero chrome reclaimed the rest. What remains in the sheet is only the 1/2/3px micro-mark family (badge padding, tick nudges) the earlier handoffs already banked. +4: GitHub triage stream + detail rebuild ("Cody Github.dc.html" handoff) — the mock's 5/7/9/10/11/13/14px paddings and gaps were ALL snapped to --space-1..-4 in this PR, so what remains is only the micro-mark family the Chart Room, Weaves, Review Deck and GitHub-composer handoffs already banked: 6px glyph-to-label gaps (8px separates an icon from its word, which is exactly what a stage badge, a signal chip and a gate label must not do), 6px inline padding on the 17-19px stage badges and row verbs (--space-2 turns a 17px pill into a lozenge), and the 2/3px nudges on the signal strip, the peek margin and the facts column. Retiring the table and the old glass masthead reclaimed most of what the two new sheets added, which is why this is +4 rather than the +40 they started at; every font-size in both sheets is on the type scale (-5 banked above) and tokenize-css.mjs is a no-op over them. offScaleRadiusPx: 233, // +1: GitHub triage stream ("Cody Github.dc.html" handoff) — the row's three-segment signal strip paints 4px-wide bars, and a 1px radius is the only step that reads as a rounded mark rather than a lozenge; --radius-sm turns a 4px bar into a dot. Same short-solid-mark family the research-desk, Research Reader, daily-report and Rituals-sparkline entries banked. The strip's 2px track/fill radii reuse the existing budget-meter step, every container corner in the new stream and detail sheets uses --radius-sm/-md/-lg/-control/-pill/-panel, and both sheets went DOWN on spacing and font-size in the same PR. // -2: banked after the chat-detail merge removed two off-step radii. +4: Chart Room v2 (cave-iuc8h) — the 2px project dot and its 3px large variant, plus the 2px progress track and its fill: short solid marks 6-10px across, the same family the research-desk, Research Reader, daily-report, Projects and GitHub-composer handoffs banked (--radius-control 8px reads as a full circle at that size, which is the one shape a square project swatch must not be). Every container corner in the sheet uses --radius-sm/-md/-control/-pill, and the dependency port is a true 50% circle. // +6: chat session redesign (Chat.dc.html 2a/2b) — the context row's 6px mono chip and 4px stat cell, the group headers' 4px count pills and the rail row's 2px state tick: short compact marks between --radius-control 8px and square. Every container corner uses --radius-card/--radius-panel/--radius-pill. 4px/6px/10px/14px/… radii between the sanctioned steps. +5: research-desk 2px/4px accent-mark radii (short solid marks, not container corners). -5: projects access page rebuild removed the hub's off-step radii. +1: Research Reader accent-mark radius (the section-heading 2px tick, same short-solid-mark family as the research desk). -3: orphaned skill-browser removal reclaimed its off-step radii. -3: Phone control-sheet extraction replaced the legacy block with section-local tokenized CSS. +8: daily report redesign (2a handoff) — the chaptered-day surface's 1/2/3/4px radii on short solid marks (week-strip activity bars, streak pips, swimlane segments and merge ticks, the spine's accent rail), the same short-mark family the research-desk and Research Reader handoffs banked; every container corner snapped to --radius-control/-card/-panel/-sm in the same PR. -2: Canvas page redesign (Canvas.dc.html handoff) — the gallery card's 10px corner and the inspector swatch's `50%` circle snapped to --radius-card and --radius-pill. +3: Projects access refresh — the 5px radius on the card select-checkbox (17px) and the two 18px icon buttons (disclose, gear); --radius-control (8px) reads as a circle at those sizes. Every container corner in the new sheet uses --radius-control/-card/-pill. +1: the Rituals inbox daily-report row's 24-bar merge sparkline, same short-solid-mark family (a 3px-wide bar cannot take a scale radius). +8: GitHub card composer (cave-076kh) — the composer's 4px chips (reactions, assignee/label chips, gate-row actions, scope chips, toolbar buttons) and 3px segment thumbs (Write/Preview, merge method, verb mode), the same short-compact-mark family the research-desk, Research Reader, daily-report and Projects handoffs banked; --radius-control (8px) reads as a pill at a 19-22px chip height. Every control corner uses --radius-sm (the design's own "5-6px control" band) and every container --radius-control/--radius-pill. hexOutsideDefinitions: 0, // hex in render CSS (token definitions excluded) — -104: cave-gyh2 chunk 1 (dropped stale var(--token, #hex) fallbacks; mapped accent/danger/success fills to their semantic foregrounds; promoted --color-success-foreground and the codex --cv-* strays). -1 banked in the design-doc reconcile PR (cave-kf3x). -51: cave-yxiz chunk 2 zeroed the ratchet — document grounds (sketch/preview/thumb/QR) now share the fixed --surface-paper token in foundations.css; GitHub state badges promote --gh-merged/--gh-merged-ink and pair open/closed with the semantic status foregrounds; profile-card strays joined the --pfc-* palette; the magic-cast spell art promotes --spell-violet/--spell-core; QR ink, dashboard mark ink, and the avatar photo-overlay ink became local definitions; and pure shade/alpha arithmetic in color-mix()/mask gradients uses the CSS black/white keywords (sanctioned: they're mix anchors, not colors — matching the pre-existing keyword usage in dashboard.css and surface-compact-calendar.css). New hexes belong in token definitions; keywords are only for mix/mask arithmetic. - inlineTsxStyles: 207, // +5: familiar-analytics workbench rebuild (cave-l4ttp) — every one paints a live percentage that only exists at render time: the four 0-100 thread-metric bars (analysis panel + trust modal), the per-report score bar in the report ledger, the self-heal severity meter, and the expanded pulse's per-day bar height (a day's count as a share of the window's peak). All are model-derived from self-reports and session counts, so no token can express them; track, radius, colour and motion are all CSS. Three avoidable sites went the other way in the same PR — the renown meter now carries ONE --fa-renown-pct that its fill, sheen and quarter-tick gradient all read, and the contract pass/fail split sizes its two segments from --fa-pass/--fa-fail instead of two inline flex-grows. // +3 on top of main's 199: Expand reader (Reader.dc.html 3a, cave-zhmto) — both carry a value that only exists at render time. The progress hairline paints `scaleX()`, read off the scroll container every frame; a token cannot express "how far down this reader is". The rail entry paints `--reader-depth` = the heading's level minus the answer's own shallowest level, so an answer written entirely in `##` is not uniformly indented — the indent step itself IS a token (--space-3), only the multiplier is per-answer. The third is the footer's by-tool share bar, whose fill width is that tool's measured fraction of the turn's wall time — read off the turn's own ToolEvent durations, so it exists only once the turn has run. Every other value in the reader is a class in cave-chat/reader.css; the sheet added no off-scale spacing or radius (both ratchets held at baseline in this PR). // +1: chat context breakdown bar (a232c9d63) — each segment's `width` is that row's share of the context window, clamped 0-100 from live token counts; the value only exists at render time from model state, so there is no token that could express it. +1: thread-instruments stamp lane (cave-xb6g5) — the spine hands the stylesheet `--cave-spine-stamp-chars`, the length of the longest clock string it is about to render. That is the reader's own locale and clock format (24-hour "23:00" is 5, 12-hour "11:00 PM" is 8), so it is unknowable at author time and cannot be a token; a fixed value clips timestamps on any machine whose clock is wider than ours, which is the exact bug the lane was added to end. +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) +5: Chart Room v2 (cave-iuc8h) — every one carries a computed value into CSS through a custom property, never presentation: the chain diagram's per-node x/y from the connected-component layout, the gantt bar's left/width from dependency depth and its fill from the card's real project colour, the table's per-column width, and the help grid's column count from the measured breakpoint. The room's flow, graph and orchestration lanes do the same through multi-line style props; all of them are layout measured at runtime or colour read from board data, so none can be a token. // +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) // +1: Thread Signal triage card (cave-vkegj) — the rationale strip's fill paints the selected metric's score as a percentage width. The value is a familiar's self-reported 0-100 for whichever tile the reader just tapped, so it exists only at render time and no token can express it; the bar's track, radius, colour and transition are all CSS. Same dynamic-percentage family as the context breakdown bar and the Review Deck bucket bar below. // +1: chat context breakdown bar (a232c9d63) — each segment's `width` is that row's share of the context window, clamped 0-100 from live token counts; the value only exists at render time from model state, so there is no token that could express it. +1: thread-instruments stamp lane (cave-xb6g5) — the spine hands the stylesheet `--cave-spine-stamp-chars`, the length of the longest clock string it is about to render. That is the reader's own locale and clock format (24-hour "23:00" is 5, 12-hour "11:00 PM" is 8), so it is unknowable at author time and cannot be a token; a fixed value clips timestamps on any machine whose clock is wider than ours, which is the exact bug the lane was added to end. +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) +5: Chart Room v2 (cave-iuc8h) — every one carries a computed value into CSS through a custom property, never presentation: the chain diagram's per-node x/y from the connected-component layout, the gantt bar's left/width from dependency depth and its fill from the card's real project colour, the table's per-column width, and the help grid's column count from the measured breakpoint. The room's flow, graph and orchestration lanes do the same through multi-line style props; all of them are layout measured at runtime or colour read from board data, so none can be a token. // +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) + inlineTsxStyles: 206, // -1: performance monitor moved its static rating colors into token-backed CSS. // +5: familiar-analytics workbench rebuild (cave-l4ttp) — every one paints a live percentage that only exists at render time: the four 0-100 thread-metric bars (analysis panel + trust modal), the per-report score bar in the report ledger, the self-heal severity meter, and the expanded pulse's per-day bar height (a day's count as a share of the window's peak). All are model-derived from self-reports and session counts, so no token can express them; track, radius, colour and motion are all CSS. Three avoidable sites went the other way in the same PR — the renown meter now carries ONE --fa-renown-pct that its fill, sheen and quarter-tick gradient all read, and the contract pass/fail split sizes its two segments from --fa-pass/--fa-fail instead of two inline flex-grows. // +3 on top of main's 199: Expand reader (Reader.dc.html 3a, cave-zhmto) — both carry a value that only exists at render time. The progress hairline paints `scaleX()`, read off the scroll container every frame; a token cannot express "how far down this reader is". The rail entry paints `--reader-depth` = the heading's level minus the answer's own shallowest level, so an answer written entirely in `##` is not uniformly indented — the indent step itself IS a token (--space-3), only the multiplier is per-answer. The third is the footer's by-tool share bar, whose fill width is that tool's measured fraction of the turn's wall time — read off the turn's own ToolEvent durations, so it exists only once the turn has run. Every other value in the reader is a class in cave-chat/reader.css; the sheet added no off-scale spacing or radius (both ratchets held at baseline in this PR). // +1: chat context breakdown bar (a232c9d63) — each segment's `width` is that row's share of the context window, clamped 0-100 from live token counts; the value only exists at render time from model state, so there is no token that could express it. +1: thread-instruments stamp lane (cave-xb6g5) — the spine hands the stylesheet `--cave-spine-stamp-chars`, the length of the longest clock string it is about to render. That is the reader's own locale and clock format (24-hour "23:00" is 5, 12-hour "11:00 PM" is 8), so it is unknowable at author time and cannot be a token; a fixed value clips timestamps on any machine whose clock is wider than ours, which is the exact bug the lane was added to end. +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) +5: Chart Room v2 (cave-iuc8h) — every one carries a computed value into CSS through a custom property, never presentation: the chain diagram's per-node x/y from the connected-component layout, the gantt bar's left/width from dependency depth and its fill from the card's real project colour, the table's per-column width, and the help grid's column count from the measured breakpoint. The room's flow, graph and orchestration lanes do the same through multi-line style props; all of them are layout measured at runtime or colour read from board data, so none can be a token. // +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) // +1: Thread Signal triage card (cave-vkegj) — the rationale strip's fill paints the selected metric's score as a percentage width. The value is a familiar's self-reported 0-100 for whichever tile the reader just tapped, so it exists only at render time and no token can express it; the bar's track, radius, colour and transition are all CSS. Same dynamic-percentage family as the context breakdown bar and the Review Deck bucket bar below. // +1: chat context breakdown bar (a232c9d63) — each segment's `width` is that row's share of the context window, clamped 0-100 from live token counts; the value only exists at render time from model state, so there is no token that could express it. +1: thread-instruments stamp lane (cave-xb6g5) — the spine hands the stylesheet `--cave-spine-stamp-chars`, the length of the longest clock string it is about to render. That is the reader's own locale and clock format (24-hour "23:00" is 5, 12-hour "11:00 PM" is 8), so it is unknowable at author time and cannot be a token; a fixed value clips timestamps on any machine whose clock is wider than ours, which is the exact bug the lane was added to end. +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) +5: Chart Room v2 (cave-iuc8h) — every one carries a computed value into CSS through a custom property, never presentation: the chain diagram's per-node x/y from the connected-component layout, the gantt bar's left/width from dependency depth and its fill from the card's real project colour, the table's per-column width, and the help grid's column count from the measured breakpoint. The room's flow, graph and orchestration lanes do the same through multi-line style props; all of them are layout measured at runtime or colour read from board data, so none can be a token. // +1: Review Deck summary strip (cave-d9nta) — the bucket bar's fill paints each bucket's computed share of the deck through a `--rd-share` custom property; the percentage is derived from live GitHub state, so it cannot be a token. +8: thread instruments (cave-j86la) — every one is a measured or model-computed value, not presentation: the spine's per-node `top` and line height come from live turn-offset measurement, its stack height/segment heights are proportional to the turn's tool counts, and the minimap's pane height, caret `top`, per-event bar width/height and the shared hover card's row-anchored `top` are pane-measure + model-derived. +2: the context row's context-window meter paints a computed percentage width — a genuinely dynamic value, not presentation. style={{…}} in TSX; many are legit dynamic values (banked from 500 — the count had drifted far below the old ceiling; -2: projects access page rebuild dropped the hub's inline styles; +1: vendored Blaze.tsx canvas host uses dynamic style spread for WebGL positioning; +1: vendored Peel.tsx canvas host for page-peel reveal effect uses runtime-mutated inline styles; +2: project-setup modal color swatches paint runtime-only values (per-root tint + oklch palette choice); -1: orphaned skill-browser removal dropped its inline style; -45: Phone extraction moved static presentation out of settings-shell.tsx; -1 net: the daily report redesign retired daily-report-ui.tsx and shipped-table.tsx, and its own new inline styles are dynamic-only — CSS custom properties carrying computed percentages and the model's semantic tone; -1 more when the shipped rows' external links became in-app buttons opening the app's GitHub card; -3 net against the declared baseline after rebasing: one concurrent reduction banked, and two GitHub task-status dots moved from token-valued inline styles to state classes) }; // ── unit sanity for the codemod transform ─────────────────────────────────── diff --git a/src/lib/perf/system-performance-format.test.ts b/src/lib/perf/system-performance-format.test.ts new file mode 100644 index 0000000000..fff4fcfdf0 --- /dev/null +++ b/src/lib/perf/system-performance-format.test.ts @@ -0,0 +1,34 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + formatMemoryUsage, + formatPercent, + formatPowerImpact, + performanceTone, +} from "./system-performance-format.ts"; + +test("formats CPU and binary memory readings compactly", () => { + assert.equal(formatPercent(4.24), "4.2%"); + assert.equal(formatPercent(84.9), "85%"); + assert.equal(formatMemoryUsage(512 * 1024 ** 2, 16 * 1024 ** 3), "512 MiB / 16.0 GiB"); +}); + +test("classifies resource pressure and power impact", () => { + assert.equal(performanceTone(49.9), "good"); + assert.equal(performanceTone(50), "needs-improvement"); + assert.equal(performanceTone(80), "poor"); + assert.equal(formatPowerImpact(4.9), "Idle"); + assert.equal(formatPowerImpact(24.9), "Low"); + assert.equal(formatPowerImpact(59.9), "Moderate"); + assert.equal(formatPowerImpact(60), "High"); +}); + +test("invalid readings render as unavailable", () => { + assert.equal(formatPercent(Number.NaN), "—"); + assert.equal(formatMemoryUsage(-1, 100), "—"); + assert.equal(formatPowerImpact(Number.POSITIVE_INFINITY), "—"); + assert.equal(performanceTone(Number.NaN), "unknown"); +}); + +console.log("system-performance-format.test.ts: ok"); diff --git a/src/lib/perf/system-performance-format.ts b/src/lib/perf/system-performance-format.ts new file mode 100644 index 0000000000..c3b3527a47 --- /dev/null +++ b/src/lib/perf/system-performance-format.ts @@ -0,0 +1,46 @@ +import type { WebVitalRating } from "./web-vitals-format"; + +const MEBIBYTE = 1024 ** 2; +const GIBIBYTE = 1024 ** 3; + +export type SystemPerformanceSnapshot = { + cpuPercent: number; + memoryUsedBytes: number; + memoryTotalBytes: number; + powerImpactPercent: number; + sampledAtMs: number; +}; + +export function performanceTone(percent: number): WebVitalRating { + if (!Number.isFinite(percent)) return "unknown"; + if (percent < 50) return "good"; + if (percent < 80) return "needs-improvement"; + return "poor"; +} + +export function formatPercent(percent: number): string { + if (!Number.isFinite(percent)) return "—"; + const digits = Math.abs(percent) < 10 ? 1 : 0; + return `${percent.toFixed(digits)}%`; +} + +function formatMemory(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "—"; + if (bytes >= GIBIBYTE) return `${(bytes / GIBIBYTE).toFixed(1)} GiB`; + return `${Math.round(bytes / MEBIBYTE)} MiB`; +} + +export function formatMemoryUsage(usedBytes: number, totalBytes: number): string { + const used = formatMemory(usedBytes); + const total = formatMemory(totalBytes); + if (used === "—" || total === "—") return "—"; + return `${used} / ${total}`; +} + +export function formatPowerImpact(percent: number): string { + if (!Number.isFinite(percent)) return "—"; + if (percent < 5) return "Idle"; + if (percent < 25) return "Low"; + if (percent < 60) return "Moderate"; + return "High"; +} diff --git a/src/lib/tauri-platform.ts b/src/lib/tauri-platform.ts index 763ee9fcba..7f1402be0e 100644 --- a/src/lib/tauri-platform.ts +++ b/src/lib/tauri-platform.ts @@ -24,6 +24,22 @@ export function isTauri(): boolean { return (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== undefined; } +function runtimePlatformHint(): string { + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + return nav.userAgentData?.platform || nav.userAgent || nav.platform || ""; +} + +/** + * Synchronous desktop-shell detection for mount-time DOM markers. The async + * platform hook remains authoritative for native capabilities; this narrower + * check distinguishes desktop Tauri from its iOS and Android shells without a + * second render. + */ +export function isTauriDesktopShell(): boolean { + if (!isTauri()) return false; + return !/iPhone|iPad|iPod|Android/i.test(runtimePlatformHint()); +} + let cachedPlatform: TauriPlatform | null = null; async function resolvePlatform(): Promise { @@ -99,9 +115,5 @@ export function useIsTauriMobile(): boolean { * UAs contain "like Mac OS X", so Tauri-mobile is excluded explicitly. */ export function isMacDesktopShell(): boolean { - if (typeof window === "undefined" || !isTauri()) return false; - const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; - const platform = nav.userAgentData?.platform || nav.userAgent || nav.platform || ""; - if (/iPhone|iPad|iPod/i.test(platform)) return false; - return /Mac/i.test(platform); + return isTauriDesktopShell() && /Mac/i.test(runtimePlatformHint()); } diff --git a/src/lib/use-mount-effect.ts b/src/lib/use-mount-effect.ts new file mode 100644 index 0000000000..67c02eb01d --- /dev/null +++ b/src/lib/use-mount-effect.ts @@ -0,0 +1,10 @@ +"use client"; + +import { useEffect, type EffectCallback } from "react"; + +/** Runs a one-time external-system synchronization when a component mounts. */ +export function useMountEffect(effect: EffectCallback): void { + // The empty dependency list is the contract of this narrow escape hatch. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, []); +} diff --git a/src/styles/globals/desktop-chrome.css b/src/styles/globals/desktop-chrome.css index b94073521a..dd76b84627 100644 --- a/src/styles/globals/desktop-chrome.css +++ b/src/styles/globals/desktop-chrome.css @@ -104,6 +104,23 @@ button:active > .edge-rail-chip { --titlebar-lights-inset: 78px; } +/* Development shell signature. The native app name and icon carry the literal + environment label; this token-derived inner frame keeps that context visible + inside the window without adding another permanent piece of chrome. The root + marker is published only in desktop Tauri development shells. */ +:root[data-cave-development] body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 2147482000; + pointer-events: none; + border: calc(var(--ring-width) * 1.5) solid color-mix(in oklch, var(--accent-presence) 66%, var(--border-strong)); + border-radius: var(--radius-control); + box-shadow: + inset 0 0 0 1px color-mix(in oklch, var(--accent-presence) 16%, transparent), + inset 0 0 var(--space-3) color-mix(in oklch, var(--accent-presence) 10%, transparent); +} + /* Fit at EVERY window width: the lights float over the webview no matter how narrow the window is, so the reserve can't be desktop-media-gated — in a sub-1024 window the mobile top-bar's controls sat underneath the buttons. */ diff --git a/src/styles/perf-overlay.css b/src/styles/perf-overlay.css new file mode 100644 index 0000000000..3febecd0cd --- /dev/null +++ b/src/styles/perf-overlay.css @@ -0,0 +1,118 @@ +.perf-overlay { + position: fixed; + inset-inline-end: max(var(--space-2), var(--sai-right)); + inset-block-end: max(var(--space-2), var(--sai-bottom)); + z-index: 2147483000; + width: min(17rem, calc(100vw - var(--space-4))); + max-height: calc(100vh - var(--space-4)); + overflow: auto; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-card); + background: var(--bg-elevated); + box-shadow: 0 var(--space-2) var(--space-6) color-mix(in oklch, var(--shadow-color) 42%, transparent); + color: var(--text-secondary); + font-family: var(--font-mono), ui-monospace, monospace; + font-size: var(--text-xs); +} + +.perf-overlay__header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--space-8); + padding: var(--space-1) var(--space-2) var(--space-1) var(--space-3); + border-bottom: 1px solid var(--border-hairline); +} + +.perf-overlay__title, +.perf-overlay__section-label { + color: var(--text-primary); + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: var(--tracking-eyebrow); + text-transform: uppercase; +} + +.perf-overlay__dismiss { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--space-8); + height: var(--space-8); + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.perf-overlay__dismiss:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.perf-overlay__section { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); +} + +.perf-overlay__section + .perf-overlay__section { + border-top: 1px solid var(--border-hairline); +} + +.perf-overlay__section-label { + margin-bottom: var(--space-1); + color: var(--text-muted); +} + +.perf-overlay__metric { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + min-width: 0; +} + +.perf-overlay__value { + flex: none; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.perf-overlay__metric[data-tone="good"] .perf-overlay__value { + color: var(--color-success); +} + +.perf-overlay__metric[data-tone="needs-improvement"] .perf-overlay__value { + color: var(--color-warning); +} + +.perf-overlay__metric[data-tone="poor"] .perf-overlay__value { + color: var(--color-danger); +} + +.perf-overlay__status, +.perf-overlay__note { + margin: var(--space-1) 0 0; + color: var(--text-muted); + line-height: var(--leading-normal); +} + +.perf-overlay__note { + font-size: var(--text-2xs); +} + +.perf-overlay__measure-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (hover: none) and (pointer: coarse) { + .perf-overlay__dismiss { + width: var(--touch-target); + height: var(--touch-target); + } +}