openclaw-nerve/docs/ARCHITECTURE.md
Kim AI c9ad1bd745
chore(release): prepare 1.5.2 (#201)
* chore(release): prepare 1.5.2

* docs(changelog): include kanban assignee picker in 1.5.2

* fix(sessions): preserve status timers across resubscribe

* fix(sessions): use latest gateway for delayed refreshes

* fix(sessions): refresh unknown sessions via latest gateway
2026-03-31 03:27:03 +03:00

724 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Architecture
> **Nerve** is a web interface for OpenClaw — chat, voice input, TTS, and agent monitoring in the browser. It connects to the OpenClaw gateway over WebSocket and provides a rich UI for interacting with AI agents.
## System Diagram
```
┌──────────────────────────────────────────────────────────────────┐
│ Browser (React SPA) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌────────────────┐ │
│ │ ChatPanel│ │ Sessions │ │ Workspace │ │ Command Palette│ │
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ └────────────────┘ │
│ │ │ │ │
│ ┌────┴──────────────┴──────────────┴────────────────────────┐ │
│ │ React Contexts (Gateway, Session, Chat, Settings)│ │
│ └────────────────────────────┬──────────────────────────────┘ │
│ │ WebSocket (/ws proxy) │
└───────────────────────────────┼──────────────────────────────────┘
┌───────────────────────────────┼──────────────────────────────────┐
│ Nerve Server (Hono + Node) │ │
│ │ │
│ ┌────────────────────────────┴─────────────┐ │
│ │ WebSocket Proxy (ws-proxy.ts) │ │
│ │ - Intercepts connect.challenge │ │
│ │ - Injects device identity (Ed25519) │ │
│ └────────────────────────────┬──────────────┘ │
│ │ │
│ ┌───────────────┐ ┌────────┴─────┐ ┌───────────────────────┐ │
│ │ REST API │ │ SSE Stream │ │ Static File Server │ │
│ │ /api/* │ │ /api/events │ │ Vite build → dist/ │ │
│ └───────┬───────┘ └──────────────┘ └───────────────────────┘ │
│ │ │
│ ┌───────┴──────────────────────────────────────────────────┐ │
│ │ Services: TTS (OpenAI, Replicate, Edge), Whisper, │ │
│ │ Claude Usage, TTS Cache, Usage Tracker │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
│ HTTP / WS
┌──────────┴──────────┐
│ OpenClaw Gateway │
│ (ws://127.0.0.1: │
│ 18789) │
└─────────────────────┘
```
## Frontend Structure
Built with **React 19**, **TypeScript**, **Vite**, and **Tailwind CSS v4**.
### Entry Point
| File | Purpose |
|------|---------|
| `src/main.tsx` | Mounts the React tree: `ErrorBoundary → StrictMode → AuthGate`. The auth gate checks session status before rendering the app |
| `src/App.tsx` | Root layout — wires contexts to lazy-loaded panels, manages keyboard shortcuts and command palette |
### Context Providers (State Management)
All global state flows through four React contexts, nested in dependency order:
| Context | File | Responsibilities |
|---------|------|-----------------|
| **GatewayContext** | `src/contexts/GatewayContext.tsx` | WebSocket connection lifecycle, RPC method calls, event fan-out via pub/sub pattern, model/thinking status polling, activity sparkline |
| **SettingsContext** | `src/contexts/SettingsContext.tsx` | Sound, TTS provider/model, wake word, panel ratio, theme, font, font size, telemetry/events visibility. Persists to `localStorage` |
| **SessionContext** | `src/contexts/SessionContext.tsx` | Session list (via gateway RPC), granular agent status tracking (IDLE/THINKING/STREAMING/DONE/ERROR), busy state derivation, unread session tracking, agent log, event log, session CRUD (delete, spawn, rename, abort) |
| **ChatContext** | `src/contexts/ChatContext.tsx` | Thin orchestrator composing 4 hooks: `useChatMessages` (CRUD, history, scroll), `useChatStreaming` (deltas, processing stage, activity log), `useChatRecovery` (reconnect, retry, gap detection), `useChatTTS` (playback, voice fallback, sound feedback) |
**Data flow pattern:** Contexts subscribe to gateway events via `GatewayContext.subscribe()`. The `SessionContext` listens for `agent` and `chat` events to update granular status. The `ChatContext` listens for streaming deltas and lifecycle events to render real-time responses.
### Feature Modules
Each feature lives in `src/features/<name>/` with its own components, hooks, types, and operations.
#### `features/auth/`
Authentication gate and login UI.
| File | Purpose |
|------|---------|
| `AuthGate.tsx` | Top-level component — shows loading spinner, login page, or the full app depending on auth state |
| `LoginPage.tsx` | Full-screen password form matching Nerve's dark theme. Auto-focus, Enter-to-submit, gateway token hint |
| `useAuth.ts` | Auth state via `useSyncExternalStore`. Module-level fetch checks `/api/auth/status` once on load. Exposes `login`/`logout` callbacks |
| `index.ts` | Barrel export |
#### `features/chat/`
The main chat interface.
| File | Purpose |
|------|---------|
| `ChatPanel.tsx` | Full chat view — message list with infinite scroll, input bar, streaming indicator, search |
| `InputBar.tsx` | Text input with voice recording, image attachment, tab completion, input history |
| `MessageBubble.tsx` | Renders individual messages (user, assistant, tool, system) with markdown |
| `ToolCallBlock.tsx` | Renders tool call blocks with name, arguments, and results |
| `DiffView.tsx` | Side-by-side diff rendering for file edits |
| `FileContentView.tsx` | Syntax-highlighted file content display |
| `ImageLightbox.tsx` | Full-screen image viewer |
| `SearchBar.tsx` | In-chat message search (Cmd+F) |
| `MemoriesSection.tsx` | Inline memory display within chat |
| `edit-blocks.ts` | Parses edit/diff blocks from tool output |
| `extractImages.ts` | Extracts image content blocks from messages |
| `image-compress.ts` | Client-side image compression before upload |
| `types.ts` | Chat-specific types (`ChatMsg`, `ImageAttachment`) |
| `utils.ts` | Chat utility functions |
| `useMessageSearch.ts` | Hook for message search filtering |
| `operations/` | Pure business logic (no React): `loadHistory.ts`, `sendMessage.ts`, `streamEventHandler.ts` |
| `components/` | Sub-components: `ActivityLog`, `ChatHeader`, `HeartbeatPulse`, `ProcessingIndicator`, `ScrollToBottomButton`, `StreamingMessage`, `ThinkingDots`, `ToolGroupBlock`, `useModelEffort` |
#### `features/sessions/`
Session management sidebar.
| File | Purpose |
|------|---------|
| `SessionList.tsx` | Hierarchical session tree with parent-child relationships |
| `SessionNode.tsx` | Individual session row with status indicator, context menu |
| `SessionInfoPanel.tsx` | Session detail panel (model, tokens, thinking level) |
| `SpawnAgentDialog.tsx` | Dialog for spawning top-level agents and subagents with task, model, thinking, and subagent **After run** cleanup config |
| `sessionTree.ts` | Builds tree structure from flat session list using `parentId` |
| `statusUtils.ts` | Maps agent status to icons and labels |
#### `features/file-browser/`
Full workspace file browser with tabbed CodeMirror editor.
| File | Purpose |
|------|---------|
| `FileTreePanel.tsx` | Collapsible file tree sidebar with directory expand/collapse |
| `FileTreeNode.tsx` | Individual file/directory row with icon and indent |
| `EditorTabBar.tsx` | Tab bar for open files with close buttons |
| `EditorTab.tsx` | Single editor tab with modified indicator |
| `FileEditor.tsx` | CodeMirror 6 editor — syntax highlighting, line numbers, search, Cmd+S save |
| `TabbedContentArea.tsx` | Manages chat/editor tab switching (chat never unmounts) |
| `editorTheme.ts` | One Dark-inspired CodeMirror theme matching Nerve's dark aesthetic |
| `hooks/useFileTree.ts` | File tree data fetching and directory toggle state |
| `hooks/useOpenFiles.ts` | Open file tab management, save with mtime conflict detection |
| `utils/fileIcons.tsx` | File extension → icon mapping |
| `utils/languageMap.ts` | File extension → CodeMirror language extension mapping |
| `types.ts` | Shared types (FileNode, OpenFile, etc.) |
#### `features/workspace/`
Workspace file editor and management tabs.
The workspace scope is derived from the **owning top-level agent**. Memory, Config, Skills, file-browser state, and persisted drafts follow that top-level agent. Crons and Kanban stay global.
| File | Purpose |
|------|---------|
| `WorkspacePanel.tsx` | Container for workspace tabs, scoped by the current top-level workspace agent |
| `WorkspaceTabs.tsx` | Tab switcher (Memory, Config, Crons, Skills) |
| `tabs/MemoryTab.tsx` | View/edit the selected top-level agent's `MEMORY.md` and daily files |
| `tabs/ConfigTab.tsx` | Edit scoped workspace files (`SOUL.md`, `TOOLS.md`, `USER.md`, etc.) for the selected top-level agent |
| `tabs/CronsTab.tsx` | Cron job management (list, create, toggle, run). Remains global |
| `tabs/CronDialog.tsx` | Cron creation/edit dialog |
| `tabs/SkillsTab.tsx` | View installed skills for the selected top-level agent workspace |
| `hooks/useWorkspaceFile.ts` | Fetch/save scoped workspace files via REST API + `agentId` |
| `hooks/useCrons.ts` | Cron CRUD operations via REST API |
| `hooks/useSkills.ts` | Fetch scoped skills list via `agentId` |
| `workspaceScope.ts` | Derives workspace scope and localStorage keys from the owning top-level agent |
| `workspaceSwitchGuard.ts` | Pure guard logic for dirty-file prompts when switching between top-level agents |
### Workspace scope rules
- Root sessions use their own top-level agent as workspace scope
- Subagent and cron-run views inherit the owning top-level agent workspace
- The child session itself does **not** create a separate workspace scope
- Cross-agent dirty file prompts only fire when the owning top-level agent changes
- Crons and Kanban stay global even while Memory, Config, Skills, and file-browser state switch per agent
#### `features/settings/`
Settings drawer with tabbed sections.
| File | Purpose |
|------|---------|
| `SettingsDrawer.tsx` | Slide-out drawer container. Includes logout button when auth is enabled |
| `ConnectionSettings.tsx` | Gateway URL/token, reconnect |
| `AudioSettings.tsx` | TTS provider, model, voice, wake word |
| `AppearanceSettings.tsx` | Theme, font family, font size selection |
#### `features/tts/`
Text-to-speech integration.
| File | Purpose |
|------|---------|
| `useTTS.ts` | Core TTS hook, speaks text via server `/api/tts` endpoint. Supports OpenAI, Replicate, Edge (default), and Xiaomi MiMo providers |
| `useTTSConfig.ts` | Server-side TTS voice configuration management for Qwen, OpenAI, Edge, and Xiaomi MiMo settings |
#### `features/voice/`
Voice input and audio feedback.
| File | Purpose |
|------|---------|
| `useVoiceInput.ts` | Web Speech API integration for voice-to-text with Whisper fallback |
| `audio-feedback.ts` | Notification sounds (ping on response complete) |
#### `features/markdown/`
Markdown rendering pipeline.
| File | Purpose |
|------|---------|
| `MarkdownRenderer.tsx` | `react-markdown` with `remark-gfm`, syntax highlighting via `highlight.js` |
| `CodeBlockActions.tsx` | Copy/run buttons on code blocks |
#### `features/charts/`
Inline chart rendering with three renderers: **TradingView widgets** for live financial data, **Lightweight Charts** for custom time-series and candlestick data, and **Recharts** for bar/pie charts.
| File | Purpose |
|------|---------|
| `InlineChart.tsx` | Chart router — dispatches `[chart:{...}]` markers to the correct renderer based on `type` |
| `extractCharts.ts` | Bracket-balanced parser for `[chart:{...}]` markers. Validates chart data by type (bar, line, pie, area, candle, tv) |
| `LightweightChart.tsx` | Renders line, area, and candlestick charts using `lightweight-charts` (TradingView). Dark theme, gradient fills, crosshair, percentage change badges |
| `TradingViewWidget.tsx` | Embeds TradingView Advanced Chart widget via official script injection for real financial tickers (e.g. `TVC:GOLD`, `BITSTAMP:BTCUSD`) |
**Chart type routing:**
| Type | Renderer | Use case |
|------|----------|----------|
| `tv` | TradingView Widget | Live financial tickers (stocks, crypto, forex, commodities) |
| `line`, `area` | Lightweight Charts | Custom time-series data |
| `candle` | Lightweight Charts | Custom OHLC candlestick data |
| `bar`, `pie` | Recharts | Category comparisons, proportions |
#### `features/command-palette/`
Cmd+K command palette.
| File | Purpose |
|------|---------|
| `CommandPalette.tsx` | Fuzzy-search command list |
| `commands.ts` | Command definitions (new session, reset, theme, TTS, etc.) |
#### `features/connect/`
| File | Purpose |
|------|---------|
| `ConnectDialog.tsx` | Initial gateway connection dialog. Uses official gateway URL + `serverSideAuth` metadata from `/api/connect-defaults` to decide whether the token field is needed |
#### `features/activity/`
| File | Purpose |
|------|---------|
| `AgentLog.tsx` | Scrolling agent activity log (tool calls, lifecycle events) |
| `EventLog.tsx` | Raw gateway event stream display |
#### `features/dashboard/`
| File | Purpose |
|------|---------|
| `TokenUsage.tsx` | Token usage and cost display |
| `MemoryList.tsx` | Memory listing component |
| `useLimits.ts` | Claude Code / Codex rate limit polling |
#### `features/memory/`
| File | Purpose |
|------|---------|
| `MemoryEditor.tsx` | Inline memory editing |
| `MemoryItem.tsx` | Individual memory display with edit/delete |
| `AddMemoryDialog.tsx` | Dialog for adding new memories |
| `ConfirmDeleteDialog.tsx` | Delete confirmation |
| `useMemories.ts` | Memory CRUD operations |
### Shared Components
| Path | Purpose |
|------|---------|
| `components/TopBar.tsx` | Header with agent log, token data, event indicators |
| `components/StatusBar.tsx` | Footer with connection state, session count, sparkline, context meter |
| `components/ResizablePanels.tsx` | Draggable split layout (chat left, panels right) |
| `components/ContextMeter.tsx` | Visual context window usage bar |
| `components/ConfirmDialog.tsx` | Reusable confirmation modal |
| `components/ErrorBoundary.tsx` | Top-level error boundary |
| `components/PanelErrorBoundary.tsx` | Per-panel error boundary (isolates failures) |
| `components/NerveLogo.tsx` | SVG logo component |
| `components/skeletons/` | Loading skeleton components (Message, Session, Memory) |
| `components/ui/` | Primitives: `button`, `card`, `dialog`, `input`, `switch`, `scroll-area`, `collapsible`, `AnimatedNumber`, `InlineSelect` |
### Hooks
| Hook | File | Purpose |
|------|------|---------|
| `useWebSocket` | `hooks/useWebSocket.ts` | Core WebSocket management — connect, RPC, auto-reconnect with exponential backoff |
| `useConnectionManager` | `hooks/useConnectionManager.ts` | Auto-connect logic, official gateway resolution, `serverSideAuth` gating, and credential persistence in `localStorage` |
| `useDashboardData` | `hooks/useDashboardData.ts` | Fetches memories and token data via REST + SSE |
| `useServerEvents` | `hooks/useServerEvents.ts` | SSE client for `/api/events` |
| `useInputHistory` | `hooks/useInputHistory.ts` | Up/down arrow input history |
| `useTabCompletion` | `hooks/useTabCompletion.ts` | Tab completion for slash commands |
| `useKeyboardShortcuts` | `hooks/useKeyboardShortcuts.ts` | Global keyboard shortcut registration |
| `useGitInfo` | `hooks/useGitInfo.ts` | Git branch/status display |
### Libraries
| File | Purpose |
|------|---------|
| `lib/constants.ts` | App constants: context window limits (with dynamic `getContextLimit()` fallback), wake/stop/cancel phrase builders, attachment limits |
| `lib/themes.ts` | Theme definitions and CSS variable application |
| `lib/fonts.ts` | Font configuration |
| `lib/formatting.ts` | Message formatting utilities |
| `lib/sanitize.ts` | HTML sanitization via DOMPurify |
| `lib/highlight.ts` | Syntax highlighting configuration |
| `lib/utils.ts` | `cn()` classname merge utility (clsx + tailwind-merge) |
| `lib/progress-colors.ts` | Color scales for progress indicators |
| `lib/text/isStructuredMarkdown.ts` | Detects structured markdown for rendering decisions |
---
## Backend Structure
Built with **Hono** (lightweight web framework), **TypeScript**, running on **Node.js ≥22**.
### Entry Point
| File | Purpose |
|------|---------|
| `server/index.ts` | Starts HTTP + HTTPS servers, sets up WebSocket proxy, file watchers, graceful shutdown |
| `server/app.ts` | Hono app definition — middleware stack, route mounting, static file serving with SPA fallback |
### Middleware Stack
Applied in order in `app.ts`:
| Middleware | File | Purpose |
|------------|------|---------|
| Error handler | `middleware/error-handler.ts` | Catches unhandled errors, returns consistent JSON. Shows stack in dev |
| Logger | Hono built-in | Request logging |
| CORS | Hono built-in + custom | Whitelist of localhost origins + `ALLOWED_ORIGINS` env var. Validates via `URL` constructor. Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS |
| Security headers | `middleware/security-headers.ts` | Standard security headers (CSP, X-Frame-Options, etc.) |
| Body limit | Hono built-in | Configurable max body size (from `config.limits.maxBodyBytes`) |
| Auth | `middleware/auth.ts` | When `NERVE_AUTH=true`, requires a valid signed session cookie on `/api/*` routes (except auth endpoints and health). WebSocket upgrades checked separately in `ws-proxy.ts` |
| Compression | Hono built-in | gzip/brotli on all routes **except** SSE (`/api/events`) |
| Cache headers | `middleware/cache-headers.ts` | Hashed assets → immutable, API → no-cache, non-hashed static → must-revalidate |
| Rate limiting | `middleware/rate-limit.ts` | Per-IP sliding window. Separate limits for general API vs TTS/transcribe. Client ID from socket or custom header |
### API Routes
| Route | File | Methods | Purpose |
|-------|------|---------|---------|
| `/health` | `routes/health.ts` | GET | Health check with gateway connectivity probe |
| `/api/auth/status` | `routes/auth.ts` | GET | Check whether auth is enabled and current session validity |
| `/api/auth/login` | `routes/auth.ts` | POST | Authenticate with password, set signed session cookie |
| `/api/auth/logout` | `routes/auth.ts` | POST | Clear session cookie |
| `/api/connect-defaults` | `routes/connect-defaults.ts` | GET | Returns the official gateway WS URL plus `authEnabled` / `serverSideAuth` metadata. `token` is always `null`; trusted flows use server-side token injection |
| `/api/events` | `routes/events.ts` | GET, POST | SSE stream for real-time push (memory.changed, tokens.updated, status.changed, ping). POST for test events |
| `/api/tts` | `routes/tts.ts` | POST | Text-to-speech with provider auto-selection (OpenAI → Replicate → Edge). LRU cache with TTL |
| `/api/tts/config` | `routes/tts.ts` | GET, PUT | TTS voice configuration per provider (read / partial update) |
| `/api/transcribe` | `routes/transcribe.ts` | POST | Audio transcription via OpenAI Whisper or local whisper.cpp (`STT_PROVIDER`). Multipart file upload, MIME validation |
| `/api/transcribe/config` | `routes/transcribe.ts` | GET, PUT | STT runtime config (provider/model/language), model readiness/download status, hot-reload updates |
| `/api/language` | `routes/transcribe.ts` | GET, PUT | Language preference + Edge voice gender management (`NERVE_LANGUAGE`, `EDGE_VOICE_GENDER`) |
| `/api/language/support` | `routes/transcribe.ts` | GET | Full provider × language compatibility matrix + current local model multilingual state |
| `/api/voice-phrases` | `routes/voice-phrases.ts` | GET | Merged recognition phrase set (selected language + English fallback) |
| `/api/voice-phrases/status` | `routes/voice-phrases.ts` | GET | Per-language custom phrase configuration status |
| `/api/voice-phrases/:lang` | `routes/voice-phrases.ts` | GET, PUT | Read/save language-specific stop/cancel/wake phrase overrides |
| `/api/agentlog` | `routes/agent-log.ts` | GET, POST | Agent activity log persistence. Zod-validated entries. Mutex-protected file I/O |
| `/api/tokens` | `routes/tokens.ts` | GET | Token usage statistics — scans session transcripts, persists high water mark |
| `/api/memories` | `routes/memories.ts` | GET, POST, DELETE | Agent-scoped memory management — reads `MEMORY.md` + daily files, stores/deletes via gateway tool invocation |
| `/api/memories/section` | `routes/memories.ts` | GET, PUT | Read/replace a specific memory section by title, scoped via `agentId` |
| `/api/gateway/models` | `routes/gateway.ts` | GET | Config-backed model catalog from the active OpenClaw config. Returns `{ models, error, source: "config" }` |
| `/api/gateway/session-info` | `routes/gateway.ts` | GET | Current session model/thinking level |
| `/api/gateway/session-patch` | `routes/gateway.ts` | POST | HTTP fallback for model changes. Thinking changes belong on WS `sessions.patch` |
| `/api/server-info` | `routes/server-info.ts` | GET | Server time, gateway uptime, agent name |
| `/api/version` | `routes/version.ts` | GET | Package version from `package.json` |
| `/api/version/check` | `routes/version-check.ts` | GET | Check whether a newer published version is available |
| `/api/channels` | `routes/channels.ts` | GET | List configured messaging channels from OpenClaw config |
| `/api/gateway/restart` | `routes/gateway.ts` | POST | Restart the OpenClaw gateway service and verify readiness |
| `/api/sessions/hidden` | `routes/sessions.ts` | GET | List hidden cron-like sessions from stored session metadata |
| `/api/sessions/:id/model` | `routes/sessions.ts` | GET | Read the actual model used by a session from its transcript |
| `/api/workspace` | `routes/workspace.ts` | GET | List allowlisted workspace files for the selected agent workspace |
| `/api/workspace/:key` | `routes/workspace.ts` | GET, PUT | Read/write allowlisted workspace files (`soul`, `tools`, `identity`, `user`, `agents`, `heartbeat`) via `agentId` |
| `/api/crons` | `routes/crons.ts` | GET, POST, PATCH, DELETE | Cron job CRUD via gateway tool invocation |
| `/api/crons/:id/toggle` | `routes/crons.ts` | POST | Toggle cron enabled/disabled |
| `/api/crons/:id/run` | `routes/crons.ts` | POST | Run cron job immediately |
| `/api/crons/:id/runs` | `routes/crons.ts` | GET | Cron run history |
| `/api/skills` | `routes/skills.ts` | GET | List skills for the selected agent workspace via a scoped OpenClaw config |
| `/api/keys` | `routes/api-keys.ts` | GET, PUT | Read API-key presence and persist updated key values to `.env` |
| `/api/files` | `routes/files.ts` | GET | Serve local image files (MIME-type restricted, directory traversal blocked) |
| `/api/files/tree` | `routes/file-browser.ts` | GET | Agent-scoped workspace directory tree (excludes node_modules, .git, etc.) |
| `/api/files/read` | `routes/file-browser.ts` | GET | Read scoped file contents with mtime for conflict detection |
| `/api/files/write` | `routes/file-browser.ts` | PUT | Write scoped file contents with optimistic concurrency (409 on conflict) |
| `/api/files/rename` | `routes/file-browser.ts` | POST | Rename a file or directory within the selected workspace |
| `/api/files/move` | `routes/file-browser.ts` | POST | Move a file or directory within the selected workspace |
| `/api/files/trash` | `routes/file-browser.ts` | POST | Trash a file or directory, or permanently delete when using `FILE_BROWSER_ROOT` |
| `/api/files/restore` | `routes/file-browser.ts` | POST | Restore a trashed file or directory |
| `/api/files/raw` | `routes/file-browser.ts` | GET | Serve scoped image previews from the selected workspace |
| `/api/claude-code-limits` | `routes/claude-code-limits.ts` | GET | Claude Code rate limits via PTY + CLI parsing |
| `/api/codex-limits` | `routes/codex-limits.ts` | GET | Codex rate limits via OpenAI API with local file fallback |
| `/api/kanban/tasks` | `routes/kanban.ts` | GET, POST | Task CRUD -- list (with filters/pagination) and create |
| `/api/kanban/tasks/:id` | `routes/kanban.ts` | GET, PATCH, DELETE | Get, update (CAS-versioned), and delete tasks |
| `/api/kanban/tasks/:id/reorder` | `routes/kanban.ts` | POST | Reorder/move tasks across columns |
| `/api/kanban/tasks/:id/execute` | `routes/kanban.ts` | POST | Spawn agent session for task |
| `/api/kanban/tasks/:id/complete` | `routes/kanban.ts` | POST | Complete a running task (auto-called by poller) |
| `/api/kanban/tasks/:id/approve` | `routes/kanban.ts` | POST | Approve task in review -> done |
| `/api/kanban/tasks/:id/reject` | `routes/kanban.ts` | POST | Reject task in review -> todo |
| `/api/kanban/tasks/:id/abort` | `routes/kanban.ts` | POST | Abort running task -> todo |
| `/api/kanban/proposals` | `routes/kanban.ts` | GET, POST | List and create proposals |
| `/api/kanban/proposals/:id/approve` | `routes/kanban.ts` | POST | Approve pending proposal |
| `/api/kanban/proposals/:id/reject` | `routes/kanban.ts` | POST | Reject pending proposal |
| `/api/kanban/config` | `routes/kanban.ts` | GET, PUT | Board configuration |
### Server Libraries
| File | Purpose |
|------|---------|
| `lib/config.ts` | Centralized configuration from env vars — ports, keys, paths, limits, auth settings. Validated at startup |
| `lib/session.ts` | Session token creation/verification (HMAC-SHA256), password hashing (scrypt), cookie parsing for WS upgrade requests |
| `lib/ws-proxy.ts` | WebSocket proxy — client→gateway with session cookie auth on upgrade and Ed25519 device identity injection |
| `lib/device-identity.ts` | Ed25519 keypair generation/persistence (`~/.nerve/device-identity.json`). Builds signed connect blocks for gateway auth |
| `lib/gateway-client.ts` | HTTP client for gateway tool invocation API (`/tools/invoke`) |
| `lib/file-watcher.ts` | Discovers agent workspaces, watches each `MEMORY.md` and `memory/` directory, and optionally watches full workspaces recursively. Broadcasts agent-tagged `memory.changed` / `file.changed` SSE events |
| `lib/file-utils.ts` | File browser utilities — path validation, directory exclusions, binary file detection |
| `lib/files.ts` | Async file helpers (`readJSON`, `writeJSON`, `readText`) |
| `lib/mutex.ts` | Async mutex for serializing file read-modify-write. Includes keyed mutex variant |
| `lib/env-file.ts` | Mutex-protected `.env` key upserts for hot-reload settings writes (`writeEnvKey`) |
| `lib/language.ts` | Language/provider compatibility helpers (`isLanguageSupported`, fallback metadata) |
| `lib/voice-phrases.ts` | Runtime per-language phrase storage/merge (custom overrides + English fallback) |
| `lib/cached-fetch.ts` | Generic TTL cache with in-flight request deduplication |
| `lib/usage-tracker.ts` | Persistent token usage high water mark tracking |
| `lib/tts-config.ts` | TTS voice configuration file management |
| `lib/openclaw-bin.ts` | Resolves `openclaw` binary path (env → sibling of node → common paths → PATH) |
### Services
| File | Purpose |
|------|---------|
| `services/openai-tts.ts` | OpenAI TTS API client (gpt-4o-mini-tts, tts-1, tts-1-hd) |
| `services/replicate-tts.ts` | Replicate API client for hosted TTS models (Qwen3-TTS). WAV→MP3 via ffmpeg |
| `services/edge-tts.ts` | Microsoft Edge Read-Aloud TTS via WebSocket protocol. Free, zero-config. Includes Sec-MS-GEC token generation |
| `services/tts-cache.ts` | LRU in-memory TTS cache with TTL expiry (100 MB budget) |
| `services/openai-whisper.ts` | OpenAI Whisper transcription client |
| `services/whisper-local.ts` | Local whisper.cpp STT via `@fugood/whisper.node`. Singleton model context, auto-download from HuggingFace, GPU detection |
| `services/claude-usage.ts` | Claude Code CLI usage/limits parser via node-pty |
### Updater (`server/lib/updater/`)
Self-update system invoked via `npm run update` (entrypoint: `bin/nerve-update.ts``bin-dist/bin/nerve-update.js`).
| File | Purpose |
|------|---------|
| `orchestrator.ts` | State machine: lock → preflight → resolve → snapshot → update → build → restart → health → rollback |
| `preflight.ts` | Validates git, Node.js, npm versions and git repo state |
| `release-resolver.ts` | Finds latest semver tag via `git ls-remote --tags`, falls back to local tags |
| `snapshot.ts` | Saves current git ref, version, and `.env` backup to `~/.nerve/updater/` |
| `installer.ts` | `git fetch + checkout --force`, `npm install`, `npm run build + build:server` |
| `service-manager.ts` | Auto-detects systemd or launchd, provides restart/status/logs |
| `health.ts` | Polls `/health` and `/api/version` with exponential backoff (60s deadline) |
| `rollback.ts` | Restores snapshot ref, clean rebuilds, restarts service |
| `lock.ts` | PID-based exclusive lock file (`wx` flag) with stale detection |
| `reporter.ts` | Formatted terminal output with stage progress, colors, and dry-run markers |
| `types.ts` | Shared types, exit codes, `UpdateError` class |
Compiled separately via `config/tsconfig.bin.json` to avoid changing the server's `rootDir`.
---
## Data Flow
### WebSocket Proxy
```
Browser WS → /ws?target=ws://gateway:18789/ws → ws-proxy.ts → OpenClaw Gateway
```
1. Client connects to `/ws` endpoint on Nerve server
2. When auth is enabled, the session cookie is verified on the HTTP upgrade request (rejects with 401 if invalid)
3. Proxy validates target URL against `WS_ALLOWED_HOSTS` allowlist
4. Proxy opens upstream WebSocket to the gateway
5. On `connect.challenge` event, proxy intercepts the client's `connect` request and injects Ed25519 device identity (`device` block with signed nonce)
6. If the gateway rejects the device (close code 1008), proxy retries without device identity (reduced scopes)
7. After handshake, all messages are transparently forwarded bidirectionally
8. Pending messages are buffered (capped at 100 messages / 1 MB) while upstream connects
### Server-Sent Events (SSE)
```
Browser → GET /api/events → SSE stream (text/event-stream)
```
Events pushed by the server:
- `memory.changed` — File watcher or memory API detects `MEMORY.md` / daily file changes, tagged with `agentId`
- `file.changed` — File watcher detects a workspace file change, tagged with `agentId`
- `tokens.updated` — Token usage data changed
- `status.changed` — Gateway status changed
- `ping` — Keep-alive every 30 seconds
SSE is excluded from compression middleware to avoid buffering.
### REST API
REST endpoints serve two purposes:
1. **Proxy to gateway** — Routes like `/api/crons`, `/api/memories` (POST/DELETE), `/api/gateway/*` invoke gateway tools via `invokeGatewayTool()`
2. **Local server data** — Routes like `/api/tokens`, `/api/agentlog`, `/api/server-info` read from local files or process info
### Gateway RPC (via WebSocket)
The frontend calls gateway methods via `GatewayContext.rpc()`:
| Method | Purpose |
|--------|---------|
| `status` | Get current agent model, thinking level |
| `sessions.list` | List active sessions |
| `sessions.delete` | Delete a session |
| `sessions.reset` | Clear session context |
| `sessions.patch` | Patch session metadata and settings, including rename/model/thinking flows the gateway supports |
| `chat.send` | Send a message (with idempotency key) |
| `chat.history` | Load message history |
| `chat.abort` | Abort current generation |
| `connect` | Initial handshake with auth/device identity |
### Event Types (Gateway → Client)
| Event | Payload | Purpose |
|-------|---------|---------|
| `connect.challenge` | `{ nonce }` | Auth handshake initiation |
| `chat` | `{ sessionKey, state, message?, content? }` | Chat state changes: `started`, `delta`, `final`, `error`, `aborted` |
| `agent` | `{ sessionKey, state, stream, data? }` | Agent lifecycle: `lifecycle.start/end/error`, `tool.start/result`, `assistant` stream |
| `cron` | `{ name }` | Cron job triggered |
| `exec.approval.request` | — | Exec approval requested |
| `exec.approval.resolved` | — | Exec approval granted |
| `presence` | — | Presence updates |
---
## Kanban Subsystem
The kanban board provides task management with agent execution, drag-and-drop reordering, and a proposal system for agent-initiated changes.
### Store Design
```
${NERVE_DATA_DIR:-~/.nerve}/kanban/tasks.json -- single JSON file (tasks + proposals + config)
${NERVE_DATA_DIR:-~/.nerve}/kanban/audit.log -- append-only audit log (JSONL)
```
All data lives in one JSON file (`StoreData`). Every mutation acquires an async mutex, reads the file, applies the change, and writes back atomically via temp-file rename. This guarantees consistency under concurrent requests without a database. On first startup, the store migrates legacy data from `server-dist/data/kanban/` or `server/data/kanban/` into the canonical runtime directory if needed.
| File | Purpose |
|------|---------|
| `server/lib/kanban-store.ts` | `KanbanStore` class -- mutex-protected CRUD, workflow transitions, CAS versioning, proposal management |
| `server/routes/kanban.ts` | Hono routes, Zod validation, gateway session spawning, poll loop |
| `server/lib/parseMarkers.ts` | Regex-based `[kanban:create]`/`[kanban:update]` marker extraction |
The store schema is versioned (`meta.schemaVersion`). A `migrate()` function runs on every read to backfill new fields transparently.
### State Machine
```
execute complete (success) approve
backlog --------+ +---- review ------------ done
v | |
todo --------- in-progress -------+ | reject
| v
| abort / error todo (run cleared)
+---------------------> todo
```
| Transition | From | To | Trigger |
|------------|------|----|---------|
| Execute | `backlog`, `todo` | `in-progress` | `POST .../execute` |
| Complete (success) | `in-progress` | `review` | Poller or `POST .../complete` |
| Complete (error) | `in-progress` | `todo` | Poller or `POST .../complete` with `error` |
| Approve | `review` | `done` | `POST .../approve` |
| Reject | `review` | `todo` | `POST .../reject` (clears run + result) |
| Abort | `in-progress` | `todo` | `POST .../abort` |
The `cancelled` status exists in the schema but has no automatic transitions -- tasks are moved there manually via `PATCH`.
### CAS Versioning
Every task has a `version` field (starts at 1, incremented on every mutation). Mutating endpoints (`PATCH`, reorder, workflow actions) require the client to send the current version. If it doesn't match, the server returns **409** with the latest task so the client can retry:
```json
{ "error": "version_conflict", "serverVersion": 5, "latest": { "..." } }
```
This prevents stale overwrites from concurrent editors (drag-and-drop, API clients, agent completions).
### Agent Execution Flow
```
1. POST /api/kanban/tasks/:id/execute
+-- withMutex(`kanban-execute:${id}`) prevents double-launch races
+-- if task already in-progress: return 409 duplicate_execution
+-- if task has an assignee root:
| +-- resolve assignee root -> agent:<assignee>:main
| +-- gatewayRpcCall('sessions.list', ...) confirms the parent root exists
| +-- store.executeTask(..., { sessionKey }) -> status = in-progress, run.status = running
| +-- launchKanbanFallbackSubagentViaRpc({ label, task, parentSessionKey, model?, thinking? })
| +-- gatewayRpcCall('sessions.create', { key: childSessionKey, parentSessionKey, label, model? })
| +-- gatewayRpcCall('sessions.send', { key: childSessionKey, message: task, thinking?, idempotencyKey })
| +-- if send fails after create: best-effort gatewayRpcCall('sessions.delete', { key: childSessionKey, deleteTranscript: true })
| +-- return correlationKey + childSessionKey + runId?
| +-- attach childSessionKey / runId immediately when available
| +-- start pollFallbackSessionCompletion(taskId, { correlationKey, parentSessionKey, childSessionKey?, expectedChildLabel, knownSessionKeysBefore, runId? })
|
+-- else if task is unassigned / operator:
| +-- on macOS: return 409 invalid_execution_target
| +-- otherwise use invokeGatewayTool('sessions_spawn', { task, mode:'run', label: runSessionKey, model?, thinking? })
| +-- attach childSessionKey / runId when available
| +-- start pollSessionCompletion(taskId, { correlationKey: runSessionKey, childSessionKey?, runId? })
|
+-- if an assigned parent root is missing: return 409 invalid_execution_target
+-- on launch failure: store.completeRun(taskId, sessionKey, undefined, 'Spawn failed: ...')
2. pollSessionCompletion() / pollFallbackSessionCompletion()
+-- sessions_spawn path polls gateway subagents by correlation key / childSessionKey / runId
+-- assignee-root path polls gateway RPC sessions.list every 5s (max 720 attempts / 60 min)
+-- assignee-root path prefers the known childSessionKey; otherwise it discovers the new child beneath the parent root and attaches it
+-- if the child completes successfully:
| fetch child history via sessions.get / sessions_history
| parseKanbanMarkers(resultText) -> create proposals
| stripKanbanMarkers(resultText) -> clean result
| store.completeRun(taskId, sessionKey, cleanResult)
+-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: completionReport })
+-- if status=error/failed:
| store.completeRun(taskId, sessionKey, undefined, errorMsg)
+-- gatewayRpcCall('sessions.send', { key: parentSessionKey, message: failureReport })
+-- if task/run no longer matches the active session key:
+-- stop polling as stale
+-- otherwise:
+-- schedule next poll
3. store.completeRun()
|-- success -> run.status = done, task.status = review
+-- error -> run.status = error, task.status = todo
```
Assigned-root execution now uses real session primitives instead of synthetic marker-message spawn conventions. The model cascade is: execute request `model` -> task `model` -> board config `defaultModel` -> OpenClaw's configured default model. Thinking follows the same pattern with `defaultThinking`.
### Marker Parsing
When agent output arrives (via poller or `POST .../complete`), it's scanned for kanban markers:
```
[kanban:create]{"title":"Fix login bug","priority":"high"}[/kanban:create]
[kanban:update]{"id":"abc","status":"done"}[/kanban:update]
```
Each valid marker creates a **proposal** in the store. The markers are then stripped from the result text before it's saved on the task. See [Agent Markers](./AGENT-MARKERS.md#kanban-markers----kanbancreate--kanbanupdate) for the full format.
### Proposal System
Proposals let agents suggest task changes without directly modifying the board:
1. Agent emits `[kanban:create]` or `[kanban:update]` markers in its output
2. Backend parses markers -> creates proposals with `status: "pending"`
3. Frontend polls proposals every 5 seconds -> shows inbox notification
4. Operator approves or rejects each proposal
The `proposalPolicy` config controls behavior:
- `"confirm"` (default) -- proposals stay pending until the operator acts
- `"auto"` -- proposals are applied immediately on creation
### Polling Architecture
| What | Who | Interval | Purpose |
|------|-----|----------|---------|
| Task list | Frontend | 5s | Sync board state across tabs/users |
| Proposals | Frontend | 5s | Show new proposals in inbox |
| Gateway subagents | Backend | 5s | Detect when agent runs complete |
Backend polling for each running task is independent -- each `executeTask` call starts its own poll loop (capped at 720 attempts = 60 minutes). Stale runs are reconciled by `reconcileStaleRuns()`.
---
## Build System
### Development
```bash
npm run dev # Vite dev server (frontend) — port 3080
npm run dev:server # tsx watch (backend) — port 3081
```
Vite proxies `/api` and `/ws` to the backend dev server.
### Production
```bash
npm run prod # Builds frontend + backend, then starts
# Equivalent to:
npm run build # tsc -b && vite build → dist/
npm run build:server # tsc -p config/tsconfig.server.json → server-dist/
npm start # node server-dist/index.js
```
### Vite Configuration
- **Plugins:** `@vitejs/plugin-react`, `@tailwindcss/vite`
- **Path alias:** `@/``./src/`
- **Manual chunks:** `react-vendor`, `markdown` (react-markdown + highlight.js), `ui-vendor` (lucide-react), `utils` (clsx, tailwind-merge, dompurify)
- **HTTPS:** Auto-enabled if `certs/cert.pem` and `certs/key.pem` exist
### TypeScript Configuration
Project references with four configs:
- `config/tsconfig.app.json` — Frontend (src/)
- `config/tsconfig.node.json` — Vite/build tooling
- `config/tsconfig.server.json` — Backend (server/) → compiled to `server-dist/`
- `config/tsconfig.scripts.json` — Setup scripts
- `config/tsconfig.bin.json` — CLI tools (bin/) → compiled to `bin-dist/`
---
## Testing
**Framework:** Vitest with jsdom environment for React tests.
```bash
npm test # Run all tests
npm run test:coverage # With V8 coverage
```
### Test Files
48 test files, 692 tests. Key areas:
| Area | Files | Coverage |
|------|-------|----------|
| Server lib | `config`, `device-identity`, `env-file`, `gateway-client`, `mutex`, `voice-language-coverage`, `ws-proxy` | Auth, config resolution, WS proxy relay, device identity signing |
| Server routes | `auth`, `events`, `files`, `gateway`, `health`, `sessions`, `skills` | API endpoints, error handling, gateway proxy |
| Server middleware | `auth`, `error-handler`, `rate-limit`, `security-headers` | Request pipeline |
| Chat operations | `sendMessage`, `streamEventHandler`, `loadHistory`, `mergeRecoveredTail` | Message lifecycle, streaming, history recovery |
| Client features | `extractCharts`, `extractImages`, `edit-blocks`, `MarkdownRenderer`, `ContextMeter`, `ErrorBoundary`, `sessionTree` | Rendering, parsing, UI components |
| Hooks | `useAuth`, `useInputHistory`, `useKeyboardShortcuts`, `useServerEvents`, `useTTS` | Client-side state and interaction |
| Voice/audio | `audio-feedback`, `useVoiceInput`, `voice-prefix` | Voice input, TTS, wake/stop phrase parsing |
| Utilities | `constants`, `formatting`, `sanitize`, `unreadSessions` | Shared logic |
### Configuration
- **Environment:** jsdom (browser APIs mocked)
- **Setup:** `src/test/setup.ts`
- **Exclusions:** `node_modules/`, `server-dist/` (avoids duplicate compiled test files)
- **Coverage:** V8 provider, text + HTML + lcov reporters