Open-source desktop client for the Agent Client Protocol. Uses the @anthropic-ai/claude-agent-sdk to programmatically manage Agent sessions via query(). Supports multiple concurrent sessions with persistent chat history, project workspaces, background agents, tool permissions, and context compaction.
- Runtime: Electron 40 (main process) + React 19 (renderer)
- Build: Vite 7, TypeScript 5.9, tsup (electron TS→JS)
- Styling: Tailwind CSS v4 + ShadCN UI (includes Preflight — no CSS resets needed)
- UI Components: ShadCN (Button, Badge, ScrollArea, Tooltip, Collapsible, Separator, DropdownMenu, Avatar)
- Icons: lucide-react
- Markdown: react-markdown + remark-gfm + react-syntax-highlighter + @tailwindcss/typography
- Diff: diff (word-level diff rendering)
- Glass effect: electron-liquid-glass (macOS Tahoe+ transparency)
- SDK: @anthropic-ai/claude-agent-sdk (ESM-only, async-imported from CommonJS)
- Terminal: node-pty (main process) + @xterm/xterm + @xterm/addon-fit (renderer)
- Browser: Electron
<webview>tag (requireswebviewTag: truein webPreferences) - Package manager: pnpm
- Path alias:
@/→./src/
electron/
├── tsconfig.json # Electron-specific TS config (CJS output)
├── dist/ # tsup build output (gitignored)
│ ├── main.js
│ └── preload.js
└── src/
├── main.ts # App entry: createWindow, app lifecycle, devtools, registers all IPC
├── preload.ts # contextBridge exposing window.agent API + glass detection
├── lib/
│ ├── logger.ts # log(), logStream setup
│ ├── async-channel.ts # AsyncChannel class for multi-turn SDK input
│ ├── data-dir.ts # getDataDir, getProjectSessionsDir, getSessionFilePath
│ ├── glass.ts # Liquid glass detection + glassEnabled export
│ ├── sdk.ts # Cached getSDK() for @anthropic-ai/claude-agent-sdk
│ └── git-exec.ts # gitExec() helper + ALWAYS_SKIP set
└── handlers/ # IPC handlers
├── oagent-sessions.handler.ts # oagent:start/send/stop/interrupt/permission_response
├── title-gen.handler.ts # oagent:generate-title, git:generate-commit-message
├── projects.handler.ts # projects:list/create/delete/rename
├── sessions.handler.ts # sessions:save/load/list/delete/search
├── spaces.handler.ts # spaces:list/save
├── files.handler.ts # files:list/read-multiple, file:read/open-in-editor
├── terminal.handler.ts # terminal:create/write/resize/destroy
├── git.handler.ts # git:status/stage/unstage/commit/branches/checkout
└── legacy-import.handler.ts # legacy-sessions:list/import (transcripts)
src/
├── main.tsx # React entry point
├── App.tsx # Root: glass detection, TooltipProvider + AppLayout
├── index.css # Tailwind v4 + ShadCN theme (light/dark, glass morphism)
│
├── components/
│ └── ui/ # ShadCN base components (auto-generated)
│
├── core/ # Runtime and workspace orchestration
│ ├── agents/ # Multi-agent coordination and background polling
│ ├── runtime/ # Streaming buffers, protocol parsing, eventing
│ └── workspace/ # Active project orchestration
│
├── domains/ # Domain models and exports
│ ├── project/ # Project metadata operations
│ ├── session/ # Session state data and abstractions
│ ├── settings/ # User settings and permissions
│ ├── space/ # Windowing and workspace panes
│ └── tools/ # Tool definitions and MCP metadata
│
├── features/ # UI Feature slices
│ ├── chat/ # Messages, tool calls, ChatView, inputs
│ ├── common/ # Shared components (Sidebar, Loaders)
│ ├── settings/ # Configuration menus
│ ├── tools/ # Resizable tool panels (Browser, Terminal, Files)
│ └── workspace/ # App layout, Empty states, Welcome Screens
│
├── hooks/ # Shared general hooks
├── lib/ # Shared utilities (class names, markdown parser bindings)
└── types/ # Type definitions (protocol, ui, window.d.ts)
pnpm install
pnpm dev # Starts Vite dev server + tsup watch + Electron
pnpm build # tsup (electron/) + Vite (renderer) production build
pnpm start # Run Electron with pre-built dist/The main process uses @anthropic-ai/claude-agent-sdk (ESM-only, loaded via await import()). Each session runs a long-lived SDK query() with an AsyncChannel for multi-turn input.
Session Map: Map<sessionId, { channel, queryHandle, eventCounter, pendingPermissions }>
channel— AsyncChannel (push-based async iterable) for sending user messages to SDKqueryHandle— SDK query handle for interrupt/close/setPermissionModependingPermissions— Map<requestId, { resolve }> for bridging SDK permission callbacks to UI
IPC API — Agent Sessions:
oagent:start(options)→ spawns SDK query with AsyncChannel, returns{ sessionId, pid }- Options:
cwd,model,permissionMode,resume(session continuation) - Configures
canUseToolcallback for permission bridging - Thinking:
{ type: "enabled", budgetTokens: 16000 }
- Options:
oagent:send({ sessionId, message })→ pushes user message to session's AsyncChanneloagent:stop(sessionId)→ closes channel + query handle, removes from Mapoagent:interrupt(sessionId)→ denies all pending permissions, callsqueryHandle.interrupt()oagent:permission_response(sessionId, requestId, ...)→ resolves pending permission Promiseagent:set-permission-mode(sessionId, mode)→ callsqueryHandle.setPermissionMode()oagent:generate-title(message, cwd?)→ one-shot Haiku query for chat title- Events sent to renderer via
oagent:eventtagged with_sessionId - Permission requests sent via
oagent:permission_requestwith requestId
IPC API — Projects:
projects:list/projects:create/projects:delete/projects:rename
IPC API — Session Persistence:
sessions:save(data)— writes to{userData}/oagent-data/sessions/{projectId}/{id}.jsonsessions:load(projectId, id)— reads session filesessions:list(projectId)— returns session metadata sorted by datesessions:delete(projectId, id)— removes session file
IPC API — Agent Code Import:
legacy-sessions:list(projectPath)— lists JSONL files in~/.claude/projects/{hash}legacy-sessions:import(projectPath, legacySessionId)— converts JSONL transcript to UIMessage[]
IPC API — File Operations:
files:list(cwd)— git ls-files respecting .gitignore, returns{ files, dirs }files:read-multiple(cwd, paths)— batch read with path validation and size limitsfile:read(filePath)— single file read (used for diff context)file:open-in-editor({ filePath, line? })— opens file in external editor (tries cursor, code, zed CLIs with--goto, falls back to OS default)
IPC API — Terminal (PTY):
terminal:create({ cwd, cols, rows })→ spawns shell via node-pty, returns{ terminalId }terminal:write({ terminalId, data })→ sends keystrokes to PTYterminal:resize({ terminalId, cols, rows })→ resizes PTY dimensionsterminal:destroy(terminalId)→ kills the PTY process- Events:
terminal:data(PTY output),terminal:exit(process exit)
- Top-level orchestrators manage the session list, active project routing, and workspace pane states.
- Domain feature slices handle specific subagent routing and stream buffering within active tasks.
- Background agents update local state arrays by polling async output files.
BackgroundSessionStore — accumulates events for non-active sessions to prevent state loss when switching. On switch-away, session state is captured into the store; on switch-back, state is consumed from the store (or loaded from disk if no live process).
Key event types in order:
system(init) — session metadata, model, tools, permissionMode, versionsystem(status) — status updatessystem(compact_boundary) — context compaction markerstream_eventwrapping:message_start→content_block_start→content_block_delta(repeated) →content_block_stop→message_delta→message_stopassistant— complete message snapshot (withincludePartialMessages, sent after thinking and after text)user(tool_result) — tool execution results withtool_use_resultmetadataresult— turn complete with cost/duration/modelUsage
rAF streaming flush: React 19 batches rapid setState calls into a single render. When SDK events arrive in a tight loop, all IPC-fired setState calls merge into one render → text appears all at once. Fix: accumulate deltas in StreamingBuffer (refs), schedule a single requestAnimationFrame to flush to React state at ~60fps.
Subagent routing via parent_tool_use_id: Events from Task subagents have parent_tool_use_id set to the Task tool_use block's id. A parentToolMap (Map<string, string>) maps this ID to the tool_call message ID in the UI, allowing subagent activity to be routed to the correct Task card with subagentSteps.
Thinking with includePartialMessages: Two assistant events per turn — first contains only thinking blocks, second contains only text blocks. The hook merges both into the same streaming message.
Permission bridging: SDK's async canUseTool callback creates a Promise stored in pendingPermissions Map. Main process sends oagent:permission_request to renderer. UI shows PermissionPrompt. User decision sent back via oagent:permission_response, resolving the stored Promise to allow/deny the tool.
Background session store: When switching sessions, the active session's state (messages, processing flag, sessionInfo, cost) is captured into BackgroundSessionStore. Events for non-active sessions route to the store instead of React state. On switch-back, state is consumed from the store to restore the UI instantly.
Glass morphism: On macOS Tahoe+, uses electron-liquid-glass for native transparency. DevTools opened via remote debugging on a separate window to avoid Electron bug #42846 (transparent + frameless + DevTools = broken clicks).
The right side of the layout has a ToolPicker strip (vertical icon bar, always visible) that toggles tool panels on/off. Active tools state (Set<ToolId>) is persisted to localStorage.
Layout: Sidebar | Chat | Tasks/Agents | [Tool Panels] | ToolPicker
Tool panels share a resizable column. When multiple tools are active, they split vertically with a draggable divider (ratio persisted to localStorage, clamped 20%–80%). The column width is also resizable (280–800px).
Terminal: Multi-tab xterm.js instances. Each tab spawns a node-pty process in the main process via IPC. Uses allowTransparency: true + background: "#00000000" for transparent canvas that inherits the island's bg-background. The FitAddon + ResizeObserver auto-sizes the terminal on panel resize.
Browser: Multi-tab Electron <webview> with URL bar, back/forward/reload, HTTPS indicator. Smart URL input: bare domains get https:// prefix, non-URL text becomes a Google search.
Open Files: Derives accessed files from the session's chat history — no IPC needed. Scans tool_call messages for edit tools + subagent steps. Tracks per-file access type (read/modified/created), deduplicates by path, sorts by most recently accessed.
MCP tool calls are rendered with rich, tool-specific UIs. The system supports both SDK sessions (mcp__Server__tool) and OAP sessions (Tool: Server/tool).
Registry: Two-tier lookup:
- Exact match map — keyed by canonical tool suffix
- Pattern match array — using
[/_]+character class to match both__(SDK) and/(OAP) separators
Coding Conventions:
- Tailwind v4 — no CSS resets, Preflight handles normalization
- ShadCN UI — use
@/components/ui/*for base components - Path aliases — always use
@/imports in src/ files - Logical margins — use
ms-*/me-*instead ofml-*/mr-*
- Agent SDK (Anthropic engine):
docs/ai-sdk/— coversquery(), MCP config, permissions, streaming, session management, subagents, etc. - OAP TypeScript SDK:
docs/typescript-sdk-main/— the@agentclientprotocol/sdkpackage, OAP client/server types, transport - Agent Client Protocol spec:
docs/agent-client-protocol-main/— OAP protocol spec, schema definitions, event types