diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3a904b3..84ff8c9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [main]
+ branches: [main, v2-pipeline-agents]
pull_request:
jobs:
@@ -23,3 +23,39 @@ jobs:
- name: Typecheck
run: pnpm typecheck
+
+ - name: Test
+ run: pnpm test
+
+ # Windows parity is code-level (no hardware QA): prove the repo installs,
+ # typechecks, tests, and produces an unsigned Windows build on windows-latest.
+ windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: pnpm/action-setup@v6
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Typecheck
+ run: pnpm typecheck
+
+ - name: Test
+ run: pnpm test
+
+ - name: Pack web + agent
+ run: |
+ pnpm --filter @openlive/desktop pack:web
+ pnpm --filter @openlive/desktop pack:agent
+
+ # --publish never: this is a build-only smoke test. Without it,
+ # electron-builder's publish auto-detection demands a GH_TOKEN in
+ # pull_request runs and fails the job after a successful build.
+ - name: Build Windows installer (unsigned)
+ run: pnpm --filter @openlive/desktop exec electron-builder --win --publish never
diff --git a/.gitignore b/.gitignore
index 92d7639..eabac03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,6 @@ apps/desktop/dist/
# demo videos are hosted as GitHub assets, not committed (100MB limit + no inline playback)
*.mp4
+
+# vendored voice runtime assets (copied from node_modules by copy-voice-assets.mjs)
+apps/web/public/vad/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9e2970..2f3d6e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,111 @@ All notable changes to OpenLive are recorded here. The newest version is on top.
Releases before 0.1.9 predate this file — see the
[GitHub releases](https://github.com/katipally/openlive/releases) for those.
+## [0.2.0] - 2026-07-16
+
+### Fixed
+- **"The web service keeps crashing" on launch (#6).** A crashed or force-killed
+ run could leave the app's server processes alive (Windows especially), still
+ holding OpenLive's ports — every later launch then died in an EADDRINUSE
+ respawn loop that relaunching never fixed. The app now clears its own stale
+ server processes from those ports at startup (other apps' processes are left
+ alone), and the crash dialog says what to check if it ever still happens.
+- **The workspace you picked is now the workspace the agent gets.** A bind race
+ on new conversations could silently strand the session with no agent and no
+ folder while the top bar showed both (the server's boot-time bind restore could
+ supersede and reverse the client's bind). The server now yields to the client's
+ bind, always receives the folder explicitly (empty means clear, not "keep
+ stale"), and echoes back what it actually bound so the UI can't drift — with a
+ one-shot self-heal re-bind if they ever disagree.
+- **No more silent brain swap.** If a coding agent is selected but can't run
+ (no folder, failed start), OpenLive says exactly that instead of quietly
+ answering with the built-in assistant as if it were the agent.
+- **Speaking to answer a permission no longer cancels it.** Starting to talk
+ while an agent asked for permission counted as barge-in: the ask vanished
+ mid-answer and your words became a new turn. Speech during a pending ask is
+ now the answer ("yes"/"no", matched against the agent's real option ids).
+- **Model/mode pickers stop blinking out.** Resumed sessions kept their model
+ and mode lists (updates during session replay were dropped), the server
+ re-sends them on a same-bind reconnect, and the in-call top bar falls back to
+ the per-agent cache like the lobby always did.
+- **Pre-call verification.** The lobby now checks the project folder actually
+ exists on disk, the built-in brain's provider has an API key, and a microphone
+ is present — gaps surface as chips (with a jump to the right Settings tab)
+ before Start instead of failing mid-call. A missing folder also gets a clear
+ error instead of a baffling "spawn npx ENOENT".
+- **Cross-process data race.** Settings and conversations are written by both the
+ web and agent processes; every read-modify-write now runs under a file lock, so
+ a concurrent save can no longer silently drop the other side's update.
+- **Agent plans and usage now actually show.** The server has always emitted the
+ agent's working plan (ACP plan updates) and context/cost usage — the UI dropped
+ both. Plans render as a live checklist above the transcript; a context/cost chip
+ sits in the top bar.
+- **Permission asks no longer time out silently.** An unanswered agent permission
+ auto-denies after 2 minutes — the prompt now shows a visible countdown and the
+ voice speaks a reminder 30 seconds before the deadline.
+- **Hermes session history.** Discovery was querying columns that don't exist in
+ hermes' database; rewritten against the real hermes-agent 0.18.2 schema.
+- **History with huge session logs.** Reading titles from Codex rollout logs
+ (hundreds of MB) no longer loads whole files into memory.
+- **Workspace file confinement.** The built-in assistant's file tools now refuse
+ symlinks that point outside the workspace, not just `../` escapes.
+- Crash screen follows the OS theme and uses the brand accent.
+
+### Added
+- **Clone Voice — clone your own voice.** A dedicated Settings tab: pick a script (or
+ just talk), record 5–30 seconds with a live level meter, listen back before
+ saving, fix the auto-transcript, and your assistant speaks as you — zero-shot
+ cloning (ZipVoice, Apache-2.0, via sherpa-onnx) running locally in the agent
+ service at ~4x realtime on CPU. Optional ~208 MB download, removable anytime.
+ Profiles preview with any text you type, rename, export/import between
+ machines, play back their original recording, and delete; automatic Kokoro
+ fallback; consent required — clone only your own voice or one you have
+ permission for. The Pipeline TTS stage just picks among your cloned voices.
+- **Persona.** Settings → General gains "Your assistant's style": your own words
+ on how it should behave and speak, applied to the built-in assistant AND every
+ coding agent via its session preamble.
+- **Spoken progress narration (opt-in).** While a coding agent works in silence,
+ OpenLive voices its plan steps ("Step 2 of 4 — …"), throttled and barge-in aware.
+- **Notifications + menu bar.** A tray icon (Open / Mini mode / Quit) and OS
+ notifications when a turn finishes or an agent asks permission while you're in
+ another app — clicking brings OpenLive forward.
+- **Markdown transcript.** Agent replies render as real markdown — code blocks
+ with copy buttons, lists, tables — plus per-message copy and a one-tap export
+ of the whole conversation to a Markdown file.
+- **In-call keyboard shortcuts.** M mute, C camera, S screen share, T activity
+ panel, H history, Cmd/Ctrl-E end call — press `?` for the cheat sheet.
+- **Lobby readiness check.** Picking an agent that isn't installed or signed in
+ shows a one-tap jump to Settings → Agents instead of failing the call.
+- **A real player for voice previews.** Everything Clone Voice plays back — the
+ recorded take, a synthesized preview, the original recording — now goes through
+ a compact seekable player (play/pause, drag to seek, elapsed/total time) instead
+ of fire-and-forget playback. Only one plays at a time.
+- **Agent sign-in that can't strand you.** Sign-in and setup flows open in your
+ terminal; the row now polls while you finish there and flips to Ready by itself.
+ If the terminal can't open (macOS Automation permission), the panel explains the
+ fix and a Copy command button gives you the manual path. Hermes gets an honest
+ "Setup incomplete" state (its wizard was started but no provider picked), a
+ "Finish setup" button, and an Uninstall that removes `~/.hermes` after a warning.
+
+### Changed
+- **Light mode rebuilt.** A stepped warm-paper ladder (no pure white): cards,
+ panels, and popovers now separate cleanly instead of fusing into one white
+ field, and borders are actually visible.
+- **README and docs rewritten** around what OpenLive is: the open voice and
+ vision layer for AI agents — bring your own model, with coding agents over ACP
+ as the flagship integration. Includes an honest note that the pipeline is
+ cascaded, not full-duplex speech-to-speech.
+- **VAD assets are served from the app itself** (vendored at build time) instead
+ of a CDN — the voice loop no longer touches jsdelivr at runtime.
+- **Agents settings shows each CLI's version** and gains an **Update** button;
+ a failed npm install from a root-owned prefix now gets actionable guidance
+ instead of a raw error dump.
+- Slash-command metadata (never surfaced in the UI) removed from the wire protocol.
+- **Settings reorganized.** The Voices tab is now **Clone Voice**. Speaking speed
+ and "Narrate agent progress" moved from Pipeline → Text-to-speech to General
+ under a new **Voice & speech** group, next to voice input — everyday preferences
+ in General, engine choices in Pipeline. Same settings underneath; nothing resets.
+
## [0.1.9] - 2026-07-11
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 46bf294..6036edb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -30,16 +30,22 @@ pnpm desktop:build:win # build the Windows installer (run on Windows)
## Where things live
```
-apps/desktop Electron shell: local servers, permissions, window, auto-update
-apps/web Next.js UI + the on-device voice engine in src/lib/live
-services/agent the /live WebSocket and the model tools
+apps/desktop Electron shell: local servers, permissions, window, mini mode,
+ tray + notifications, auto-update
+apps/web Next.js UI + the on-device voice engine in src/lib/live + /api
+ routes (agent install/auth, history discovery, settings)
+services/agent the /live WebSocket, the ACP coding-agent driver (agents/*),
+ local voice cloning (voice/*), and the built-in model tools
packages/harness model adapters (Anthropic / OpenAI Responses / OpenAI Chat), model listing
-packages/shared the wire protocol and shared types
+packages/shared the agent registry (single source of agent identity), wire
+ protocol, shared types
packages/db JSON-file store for keys, settings, conversations
```
-The voice loop (VAD, STT, end-of-turn, TTS, barge-in) is in
-`apps/web/src/lib/live`. The model turn goes out from `services/agent`.
+The voice loop (VAD, STT, end-of-turn, TTS — Kokoro, Supertonic, or a cloned
+voice — and barge-in) is in `apps/web/src/lib/live`. The model turn goes out from
+`services/agent`, which either streams a provider reply or drives a coding agent
+(Claude Code, Codex, Cursor, OpenCode, Hermes) over ACP as a child process.
## Sending a change
diff --git a/README.md b/README.md
index 3651dad..3ec32fe 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,10 @@
### The open voice and vision layer for AI agents.
-Bring your own model. Get real-time speech and sight, running on your own machine.
-An open alternative to ElevenLabs, Gemini Live, and OpenAI Realtime.
+Your AI can think. OpenLive gives it ears, a mouth, and eyes.
+Bring your own model, or talk to the coding agents you already use, with the whole
+voice loop running on your own machine. An open alternative to ElevenLabs Agents,
+Gemini Live, and OpenAI Realtime.
[](https://github.com/katipally/openlive/releases/latest)
[](https://github.com/katipally/openlive/actions/workflows/ci.yml)
@@ -28,42 +30,78 @@ https://github.com/user-attachments/assets/6ebe0e47-44cb-4d4f-bc33-7f15651e6342
## What this is
-Wiring an AI agent to a real conversation is harder than it looks. You need voice
-activity detection, a way to know when the person actually stopped talking,
-streaming speech-to-text, a model turn, streaming text-to-speech, and barge-in so
-the user can interrupt. Then you want it to see, so add camera and screen frames on
-top. Most people give up and rent a closed platform that meters every minute and
-sends the audio to someone else's cloud.
-
-OpenLive is that plumbing, built and open. It connects any chat model to real-time
-voice and vision, and the whole voice loop runs on-device. The desktop app is the
-reference build: download it, paste a key for the model you want, and talk.
-
-You bring the brain — Anthropic, OpenAI, Google, xAI, DeepSeek, Groq, Ollama, and a
-dozen more. OpenLive gives it ears, a mouth, and eyes.
+Wiring an AI into a real conversation is harder than it looks: voice activity
+detection, knowing when someone actually stopped talking, streaming speech-to-text,
+the model turn, streaming text-to-speech, and barge-in so you can interrupt. Then
+camera and screen on top. Hosted platforms rent you that pipeline by the minute and
+run it on their cloud.
+
+OpenLive is that pipeline, open and local. The listening, the speaking, and the
+watching all run on-device (WebGPU). You bring the brain, and any brain works:
+
+- **A model you have a key for.** Anthropic, OpenAI, Google, xAI, DeepSeek, Groq,
+ Ollama (fully local), and a dozen more. No per-minute audio fees; you pay only
+ the model costs you'd pay anyway.
+- **The coding agent you already use.** Claude Code, Codex, Cursor, OpenCode, or
+ Hermes, driven locally over the
+ [Agent Client Protocol](https://agentclientprotocol.com) (JSON-RPC over stdio),
+ under your own login. Talk to your agent, watch it work, answer its permission
+ asks by voice.
+
+Whichever brain you pick, OpenLive is the same thing it has always been: the ears,
+mouth, and eyes around it. Nothing you say leaves the machine. The only thing that
+goes out is the final transcript (plus camera or screen frames if you turn them on),
+to whatever brain you picked.
+
+An honest note on architecture: OpenLive is a cascaded pipeline (speech to text to
+model to speech), not a full-duplex speech-to-speech model like GPT-Live. That's a
+real trade. A speech-native model can overlap talk and listen in ways a cascade
+can't, but the cascade is exactly what makes "any brain, all local, no audio fees"
+possible.
## Features
-- **On-device voice loop.** Voice activity detection (Silero), speech-to-text
- (Whisper), end-of-turn detection (Smart-Turn), and text-to-speech (Kokoro) all
- run in the app on WebGPU. Nothing you say leaves the machine.
-- **It can see.** Turn on your camera or share your screen and the model watches it
- live, like a video call. The `look` tool grabs a crisp hi-res frame when it needs
- to read a label or a line of code. Running a text-only model? Point OpenLive at a
- separate vision model and it does the seeing while your main model does the talking.
-- **Bring your own model.** Over a dozen providers out of the box — Anthropic,
- OpenAI, Google Gemini, xAI Grok, DeepSeek, Mistral, MiniMax, the fast-inference
- hosts (Groq, Cerebras, Together, Fireworks), OpenRouter, Perplexity, and Ollama
- (local or cloud). Models are fetched live from each provider with vision / reasoning
- / context / price surfaced in the picker, and reasoning effort is a dial from Auto
- to Max. It's a layer, not a walled app — fork it to wire up your own agent backend
- or a self-hosted endpoint.
-- **Agent tools.** Web search, fetch a URL, remember a fact across calls, and a live
- checklist — plus, in the desktop app, read/write your clipboard and open a URL.
+The core, the ears / mouth / eyes:
+
+- **On-device voice loop.** Silero VAD, Whisper STT, Smart-Turn end-of-turn, and
+ your pick of two TTS engines: Kokoro (28 voices, light) or Supertonic (10 voices,
+ 44.1 kHz). All of it runs in the app on WebGPU.
+- **Speak as yourself.** Settings → Clone Voice records 5 to 30 seconds of you
+ (with a seekable listen-back before anything is saved) and your assistant speaks
+ in your voice from then on. Zero-shot cloning (ZipVoice, Apache-2.0) running
+ locally, an optional ~208 MB install, deletable anytime. Profiles preview with any
+ text, rename, and export/import between machines. Clone only your own voice or
+ one you have clear permission to use; impersonation is on you, not the tool.
+- **It can see.** Camera or screen frames ride each turn, and the `look` tool grabs
+ a crisp hi-res frame on demand. A text-only model can borrow a separate vision
+ model's eyes.
- **Barge-in.** Interrupt any time and it stops mid-word, like a real conversation.
+- **Your assistant, your way.** Custom instructions in Settings → General apply to
+ every brain, built-in or agent. Speaking speed and spoken progress narration live
+ there too.
+
+The integrations that serve it:
+
+- **Voice-drive your coding agent.** Pick Claude Code / Codex / Cursor / OpenCode /
+ Hermes per conversation, pick its project folder, and talk. Model, mode
+ (ask / accept edits / bypass), and the agent's other options switch mid-call, all
+ reported by the agent itself over ACP.
+- **Sessions are the agent's own.** A call with Claude Code lands in
+ `~/.claude/projects/…` where `claude --resume` finds it, and the agent's existing
+ CLI sessions show up in OpenLive's History. Resume from either side.
+- **Permission relay.** When the agent wants to run a command or edit files,
+ OpenLive speaks the question; answer by voice ("yes" / "no") or tap.
+- **Narrated progress.** Optional: while the agent works in silence, OpenLive speaks
+ its plan steps out loud ("Step 2 of 4 — refactor the store").
+- **Live plans and costs.** The agent's working plan renders as a checklist while it
+ works, and a context/cost chip tracks the session.
+- **Manage agents in Settings.** Install, sign in, update, and uninstall each
+ agent's CLI from the app. Status updates itself while you finish a sign-in in the
+ terminal, and if the terminal can't open you get the exact command to run instead.
- **Floating mini mode.** Shrink to an always-on-top pill that keeps listening while
- you work; camera and screen previews stack right above it.
-- **Resume conversations.** Sessions are saved locally — pick one and carry on.
+ you work, with a menu-bar tray and notifications to close the loop.
+- **A transcript you can use.** Agent replies render as markdown with copy buttons
+ on code blocks, and the whole conversation exports to a Markdown file.
- **Private by design.** Audio never uploads. API keys are encrypted at rest
(AES-256-GCM) and only the last four digits are ever shown.
@@ -72,39 +110,45 @@ dozen more. OpenLive gives it ears, a mouth, and eyes.
| Home | In a live call |
|---|---|
|  |  |
-| **Pre-call setup** | **Settings — bring your own model** |
+| **Pre-call setup** | **Settings — agents** |
|  |  |
+| **Clone your voice** | **Mini mode** |
+|  |  |
## Why on-device voice matters
The listening and speaking never leave your computer. The only thing that goes out
-is the text turn to the model provider you picked — the same call you would make
-from any app. No audio uploads, no per-minute meter, no lock-in.
+is the text turn to the brain you picked, the same call you would make from any app.
+No audio uploads, no per-minute meter, no lock-in.
That also skips the separate speech-to-text, text-to-speech, and real-time-audio
fees hosted platforms charge on top. You still pay your normal model and vision API
-costs — nothing more.
+costs, nothing more. With a coding agent as the brain there's nothing extra to pay
+at all; it runs under the login you already have.
## How it works
```
-mic ─▶ VAD ─▶ streaming STT ─▶ end-of-turn ─▶ your model ─▶ streaming TTS ─▶ speaker
- (Whisper) (Smart-Turn) (BYO key) (Kokoro)
- ▲
- camera / screen frames ┘ (vision)
+mic ─▶ VAD ─▶ streaming STT ─▶ end-of-turn ─▶ your AI ──────────▶ streaming TTS ─▶ speaker
+ (Silero) (Whisper) (Smart-Turn) (BYO model, or a (Kokoro / Supertonic /
+ ▲ coding agent over your cloned voice)
+ camera / screen ────┘ ACP on local stdio)
+ frames (vision)
```
-Everything above the model runs locally in the renderer. The model turn goes out
-over a warm WebSocket to a small local agent, which streams the reply back so the
-app can start speaking sentence by sentence. Interrupt any time and it stops mid-word.
+Everything outside "your AI" runs locally in the renderer. The turn goes over a warm
+local WebSocket to a small agent server, which either streams a provider reply or
+drives your coding agent's ACP adapter as a child process. The app starts speaking
+sentence by sentence while the reply is still being written.
## Get started
**Just use it:** grab the installer from the
-[latest release](https://github.com/katipally/openlive/releases/latest), open the
-app, go to Settings, pick a provider, paste your API key, and start a call. Keys are
-encrypted on disk and the voice models download the first time you talk (about
-200 MB, cached after that).
+[latest release](https://github.com/katipally/openlive/releases/latest), open the app,
+paste a model key (or pick the coding agent you already use — install/sign in from
+Settings → Agents if needed), and start a call. The voice models download from
+Hugging Face the first time you talk — roughly 200 MB with Kokoro, more with
+Supertonic or a bigger Whisper — and are cached after that.
**Build it from source:**
@@ -114,34 +158,26 @@ pnpm desktop:dev # runs the web + agent servers and opens the app window
```
You can also run it in a browser during development with `pnpm dev`, then open
-`localhost:3000`.
+`localhost:3000`. Run the tests with `pnpm test`.
## Repo layout
```
-apps/desktop Electron shell: spawns the local servers, media perms, window, auto-update
-apps/web Next.js UI + the on-device voice engine (src/lib/live/*) + /api settings
-services/agent Hono + ws: the /live WebSocket, the delegate → worker tool loop
- (web search via Exa, fetch_url), remember, update_todos, look, clipboard
+apps/desktop Electron shell: spawns the local servers, media perms, window,
+ mini mode, tray + notifications
+apps/web Next.js UI + the on-device voice engine (src/lib/live/*) + /api routes
+ (agents install/auth, history discovery, settings)
+services/agent Hono + ws: the /live WebSocket, the ACP agent driver (acp-agent.ts,
+ supervisor.ts), voice cloning (voice/*), the built-in provider turn loop
+packages/shared the agent registry (single source of agent identity), wire protocol,
+ shared types
packages/harness provider-neutral model adapters, live model listing, cost/effort
-packages/shared wire protocol + shared types
packages/db JSON-file store: encrypted keys, settings, conversations
```
-For how the pieces fit together — the thin-server design, the voice loop, and the
+For how the pieces fit together — the ACP driver, the voice loop, resume, and the
delegate/worker tool flow — see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
-## Other ways to run it
-
-`main` is the desktop app, which has the lowest latency because the servers run
-locally with a warm socket. Two other branches trade some latency for reach:
-
-| Branch | What it is | Where it runs |
-|---|---|---|
-| `main` | Desktop app (Electron), WebSocket agent | your machine |
-| `docker-websocket` | The same WebSocket app as one Docker image | self-host |
-| `serverless-sse` | A serverless rewrite, one streaming turn per request | Vercel |
-
## Contributing
OpenLive is open to contributions. Start with [CONTRIBUTING.md](CONTRIBUTING.md) for
@@ -151,5 +187,3 @@ labeled in the tracker.
## License
[MIT](LICENSE). Use it, change it, ship it.
-
-
diff --git a/RELEASING.md b/RELEASING.md
index 86ab475..48a704f 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -5,7 +5,7 @@ Maintainer notes — not linked from the README on purpose.
CI typechecks every push and PR. A release is one tag, no manual version bump:
```bash
-git tag v0.1.8 && git push origin v0.1.8
+git tag v0.2.0 && git push origin v0.2.0
```
The tag drives the version. CI builds the macOS (universal, signed and notarized)
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index cb82f08..1feacc8 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -22,7 +22,7 @@ then runs electron-builder. Ports are the uncommon `47823` (agent) / `47824`
(web) to avoid collisions on users' machines.
```bash
-pnpm desktop:build:mac # → apps/desktop/release/OpenLive--arm64.dmg (+ x64)
+pnpm desktop:build:mac # → apps/desktop/release/OpenLive--mac.dmg (universal)
pnpm desktop:build:win # → NSIS installer (run this on Windows / CI)
```
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
index d07622a..b41ff7f 100644
--- a/apps/desktop/electron-builder.yml
+++ b/apps/desktop/electron-builder.yml
@@ -20,6 +20,9 @@ files:
- main.cjs
- preload.cjs
- splash.html
+ # Packed INTO the asar so the menu-bar tray (main.cjs loads build/icon.png via
+ # __dirname) has its image in installed builds — without this the tray was blank.
+ - build/icon.png
extraResources:
- from: dist/web
diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs
index def2b8b..453b0dc 100644
--- a/apps/desktop/main.cjs
+++ b/apps/desktop/main.cjs
@@ -2,12 +2,11 @@
// OpenLive desktop shell. Runs the web (Next) + agent (ws) servers locally and
// shows the UI in a native window. Everything is on localhost — the voice models
// run in the renderer (Chromium/WebGPU), the LLM call goes out from the agent.
-const { app, BrowserWindow, Menu, session, shell, dialog, desktopCapturer, ipcMain, screen, clipboard } = require("electron");
-const { spawn } = require("node:child_process");
+const { app, BrowserWindow, Menu, Notification, Tray, nativeImage, session, shell, dialog, desktopCapturer, ipcMain, screen, clipboard, globalShortcut } = require("electron");
+const { spawn, execSync } = require("node:child_process");
const path = require("node:path");
const fs = require("node:fs");
const http = require("node:http");
-const crypto = require("node:crypto");
// Crash early, loud, and visible instead of dying silently.
process.on("uncaughtException", (e) => { console.error("[main] uncaught:", e); });
@@ -78,7 +77,7 @@ function spawnServer(name, scriptRel, env) {
const r = (restarts[name] ||= { count: 0, first: Date.now() });
if (Date.now() - r.first > 60000) { r.count = 0; r.first = Date.now(); } // reset the window
if (++r.count > 5) {
- dialog.showErrorBox("OpenLive stopped", `The ${name} service keeps crashing. Please relaunch the app.`);
+ dialog.showErrorBox("OpenLive stopped", `The ${name} service keeps crashing. Relaunch the app; if it keeps happening, check that nothing else is using ports ${AGENT_PORT} and ${WEB_PORT}, and please attach any console output to a GitHub issue.`);
return;
}
setTimeout(() => { if (!app.isQuitting) spawnServer(name, scriptRel, env); }, 500);
@@ -87,24 +86,60 @@ function spawnServer(name, scriptRel, env) {
return child;
}
+// A crashed or force-killed run can leave the server children alive — Windows
+// especially, where children aren't tied to the parent's lifetime — still holding
+// 47823/47824. The next launch's servers then die with EADDRINUSE in a respawn
+// loop that ends at "The web service keeps crashing" (issue #6), and relaunching
+// never helps. We hold the single-instance lock, so any OTHER OpenLive process
+// listening on our ports is a zombie: kill it. Foreign processes are left alone.
+function freeStalePorts() {
+ const sh = (cmd) => { try { return execSync(cmd, { encoding: "utf8" }); } catch { return ""; } };
+ const mine = path.basename(process.execPath).toLowerCase(); // zombie children ran OUR binary (ELECTRON_RUN_AS_NODE)
+ for (const port of [AGENT_PORT, WEB_PORT]) {
+ if (process.platform === "win32") {
+ for (const line of sh("netstat -ano -p tcp").split("\n")) {
+ const c = line.trim().split(/\s+/); // proto | local | foreign | state | pid
+ if (c.length < 5 || c[3] !== "LISTENING" || !c[1].endsWith(`:${port}`)) continue;
+ const pid = c[4];
+ if (!pid || pid === "0" || Number(pid) === process.pid) continue;
+ if (sh(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`).toLowerCase().includes(mine)) {
+ console.error(`[main] killing stale ${mine} (pid ${pid}) holding port ${port}`);
+ sh(`taskkill /F /PID ${pid}`);
+ }
+ }
+ } else {
+ for (const pid of sh(`lsof -ti tcp:${port} -sTCP:LISTEN`).split("\n").filter(Boolean)) {
+ if (Number(pid) === process.pid) continue;
+ if (sh(`ps -p ${pid} -o comm=`).trim().toLowerCase().includes(mine)) {
+ console.error(`[main] killing stale ${mine} (pid ${pid}) holding port ${port}`);
+ sh(`kill -9 ${pid}`);
+ }
+ }
+ }
+ }
+}
+
function startServers() {
if (DEV) return; // dev servers come from `pnpm dev`
+ freeStalePorts();
const dataDir = path.join(app.getPath("userData"), "data");
- const secret = crypto.randomUUID();
- // Agent (internal): the renderer connects to it directly over ws on localhost.
+ // The agent binds loopback only (services/agent/src/server.ts defaults AGENT_HOST
+ // to 127.0.0.1), so it is never reachable off this machine. That closes the LAN
+ // exposure by itself; the renderer connects over localhost.
spawnServer("agent", "agent/agent.mjs", {
AGENT_PORT: String(AGENT_PORT),
+ AGENT_HOST: "127.0.0.1",
OPENLIVE_DATA_DIR: dataDir,
WEB_PUBLIC_URL: WEB_URL,
- // No OPENLIVE_AGENT_SECRET → the agent accepts the direct localhost socket.
});
// Web (Next standalone) serves the UI + the /api settings routes (JSON store).
+ // AGENT_PORT: the /api/voice proxy forwards to the agent on localhost.
spawnServer("web", "web/server.js", {
PORT: String(WEB_PORT),
HOSTNAME: WEB_HOST,
NODE_ENV: "production",
OPENLIVE_DATA_DIR: dataDir,
- OPENLIVE_AGENT_SECRET: secret,
+ AGENT_PORT: String(AGENT_PORT),
});
}
@@ -178,6 +213,9 @@ function createMainWindow() {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
+ // Mini mode HIDES this window while its renderer keeps running the whole voice
+ // pipeline — throttled timers would wreck turn-taking (hold timers, TTS drain).
+ backgroundThrottling: false,
// Hand the app version to the preload (app.* isn't reachable there). Released
// builds show the tag version (CI stamps it); unpackaged dev builds get a
// "-dev" suffix so it's obvious you're not on a release.
@@ -186,10 +224,18 @@ function createMainWindow() {
});
for (const ev of ["resize", "move", "close"]) mainWin.on(ev, saveWindowState);
- // Open external links (docs, etc.) in the real browser, not the app window.
+ // Open external http(s) links (docs, etc.) in the real browser; DENY every other
+ // popup (file:, data:, etc.) rather than letting it open an in-app window.
mainWin.webContents.setWindowOpenHandler(({ url }) => {
- if (url.startsWith("http")) { shell.openExternal(url); return { action: "deny" }; }
- return { action: "allow" };
+ if (/^https?:\/\//i.test(url)) shell.openExternal(url);
+ return { action: "deny" };
+ });
+ // Keep the main frame pinned to our own UI: an in-page navigation to anywhere
+ // other than the local app is blocked (http(s) is handed to the real browser).
+ mainWin.webContents.on("will-navigate", (e, url) => {
+ if (url.startsWith(WEB_URL)) return;
+ e.preventDefault();
+ if (/^https?:\/\//i.test(url)) shell.openExternal(url);
});
mainWin.loadURL(WEB_URL);
@@ -201,70 +247,152 @@ function createMainWindow() {
mainWin.on("closed", () => { mainWin = null; });
}
-// ── minimized (floating pill) mode ───────────────────────────────────────────
-// Mini mode shrinks the SINGLE main window to a small floating pill that still runs
-// the whole voice pipeline AND owns the camera/screen streams — so the renderer
-// shows the previews INLINE (stacked above the pill) with no separate windows. The
-// pill grows UPWARD as previews appear (its bottom edge stays put). Opaque,
-// always-on-top; macOS rounds the frameless window natively.
+// ── minimized (floating panel) mode ──────────────────────────────────────────
+// Mini mode HIDES the main window (its renderer keeps running the voice pipeline —
+// backgroundThrottling is off) and shows a separate thin PANEL window with the pill
+// UI. The panel is non-activating (clicking it never steals focus from the app
+// you're working in), floats above fullscreen apps, and lives on every Space.
+// State flows main-renderer → main process → panel; commands flow back the same
+// way (a MediaStream can't cross windows, so previews arrive as ~1 fps JPEGs).
const PILL_W = 430, PILL_H = 56;
-let savedBounds = null;
+let panelWin = null;
function miniDisplay() {
- return screen.getDisplayMatching(savedBounds || (mainWin ? mainWin.getBounds() : { x: 0, y: 0, width: 0, height: 0 }));
+ return screen.getDisplayMatching(mainWin ? mainWin.getBounds() : { x: 0, y: 0, width: 0, height: 0 });
}
function pillBottom(area) { return area.y + area.height - 72; } // clear the dock
-function wireMiniIpc() {
- ipcMain.on("openlive:mini", () => {
- if (!mainWin) return;
- const applyPill = () => {
- if (!mainWin) return;
- if (!savedBounds) savedBounds = mainWin.getBounds();
- const area = miniDisplay().workArea;
- mainWin.setResizable(false);
- mainWin.setMinimumSize(PILL_W, PILL_H);
- mainWin.setBounds({ width: PILL_W, height: PILL_H, x: area.x + Math.round((area.width - PILL_W) / 2), y: pillBottom(area) - PILL_H });
- mainWin.setAlwaysOnTop(true, "floating");
- mainWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
- };
- // A fullscreen (or simple-fullscreen) window ignores setBounds — leave it first,
- // then shrink to the pill once the OS transition completes.
- if (mainWin.isFullScreen() || mainWin.isSimpleFullScreen()) {
- mainWin.once("leave-full-screen", applyPill);
- mainWin.setFullScreen(false);
- } else {
- if (mainWin.isMaximized()) mainWin.unmaximize();
- applyPill();
- }
+function createPanelWindow() {
+ if (panelWin && !panelWin.isDestroyed()) { panelWin.show(); return; }
+ const area = miniDisplay().workArea;
+ panelWin = new BrowserWindow({
+ width: PILL_W, height: PILL_H,
+ x: area.x + Math.round((area.width - PILL_W) / 2), y: pillBottom(area) - PILL_H,
+ show: false, frame: false, resizable: false, skipTaskbar: true,
+ roundedCorners: true, backgroundColor: DARK_BG,
+ // macOS: a "panel"-type window is non-activating — clicks land on its buttons
+ // without pulling focus away from whatever app the user is working in.
+ ...(process.platform === "darwin" ? { type: "panel", focusable: false } : {}),
+ webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false },
});
- ipcMain.on("openlive:unmini", () => {
+ panelWin.setAlwaysOnTop(true, "floating", 1);
+ panelWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, skipTransformProcessType: true });
+ panelWin.loadURL(`${WEB_URL}/mini`);
+ panelWin.once("ready-to-show", () => { if (panelWin) panelWin.showInactive(); });
+ panelWin.on("closed", () => { panelWin = null; });
+}
+
+// The global mini-mode talk hotkey. Configurable from Settings → General; kept in
+// memory here (the renderer persists it and re-sends on each mini entry).
+// globalShortcut has no keyup, so it's always a press-to-TOGGLE.
+let miniHotkey = "Alt+Space";
+
+// Module-scope so BOTH enterMini() and the set-mini-hotkey IPC handler can arm it.
+// (It used to be declared inside wireMiniIpc(), so enterMini()'s call threw a
+// swallowed ReferenceError → the "Alt+Space from anywhere" hotkey never armed.)
+function registerMiniHotkey() {
+ try {
+ globalShortcut.unregisterAll();
+ return globalShortcut.register(miniHotkey, () => { if (mainWin) mainWin.webContents.send("openlive:ptt-toggle"); });
+ } catch { return false; }
+}
+
+/** Enter mini mode: spawn the always-on-top panel, hide the main window, arm the
+ * global talk hotkey. Shared by the minimize button (IPC) and the tray menu. */
+function enterMini() {
+ if (!mainWin) return;
+ const apply = () => {
if (!mainWin) return;
- mainWin.setAlwaysOnTop(false);
- mainWin.setVisibleOnAllWorkspaces(false);
- mainWin.setResizable(true);
- mainWin.setMinimumSize(940, 640);
- if (savedBounds) { mainWin.setBounds(savedBounds); savedBounds = null; }
+ createPanelWindow();
+ mainWin.hide();
+ };
+ // Leaving fullscreen first: hiding a fullscreen window strands an empty Space.
+ if (mainWin.isFullScreen() || mainWin.isSimpleFullScreen()) {
+ mainWin.once("leave-full-screen", apply);
+ mainWin.setFullScreen(false);
+ } else apply();
+ // Global push-to-talk while the panel is up: talk to the agent from any app.
+ registerMiniHotkey();
+}
+
+/** Leave mini mode / bring the app forward. Shared by IPC, the tray menu, and
+ * notification clicks. */
+function restoreMainWindow() {
+ globalShortcut.unregisterAll();
+ if (panelWin && !panelWin.isDestroyed()) panelWin.destroy();
+ panelWin = null;
+ if (mainWin) { mainWin.show(); mainWin.focus(); }
+}
+
+// ── menu-bar (tray) presence + notifications ─────────────────────────────────
+let tray = null;
+
+function createTray() {
+ try {
+ const img = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png")).resize({ height: 18 });
+ tray = new Tray(img);
+ tray.setToolTip("OpenLive");
+ tray.setContextMenu(Menu.buildFromTemplate([
+ { label: "Open OpenLive", click: restoreMainWindow },
+ { label: "Mini mode", click: enterMini },
+ { type: "separator" },
+ { label: "Quit OpenLive", role: "quit" },
+ ]));
+ } catch (e) { console.error("[main] tray:", e); } // no tray beats no app
+}
+
+function wireNotifyIpc() {
+ // Renderer asks for an OS notification ("agent finished", "permission needed").
+ // Only shown when the user ISN'T looking at the app — focused-and-visible means
+ // they already see it. Clicking brings OpenLive forward (also out of mini mode).
+ ipcMain.on("openlive:notify", (_e, p) => {
+ const title = String(p?.title ?? "").slice(0, 80);
+ if (!title || !Notification.isSupported()) return;
+ if (mainWin && mainWin.isVisible() && mainWin.isFocused()) return;
+ const n = new Notification({ title, body: String(p?.body ?? "").slice(0, 180), silent: true });
+ n.on("click", restoreMainWindow);
+ n.show();
+ });
+}
+
+function wireMiniIpc() {
+ // Change the hotkey (Settings → General). Re-registers live if the panel is up;
+ // an invalid/taken accelerator falls back to the previous one and reports it.
+ ipcMain.handle("openlive:set-mini-hotkey", (_e, acc) => {
+ const prev = miniHotkey;
+ miniHotkey = String(acc || "Alt+Space");
+ if (panelWin && !panelWin.isDestroyed()) {
+ if (!registerMiniHotkey()) { miniHotkey = prev; registerMiniHotkey(); return { ok: false, hotkey: prev }; }
+ }
+ return { ok: true, hotkey: miniHotkey };
});
- // The pill fits its content: the renderer measures the stacked previews + pill and
- // asks for a height. Grow UPWARD — keep the bottom edge fixed so the pill doesn't
- // walk up/down the screen as previews toggle.
+ ipcMain.on("openlive:mini", enterMini);
+ ipcMain.on("openlive:unmini", restoreMainWindow);
+ // The panel fits its content: its renderer measures the stacked previews + pill
+ // and asks for a height. Grow UPWARD — the bottom edge stays put.
ipcMain.on("openlive:mini-size", (_e, h) => {
- if (!mainWin || !mainWin.isAlwaysOnTop()) return;
+ if (!panelWin || panelWin.isDestroyed()) return;
const area = miniDisplay().workArea;
- // Fit the content snugly (down to ~a bare pill); don't force the full PILL_H so
- // there's no empty strip above the composer.
const height = Math.max(44, Math.min(area.height - 96, Math.round(h) || PILL_H));
- const b = mainWin.getBounds();
+ const b = panelWin.getBounds();
if (height === b.height) return;
const bottom = b.y + b.height;
const y = Math.max(area.y + 8, bottom - height);
- mainWin.setBounds({ x: b.x, y, width: PILL_W, height }, true); // animate the grow (mac)
+ panelWin.setBounds({ x: b.x, y, width: PILL_W, height }, true); // animate the grow (mac)
});
+ // State/command relay between the main renderer (voice pipeline) and the panel.
+ ipcMain.on("openlive:panel-state", (_e, s) => { if (panelWin && !panelWin.isDestroyed()) panelWin.webContents.send("openlive:panel-state", s); });
+ ipcMain.on("openlive:panel-cmd", (_e, c) => { if (mainWin) mainWin.webContents.send("openlive:panel-cmd", c); });
}
// ── custom window controls (frameless window → no native traffic lights) ─────
function wireWindowIpc() {
+ // Launch-at-login (Settings → General). Invoke with a boolean to set; with
+ // undefined to just read the current state.
+ ipcMain.handle("openlive:login-item", (_e, v) => {
+ if (typeof v === "boolean") app.setLoginItemSettings({ openAtLogin: v });
+ return app.getLoginItemSettings().openAtLogin;
+ });
ipcMain.on("openlive:win-close", () => { if (mainWin) mainWin.close(); });
ipcMain.on("openlive:win-min", () => { if (mainWin) mainWin.minimize(); });
ipcMain.on("openlive:win-zoom", () => {
@@ -279,6 +407,11 @@ function wireBridgeIpc() {
try {
if (op === "clipboard_read") { const t = clipboard.readText(); return t ? `The clipboard contains: ${t}` : "The clipboard is empty."; }
if (op === "clipboard_write") { clipboard.writeText(String(arg ?? "")); return "Copied it to the clipboard."; }
+ if (op === "pick_folder") {
+ const opts = { title: "Choose a project folder", properties: ["openDirectory", "createDirectory"] };
+ const r = await (mainWin ? dialog.showOpenDialog(mainWin, opts) : dialog.showOpenDialog(opts));
+ return r.canceled ? "" : (r.filePaths[0] ?? "");
+ }
if (op === "open_url") {
let u = String(arg ?? "").trim();
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
@@ -366,8 +499,10 @@ function checkForUpdatesNow() {
async function boot() {
buildMenu();
+ createTray();
wirePermissions();
wireMiniIpc();
+ wireNotifyIpc();
wireWindowIpc();
wireBridgeIpc();
createSplash();
@@ -387,3 +522,4 @@ app.whenReady().then(boot);
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); });
app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });
app.on("before-quit", () => { app.isQuitting = true; for (const c of children) { try { c.kill(); } catch { /* */ } } });
+app.on("will-quit", () => globalShortcut.unregisterAll());
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 7d33499..58c11f9 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,13 +1,13 @@
{
"name": "@openlive/desktop",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"description": "OpenLive — live voice + vision AI assistant (desktop)",
"author": "OpenLive",
"main": "main.cjs",
"scripts": {
"start": "electron .",
- "start:dev": "ELECTRON_DEV=1 electron .",
+ "start:dev": "cross-env ELECTRON_DEV=1 electron .",
"pack:web": "node scripts/pack-web.cjs",
"pack:agent": "node scripts/pack-agent.cjs",
"prebuild": "node scripts/pack-web.cjs && node scripts/pack-agent.cjs",
@@ -21,6 +21,7 @@
"devDependencies": {
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
- "esbuild": "^0.28.1"
+ "esbuild": "^0.28.1",
+ "cross-env": "^10.0.0"
}
}
diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs
index 8effd16..0453841 100644
--- a/apps/desktop/preload.cjs
+++ b/apps/desktop/preload.cjs
@@ -5,6 +5,7 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("openlive", {
+ platform: process.platform,
// Enter minimized mode: shrink to a small floating pill (always-on-top).
mini: () => ipcRenderer.send("openlive:mini"),
// Restore the normal window.
@@ -19,10 +20,26 @@ contextBridge.exposeInMainWorld("openlive", {
// OS bridge for agent tools. op: "clipboard_read" | "clipboard_write" | "open_url".
// Resolves to a short result string the agent speaks back.
bridge: (op, arg) => ipcRenderer.invoke("openlive:bridge", { op, arg }),
+ // OS notification — shown only when the app isn't focused (main decides).
+ notify: (title, body) => ipcRenderer.send("openlive:notify", { title, body }),
+ // Settings → General: launch-at-login (boolean sets, undefined reads) and the
+ // configurable global mini-mode talk hotkey.
+ loginItem: (v) => ipcRenderer.invoke("openlive:login-item", v),
+ setMiniHotkey: (acc) => ipcRenderer.invoke("openlive:set-mini-hotkey", acc),
// True when running inside the desktop app.
isDesktop: true,
// App version, passed from main via additionalArguments (set from the release tag).
version: (process.argv.find((a) => a.startsWith("--openlive-version=")) || "").split("=")[1] || "",
// The native menu (⌘,) asks the UI to open Settings.
onOpenSettings: (cb) => ipcRenderer.on("openlive:open-settings", () => cb()),
+ // Global push-to-talk toggle (mini mode's Alt+Space). Single listener: each call
+ // replaces the previous callback so remounts don't stack stale handlers.
+ onPttToggle: (cb) => { ipcRenderer.removeAllListeners("openlive:ptt-toggle"); ipcRenderer.on("openlive:ptt-toggle", () => cb()); },
+ // Mini-panel bridge. The main renderer (voice pipeline) publishes state; the panel
+ // window renders it and sends control commands back. Single listener each, same
+ // replace-on-subscribe rule as above.
+ panelState: (s) => ipcRenderer.send("openlive:panel-state", s),
+ onPanelState: (cb) => { ipcRenderer.removeAllListeners("openlive:panel-state"); ipcRenderer.on("openlive:panel-state", (_e, s) => cb(s)); },
+ panelCmd: (c) => ipcRenderer.send("openlive:panel-cmd", c),
+ onPanelCmd: (cb) => { ipcRenderer.removeAllListeners("openlive:panel-cmd"); ipcRenderer.on("openlive:panel-cmd", (_e, c) => cb(c)); },
});
diff --git a/apps/desktop/scripts/pack-agent.cjs b/apps/desktop/scripts/pack-agent.cjs
index 8e42fdc..50cae08 100644
--- a/apps/desktop/scripts/pack-agent.cjs
+++ b/apps/desktop/scripts/pack-agent.cjs
@@ -1,12 +1,61 @@
"use strict";
-// Bundle the agent service into a single CJS file the Electron app runs. No
-// native modules anymore (the DB is JSON files), so everything bundles clean.
+// Bundle the agent service into a single CJS file the Electron app runs. The
+// JSON-file DB keeps the bundle native-free; the ONE native piece — the
+// sherpa-onnx voice-cloning addon — is loaded lazily via createRequire at
+// runtime (never bundled), so it ships as a real node_modules dir next to
+// agent.mjs and the app still boots fine without it.
const esbuild = require("esbuild");
const path = require("node:path");
+const fs = require("node:fs");
+const os = require("node:os");
+const { execSync } = require("node:child_process");
const root = path.resolve(__dirname, "..", "..", "..");
const outdir = path.resolve(__dirname, "..", "dist", "agent");
+// The platform packages the TARGET OS needs. macOS ships BOTH archs — the DMG
+// is universal and the addon picks by os.arch() at runtime; Windows/Linux ship
+// their own. (Builds run on the target OS, matching the repo's release flow.)
+function sherpaTargets() {
+ if (process.platform === "darwin") return ["darwin-arm64", "darwin-x64"];
+ if (process.platform === "win32") return ["win-x64"];
+ return [`linux-${os.arch()}`];
+}
+
+// sherpa-onnx-node resolves its prebuilt binary from a SIBLING package
+// (../sherpa-onnx--/sherpa-onnx.node), so ship the JS package
+// plus each target platform package next to agent.mjs. pnpm only installs the
+// build machine's own arch — any missing target is fetched with `npm pack`
+// (same pinned version) so a universal mac build works from either machine.
+function copySherpa() {
+ const nmSrc = path.join(root, "services/agent/node_modules");
+ const nmOut = path.join(outdir, "node_modules");
+ if (!fs.existsSync(path.join(nmSrc, "sherpa-onnx-node"))) { console.warn("[pack-agent] sherpa-onnx-node not installed — packing without voice cloning"); return; }
+ const resolved = fs.realpathSync(path.join(nmSrc, "sherpa-onnx-node"));
+ const storeDir = path.dirname(resolved);
+ const version = JSON.parse(fs.readFileSync(path.join(resolved, "package.json"), "utf8")).version;
+ fs.cpSync(resolved, path.join(nmOut, "sherpa-onnx-node"), { recursive: true, dereference: true });
+
+ const shipped = [];
+ for (const t of sherpaTargets()) {
+ const name = `sherpa-onnx-${t}`;
+ const local = path.join(storeDir, name);
+ if (fs.existsSync(local)) {
+ fs.cpSync(local, path.join(nmOut, name), { recursive: true, dereference: true });
+ } else {
+ // Cross-arch fetch (e.g. darwin-x64 on an arm64 Mac): npm pack + extract.
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sherpa-pack-"));
+ execSync(`npm pack ${name}@${version} --pack-destination "${tmp}"`, { stdio: "pipe" });
+ const tgz = fs.readdirSync(tmp).find((f) => f.endsWith(".tgz"));
+ execSync(`tar -xzf "${path.join(tmp, tgz)}" -C "${tmp}"`, { stdio: "pipe" }); // bsdtar ships on macOS + Win10+
+ fs.cpSync(path.join(tmp, "package"), path.join(nmOut, name), { recursive: true });
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ shipped.push(name);
+ }
+ console.log(`[pack-agent] shipped sherpa-onnx-node@${version} + ${shipped.join(", ")}`);
+}
+
esbuild.build({
entryPoints: [path.join(root, "services/agent/src/server.ts")],
bundle: true,
@@ -15,10 +64,12 @@ esbuild.build({
format: "esm",
outfile: path.join(outdir, "agent.mjs"),
// ws pulls these optional native speedups; it works fine without them.
- external: ["bufferutil", "utf-8-validate"],
+ // sherpa-onnx-node is a native addon shipped as node_modules (see above).
+ external: ["bufferutil", "utf-8-validate", "sherpa-onnx-node"],
// Some bundled CJS deps reference these — shim them for the ESM output.
banner: { js: "import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" },
logLevel: "info",
}).then(() => {
+ copySherpa();
console.log("[pack-agent] wrote dist/agent/agent.mjs");
}).catch((e) => { console.error(e); process.exit(1); });
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index f4eb29f..12c16f5 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -13,9 +13,13 @@ import { join } from "node:path";
// ponytail: this widens the canvas-script exfiltration surface to the HF/jsdelivr
// hosts (not arbitrary). Acceptable pre-launch; to fully re-fence, proxy model
// downloads through a same-origin /api route with a host allowlist.
+// VAD assets are vendored same-origin (public/vad); jsdelivr stays allowed only
+// because transformers.js can fall back to it for its own ort wasm loader.
const MODEL_HOSTS = "https://huggingface.co https://*.huggingface.co https://*.hf.co https://cdn.jsdelivr.net";
-// Live voice connects to the agent over a WebSocket (direct, not via the Next
-// proxy). Allow its origin in connect-src, derived from env (dev: ws://localhost:8787).
+// Live voice connects to the agent over a WebSocket. Desktop/dev: directly to the
+// agent port (NEXT_PUBLIC_LIVE_WS_URL / localhost). Container: same-origin through
+// the server.mjs proxy, which injects the shared secret (browsers can't set that
+// header themselves). Both origins stay in connect-src.
const LIVE_WS = (() => {
const raw = process.env.NEXT_PUBLIC_LIVE_WS_URL || `ws://localhost:${process.env.AGENT_PORT || 8787}`;
try { return new URL(raw).origin; } catch { return "ws://localhost:8787"; }
diff --git a/apps/web/package.json b/apps/web/package.json
index ae226c5..d5de8b2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,15 +1,18 @@
{
"name": "@openlive/web",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
- "dev": "NEXT_PUBLIC_LIVE_WS_URL=ws://localhost:${AGENT_PORT:-8787} next dev -p ${WEB_PORT:-3000}",
+ "predev": "node scripts/copy-voice-assets.mjs",
+ "dev": "node scripts/dev.mjs",
+ "prebuild": "node scripts/copy-voice-assets.mjs",
"build": "next build",
"start": "node server.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@gsap/react": "^2.1.2",
"@huggingface/transformers": "^4.2.0",
"@openlive/db": "workspace:*",
"@openlive/harness": "workspace:*",
@@ -17,14 +20,16 @@
"@ricky0123/vad-web": "^0.0.29",
"@tanstack/react-query": "^5.101.2",
"clsx": "^2.1.1",
+ "gsap": "^3.15.0",
"kokoro-js": "^1.2.0",
"lucide-react": "^0.469.0",
- "motion": "^12.42.0",
"next": "^16.2.9",
"next-themes": "^0.4.6",
"onnxruntime-web": "^1.22.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0",
"ws": "^8.18.0",
"zustand": "^5.0.3"
diff --git a/apps/web/public/agents/claude.svg b/apps/web/public/agents/claude.svg
new file mode 100644
index 0000000..e29f328
--- /dev/null
+++ b/apps/web/public/agents/claude.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/agents/codex.svg b/apps/web/public/agents/codex.svg
new file mode 100644
index 0000000..78caf4f
--- /dev/null
+++ b/apps/web/public/agents/codex.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/agents/cursor.svg b/apps/web/public/agents/cursor.svg
new file mode 100644
index 0000000..a5b2ee3
--- /dev/null
+++ b/apps/web/public/agents/cursor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/public/agents/hermes.svg b/apps/web/public/agents/hermes.svg
new file mode 100644
index 0000000..f42c0da
--- /dev/null
+++ b/apps/web/public/agents/hermes.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/web/public/agents/opencode.svg b/apps/web/public/agents/opencode.svg
new file mode 100644
index 0000000..12ea009
--- /dev/null
+++ b/apps/web/public/agents/opencode.svg
@@ -0,0 +1 @@
+
diff --git a/apps/web/scripts/copy-voice-assets.mjs b/apps/web/scripts/copy-voice-assets.mjs
new file mode 100644
index 0000000..7300271
--- /dev/null
+++ b/apps/web/scripts/copy-voice-assets.mjs
@@ -0,0 +1,32 @@
+// Vendor the VAD runtime assets (Silero onnx + audio worklet + ort wasm loader)
+// from node_modules into public/vad/ so the voice loop starts without touching a
+// CDN. Runs before dev and build; public/vad is gitignored (binaries stay out of
+// the repo, versions track package.json).
+import { copyFileSync, mkdirSync, existsSync, realpathSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const out = join(here, "..", "public", "vad");
+mkdirSync(out, { recursive: true });
+
+// Resolved via the app's node_modules symlinks (pnpm); realpath for good measure.
+// require.resolve can't be used — onnxruntime-web doesn't export ./package.json.
+const nm = (pkg) => realpathSync(join(here, "..", "node_modules", pkg));
+const vadDist = join(nm("@ricky0123/vad-web"), "dist");
+const ortDist = join(nm("onnxruntime-web"), "dist");
+
+const files = [
+ [vadDist, "silero_vad_v5.onnx"],
+ [vadDist, "vad.worklet.bundle.min.js"],
+ // The VAD runs on the plain CPU wasm backend — only the threaded-simd pair is loaded.
+ [ortDist, "ort-wasm-simd-threaded.mjs"],
+ [ortDist, "ort-wasm-simd-threaded.wasm"],
+];
+
+for (const [dir, name] of files) {
+ const src = join(dir, name);
+ if (!existsSync(src)) { console.error(`copy-voice-assets: missing ${src}`); process.exit(1); }
+ copyFileSync(src, join(out, name));
+}
+console.log(`copy-voice-assets: vendored ${files.length} files → public/vad/`);
diff --git a/apps/web/scripts/dev.mjs b/apps/web/scripts/dev.mjs
new file mode 100644
index 0000000..6b69b11
--- /dev/null
+++ b/apps/web/scripts/dev.mjs
@@ -0,0 +1,13 @@
+#!/usr/bin/env node
+// Cross-platform `next dev` launcher. The old script used POSIX-only inline env +
+// ${VAR:-default} expansion, which breaks on Windows cmd/powershell.
+import { spawn } from "node:child_process";
+
+const agentPort = process.env.AGENT_PORT ?? "8787";
+const webPort = process.env.WEB_PORT ?? "3000";
+const child = spawn("next", ["dev", "-p", webPort], {
+ stdio: "inherit",
+ shell: process.platform === "win32", // .cmd shims need a shell on Windows
+ env: { ...process.env, NEXT_PUBLIC_LIVE_WS_URL: `ws://localhost:${agentPort}` },
+});
+child.on("exit", (code) => process.exit(code ?? 0));
diff --git a/apps/web/src/app/api/agents/action/route.ts b/apps/web/src/app/api/agents/action/route.ts
new file mode 100644
index 0000000..349209a
--- /dev/null
+++ b/apps/web/src/app/api/agents/action/route.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from "next/server";
+import { spawn } from "node:child_process";
+import { widenedPath } from "@openlive/shared/node";
+import { actionCommand, agentById, type Action } from "../agents";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Run install / uninstall / login / update for one agent and stream the process
+// output back as plain text so the panel can show it live. install/uninstall run
+// headless; login opens the agent's own browser sign-in (a Terminal on macOS) and
+// returns quickly. A failed global npm install from a root-owned prefix (EACCES)
+// gets actionable guidance appended instead of just a raw dump.
+const NPM_EACCES_HELP = `
+⚠ npm can't write to its global folder (permission denied).
+Fix it once, then retry:
+ mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global
+ (add ~/.npm-global/bin to your PATH)
+Or install Node via Homebrew or nvm, which use a user-writable prefix.
+`;
+
+// A terminal launch that failed never reached the user's shell — on macOS the
+// usual culprit is the Automation permission (osascript error -1743), which
+// fails with nothing visible on screen. Always give the manual path.
+const terminalHelp = (display?: string) => `
+⚠ Couldn't open your terminal automatically.
+${process.platform === "darwin"
+ ? "macOS may be blocking automation: System Settings → Privacy & Security → Automation → allow OpenLive to control Terminal."
+ : "Your system blocked launching a terminal window from OpenLive."}
+${display ? `Run this yourself in any terminal, then hit Re-check:\n ${display}\n` : ""}`;
+
+export async function POST(req: Request) {
+ const { id, action } = (await req.json().catch(() => ({}))) as { id?: string; action?: Action };
+ const agent = id ? agentById(id) : undefined;
+ const spec = agent && action ? actionCommand(agent, action) : null;
+ if (!agent || !action || !spec) return NextResponse.json({ error: "Unknown agent or action." }, { status: 400 });
+
+ // Windows: `npm` is a `.cmd` shim Node won't exec without a shell (ENOENT) — every
+ // Install/Update button died there. Real .exe launchers (powershell/cmd) don't need
+ // it. POSIX keeps shell:false.
+ const useShell = process.platform === "win32" && spec.cmd === "npm";
+ const child = spawn(spec.cmd, spec.args, { shell: useShell, env: { ...process.env, PATH: widenedPath() } });
+ const enc = new TextEncoder();
+ let sawEacces = false;
+ const stream = new ReadableStream({
+ start(controller) {
+ const push = (s: string) => { try { controller.enqueue(enc.encode(s)); } catch { /* closed */ } };
+ const watch = (s: string) => { if (/EACCES|EPERM|permission denied/i.test(s)) sawEacces = true; return s; };
+ push(`$ ${spec.cmd} ${spec.args.join(" ")}\n`);
+ child.stdout.on("data", (d: Buffer) => push(watch(d.toString())));
+ child.stderr.on("data", (d: Buffer) => push(watch(d.toString())));
+ child.on("error", (e) => {
+ push(`\n[error] ${e.message}\n`);
+ if (spec.terminal) push(terminalHelp(spec.display));
+ controller.close();
+ });
+ child.on("close", (code) => {
+ if (spec.cmd === "npm" && code !== 0 && sawEacces) push(NPM_EACCES_HELP);
+ if (spec.terminal && code !== 0) push(terminalHelp(spec.display));
+ push(
+ spec.terminal && code === 0
+ ? "\n✓ Continues in the terminal window that opened — finish there; the status updates by itself.\n"
+ : `\n[exit ${code ?? 0}]\n`,
+ );
+ controller.close();
+ });
+ },
+ cancel() { child.kill(); },
+ });
+ return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" } });
+}
diff --git a/apps/web/src/app/api/agents/agents.ts b/apps/web/src/app/api/agents/agents.ts
new file mode 100644
index 0000000..fb066fe
--- /dev/null
+++ b/apps/web/src/app/api/agents/agents.ts
@@ -0,0 +1,38 @@
+// Actions for the Agents panel, driven by the shared registry (the single source
+// of agent identity/install/auth facts). Each agent runs on THIS machine with the
+// user's own login; OpenLive just reports status and can install/remove the CLI
+// or open its sign-in/sign-out flow.
+
+import { AGENT_REGISTRY, isAgentId, type AgentDef } from "@openlive/shared";
+import { terminalCommand } from "@openlive/shared/node";
+
+export type Action = "install" | "uninstall" | "login" | "logout" | "update";
+
+export const agentById = (id: string): AgentDef | undefined => (isAgentId(id) ? AGENT_REGISTRY[id] : undefined);
+
+// The command to run for an action. npm/shell installs are non-interactive
+// (streamed inline); login/logout and terminal-flavored installs (hermes'
+// wizard) open the user's terminal (Terminal.app on macOS, cmd on Windows).
+// EACCES on global npm installs gets actionable guidance appended to the
+// stream (see action/route.ts). "update" reruns the install recipe — npm
+// pins @latest explicitly; the curl scripts always fetch the latest anyway.
+export function actionCommand(a: AgentDef, action: Action): { cmd: string; args: string[]; terminal?: boolean; display?: string } | null {
+ const isWin = process.platform === "win32";
+ // `display` = the human-runnable command behind a terminal launch, so a failed
+ // launch (macOS Automation denied) can tell the user what to run themselves.
+ if (action === "login") { const raw = (isWin && a.winLogin) || a.login; return { ...terminalCommand(raw), terminal: true, display: raw }; }
+ if (action === "logout") return a.logout ? { ...terminalCommand(a.logout), terminal: true, display: a.logout } : null;
+
+ const recipe = action === "uninstall" ? a.uninstall : a.install;
+ if (!recipe) return null;
+ const terminalRecipe = (isWin && recipe.winTerminal) || recipe.terminal;
+ if (action === "update" && terminalRecipe) return null; // interactive installs (hermes) manage their own version
+ // Interactive installs (hermes' setup wizard) run in the user's terminal.
+ if (terminalRecipe) return { ...terminalCommand(terminalRecipe), terminal: true, display: terminalRecipe };
+ if (recipe.npm) return { cmd: "npm", args: [action === "uninstall" ? "uninstall" : "install", "-g", action === "update" ? `${recipe.npm}@latest` : recipe.npm] };
+ const shell = process.platform === "win32" ? recipe.winShell : recipe.posixShell;
+ if (!shell) return null;
+ return process.platform === "win32"
+ ? { cmd: "powershell", args: ["-NoProfile", "-Command", shell] }
+ : { cmd: "bash", args: ["-lc", shell] };
+}
diff --git a/apps/web/src/app/api/agents/route.ts b/apps/web/src/app/api/agents/route.ts
new file mode 100644
index 0000000..6c062ec
--- /dev/null
+++ b/apps/web/src/app/api/agents/route.ts
@@ -0,0 +1,76 @@
+import { NextResponse } from "next/server";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { homedir } from "node:os";
+import { AGENT_LIST } from "@openlive/shared";
+import { widenedPath, evalCredProbe, readJsonHome, type CredState } from "@openlive/shared/node";
+import { getSetting } from "@openlive/db";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Status for the Agents panel: is each agent's CLI installed, is it signed in
+// (read-only credential probe — file/JSON/keychain presence, never the secret),
+// and is it hidden from selectors. Session dirs are the tools' own stores.
+async function present(bin: string): Promise {
+ const finder = process.platform === "win32" ? "where" : "which";
+ try { await promisify(execFile)(finder, [bin], { env: { ...process.env, PATH: widenedPath() }, timeout: 3000 }); return true; }
+ catch { return false; }
+}
+
+/** ` --version` → a short version string, or undefined. Best-effort: some
+ * CLIs print banners — keep just the first line, capped. */
+async function binVersion(bin: string): Promise {
+ try {
+ const { stdout } = await promisify(execFile)(bin, ["--version"], { env: { ...process.env, PATH: widenedPath() }, timeout: 4000 });
+ const line = stdout.trim().split("\n")[0]?.trim();
+ return line ? line.slice(0, 48) : undefined;
+ } catch { return undefined; }
+}
+
+/** A human detail for the signed-in state where the store exposes one (cursor
+ * keeps the account email in its CLI config). */
+function authDetail(id: string): string | undefined {
+ if (id !== "cursor") return undefined;
+ const info = readJsonHome("~/.cursor/cli-config.json")?.authInfo as { email?: string } | undefined;
+ return typeof info?.email === "string" ? info.email : undefined;
+}
+
+export async function GET() {
+ const home = homedir();
+ const rows = await Promise.all(AGENT_LIST.map(async (a) => {
+ // Installed = runner binary on PATH, AND (where the binary alone proves
+ // nothing — hermes via uvx) the agent's own footprint exists.
+ const presentBins = await Promise.all(a.bins.map(async (b) => ((await present(b)) ? b : null)));
+ const firstBin = presentBins.find(Boolean) ?? null;
+ const installed = !!firstBin && (!a.installedProbe || (await evalCredProbe(a.installedProbe)) === "ready");
+ // Only probe credentials when actually installed — a leftover config file
+ // from an old install shouldn't render as signed in.
+ const credState: CredState = installed ? await evalCredProbe(a.credProbe) : "unknown";
+ // CLI version, shown in the row. Skipped for hermes — its runner bin is uvx,
+ // whose --version reports uv, not hermes.
+ const version = installed && firstBin && a.id !== "hermes" ? await binVersion(firstBin) : undefined;
+ return {
+ id: a.id, label: a.label,
+ installed,
+ credState,
+ version,
+ authDetail: credState === "ready" ? authDetail(a.id) : undefined,
+ // Wizard-style agents (hermes): sign-in IS the setup flow, so the UI says
+ // "Setup incomplete"/"Finish setup" instead of "Sign in needed"/"Sign in".
+ wizard: !!a.wizard,
+ // The raw sign-in command, for the Copy button — the manual path when
+ // opening a terminal automatically is blocked (macOS Automation).
+ loginCommand: (process.platform === "win32" && a.winLogin) || a.login,
+ canInstall: !!a.install,
+ canUninstall: !!a.uninstall,
+ canLogout: !!a.logout,
+ // Update = rerun the headless install recipe with @latest; terminal-wizard
+ // installs (hermes, pinned) manage their own version.
+ canUpdate: installed && !!a.install && !a.install.terminal,
+ hidden: getSetting(`agentHidden:${a.id}`) === "1",
+ sessions: a.sessionsDir, home,
+ };
+ }));
+ return NextResponse.json(rows);
+}
diff --git a/apps/web/src/app/api/chats/[id]/route.ts b/apps/web/src/app/api/chats/[id]/route.ts
index 2447b21..a79c585 100644
--- a/apps/web/src/app/api/chats/[id]/route.ts
+++ b/apps/web/src/app/api/chats/[id]/route.ts
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { listMessages, deleteChat } from "@openlive/db";
+import { listMessages, deleteChat, renameChat } from "@openlive/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -10,8 +10,18 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
return NextResponse.json(listMessages(id));
}
+// Rename an OpenLive conversation (History → session → Rename).
+export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const { title } = (await req.json().catch(() => ({}))) as { title?: string };
+ const t = title?.trim();
+ if (!t) return NextResponse.json({ error: "Title required." }, { status: 400 });
+ await renameChat(id, t);
+ return NextResponse.json({ ok: true });
+}
+
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
- deleteChat(id);
+ await deleteChat(id);
return NextResponse.json({ ok: true });
}
diff --git a/apps/web/src/app/api/history/agentSessions.ts b/apps/web/src/app/api/history/agentSessions.ts
new file mode 100644
index 0000000..747220a
--- /dev/null
+++ b/apps/web/src/app/api/history/agentSessions.ts
@@ -0,0 +1,240 @@
+import { readFileSync, readdirSync, statSync, existsSync, rmSync, openSync, readSync, closeSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, join } from "node:path";
+import { AGENT_LIST, type AgentDef } from "@openlive/shared";
+
+// Discover each coding agent's OWN prior sessions from its on-disk storage (the
+// same ones its `/resume` would show), so History can surface them alongside
+// OpenLive's. Read-only + best-effort: a format change or unreadable file is
+// skipped, never fatal. ACP `session/list` is still an unratified RFD (no agents
+// implement it), so disk is the reliable path in July 2026.
+//
+// ponytail: parses agent-specific on-disk formats — brittle by nature. Capped at
+// RECENT files, first LINES only for the title. If an agent changes its layout,
+// that agent's sessions just stop appearing (the rest keep working).
+
+export interface ExternalSession { id: string; title: string; updatedAt: string; cwd: string }
+export interface ExternalAgentSessions { agentId: string; sessions: ExternalSession[] }
+
+const RECENT = 60; // most-recent sessions per agent (by file mtime)
+const TITLE_SCAN_LINES = 80; // lines to scan for a human title (past preambles)
+const clip = (s: string, n = 64) => { const t = s.replace(/\s+/g, " ").trim(); return t.length > n ? `${t.slice(0, n)}…` : t; };
+const iso = (ms: number) => new Date(ms).toISOString();
+
+// Skip the machine-generated preambles injected as the "first" user message — XML
+// context blocks, slash-command echoes, and OpenLive's own voice preamble/seed —
+// so the title reads as the user's actual first words.
+const isBoilerplate = (t: string) => !t || /^\s*(<[a-z-]+|\[(you're being used|context —|image #)|base directory for this skill|# files mentioned|caveat:)/i.test(t);
+
+/** Read the first N lines of a file without loading the whole thing (session logs
+ * can be hundreds of MB — see the Codex growth issue). One bounded read: the
+ * cwd/title always live in the first lines, so 1 MB is plenty; a line truncated
+ * at the boundary just fails the caller's JSON.parse and is skipped. */
+const HEAD_BYTES = 1024 * 1024;
+function headLines(path: string, max: number): string[] {
+ try {
+ const fd = openSync(path, "r");
+ try {
+ const buf = Buffer.alloc(HEAD_BYTES);
+ const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
+ return buf.toString("utf8", 0, Math.max(0, n)).split("\n").filter((l) => l.trim()).slice(0, max);
+ } finally { closeSync(fd); }
+ } catch { return []; }
+}
+
+const recentFiles = (paths: { path: string; mtimeMs: number }[]) =>
+ paths.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, RECENT);
+
+// ── Claude Code: ~/.claude/projects//.jsonl ──────────
+function claudeSessions(): ExternalSession[] {
+ const root = join(homedir(), ".claude", "projects");
+ if (!existsSync(root)) return [];
+ const files: { path: string; mtimeMs: number }[] = [];
+ for (const proj of safeReaddir(root)) {
+ const dir = join(root, proj);
+ for (const f of safeReaddir(dir)) {
+ if (!f.endsWith(".jsonl")) continue;
+ try { files.push({ path: join(dir, f), mtimeMs: statSync(join(dir, f)).mtimeMs }); } catch { /* skip */ }
+ }
+ }
+ return recentFiles(files).map(({ path, mtimeMs }) => {
+ const id = basename(path, ".jsonl");
+ let cwd = "", title = "";
+ for (const line of headLines(path, TITLE_SCAN_LINES)) {
+ let o: any; try { o = JSON.parse(line); } catch { continue; }
+ if (!cwd && typeof o.cwd === "string") cwd = o.cwd;
+ if (!title && o.type === "user" && o.message?.role === "user") {
+ const c = o.message.content;
+ const text = typeof c === "string" ? c : Array.isArray(c) ? c.find((b: any) => b?.type === "text")?.text ?? "" : "";
+ if (!isBoilerplate(text)) title = clip(text);
+ }
+ if (cwd && title) break;
+ }
+ return { id, cwd, title: title || "Claude Code session", updatedAt: iso(mtimeMs) };
+ }).filter((s) => s.cwd);
+}
+
+// ── Codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl ──────────────────────
+function codexSessions(): ExternalSession[] {
+ const root = join(homedir(), ".codex", "sessions");
+ if (!existsSync(root)) return [];
+ const files: { path: string; mtimeMs: number }[] = [];
+ const walk = (dir: string, depth: number) => {
+ for (const e of safeReaddir(dir)) {
+ const p = join(dir, e);
+ let st; try { st = statSync(p); } catch { continue; }
+ if (st.isDirectory() && depth < 4) walk(p, depth + 1);
+ else if (e.startsWith("rollout-") && e.endsWith(".jsonl")) files.push({ path: p, mtimeMs: st.mtimeMs });
+ }
+ };
+ walk(root, 0);
+ return recentFiles(files).map(({ path, mtimeMs }) => {
+ let id = "", cwd = "", title = "";
+ for (const line of headLines(path, TITLE_SCAN_LINES)) {
+ let o: any; try { o = JSON.parse(line); } catch { continue; }
+ const p = o.payload;
+ if (o.type === "session_meta" && p) { id = p.id ?? id; cwd = p.cwd ?? cwd; }
+ if (!title && p?.type === "message" && p.role === "user") {
+ const text = Array.isArray(p.content) ? p.content.find((b: any) => b?.type === "input_text")?.text ?? "" : "";
+ if (!isBoilerplate(text)) title = clip(text);
+ }
+ if (id && cwd && title) break;
+ }
+ return { id: id || basename(path), cwd, title: title || "Codex session", updatedAt: iso(mtimeMs) };
+ }).filter((s) => s.cwd);
+}
+
+// ── Cursor: ~/.cursor/acp-sessions//meta.json ({ cwd }) ───────────
+function cursorSessions(): ExternalSession[] {
+ const root = join(homedir(), ".cursor", "acp-sessions");
+ if (!existsSync(root)) return [];
+ const out: ExternalSession[] = [];
+ for (const id of safeReaddir(root)) {
+ const meta = join(root, id, "meta.json");
+ try {
+ const m = JSON.parse(readFileSync(meta, "utf8"));
+ if (typeof m.cwd !== "string" || !m.cwd) continue;
+ out.push({ id, cwd: m.cwd, title: "Cursor session", updatedAt: iso(statSync(meta).mtimeMs) });
+ } catch { /* skip */ }
+ }
+ return recentFiles(out.map((s) => ({ ...s, path: "", mtimeMs: new Date(s.updatedAt).getTime() })) as any).map((x: any) => ({ id: x.id, cwd: x.cwd, title: x.title, updatedAt: x.updatedAt }));
+}
+
+// ── OpenCode: sqlite at /opencode/opencode.db (session table) ───────────
+// = $XDG_DATA_HOME or ~/.local/share — the same path on Windows too
+// (verified against opencode's docs). Read-only via node:sqlite (Node ≥22.13 —
+// already this repo's floor). Sub-sessions (parent_id set) are agent-internal;
+// only top-level sessions are the user's.
+function opencodeSessions(): ExternalSession[] {
+ const dataDir = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
+ const db = join(dataDir, "opencode", "opencode.db");
+ if (!existsSync(db)) return [];
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite");
+ const conn = new DatabaseSync(db, { readOnly: true });
+ try {
+ const rows = conn
+ .prepare("SELECT id, directory, title, time_updated FROM session WHERE parent_id IS NULL ORDER BY time_updated DESC LIMIT ?")
+ .all(RECENT) as { id: string; directory: string; title: string; time_updated: number }[];
+ return rows
+ .filter((r) => r.directory)
+ .map((r) => ({ id: r.id, cwd: r.directory, title: clip(r.title || "OpenCode session"), updatedAt: iso(r.time_updated) }));
+ } finally { conn.close(); }
+ } catch { return []; } // locked db / schema change / old Node → just no sessions
+}
+
+// ── Hermes: sqlite at ~/.hermes/state.db (its canonical SessionDB) ────────────
+// Schema verified against hermes-agent 0.18.2 source (hermes_state.py): table
+// `sessions`, PK `id`, columns cwd/title/started_at/ended_at (REAL unix seconds),
+// parent_session_id for internal sub-sessions, archived flag. Reads stay fully
+// guarded — a future schema change = no sessions, not a crash.
+function hermesSessions(): ExternalSession[] {
+ const db = join(homedir(), ".hermes", "state.db");
+ if (!existsSync(db)) return [];
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite");
+ const conn = new DatabaseSync(db, { readOnly: true });
+ try {
+ const rows = conn
+ .prepare(`SELECT id, cwd, title, COALESCE(ended_at, started_at) AS ts FROM sessions
+ WHERE parent_session_id IS NULL AND archived = 0
+ ORDER BY ts DESC LIMIT ?`)
+ .all(RECENT) as { id: string; cwd: string | null; title: string | null; ts: number }[];
+ return rows.map((r) => ({
+ id: String(r.id),
+ cwd: r.cwd ?? "",
+ title: clip(r.title || "Hermes session"),
+ updatedAt: iso(r.ts * 1000), // REAL unix seconds → ms
+ })).filter((s) => s.cwd);
+ } finally { conn.close(); }
+ } catch { return []; }
+}
+
+function safeReaddir(dir: string): string[] {
+ try { return readdirSync(dir); } catch { return []; }
+}
+
+// Discovery dispatch, keyed on each agent's registry `sessionParser` — a future
+// agent added to the shared registry with one of these formats needs zero code here.
+const PARSERS: Record ExternalSession[]> = {
+ "claude-jsonl": claudeSessions,
+ "codex-rollout": codexSessions,
+ "cursor-meta": cursorSessions,
+ "opencode-sqlite": opencodeSessions,
+ "hermes-sqlite": hermesSessions,
+};
+
+/** External sessions per agent, discovered from disk. */
+export function readExternalAgentSessions(): ExternalAgentSessions[] {
+ return AGENT_LIST
+ .map((a) => ({ agentId: a.id, sessions: PARSERS[a.sessionParser]() }))
+ .filter((a) => a.sessions.length > 0);
+}
+
+// Permanently delete a coding agent's OWN on-disk session file/dir (History →
+// external session → Delete). Destructive and irreversible — it removes the session
+// from the agent itself, not just OpenLive. The id comes from our own history feed;
+// still, refuse anything with path separators as defense-in-depth.
+export function deleteExternalSession(agentId: string, id: string): boolean {
+ if (!id || id.includes("/") || id.includes("\\") || id.includes("..")) return false;
+ const rm = (p: string) => { try { rmSync(p, { recursive: true, force: true }); return true; } catch { return false; } };
+
+ if (agentId === "cursor") {
+ const dir = join(homedir(), ".cursor", "acp-sessions", id);
+ return existsSync(dir) && rm(dir);
+ }
+ if (agentId === "claude-code") {
+ const root = join(homedir(), ".claude", "projects");
+ for (const proj of safeReaddir(root)) {
+ const f = join(root, proj, `${id}.jsonl`);
+ if (existsSync(f)) return rm(f);
+ }
+ return false;
+ }
+ if (agentId === "codex") {
+ const root = join(homedir(), ".codex", "sessions");
+ let target: string | null = null;
+ const walk = (dir: string, depth: number) => {
+ for (const e of safeReaddir(dir)) {
+ if (target) return;
+ const p = join(dir, e);
+ let st; try { st = statSync(p); } catch { continue; }
+ if (st.isDirectory() && depth < 4) walk(p, depth + 1);
+ else if (e.startsWith("rollout-") && e.endsWith(".jsonl")) {
+ if (e.includes(id)) { target = p; return; } // id embedded in the filename
+ for (const line of headLines(p, 5)) { // else match session_meta.id
+ let o: any; try { o = JSON.parse(line); } catch { continue; }
+ if (o.type === "session_meta" && o.payload?.id === id) { target = p; return; }
+ }
+ }
+ }
+ };
+ walk(root, 0);
+ return target ? rm(target) : false;
+ }
+ // opencode/hermes: sessions live inside THEIR sqlite databases — never write into
+ // a third-party live db from here. History hides the delete affordance for these.
+ return false;
+}
diff --git a/apps/web/src/app/api/history/route.ts b/apps/web/src/app/api/history/route.ts
new file mode 100644
index 0000000..f42dd55
--- /dev/null
+++ b/apps/web/src/app/api/history/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { listChats, chatMessageCounts, getSetting } from "@openlive/db";
+import type { HistoryChat, HistoryWorkspace } from "@openlive/shared";
+import { readExternalAgentSessions } from "./agentSessions";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Agents toggled off in Settings disappear from History too (their sessions stay
+// on disk / in the DB — un-hiding restores everything).
+const isHidden = (id: string | null) => !!id && getSetting(`agentHidden:${id}`) === "1";
+
+// History grouped workspace → chats: all agents' chats for the same project live
+// together, newest first; each chat carries its agent for the row's brand mark.
+// Merges OpenLive's own sessions (from our DB) with each coding agent's OWN
+// external sessions read from disk (source:"external", resumable via ACP
+// loadSession). Only truly empty chats (never spoken in) are hidden — folderless
+// conversations show under a "No folder" workspace, always sorted last.
+export function GET() {
+ const byCwd = new Map();
+ const add = (cwd: string, c: HistoryChat) => {
+ const ws = byCwd.get(cwd) ?? { cwd, chats: [] };
+ ws.chats.push(c);
+ byCwd.set(cwd, ws);
+ };
+
+ // OpenLive's own sessions (folderless ones grouped under "" → "No folder").
+ const counts = chatMessageCounts();
+ for (const c of listChats()) {
+ if ((counts[c.id] ?? 0) === 0) continue; // hide empty (a lobby connect never spoken in)
+ if (isHidden(c.agentId ?? null)) continue;
+ // Carry the agent's own session id so this OpenLive chat dedups against its
+ // on-disk agent session (below) — and so the UI can "continue in the CLI".
+ add(c.cwd ?? "", { id: c.id, title: c.title || "Conversation", updatedAt: c.updatedAt ?? c.createdAt, agentId: c.agentId ?? null, source: "openlive", resumeSessionId: c.agentSessionId });
+ }
+
+ // Each agent's own external sessions (from disk). Hidden agents are skipped
+ // entirely (no discovery work either).
+ const seen = new Set([...byCwd.values()].flatMap((w) => w.chats.map((s) => s.resumeSessionId ?? s.id)));
+ for (const a of readExternalAgentSessions()) {
+ if (isHidden(a.agentId)) continue;
+ for (const s of a.sessions) {
+ if (seen.has(s.id)) continue; // already surfaced as an OpenLive resume of this session
+ add(s.cwd, { id: s.id, title: s.title, updatedAt: s.updatedAt, agentId: a.agentId, source: "external", resumeSessionId: s.id });
+ }
+ }
+
+ const recent = (ws: HistoryWorkspace) => ws.chats.reduce((m, s) => (s.updatedAt > m ? s.updatedAt : m), "");
+ const workspaces: HistoryWorkspace[] = [...byCwd.values()]
+ .map((ws) => ({ ...ws, chats: ws.chats.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : -1)) }))
+ // Most-recent workspace first; the folderless bucket always last.
+ .sort((x, y) => (x.cwd === "" ? 1 : y.cwd === "" ? -1 : recent(x) < recent(y) ? 1 : -1));
+
+ return NextResponse.json(workspaces);
+}
diff --git a/apps/web/src/app/api/history/session/route.ts b/apps/web/src/app/api/history/session/route.ts
new file mode 100644
index 0000000..c906e10
--- /dev/null
+++ b/apps/web/src/app/api/history/session/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from "next/server";
+import { deleteExternalSession } from "../agentSessions";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Permanently delete a coding agent's OWN on-disk session (History → external
+// session → Delete). Irreversible — removes it from Claude Code / Codex / Cursor,
+// not just OpenLive. The UI gates this behind an explicit confirm modal.
+export async function DELETE(req: Request) {
+ const { agentId, id } = (await req.json().catch(() => ({}))) as { agentId?: string; id?: string };
+ if (!agentId || !id) return NextResponse.json({ error: "agentId and id required." }, { status: 400 });
+ const ok = deleteExternalSession(agentId, id);
+ return NextResponse.json({ ok }, { status: ok ? 200 : 404 });
+}
diff --git a/apps/web/src/app/api/providers/[id]/route.ts b/apps/web/src/app/api/providers/[id]/route.ts
index a2e52bc..5d434ed 100644
--- a/apps/web/src/app/api/providers/[id]/route.ts
+++ b/apps/web/src/app/api/providers/[id]/route.ts
@@ -12,7 +12,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const body = await req.json().catch(() => ({}));
if (body.clear) {
- const provider = clearProviderKey(id);
+ const provider = await clearProviderKey(id);
if (!provider) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(provider);
}
@@ -20,7 +20,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const key = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
if (!key) return NextResponse.json({ error: "Paste an API key." }, { status: 400 });
- const provider = updateProvider(id, { apiKey: key });
+ const provider = await updateProvider(id, { apiKey: key });
if (!provider) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(provider);
}
diff --git a/apps/web/src/app/api/providers/route.ts b/apps/web/src/app/api/providers/route.ts
index 86a6b9e..69c303b 100644
--- a/apps/web/src/app/api/providers/route.ts
+++ b/apps/web/src/app/api/providers/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
-import { listProviders, createProvider, updateProvider } from "@openlive/db";
+import { listProviders, upsertProviderByKind } from "@openlive/db";
import { BUILTIN_PROVIDERS } from "@openlive/harness";
+import type { ProviderKind } from "@openlive/shared";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -12,14 +13,18 @@ export function GET() {
}
// Upsert a key for a provider by its registry id (kind). Creates the DB row on
-// first use; the first provider configured becomes the default.
+// first use (atomically — no dup rows under concurrent first-time POSTs); the
+// first provider configured becomes the default.
export async function POST(req: Request) {
- const { kind, apiKey } = (await req.json()) as { kind?: string; apiKey?: string };
+ let body: { kind?: string; apiKey?: string };
+ try {
+ body = (await req.json()) as { kind?: string; apiKey?: string };
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+ const { kind, apiKey } = body ?? {};
const info = BUILTIN_PROVIDERS.find((p) => p.id === kind);
if (!kind || !info) return NextResponse.json({ error: "Unknown provider." }, { status: 400 });
- const existing = listProviders().find((p) => p.kind === kind);
- const row = existing
- ? updateProvider(existing.id, { apiKey })
- : createProvider({ name: info.name, kind, apiKey, isDefault: listProviders().length === 0 });
+ const row = await upsertProviderByKind(kind as ProviderKind, info.name, apiKey);
return NextResponse.json(row);
}
diff --git a/apps/web/src/app/api/settings/route.ts b/apps/web/src/app/api/settings/route.ts
index 9aae189..602db4e 100644
--- a/apps/web/src/app/api/settings/route.ts
+++ b/apps/web/src/app/api/settings/route.ts
@@ -7,16 +7,56 @@ export const dynamic = "force-dynamic";
// Provider + model are chosen live in Settings; nothing hardcoded. Live effort
// defaults to "auto" (lowest the model supports → smoothest voice).
const DEFAULTS = { liveEffort: "auto" };
-const KEYS = ["liveModel", "liveProviderId", "liveEffort", "visionProviderId", "visionModel"];
+const KEYS = ["liveModel", "liveProviderId", "liveEffort", "visionProviderId", "visionModel", "agentCwd", "customInstructions", "narrateProgress"];
+// Per-agent config keys (acpCommand: ACP override, agentHidden:
+// visibility toggle) are also readable/writable.
+const PREFIXES = ["acpCommand:", "agentHidden:"];
+
+const isExposed = (k: string) => KEYS.includes(k) || PREFIXES.some((p) => k.startsWith(p));
+
+// The store holds secrets (exa_api_key) and the agent's private memory
+// (agent_notes) alongside UI settings. NEVER dump the whole blob to the browser —
+// return only the keys the settings UI legitimately reads.
+function exposedSettings() {
+ const all = getAllSettings();
+ const out: Record = {};
+ for (const [k, v] of Object.entries(all)) if (isExposed(k)) out[k] = v;
+ return out;
+}
+
+// An acpCommand override is spawned verbatim (services/agent/src/agents/acp-agent.ts).
+// Even though the settings route is loopback-only, an in-origin prompt-injected
+// HTML canvas could PUT here — so constrain the value to a plain argv with no shell
+// metacharacters, so it can never become `bash -c …` / `curl … | sh`. spawn() runs
+// without a shell, so a value that survives this can only launch a named program
+// with named args (npx/uvx/agent/opencode + package/version tokens).
+function isSafeAcpCommand(v: string): boolean {
+ if (v.length > 512) return false;
+ const tokens = v.trim().split(/\s+/);
+ if (!tokens.length || !tokens[0]) return false;
+ // Each token: program name, package spec, flag, version, or path — no shell
+ // metacharacters, quotes, whitespace-in-arg, or control chars.
+ return tokens.every((t) => /^[A-Za-z0-9@._:/+=\[\]~-]+$/.test(t));
+}
export function GET() {
- return NextResponse.json({ ...DEFAULTS, ...getAllSettings() });
+ return NextResponse.json({ ...DEFAULTS, ...exposedSettings() });
}
export async function PUT(req: Request) {
- const body = (await req.json()) as Record;
+ let body: Record;
+ try {
+ body = (await req.json()) as Record;
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+ if (!body || typeof body !== "object") return NextResponse.json({ error: "Expected a JSON object" }, { status: 400 });
for (const [k, v] of Object.entries(body)) {
- if (KEYS.includes(k) && typeof v === "string") setSetting(k, v);
+ if (typeof v !== "string" || !isExposed(k)) continue;
+ if (k.startsWith("acpCommand:") && v.trim() && !isSafeAcpCommand(v)) {
+ return NextResponse.json({ error: `Rejected unsafe command for ${k}` }, { status: 400 });
+ }
+ await setSetting(k, v);
}
- return NextResponse.json({ ...DEFAULTS, ...getAllSettings() });
+ return NextResponse.json({ ...DEFAULTS, ...exposedSettings() });
}
diff --git a/apps/web/src/app/api/voice/[...path]/route.ts b/apps/web/src/app/api/voice/[...path]/route.ts
new file mode 100644
index 0000000..dee8cd7
--- /dev/null
+++ b/apps/web/src/app/api/voice/[...path]/route.ts
@@ -0,0 +1,36 @@
+import type { NextRequest } from "next/server";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Same-origin proxy for the agent service's /voice REST surface. One path for
+// every deployment: dev and desktop hop over localhost, and the container gets
+// the shared secret injected server-side (browsers can't set that header).
+const AGENT = `http://localhost:${process.env.AGENT_PORT || 8787}`;
+const SECRET = process.env.OPENLIVE_AGENT_SECRET?.trim() || "";
+
+async function forward(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ const { path } = await params;
+ const url = `${AGENT}/voice/${path.join("/")}${req.nextUrl.search}`;
+ const res = await fetch(url, {
+ method: req.method,
+ headers: {
+ "content-type": req.headers.get("content-type") ?? "application/json",
+ ...(SECRET ? { "x-openlive-secret": SECRET } : {}),
+ },
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
+ // @ts-expect-error node fetch needs duplex for streamed request bodies
+ duplex: "half",
+ });
+ // Stream the body through (download progress + PCM depend on it).
+ return new Response(res.body, {
+ status: res.status,
+ headers: {
+ "content-type": res.headers.get("content-type") ?? "application/octet-stream",
+ ...(res.headers.get("x-sample-rate") ? { "x-sample-rate": res.headers.get("x-sample-rate")! } : {}),
+ "cache-control": "no-cache",
+ },
+ });
+}
+
+export { forward as GET, forward as POST, forward as DELETE, forward as PATCH };
diff --git a/apps/web/src/app/api/workspace/route.ts b/apps/web/src/app/api/workspace/route.ts
new file mode 100644
index 0000000..c1616db
--- /dev/null
+++ b/apps/web/src/app/api/workspace/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { statSync } from "node:fs";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+// Pre-call check: does the chosen project folder actually exist (and is it a
+// directory)? The lobby gates Start on this — a deleted/renamed/typo'd folder
+// used to surface only as a confusing mid-call failure.
+export async function GET(req: Request) {
+ const path = new URL(req.url).searchParams.get("path") ?? "";
+ if (!path) return NextResponse.json({ ok: false, reason: "empty" });
+ try {
+ const s = statSync(path);
+ return NextResponse.json(s.isDirectory() ? { ok: true } : { ok: false, reason: "not-a-folder" });
+ } catch {
+ return NextResponse.json({ ok: false, reason: "missing" });
+ }
+}
diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx
index dd11e34..41b0fba 100644
--- a/apps/web/src/app/global-error.tsx
+++ b/apps/web/src/app/global-error.tsx
@@ -2,22 +2,32 @@
// Catches render errors anywhere in the tree so a crash shows a recovery screen
// instead of a blank window. global-error replaces the root layout, so it ships
-// its own /.
+// its own / — and no stylesheet, so colors are inlined. They follow
+// the user's OS theme via a media query (globals.css isn't loaded here) and use
+// the brand accent, not an off-palette indigo.
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
return (
-
+
+
+
Something went wrong
-
+
OpenLive hit an unexpected error. You can reload without losing your saved settings or chats.
-
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 58f080e..c92305e 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -1,6 +1,6 @@
@import "tailwindcss";
-/* ─── Takt design tokens ───────────────────────────────────────────────────
+/* ─── OpenLive design tokens ───────────────────────────────────────────────
Token *names* are mapped to CSS custom properties via @theme inline, so the
Tailwind utilities (bg-card, text-accent, …) resolve to var() at runtime.
The actual values live in :root (warm-paper light) and .dark (near-black),
@@ -25,6 +25,11 @@
--color-danger: var(--destructive);
--color-success: var(--success);
--color-link-foreground: var(--link-foreground);
+ --color-surface-raised: var(--surface-raised);
+
+ --shadow-xs: var(--shadow-xs);
+ --shadow-card: var(--shadow-card);
+ --shadow-pop: var(--shadow-pop);
--text-chat: 13px;
--text-chat--line-height: 21px;
@@ -38,19 +43,22 @@
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
}
-/* Light — "warm paper": warm off-white page, white cards, near-black ink. */
+/* Light — "warm paper": a stepped oat-to-cream ladder, no pure white anywhere.
+ Hierarchy mirrors dark mode's 4 clean levels: page < surface-raised < card <
+ elevated/popover. Cards used to be #ffffff on a near-white page (Δ≈4/255)
+ with 9%-alpha borders — everything fused into one white field. */
:root {
color-scheme: light;
- --background: #f5f4f1;
- --surface: #ecebe6;
- --card: #ffffff;
- --elevated: #ffffff;
- --popover: #ffffff;
+ --background: #efede8;
+ --surface: #e7e4dd;
+ --card: #faf8f3;
+ --elevated: #fcfaf6;
+ --popover: #fcfaf6;
--foreground: #1a1a18;
--muted-foreground: #6a6a66;
--faint: rgba(0, 0, 0, 0.45);
- --border: rgba(0, 0, 0, 0.09);
- --border-heavy: rgba(0, 0, 0, 0.16);
+ --border: rgba(0, 0, 0, 0.14);
+ --border-heavy: rgba(0, 0, 0, 0.22);
--accent: #2f6fed;
--accent-foreground: #ffffff;
--accent-soft: rgba(47, 111, 237, 0.10);
@@ -62,7 +70,12 @@
--scrollbar-thumb: rgba(0, 0, 0, 0.16);
--scrollbar-thumb-hover: rgba(0, 0, 0, 0.26);
- --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px -12px rgba(0, 0, 0, 0.12);
+ /* Elevation scale — hierarchy comes from surface shifts + these soft shadows,
+ not boxes of borders (HIG-style de-boxing). xs ≈ a hairline substitute. */
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.07);
+ --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.05), 0 8px 24px -12px rgba(0, 0, 0, 0.14);
+ --shadow-pop: 0 4px 12px rgba(0, 0, 0, 0.09), 0 24px 48px -16px rgba(0, 0, 0, 0.24);
+ --surface-raised: #f4f2ec;
/* Shared motion vocabulary (used by CSS keyframes and JS presets alike). */
--ease-out-quart: cubic-bezier(0.22, 1, 0.36, 1);
@@ -93,7 +106,10 @@
--scrollbar-thumb: rgba(255, 255, 255, 0.14);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.24);
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px -16px rgba(0, 0, 0, 0.6);
+ --shadow-pop: 0 4px 12px rgba(0, 0, 0, 0.5), 0 24px 48px -16px rgba(0, 0, 0, 0.7);
+ --surface-raised: #17171a;
}
html, body {
@@ -129,18 +145,34 @@ body::before {
.desktop .app-drag :is(button, a, input, select, textarea, label, [role="button"], [data-interactive]) { -webkit-app-region: no-drag; }
/* ─── Scrollbars ──────────────────────────────────────────────────────────── */
-.takt-scroll {
+/* Markdown inside the transcript (agent replies). Tailwind preflight strips
+ list/heading/paragraph styling — restore a compact, chat-scaled subset. */
+.ol-md > :first-child { margin-top: 0; }
+.ol-md > :last-child { margin-bottom: 0; }
+.ol-md p { margin: 0.4em 0; white-space: pre-wrap; }
+.ol-md ul, .ol-md ol { margin: 0.4em 0; padding-left: 1.3em; }
+.ol-md ul { list-style: disc; }
+.ol-md ol { list-style: decimal; }
+.ol-md li { margin: 0.15em 0; }
+.ol-md h1, .ol-md h2, .ol-md h3, .ol-md h4 { font-weight: 600; margin: 0.7em 0 0.3em; font-size: 1.05em; }
+.ol-md blockquote { border-left: 2px solid var(--border-heavy); padding-left: 0.8em; color: var(--muted-foreground); margin: 0.4em 0; }
+.ol-md hr { border: 0; border-top: 1px solid var(--border); margin: 0.7em 0; }
+.ol-md table { border-collapse: collapse; margin: 0.5em 0; font-size: 12px; display: block; overflow-x: auto; }
+.ol-md th, .ol-md td { border: 1px solid var(--border); padding: 3px 8px; text-align: left; }
+.ol-md strong { font-weight: 600; }
+
+.openlive-scroll {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
-.takt-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
-.takt-scroll::-webkit-scrollbar-thumb {
+.openlive-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
+.openlive-scroll::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
-.takt-scroll::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); background-clip: content-box; }
+.openlive-scroll::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); background-clip: content-box; }
/* ─── Interaction animations ───────────────────────────────────────────────── */
@keyframes streaming-fade {
@@ -161,6 +193,14 @@ body::before {
}
.animate-pulse-dot { animation: pulse-dot 1.6s ease-in-out infinite; }
+/* Floating panel entrance — slides in from the right edge with a soft settle */
+@keyframes panel-in {
+ from { opacity: 0; transform: translateX(14px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+.ol-panel-in { animation: panel-in 320ms var(--ease-out-quart); }
+@media (prefers-reduced-motion: reduce) { .ol-panel-in { animation: none; } }
+
/* Live stage entrance — a gentle fade + settle when a call becomes active */
@keyframes live-in {
from { opacity: 0; transform: scale(0.985) translateY(8px); }
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 1d3382c..28b4f13 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "./providers";
import { WindowControls } from "@/components/WindowControls";
+import { Toasts } from "@/components/Toasts";
import "./globals.css";
const geistSans = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
@@ -9,7 +10,7 @@ const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono"
export const metadata: Metadata = {
title: "OpenLive",
- description: "A live voice + vision AI assistant — talk to it, show it your camera, and it talks back in real time.",
+ description: "Ears, eyes, and a voice for your AI. Bring your own model or talk to your coding agents — the whole voice loop runs on your device.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -18,6 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+
);
diff --git a/apps/web/src/app/mini/page.tsx b/apps/web/src/app/mini/page.tsx
new file mode 100644
index 0000000..442fc01
--- /dev/null
+++ b/apps/web/src/app/mini/page.tsx
@@ -0,0 +1,7 @@
+import { PanelMiniBar } from "@/components/live/PanelMiniBar";
+
+// The desktop mini panel window loads this route in its own BrowserWindow.
+// Pure display surface: all state arrives over IPC from the main window.
+export default function MiniPage() {
+ return ;
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index bf921de..bedc096 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,70 +1,27 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useEffect } from "react";
import { Settings2, MessageSquare, Plus } from "lucide-react";
-import { api } from "@/lib/api";
import { useUi } from "@/lib/uiStore";
import { LiveDock } from "@/components/live/LiveDock";
-import { SettingsModal } from "@/components/settings/SettingsModal";
+import { SettingsPage } from "@/components/settings/SettingsPage";
+import { HistorySidebar } from "@/components/HistorySidebar";
+import { SpotlightTour } from "@/components/SpotlightTour";
+import { AgentSelect } from "@/components/live/AgentControls";
import { OpenLiveMark } from "@/components/OpenLiveMark";
import { useAppVersion } from "@/lib/useAppVersion";
+import { setConversationBind } from "@/lib/live/useLiveSession";
+import { useLiveStore } from "@/lib/live/liveStore";
import { loadModels, modelsCached, modelsReady } from "@/lib/live/models";
-function relTime(iso: string): string {
- const t = new Date(iso).getTime();
- if (!t) return "";
- const s = Math.floor((Date.now() - t) / 1000);
- if (s < 60) return "just now";
- if (s < 3600) return `${Math.floor(s / 60)}m ago`;
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
- return `${Math.floor(s / 86400)}d ago`;
-}
-
-// Resume dropdown: shows saved conversations; picking one resumes it (its context
-// rehydrates) and drops into the lobby (where camera/mic/model options live).
-function ResumeMenu({ onPick }: { onPick: (id: string) => void }) {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
- const { data: chats = [] } = useQuery({ queryKey: ["chats"], queryFn: api.chats, enabled: open });
- useEffect(() => {
- if (!open) return;
- const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
- document.addEventListener("mousedown", onDoc);
- return () => document.removeEventListener("mousedown", onDoc);
- }, [open]);
- return (
-
+ );
+}
diff --git a/apps/web/src/components/WindowControls.tsx b/apps/web/src/components/WindowControls.tsx
index 0dcf274..dad9e83 100644
--- a/apps/web/src/components/WindowControls.tsx
+++ b/apps/web/src/components/WindowControls.tsx
@@ -3,11 +3,13 @@
import { useEffect, useState } from "react";
import { useUi } from "@/lib/uiStore";
-// The window is frameless (see main.cjs), so we draw our own macOS-style controls
-// top-left on every screen. Hidden on the web build and while minimized (the mini
-// overlay is transparent + click-through). Also tags with `.desktop` so the
-// global drag/no-drag CSS applies only in the desktop app.
-type Bridge = { isDesktop?: boolean; winClose?: () => void; winMin?: () => void; winZoom?: () => void };
+// The window is frameless (see main.cjs), so we draw our own window controls:
+// macOS gets traffic-light dots top-LEFT; Windows/Linux get the native idiom —
+// minimize/maximize/close top-RIGHT as flat hover targets. Hidden on the web
+// build and while minimized (the mini overlay is transparent + click-through).
+// Also tags with `.desktop` (and `.desktop-win` off-mac) so layout can
+// clear the right chrome on the right platform.
+type Bridge = { isDesktop?: boolean; platform?: string; winClose?: () => void; winMin?: () => void; winZoom?: () => void };
const ol = (): Bridge | undefined => (typeof window !== "undefined" ? (window as unknown as { openlive?: Bridge }).openlive : undefined);
export function WindowControls() {
@@ -15,10 +17,34 @@ export function WindowControls() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
- if (ol()?.isDesktop) document.documentElement.classList.add("desktop");
+ if (ol()?.isDesktop) {
+ document.documentElement.classList.add("desktop");
+ if (ol()?.platform && ol()!.platform !== "darwin") document.documentElement.classList.add("desktop-win");
+ }
}, []);
- if (!mounted || !ol()?.isDesktop || minimized) return null;
+ // The mini panel window (/mini) is a chromeless pill — no window controls there.
+ const isPanel = mounted && window.location.pathname.startsWith("/mini");
+ if (!mounted || !ol()?.isDesktop || minimized || isPanel) return null;
+
+ if (ol()?.platform && ol()!.platform !== "darwin") {
+ // Windows/Linux: flat right-aligned controls in the platform's order.
+ const btn = "grid h-9 w-11 place-items-center text-muted-foreground transition hover:bg-foreground/10 hover:text-foreground [-webkit-app-region:no-drag]";
+ return (
+
+ );
+}
+
+/** Top-bar controls for the bound agent: project folder (with recents + Browse),
+ * and model / mode once the agent connects. Sits beside the agent selector so you
+ * see and change what you're working on at the top of the screen, mid-conversation. */
+export function AgentBar() {
+ const activeChatId = useUi((s) => s.activeChatId);
+ const boundAgent = useLiveStore((s) => s.boundAgent);
+ const boundCwd = useLiveStore((s) => s.boundCwd);
+ const liveMeta = useLiveStore((s) => s.agentMeta);
+ if (!boundAgent || !activeChatId) return null;
+ // Live meta when the agent has reported in; otherwise the per-agent cache (same
+ // fallback the lobby uses) — so the model/mode chips don't blink out whenever a
+ // reconnect/rebind clears the store before the agent re-reports.
+ const agentMeta = liveMeta ?? cachedAgentMeta(boundAgent);
+
+ const folderItems: Item[] = recentFolders().map((f) => ({ id: f, label: basename(f), sub: f }));
+ const b = bridge;
+ const browse = async () => { if (!b) return; const p = await b("pick_folder"); if (p) setConversationFolder(activeChatId, p); };
+
+ const model = agentMeta?.models.find((m) => m.id === agentMeta.currentModelId);
+ const mode = agentMeta?.modes.find((m) => m.id === agentMeta.currentModeId);
+
+ return (
+