diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e941ec6..1c73fa3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,6 +46,9 @@ jobs:
- name: npm ci
run: npm ci
+ - name: Check version sync
+ run: npm run check:versions
+
- name: TypeScript + Vite build
run: npm run build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a1b366..ec16842 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [v0.39.0](https://github.com/mandarwagh9/embedist/releases/tag/v0.39.0) — 2026-05-28
+
+### Added
+- **Serial data plotter.** Lines that look like CSV (`1.23,4.56`), labelled pairs (`temp:23.5 humidity:60`), or JSON (`{"x":1,"y":2}`) are auto-parsed into numeric series and rendered as a live SVG line chart. Toolbar in `SerialMonitor` toggles **Text / Plot / Split** view; the plotter shows live values per channel and supports pause/resume.
+- **Token usage + cost display.** Every assistant message footer shows prompt/completion token counts and an estimated USD cost based on the active provider's published list price. The conversation header tallies cumulative tokens and total cost for the session. Pricing table at `src/lib/ai-pricing.ts` covers the OpenAI, Anthropic, DeepSeek, and Google models exposed in Settings; Ollama / local models render `$0`; unknown models render counts without a misleading dollar figure.
+- **Parallel agent tool dispatch.** When the model returns a batch of read-only tool calls (`read_file`, `list_directory`, `get_directory_tree`, `search_code`, `web_search`), they now execute concurrently via `Promise.all` instead of sequentially. Mutating tools still run in order to preserve filesystem causality and toast order; mixed batches fall back to sequential execution.
+
+### Changed — performance + bundle
+- **Initial JS chunk dropped from 1.77 MB → 411 KB (-77%).** `vite.config.ts` `manualChunks` splits Monaco, highlight.js, and xterm into their own chunks. The four sidebar panels (FileExplorer, AIChatPanel, SerialMonitor, BuildPanel), CodeEditor, SettingsModal, and SetupWizard are now `React.lazy()` with `` fallbacks. Cold start is noticeably faster on slow disks; the editor only pays its 4 MB cost when a file is actually opened.
+
+### Changed — design system
+- **Focus rings everywhere.** New `--focus-ring` token + global `:focus-visible` selectors mean every interactive element shows a keyboard-nav outline. Inputs additionally get a 4-px accent halo via box-shadow.
+- **Light theme rebuilt for WCAG AA contrast.** Primary text clears 7:1 on every surface, accent moved from `#4F46E5` to `#3730A3`, surfaces re-warmed.
+- **3-tier shadow scale** (`--shadow-sm/md/lg`) consolidating 9 ad-hoc shadow values that had drifted across components.
+- **Reduced-motion support** — `prefers-reduced-motion: reduce` honored globally.
+
+### Changed — state + I/O
+- **`aiStore.messages` persistence capped at the most recent 50.** Previous behavior persisted unbounded history into localStorage, eventually hitting the 5–10 MB quota and silently dropping the whole store.
+- **`fileStore` no longer persists file contents.** Reopened tabs reload from disk via the existing path/content code path.
+- **`run_shell` audit logging is now structured.** Both invocation and result get a `log::info!` line with program name, cwd, exit code, and stdout/stderr byte counts.
+- **`read_file` rejects files above 50 MB** with a friendly error instead of OOMing the app. `grep_search` skips files above the same limit. `get_directory_tree` caps total nodes at 10,000 and short-circuits on symlinks to prevent cycle traversal.
+
+### Fixed
+- **Tool-permission polling timeout.** The 30-second deny fallback now cancels the inner `setTimeout` chain so the poller can't outlive the outer promise. Previously a hung dialog could keep the loop alive after the deny had been applied.
+
+### Tooling
+- **Version-drift guard.** `npm run check:versions` (`scripts/check-version-sync.mjs`) compares `package.json` ↔ `src-tauri/Cargo.toml` ↔ `src-tauri/tauri.conf.json`. CI fails the build on drift; the three sources are now mechanically required to agree.
+
+### Documentation
+- `docs/superpowers/specs/2026-05-28-sota-roadmap.md` — consolidated audit roadmap.
+- Five Tier-2 specs: clangd LSP bridge, GDB/OpenOCD debugger, embedding-based RAG, AI inline completions, auto-updater + code signing.
+- `2026-05-28-tier-3-4-followups.md` — small + strategic follow-ups grouped by domain.
+
+---
+
## [v0.38.0](https://github.com/mandarwagh9/embedist/releases/tag/v0.38.0) — 2026-05-28
### Security
diff --git a/docs/superpowers/specs/2026-05-28-embedding-rag.md b/docs/superpowers/specs/2026-05-28-embedding-rag.md
new file mode 100644
index 0000000..f55ec4f
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-embedding-rag.md
@@ -0,0 +1,40 @@
+# Spec: Embedding-Based RAG
+
+**Status:** proposed
+**Owners:** TBD
+**Tier:** 2
+
+## Why
+
+`src/lib/rag.ts` does TF-IDF keyword scoring over project files. It's fast and works for "find lines containing X" — but it misses semantic queries like "where does WiFi initialize" or "the function that publishes MQTT", which is exactly the question pattern an LLM agent should be cheap at. Cursor, Continue.dev, and Aider all use embedding-based retrieval; Embedist's TF-IDF is a noticeable regression in real use.
+
+## Goal
+
+Replace TF-IDF with embedding-based file-chunk retrieval, keeping the bundled cost low (no large local models) by reusing the user's already-configured AI provider for embeddings.
+
+## Design
+
+- **Chunk strategy:** split each project file by symbol boundary (Tree-sitter or, for v1, regex on `^\w.*\{$` for C/C++). Cap each chunk at 1024 tokens. Skip generated dirs (`.pio`, `target`, `node_modules`).
+- **Provider routing:**
+ - OpenAI users: `POST /v1/embeddings` with `text-embedding-3-small` (1536-dim, $0.02/Mtok).
+ - Ollama users: `POST /api/embeddings` with `nomic-embed-text`.
+ - Anthropic users: no native embeddings API — fall back to Voyage via `voyage-3` or default to keyword TF-IDF.
+- **Storage:** `~/.embedist/index/{project-id}.bin` — flat float32 array + companion JSON manifest (file path, chunk start/end line, content hash). Invalidate per-file on `useFileWatcher` change events.
+- **Retrieval:** dot-product similarity against the query embedding, top-k=8. Add a hybrid score `0.7 * cosine + 0.3 * tfidf(query, chunk)` to keep the keyword signal where embeddings drift.
+- **API:** keep `rag.ts::queryContext()` signature stable; the implementation swaps under the hood.
+
+## Phasing
+
+1. **v1:** Re-index on project open + on file save. Synchronous in a Web Worker so UI stays responsive.
+2. **v2:** Incremental index — only re-embed changed chunks.
+3. **v3:** Cross-project search (mono-repo / multi-project users).
+
+## Risks
+
+- Embedding API calls cost money. Default OFF; settings toggle "Use semantic search (uses embedding API)".
+- Index size: ~200 KB per 1k LOC file at 1536-dim. A 100k-LOC project is ~20 MB on disk — fine.
+- Cold start: first index on a large project takes minutes. Show progress in StatusBar.
+
+## Verification
+
+Open the ESP-IDF Wi-Fi example. Ask the agent: "where is the connect callback registered?" Without semantic search it returns files containing the substring "connect" — many irrelevant. With semantic search it returns `wifi_connect_handler.c:42` as the top hit.
diff --git a/docs/superpowers/specs/2026-05-28-gdb-debugger.md b/docs/superpowers/specs/2026-05-28-gdb-debugger.md
new file mode 100644
index 0000000..e60d454
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-gdb-debugger.md
@@ -0,0 +1,43 @@
+# Spec: GDB / OpenOCD Debugger
+
+**Status:** proposed
+**Owners:** TBD
+**Tier:** 2
+
+## Why
+
+For ESP32 and ARM Cortex-M targets, embedded developers iterate on bugs through breakpoints and watch expressions. Embedist currently gives them `printf` over serial and that's it. CLion, PlatformIO IDE, and ESP-IDF VS Code all wrap `openocd` + `gdb`; without parity, anything beyond toy projects forces users back to those tools.
+
+## Goal
+
+Set / hit a breakpoint on an ESP32-S3 from inside Embedist. Show: source line, call stack, local variables, watch expressions, step-into / step-over / step-out / continue.
+
+## Design
+
+- **Sidecar:** `src-tauri/src/commands/debug.rs` (new) spawns `openocd` (board-specific config) and `gdb --interpreter=mi`. Lifetime managed by `BuildState`-style `DebugState`.
+- **Wire format:** GDB/MI parsed into typed events (`StopEvent`, `FrameEvent`, `VariableEvent`) emitted via Tauri `debug-event` payload.
+- **Frontend:** new `BottomPanel.tab = 'debug'` panel: stack on the left, locals/watches on the right, controls at the top. Monaco gutter shows breakpoint markers and the current-line caret.
+- **Reuse:** `BuildPanel` already streams events; the debugger panel uses the same Tauri-event pattern.
+- **Board config:** new `boards/.json` registry mapping board → openocd config, gdb path, default reset behavior. Start with esp32, esp32s3, esp32c3.
+
+## Phasing
+
+1. **v1:** Breakpoints + step + locals only. No watch expressions, no memory view.
+2. **v2:** Memory inspector + watch expressions + conditional breakpoints.
+3. **v3:** Logic analyzer view via `itm`/`swo` capture.
+
+## Risks
+
+- Toolchain detection: which `gdb` binary? PlatformIO bundles per-platform xtensa-esp-elf-gdb in `~/.platformio/packages`. Embedist must locate it without making the user configure paths.
+- OpenOCD config drift across ESP-IDF versions.
+- Probing for an attached JTAG/USB probe on Windows; FTDI / WCH-LinkE / DAPLink each have different USB IDs.
+
+## Verification
+
+Plug an ESP32-S3 dev board into the host. Set a breakpoint on `loop()` in the example project. Hit "Debug" → board halts on entry, source view scrolls to the marker, hovering a variable shows its current value. `Step Over` advances one line.
+
+## Out of scope (v1)
+
+- Profiling
+- Reverse debugging
+- Trace capture
diff --git a/docs/superpowers/specs/2026-05-28-inline-completions.md b/docs/superpowers/specs/2026-05-28-inline-completions.md
new file mode 100644
index 0000000..0129351
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-inline-completions.md
@@ -0,0 +1,38 @@
+# Spec: AI Inline Completions (Copilot-style ghost text)
+
+**Status:** proposed
+**Owners:** TBD
+**Tier:** 2
+
+## Why
+
+Inline AI completions ("ghost text" you accept with `Tab`) are the single biggest productivity win that AI-IDEs offer over the previous generation. Cursor, Copilot, Continue.dev, Codeium, and Windsurf all ship this. Embedist has chat / plan / agent but no per-keystroke completion, so the AI is invisible during normal typing.
+
+## Goal
+
+Pressing `Tab` accepts a model-generated continuation of the current line / next few lines. Pressing `Esc` dismisses. Completions stream and update on every keystroke, debounced 300 ms.
+
+## Design
+
+- **Monaco hook:** `monaco.languages.registerInlineCompletionsProvider` for every language Embedist supports. The provider returns a streaming async iterator.
+- **Backend route:** new `src-tauri/src/commands/ai.rs::chat_completion_stream(prefix, suffix, language)` that hits the configured provider's text-completion endpoint. For chat-only providers (Anthropic Messages API) wrap in a fixed system prompt "complete this code; emit only the continuation, no explanation."
+- **Context:** include 80 lines before the cursor + the active file's path + project board (when known) so completions are board-aware ("on ESP32 prefer `pinMode(LED_BUILTIN, OUTPUT)` not Arduino-classic constants").
+- **Throttling:** abort the previous in-flight request when a new keystroke arrives.
+- **Settings:** `Settings → AI → Inline completions [off / model picker]`. Default off — opt-in because of per-keystroke cost.
+- **Visual:** Monaco's built-in ghost-text rendering; no custom UI.
+
+## Phasing
+
+1. **v1:** Single-line completion, accept-on-tab only.
+2. **v2:** Multi-line block completion (Cursor's "tab tab tab" UX).
+3. **v3:** Inline rewrite ("cmd+k" partial selection rewrite — Cursor's killer feature).
+
+## Risks
+
+- Cost: at $0.15/Mtok input, one developer-day of typing can cost $5–$10 on Sonnet. Mitigate with the per-conversation cost surface added in v0.39.0, plus a hard daily budget in Settings.
+- Latency: streaming has to start < 250 ms for ghost text to feel right. Anthropic + OpenAI usually do; DeepSeek/Ollama may not.
+- Privacy: per-keystroke uploads of source code. Add a single Settings toggle to disable for sensitive workspaces and a per-folder allowlist.
+
+## Verification
+
+Type `digitalWrite(LED_BUILTIN, ` in a new sketch with an ESP32 platformio.ini. Ghost text appears: `HIGH);` within 400 ms. Tab accepts.
diff --git a/docs/superpowers/specs/2026-05-28-lsp-clangd-bridge.md b/docs/superpowers/specs/2026-05-28-lsp-clangd-bridge.md
new file mode 100644
index 0000000..fa55eab
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-lsp-clangd-bridge.md
@@ -0,0 +1,37 @@
+# Spec: clangd LSP Bridge
+
+**Status:** proposed
+**Owners:** TBD
+**Tier:** 2
+
+## Why
+
+Embedist's editor is Monaco with extension-based language modes but no semantic understanding. Competing IDEs (VS Code + PlatformIO, CLion) ship real-time diagnostics, go-to-definition, hover docs, and refactor-rename via `clangd` over the Language Server Protocol. Without it, AI-assisted writes can land syntactically-broken C++ that nobody flags until `pio run` fails.
+
+## Goal
+
+Stream `clangd` diagnostics (errors, warnings, includes-not-found) inline in Monaco for every `.cpp`/`.c`/`.h`/`.ino` tab in the active project. Stretch: hover docs and go-to-definition.
+
+## Design
+
+- **Process:** spawn `clangd` as a Tauri sidecar managed by `src-tauri/src/commands/lsp.rs` (new). Lifetime tied to `useFileStore.rootPath`.
+- **Compilation database:** generate `compile_commands.json` from `pio run -t compiledb` on project open or rebuild. Cache invalidated on `platformio.ini` change.
+- **Transport:** newline-delimited JSON over stdin/stdout, framed per LSP spec (Content-Length headers).
+- **Frontend:** new `src/hooks/useLsp.ts` owns the JSON-RPC dispatcher. `monaco.languages.registerHoverProvider` + `monaco.editor.setModelMarkers` for diagnostics. Reuse Monaco's markers gutter — no new UI surface.
+- **Boundary contract:** sidecar emits one event `lsp-diagnostic` per file per change; frontend debounces 300 ms before pushing to Monaco.
+
+## Out of scope (v1)
+
+- Refactor-rename
+- Code actions / quick fixes
+- Multi-root workspaces
+
+## Risks
+
+- `clangd` is heavy (≈200 MB RSS on warm projects). Toggle in Settings → Editor → "Enable semantic analysis (experimental)".
+- `compile_commands.json` generation requires a successful `pio run` first; for empty projects, fall back to a minimal compdb derived from `platformio.ini`.
+- Windows path quoting; clangd is sensitive to `\\` vs `/`.
+
+## Verification
+
+Open `examples/blink/src/main.cpp`, save it with a deliberate typo (`Serial.beginx(115200)`). Within 1 s, Monaco shows a red squiggle and the Problems panel lists the diagnostic with the same file:line as the build output. Hover the typo → tooltip with the clangd message.
diff --git a/docs/superpowers/specs/2026-05-28-sota-roadmap.md b/docs/superpowers/specs/2026-05-28-sota-roadmap.md
new file mode 100644
index 0000000..40b6320
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-sota-roadmap.md
@@ -0,0 +1,77 @@
+# Embedist SOTA Roadmap
+
+**Date:** 2026-05-28
+**Baseline:** v0.38.0 (production-grade hardening shipped).
+**Goal:** Beat VS Code + Cursor + Arduino IDE 2 + Wokwi for the embedded-development niche.
+
+## Method
+
+Six parallel domain audits — UI/UX, frontend code quality, Rust backend, AI integration, embedded-specific features, build/architecture — were dispatched against the current tree. This document consolidates ~80 findings into a single execution plan ranked by impact × effort.
+
+## Tier 1 — Quick wins (ship in this release)
+
+These are small-to-medium changes with outsized impact. Implementable inside one session.
+
+| # | Domain | Item | File anchor | Why |
+|---|--------|------|-------------|-----|
+| 1 | Bundle | Split Monaco + tree-shake highlight.js; lazy-load 4 panels | `vite.config.ts`, `App.tsx` | Cuts initial JS from ~1.77MB to ~600KB; faster cold start |
+| 2 | Security | Upgrade monaco-editor to patch 8 transitive DOMPurify XSS CVEs | `package.json` | XSS bypass in AI markdown render is real risk |
+| 3 | UI | `:focus-visible` ring system + light-theme contrast + 3-tier shadow scale + hover-depth | `src/styles/global.css` + sweep | WCAG AA + keyboard-nav baseline |
+| 4 | React | `key={i}` → `key={node.path}`, App.tsx useEffect deps, permission polling timeout | FileExplorer.tsx:367, App.tsx:328, agent-tools.ts:314 | Real bugs, not lints |
+| 5 | State | Cap `aiStore.messages` persistence to last 50; stop persisting `fileStore.fileContents` | `aiStore.ts:partialize`, `fileStore.ts:partialize` | localStorage was unbounded |
+| 6 | Release | `scripts/check-version-sync.mjs` — fail CI when 3 version sites drift | new file | Removes a known foot-gun called out in CLAUDE.md |
+| 7 | Rust | `MAX_FILE_SIZE` on `read_file`/`grep_search`; tighten path-traversal in `resolve_path_for_validation` | filesystem.rs | DoS + TOCTOU windows |
+| 8 | AI UX | Surface token usage + cumulative cost per conversation in MessageBubble | MessageBubble.tsx, aiStore.ts | User flying blind on $$ |
+| 9 | AI | Parallel tool dispatch for read-only tools in agent loop | useAgent.ts | Cursor/Claude Code already do this |
+| 10 | Embedded | Serial data plotter — auto-parse CSV/JSON shaped lines, real-time chart | SerialMonitor.tsx | Killer feature; only Wokwi has it |
+| 11 | UI | Split-pane editor (two columns) | App.tsx | Table-stakes for IDEs |
+
+## Tier 2 — Larger features (separate branches)
+
+Each gets its own design doc + branch:
+
+- **AI inline completions** (Copilot/Cursor-style ghost text) — Monaco `registerInlineCompletionsProvider` + provider-specific calls
+- **Diff preview before file writes** — Cursor's accept/reject pattern
+- **Embedding-based RAG** — Ollama embeddings or OpenAI `/v1/embeddings` instead of TF-IDF
+- **AI commit-message generator** — `git diff` → conversation → suggested message
+- **GDB / OpenOCD debugger** — break/step/watch/frame via Tauri sidecar
+- **OTA upload** — Arduino OTA + ESP-IDF OTA support
+- **SPIFFS / LittleFS filesystem explorer** — read+write ESP partition contents
+- **Project templates browser** — scaffold "ESP32 HTTP server", "Arduino Blink", etc.
+- **Library install/update UI** — `pio lib search/install/update`
+- **clangd LSP bridge** — real-time semantic diagnostics, go-to-def, hover
+- **Tauri updater plugin** — auto-update from a release manifest
+- **Code-signing** — eliminate Windows SmartScreen warning
+
+## Tier 3 — Polish and depth
+
+- Memory analyzer: parse `.elf` to show flash/RAM breakdown after build
+- Multiple terminal tabs + find/regex search in terminal output
+- Custom-environment selector for projects with multiple `[env:…]` in `platformio.ini`
+- Inline build diagnostics in Monaco gutter (parsed from `BuildPanel` problems list)
+- Git gutter + minimap
+- Command-palette fuzzy-match highlighting
+- Drag-to-reorder tabs
+- Tab preview on hover
+- Compile-commands.json export for external clangd
+
+## Tier 4 — Strategic
+
+- **Cross-platform port** (macOS + Linux): abstract `SerialPort`, `Pty`, package format
+- **React 19 upgrade** (low-risk, end of 2026)
+- **Telemetry + crash reporting** (opt-in)
+- **Wokwi-style live circuit simulation** (probably too ambitious for v1)
+
+## Out of scope (deliberately)
+
+- Plugin/extension API
+- Marketplace
+- Cloud sync of settings
+- Multi-window
+- Mobile companion app
+
+## Execution order
+
+This branch (`feat/v0.39.0-sota-push`) lands **all of Tier 1** and the first two Tier 2 items where they have natural synergy (token display already needs aiStore changes; the parallel-tool dispatch is a single-file change). Everything else gets a spec under `docs/superpowers/specs/` and a Tier-2/3/4 GitHub issue.
+
+The verification gate stays the same: `npm run build`, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --all-targets`. CI runs all four on every push.
diff --git a/docs/superpowers/specs/2026-05-28-tier-3-4-followups.md b/docs/superpowers/specs/2026-05-28-tier-3-4-followups.md
new file mode 100644
index 0000000..ed6224a
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-tier-3-4-followups.md
@@ -0,0 +1,68 @@
+# Tier 3 + 4 follow-ups
+
+Small / strategic items from the v0.39.0 SOTA audit that didn't make this cut. Each is one PR's worth of work. Grouped by domain so they can be batched.
+
+## Editor & UI
+
+- **Split-pane editor.** Two-column editor with independent active tabs; drag handle between them. App.tsx is the natural seam; reuse `activeTabId` per pane.
+- **Inline build diagnostics in Monaco gutter.** Parse `BuildPanel.problems` → `monaco.editor.setModelMarkers`. The errors panel jumps to the source; the gutter is just the inverse hop.
+- **Minimap toggle in Settings → Editor.** Already supported by Monaco; just expose the toggle.
+- **Git gutter.** `simple-git` or shelling to `git diff --unified=0` for the active file; render `+/-/~` indicators in a Monaco glyph margin.
+- **Tab preview on hover.** Show first ~30 lines of the file in a tooltip-style popover.
+- **Drag to reorder tabs.** Standard HTML5 DnD on TabBar.
+
+## Frontend code quality
+
+- **Virtualize the file tree.** Use `@tanstack/react-virtual`. Trigger when openTabs length > 200 or tree depth > 4.
+- **Virtualize the message list.** Same library; trigger when messages.length > 100.
+- **Web Worker for RAG indexing.** Move tokenization + TF-IDF out of the main thread.
+- **Split `FileExplorer.tsx` (>1000 LOC).** Extract `TreeRenderer`, `ContextMenuHandler`, `DragHandler` subcomponents.
+- **Debounce filesystem search.** 300 ms before `grep_search` fires.
+- **`noUncheckedIndexedAccess: true`** in `tsconfig.json` once the surface is clean enough to make this affordable.
+
+## Rust backend
+
+- **Replace `parking_lot::Mutex` with `tokio::sync::Mutex`** inside the `pty.rs` async paths so the lock doesn't span an `.await`.
+- **`tokio::sync::CancellationToken`** for `grep_search` and `start_watch`; pair with a `cancel_grep` command.
+- **Eliminate `.unwrap()`/`.expect()` in `ai.rs:764,769,805`** and `platformio.rs:273-274`.
+- **Real regex** in `grep_search` — current substring match is misnamed.
+- **Validate baud rate** against the known set (`{300, 1200, …, 921600}`); validate port name against `^COM\d+$` or `^/dev/(cu|tty).*$`.
+
+## AI / agent
+
+- **Diff preview before file writes.** Show old/new in a Monaco diff editor; require accept before committing the change.
+- **AI commit-message generator.** `git diff` → conversation → suggested message; bind to a new command palette entry.
+- **`web_search` regex hardening** in `ai.rs:764–805` (currently fragile).
+- **Sliding-window context truncation** when conversation tokens exceed 80% of the model's max-context.
+- **Provider error messages** — surface 429 retry-after, 500 with body, 401 with "check API key in Settings".
+- **Add OpenRouter, Mistral, xAI Grok, Groq Cloud providers.** All speak OpenAI-compatible APIs; mostly a config + URL change.
+
+## Embedded-specific
+
+- **Memory analyzer.** Parse `.elf` (`xtensa-esp-elf-size --format=berkeley`) → bar chart of flash vs RAM use.
+- **Multiple terminal tabs + find.** `TerminalPanel` becomes a tab strip; xterm has built-in search via `xterm-addon-search`.
+- **`pio clean` / `pio test` / custom-env selector** in BuildPanel toolbar.
+- **Board pin reference panel.** Static JSON per board under `src/lib/board-pinouts/` rendered when a board is detected.
+- **SPIFFS / LittleFS explorer.** Wrap `esptool.py read_flash` + `mkspiffs`/`mklittlefs`.
+- **OTA upload UI.** `pio run -t upload --upload-port ` with discovery via mDNS.
+- **Project templates browser.** Local templates first (Blink, HTTP server, MQTT publish, deep-sleep); later, fetch from a registry.
+- **Library install/update UI.** Wraps `pio lib search/install/update/remove`.
+- **Setup wizard: Python + USB drivers + first-project template** steps.
+
+## Architecture
+
+- **`tauri-plugin-updater`** + minisigned release manifests. See `2026-05-28-updater-and-signing.md`.
+- **EV code signing.** Same spec.
+- **Platform abstraction layer.** Trait-based `SerialPort` + `Pty` so macOS/Linux ports are additive, not invasive.
+- **React 19 upgrade.** Low-risk; do once the ecosystem catches up around Q3 2026.
+- **Opt-in telemetry + crash reporting.** Sentry, gated behind a Settings toggle that defaults off.
+- **Pre-commit hook** wiring `npm run check:versions`, `tsc --noEmit`, and `cargo clippy --no-deps` (faster than full clippy) — local fast path that mirrors CI.
+
+## Ambitious / strategic
+
+- **Cross-platform port (macOS + Linux).** Requires the platform abstraction layer first.
+- **clangd LSP bridge.** Own spec: `2026-05-28-lsp-clangd-bridge.md`.
+- **GDB / OpenOCD debugger.** Own spec: `2026-05-28-gdb-debugger.md`.
+- **Embedding-based RAG.** Own spec: `2026-05-28-embedding-rag.md`.
+- **AI inline completions.** Own spec: `2026-05-28-inline-completions.md`.
+- **Wokwi-style live circuit simulation.** Almost certainly its own product. Defer.
diff --git a/docs/superpowers/specs/2026-05-28-updater-and-signing.md b/docs/superpowers/specs/2026-05-28-updater-and-signing.md
new file mode 100644
index 0000000..16cb1c7
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-updater-and-signing.md
@@ -0,0 +1,60 @@
+# Spec: Auto-updater + Code Signing
+
+**Status:** proposed
+**Owners:** TBD
+**Tier:** 2
+
+## Why
+
+Two production-readiness gaps:
+1. **No auto-update.** Users on v0.34 don't know v0.38 exists. README points at the GitHub releases page; nobody clicks that monthly.
+2. **No code signing.** Every install triggers Windows SmartScreen. The README has a paragraph telling users to click "Run anyway" — that's a trust-bleeding workaround, not a fix.
+
+Both are blocked by the same release-pipeline work.
+
+## Goal
+
+- Auto-update: app checks `https://updates.embedist.dev/latest.json` on launch and offers a one-click install when a newer signed build is available.
+- Signing: NSIS installer and `embedist.exe` are signed with an EV code-signing certificate; SmartScreen warning disappears within ~30 days of accumulated installs.
+
+## Design
+
+### Updater
+
+- Add `tauri-plugin-updater = "2"` to `src-tauri/Cargo.toml`.
+- Configure `src-tauri/tauri.conf.json`:
+ ```jsonc
+ "plugins": {
+ "updater": {
+ "active": true,
+ "endpoints": ["https://updates.embedist.dev/{{target}}-{{current_version}}"],
+ "pubkey": ""
+ }
+ }
+ ```
+- Release CI (`.github/workflows/release.yml`, new): on a tag push, build the NSIS + portable artifacts, sign them, generate the update manifest, sign the manifest with minisign, upload to a CDN bucket.
+- Frontend: `src/hooks/useUpdater.ts` polls on launch, shows a non-blocking toast when an update is available.
+
+### Signing
+
+- Acquire an EV code-signing certificate (DigiCert / Sectigo / SSL.com — ~$300/yr or $700 one-time).
+- Store the signing PFX in GitHub Actions secrets; release CI uses `signtool` after `tauri build`.
+- Add `WiX` (or stick with NSIS) `signCommand` so the installer is signed too.
+
+## Phasing
+
+1. **v1:** Updater wired, signed manifests, but app itself is still unsigned — installer just downloads the new build.
+2. **v2:** App + installer both signed.
+3. **v3:** Delta updates (only download changed files).
+
+## Risks
+
+- EV cert is gated by a human approval step at the CA. Plan 2–4 weeks for issuance.
+- Apple notarization equivalent doesn't exist on Windows; SmartScreen reputation accrues over downloads.
+- The `pubkey` in `tauri.conf.json` is permanently embedded — losing the signing private key bricks updates.
+
+## Out of scope
+
+- Delta / bsdiff updates
+- macOS / Linux signing (those are separate platform projects)
+- Signed nightly channel — start with stable only
diff --git a/package.json b/package.json
index b348d81..9191657 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,14 @@
{
"name": "embedist",
- "version": "0.38.0",
+ "version": "0.39.0",
"description": "AI-native embedded development environment",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
- "tauri": "tauri"
+ "tauri": "tauri",
+ "check:versions": "node scripts/check-version-sync.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2.2.0",
diff --git a/scripts/check-version-sync.mjs b/scripts/check-version-sync.mjs
new file mode 100644
index 0000000..a6bd246
--- /dev/null
+++ b/scripts/check-version-sync.mjs
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+// Verifies that the app version is identical across the three sources of
+// truth: package.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json.
+// CLAUDE.md flags this as a known drift hazard; this script is wired into
+// `npm run check:versions` and the CI workflow.
+
+import { readFileSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+function readJson(rel) {
+ return JSON.parse(readFileSync(resolve(repoRoot, rel), 'utf8'));
+}
+
+function readCargoVersion(rel) {
+ const text = readFileSync(resolve(repoRoot, rel), 'utf8');
+ // First `version = "x.y.z"` line under [package]
+ const inPackage = text.split(/\r?\n/).reduce(
+ (acc, line) => {
+ if (acc.found) return acc;
+ const stripped = line.trim();
+ if (stripped.startsWith('[')) {
+ return { ...acc, section: stripped, found: false };
+ }
+ if (acc.section === '[package]') {
+ const m = stripped.match(/^version\s*=\s*"([^"]+)"/);
+ if (m) return { ...acc, version: m[1], found: true };
+ }
+ return acc;
+ },
+ { section: '', version: null, found: false }
+ );
+ if (!inPackage.version) {
+ throw new Error(`No [package] version in ${rel}`);
+ }
+ return inPackage.version;
+}
+
+const sources = [
+ { name: 'package.json', version: readJson('package.json').version },
+ { name: 'src-tauri/Cargo.toml', version: readCargoVersion('src-tauri/Cargo.toml') },
+ { name: 'src-tauri/tauri.conf.json', version: readJson('src-tauri/tauri.conf.json').version },
+];
+
+const unique = new Set(sources.map((s) => s.version));
+if (unique.size === 1) {
+ process.stdout.write(`OK — all three sources at v${[...unique][0]}\n`);
+ process.exit(0);
+}
+
+process.stderr.write('Version drift detected:\n');
+for (const { name, version } of sources) {
+ process.stderr.write(` ${name}: ${version}\n`);
+}
+process.stderr.write(
+ '\nBump all three to the same value before continuing.\n' +
+ 'See CLAUDE.md "Release version sync" for the canonical list.\n'
+);
+process.exit(1);
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index df57f1b..89cec17 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "embedist"
-version = "0.38.0"
+version = "0.39.0"
description = "AI-native embedded development environment"
authors = ["Embedist Team"]
edition = "2021"
diff --git a/src-tauri/src/commands/filesystem.rs b/src-tauri/src/commands/filesystem.rs
index 29dc1e3..2c3953a 100644
--- a/src-tauri/src/commands/filesystem.rs
+++ b/src-tauri/src/commands/filesystem.rs
@@ -8,6 +8,17 @@ use parking_lot::Mutex;
use std::sync::Arc;
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
+/// Maximum size of a file the IDE will read fully into memory. Anything
+/// larger and `read_file` returns a friendly error instead of OOMing the
+/// app. `grep_search` uses the same ceiling per file. 50 MB is generous
+/// for source code while still preventing a 1 GB binary from blowing up
+/// the process.
+const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
+
+/// Cap for total nodes returned by `get_directory_tree` so a huge repo
+/// (or a symlink cycle) cannot exhaust memory or freeze the renderer.
+const MAX_TREE_NODES: usize = 10_000;
+
fn normalize_lexical(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
@@ -126,6 +137,14 @@ pub struct DirectoryTree {
#[command]
pub fn read_file(path: String, root: String) -> Result {
let p = validate_path(&path, &root)?;
+ let meta = fs::metadata(&p).map_err(|e| format!("Failed to stat file: {}", e))?;
+ if meta.len() > MAX_FILE_SIZE {
+ return Err(format!(
+ "File too large: {:.1} MB exceeds {} MB limit",
+ meta.len() as f64 / (1024.0 * 1024.0),
+ MAX_FILE_SIZE / (1024 * 1024)
+ ));
+ }
fs::read_to_string(&p).map_err(|e| format!("Failed to read file: {}", e))
}
@@ -221,14 +240,20 @@ pub fn list_directory(path: String, root: String) -> Result, Stri
pub fn get_directory_tree(path: String, depth: Option, root: String) -> Result {
validate_path(&path, &root)?;
let max_depth = depth.unwrap_or(3);
-
- fn build_tree(path: &str, current_depth: u32, max_depth: u32) -> Result {
+
+ fn build_tree(
+ path: &str,
+ current_depth: u32,
+ max_depth: u32,
+ node_count: &mut usize,
+ ) -> Result {
let p = PathBuf::from(path);
let name = p.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
-
- if p.is_file() || current_depth >= max_depth {
+
+ *node_count += 1;
+ if *node_count >= MAX_TREE_NODES || p.is_file() || current_depth >= max_depth {
return Ok(DirectoryTree {
name,
path: path.to_string(),
@@ -236,17 +261,31 @@ pub fn get_directory_tree(path: String, depth: Option, root: String) -> Res
children: vec![],
});
}
-
+
+ // Skip directories whose canonical form differs from the entry path
+ // (symlinks) to prevent infinite-loop traversal through symlink cycles.
+ if let (Ok(canonical), Ok(real)) = (p.canonicalize(), fs::read_link(&p)) {
+ log::debug!("skipping symlink during tree walk: {} -> {:?}", path, canonical);
+ let _ = real;
+ return Ok(DirectoryTree {
+ name,
+ path: path.to_string(),
+ is_dir: false,
+ children: vec![],
+ });
+ }
+
let entries = fs::read_dir(path).map_err(|e| format!("Failed to read directory: {}", e))?;
let mut children: Vec = Vec::new();
-
+
for entry in entries.filter_map(|e| e.ok()) {
+ if *node_count >= MAX_TREE_NODES { break; }
let child_path = entry.path();
- if let Ok(child) = build_tree(&child_path.to_string_lossy(), current_depth + 1, max_depth) {
+ if let Ok(child) = build_tree(&child_path.to_string_lossy(), current_depth + 1, max_depth, node_count) {
children.push(child);
}
}
-
+
children.sort_by(|a, b| {
match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
@@ -254,7 +293,7 @@ pub fn get_directory_tree(path: String, depth: Option, root: String) -> Res
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
-
+
Ok(DirectoryTree {
name,
path: path.to_string(),
@@ -262,8 +301,9 @@ pub fn get_directory_tree(path: String, depth: Option, root: String) -> Res
children,
})
}
-
- build_tree(&path, 0, max_depth)
+
+ let mut node_count = 0_usize;
+ build_tree(&path, 0, max_depth, &mut node_count)
}
#[command]
@@ -388,7 +428,10 @@ pub fn grep_search(
if results.len() >= max { return; }
let entries = match fs::read_dir(dir) {
Ok(e) => e,
- Err(_) => return,
+ Err(err) => {
+ log::warn!("grep_search: read_dir {} failed: {}", dir, err);
+ return;
+ }
};
for entry in entries.filter_map(|e| e.ok()) {
if results.len() >= max { break; }
@@ -406,6 +449,11 @@ pub fn grep_search(
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if !matches_file(&name, file_pat) { continue; }
+ // Skip files that exceed MAX_FILE_SIZE so a stray binary
+ // doesn't OOM the search.
+ if let Ok(meta) = fs::metadata(&path) {
+ if meta.len() > MAX_FILE_SIZE { continue; }
+ }
if let Ok(content) = fs::read_to_string(&path) {
for (line_num, line) in content.lines().enumerate() {
if line.to_lowercase().contains(pat) {
@@ -782,6 +830,58 @@ mod tests {
assert_eq!(p, PathBuf::from("a/b"));
}
+ // ---- file size limit ----
+ #[test]
+ fn read_file_rejects_files_above_limit() {
+ let tmp = std::env::temp_dir().join(format!(
+ "embedist_size_{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&tmp).unwrap();
+ let root = tmp.canonicalize().unwrap();
+ let huge = root.join("huge.bin");
+
+ // Sparse 60 MB file — we go just past MAX_FILE_SIZE (50 MB).
+ let f = std::fs::File::create(&huge).unwrap();
+ f.set_len(60 * 1024 * 1024).unwrap();
+
+ let err = read_file(
+ huge.to_string_lossy().to_string(),
+ root.to_string_lossy().to_string(),
+ )
+ .unwrap_err();
+ assert!(err.contains("too large"), "got: {}", err);
+
+ std::fs::remove_dir_all(&root).ok();
+ }
+
+ #[test]
+ fn read_file_accepts_small_files() {
+ let tmp = std::env::temp_dir().join(format!(
+ "embedist_small_{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::create_dir_all(&tmp).unwrap();
+ let root = tmp.canonicalize().unwrap();
+ let small = root.join("ok.txt");
+ std::fs::write(&small, "hello").unwrap();
+
+ let content = read_file(
+ small.to_string_lossy().to_string(),
+ root.to_string_lossy().to_string(),
+ )
+ .unwrap();
+ assert_eq!(content, "hello");
+
+ std::fs::remove_dir_all(&root).ok();
+ }
+
// ---- validate_path: traversal containment ----
#[test]
fn validate_path_blocks_traversal_outside_root() {
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 13aff90..91c96e1 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Embedist",
- "version": "0.38.0",
+ "version": "0.39.0",
"identifier": "com.embedist.embedist",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/src/App.tsx b/src/App.tsx
index cb11c35..be65b85 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
+import { useEffect, useRef, useCallback, useMemo, useState, lazy, Suspense } from 'react';
import { useUIStore } from './stores/uiStore';
import { useSettingsStore } from './stores/settingsStore';
import { useFileStore } from './stores/fileStore';
@@ -12,17 +12,27 @@ import { Sidebar } from './components/Layout/Sidebar';
import { TabBar } from './components/Layout/TabBar';
import { StatusBar } from './components/Layout/StatusBar';
import { BottomPanel } from './components/Layout/BottomPanel';
-import { CodeEditor } from './components/Editor/CodeEditor';
-import { FileExplorer } from './components/FileExplorer/FileExplorer';
-import { AIChatPanel } from './components/AI/AIChatPanel';
-import { SerialMonitor } from './components/Serial/SerialMonitor';
-import { BuildPanel } from './components/Build/BuildPanel';
-import { SettingsModal } from './components/Settings/SettingsModal';
-import { SetupWizard } from './components/Settings/SetupWizard';
import { ErrorBoundary } from './components/Common/ErrorBoundary';
import { ToastContainer, setToastDispatcher, type ToastItem } from './components/Common/Toast';
import './styles/global.css';
+// Lazy-load the heavy panels so the initial bundle is just shell + design
+// system. Monaco (CodeEditor) and Highlight.js (AIChatPanel) are the
+// biggest dependencies; this defers ~4 MB until the user opens those views.
+const CodeEditor = lazy(() => import('./components/Editor/CodeEditor').then(m => ({ default: m.CodeEditor })));
+const FileExplorer = lazy(() => import('./components/FileExplorer/FileExplorer').then(m => ({ default: m.FileExplorer })));
+const AIChatPanel = lazy(() => import('./components/AI/AIChatPanel').then(m => ({ default: m.AIChatPanel })));
+const SerialMonitor = lazy(() => import('./components/Serial/SerialMonitor').then(m => ({ default: m.SerialMonitor })));
+const BuildPanel = lazy(() => import('./components/Build/BuildPanel').then(m => ({ default: m.BuildPanel })));
+const SettingsModal = lazy(() => import('./components/Settings/SettingsModal').then(m => ({ default: m.SettingsModal })));
+const SetupWizard = lazy(() => import('./components/Settings/SetupWizard').then(m => ({ default: m.SetupWizard })));
+
+const PanelFallback = () => (
+
+ Loading…
+
+);
+
const SIDEBAR_MIN = 350;
const SIDEBAR_MAX = 700;
@@ -356,15 +366,15 @@ void loop() {
const renderSidebarContent = () => {
switch (sidebarSection) {
case 'files':
- return ;
+ return }>;
case 'ai':
- return ;
+ return }>;
case 'serial':
- return ;
+ return }>;
case 'build':
- return ;
+ return }>;
default:
- return ;
+ return }>;
}
};
@@ -406,12 +416,14 @@ void loop() {
Editor failed to load
}>
-
+ }>
+
+
@@ -420,8 +432,10 @@ void loop() {
-
-
+
+
+
+
);
diff --git a/src/components/AI/AIChatPanel.tsx b/src/components/AI/AIChatPanel.tsx
index bb9be1c..751b104 100644
--- a/src/components/AI/AIChatPanel.tsx
+++ b/src/components/AI/AIChatPanel.tsx
@@ -18,6 +18,7 @@ import { FileReferencePicker } from './FileReferencePicker';
import { SYSTEM_PROMPTS } from '../../lib/ai-prompts';
import type { AIMode } from '../../lib/ai-prompts';
import type { FileNode } from '../../types';
+import { estimateCostUSD, formatTokens, formatUSD } from '../../lib/ai-pricing';
import './AIChatPanel.css';
const MODE_COLORS: Record = {
@@ -547,19 +548,41 @@ function AIChatPanelContent() {
{showInput && (