Architecture
The shape and the seams. How the processes are arranged, who owns what, and how they talk.
This is the most important document in this analysis — it explains the load-bearing decision of the project: separating an Electron desktop shell from a deployable host-service, with git worktrees as the isolation primitive and a long-lived PTY daemon as the durability primitive.
Sources: apps/desktop/HOST_SERVICE_ARCHITECTURE.md, apps/desktop/HOST_SERVICE_BOUNDARIES.md, apps/desktop/HOST_SERVICE_LIFECYCLE.md, plus the code paths cited inline.
1. The Five Processes
flowchart TD
subgraph Electron["Electron app (single binary)"]
Main["Main process<br/>apps/desktop/src/main/<br/>• window/tray/auto-update<br/>• tRPC IPC router"]
Preload["Preload<br/>apps/desktop/src/preload/<br/>contextBridge"]
Renderer["Renderer (React)<br/>apps/desktop/src/renderer/<br/>TanStack Router + Zustand"]
Main <--> Preload
Preload <--> Renderer
end
subgraph PerOrg["Per-organization child processes"]
Host["Host-Service (Hono)<br/>packages/host-service/<br/>• workspace CRUD<br/>• git<br/>• PTY supervisor<br/>• chat runtime<br/>• SQLite"]
PTYD["PTY Daemon (Node)<br/>packages/pty-daemon/<br/>node-pty file descriptors"]
Host <-->|"Unix socket<br/>NDJSON"| PTYD
end
Agent["Agent CLI (claude, codex, …)<br/>spawned by PTY daemon<br/>env=SUPERSET_*<br/>PATH=~/.superset/bin"]
Renderer -->|"tRPC-electron<br/>(observables only)"| Main
Renderer -->|"HTTP/WS tRPC<br/>(direct)"| Host
Main -->|"spawn/adopt<br/>via manifest"| Host
PTYD --> Agent
Cloud["Cloud<br/>apps/api · apps/relay · electric-proxy"]
Main <-->|"Better Auth"| Cloud
Host <-->|"host registration · chat ·<br/>Electric SQL row sync"| Cloud1.1 Main process — apps/desktop/src/main/
Owns:
- Window lifecycle, tray (
initTray()inapps/desktop/src/main/index.ts). - Auto-update.
- Spawning, adopting, releasing host-service child processes (one per org).
- The IPC tRPC router (
apps/desktop/src/lib/trpc/routers/, ~35 route groups). - Legacy v1 terminal-host daemon and its sockets (being phased out).
- macOS keychain access,
~/.claude/reads — Electron-only concerns, intentionally not in host-service.
1.2 Renderer — apps/desktop/src/renderer/
A standard React SPA, but with two clients to two different servers:
- electronTrpc — talks to the main process over Electron IPC (
trpc-electron). Subscriptions must use observables, not async generators (perapps/desktop/AGENTS.md). This is enforced at the type level viaisObservable()checks. - localHostService — talks directly to the local host-service over HTTP/WebSocket. The renderer first asks the main process for the host-service's port + PSK secret, then opens its own client.
Routing is file-based via TanStack Router:
apps/desktop/src/renderer/routes/
├── __root.tsx
├── -layout.tsx ← global error boundary
├── sign-in/
├── create-organization/
└── _authenticated/
├── layout.tsx ← Collections provider, init effects
└── _dashboard/
├── workspaces ← v1 workspace list
├── v2-workspaces ← v2 workspace list
├── projects
├── pending/{id} ← workspace creation progress
└── v2-workspace/{id} ← active workspace (terminals + diffs)
State lives in many small Zustand stores:
stores/tabs/— pane/tab tree per workspacestores/workspace-init.ts— creation progressstores/new-workspace-modal.ts— draft + pending state across navigationstores/settings-state.ts- Plus
providers/CollectionsProvider/for cloud-synced collections.
1.3 Host-service — packages/host-service/
This is the most important non-obvious component. It's a standalone Hono HTTP server with tRPC endpoints that owns essentially all business logic:
- Workspace CRUD (
workspace.create,workspace.list,workspace.delete) - Git operations (
workspaceCreation.create/checkout/adopt,searchBranches,getProgress) - Terminal session lifecycle and WebSocket routing (
terminal.launchSession,terminal.data,terminal.exit) - Filesystem watching (
filesystem.watchWebSocket) - Host registration (
host.info,host.register) - Chat runtime (
chat.message,chat.subscribe) - PR tracking
- Its own SQLite database via Drizzle
It has zero Electron coupling. The contract (HOST_SERVICE_BOUNDARIES.md) makes this explicit:
// packages/host-service/src/app.ts
export function createApp(options: CreateAppOptions): CreateAppResult {
const { config, providers } = options;
// config: { dbPath, cloudApiUrl, migrationsFolder, allowedOrigins }
// providers: { auth, hostAuth, credentials, modelResolver }
}What was removed from host-service to honour this boundary:
process.resourcesPath,ELECTRON_RUN_AS_NODE- Default paths like
~/.superset/host.db(passed as config) - macOS keychain access
- Reads from
~/.claude/ - Mutating
process.env
Why this matters: this code can be deployed to Docker / Lambda / Kubernetes / a remote Linux dev box and, once you give it the right providers, it will just work — including running the user's chat agents and PTYs on a remote machine. The renderer doesn't care if the host-service is on localhost or dev-box.us-east.example.com.
packages/host-service/src/serve.ts is the standalone entry point — reads env (ORGANIZATION_ID, PORT, HOST_SERVICE_SECRET, …), builds providers, calls createApp(), serves.
1.4 PTY daemon — packages/pty-daemon/
A long-lived Node process. Why Node, not Bun? node-pty's tty.ReadStream doesn't work under Bun (packages/pty-daemon/src/main.ts header). This is the only Node-only component.
Why a separate process at all (rather than libpty inside host-service)?
- Survives app restarts. Sessions are persisted; on Electron quit you can choose release (detach, daemon keeps running, sessions still alive on next boot) vs stop (SIGTERM everything).
- Owns PTY master file descriptors independently. Electron crashing doesn't kill the agent.
- Communicates with host-service via Unix domain socket + NDJSON.
Since v0.5, the daemon is supervised by host-service, not by Electron — boot the host-service and it spawns the daemon if needed.
1.5 Cloud (sketched here, expanded in 04-cloud-and-data.md)
apps/api— Next.js + tRPC + Better Auth + MCP server (/api/agent/[transport]).apps/relay— Hono on Fly.io. WebSocket tunnel fromrelay.superset.shto whichever device hosts the worktree, so SDK/MCP commands can land on a NATted laptop.apps/electric-proxy— Cloudflare Worker proxying Electric SQL with org scoping. Used to pushagent_commandsrows down to the desktop's local SQLite.
2. Worktrees: The Isolation Primitive
Workspaces ≡ git worktrees, plus metadata. The mental model:
my-project/ ← main checkout
├── .git/
├── src/
└── .worktrees/ ← Superset-managed
├── feature-add-login/ ← worktree 1 (branch=feature-add-login)
├── fix-flaky-test/ ← worktree 2
└── claude-experiment-2026-05-03/ ← worktree 3
Each worktree:
- Is a separate working directory + branch.
- Has its own dependency installs if the user's setup script does that.
- Hosts its own terminal panes.
- Has a port range assigned (so two agents can both run
bun devwithout colliding — seeport_baseonworkspacesinpackages/local-db/src/schema/schema.ts).
2.1 Three creation intents → one UX
apps/desktop/V2_WORKSPACE_CREATION.md codifies the model. The user can:
| Intent | When | Git operation |
|---|---|---|
| Fork | "New workspace" + prompt for branch name | git worktree add -b <new> <base> |
| Checkout | Pick an existing branch (local or remote) | git worktree add --track -b <branch> origin/<branch> |
| Adopt | Discover an orphan .worktrees/<branch>/ |
No git op; just register the cloud row |
All three converge in apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts → host-service workspaceCreation.create/checkout/adopt (packages/host-service/src/trpc/router/workspace-creation/).
Authority decision: the cloud's view of "is there a workspace for this branch?" wins over a host-stale local cache, because hosts come and go. The branch picker UI uses searchBranches server-side (substring + filter + cursor pagination, all in one procedure) to keep that view fresh.
3. IPC Patterns
There are four transport pairs in the system. All of them speak tRPC.
| From | To | Transport | Notes |
|---|---|---|---|
| Renderer | Main | trpc-electron over IPC |
Observables only for subscriptions. ~35 router groups under apps/desktop/src/lib/trpc/routers/. |
| Renderer | Host-service | tRPC over HTTP / WebSocket | Direct connection using port + PSK secret obtained from main. |
| Main | Host-service | tRPC over HTTP | For lifecycle ops (start/stop/restart). |
| Main | PTY daemon (v1) | Unix socket + NDJSON | Legacy. v2 routes PTY through host-service. |
| Host-service | Cloud API | tRPC over HTTPS | Host registration, chat, automations. |
| Host-service | PTY daemon (v2) | Unix socket + NDJSON | The supervisor relationship. |
| Cloud (Electric) | Desktop SQLite | Electric SQL row sync | Pushes agent_commands, device_presence, etc. |
| SDK/CLI | Cloud | HTTPS (tRPC) | Direct. |
| SDK/CLI | Device | Cloud → Relay (WS) → Device | For device-routed ops (e.g., create worktree). |
3.1 Manifest-based adoption (the resilience trick)
Each running host-service writes ~/.superset/host/{orgId}/manifest.json:
{ "pid": 12345, "endpoint": "http://127.0.0.1:54321", "authToken": "psk-…", "startedAt": "2026-05-03T…", "organizationId": "org_…" }On Electron startup, discoverAll() (see the host-service-coordinator at apps/desktop/src/main/lib/host-service-coordinator.ts) scans this directory, health-checks each manifest, and adopts healthy ones rather than spawning duplicates. On quit, the user (or app config) chooses:
- Release — Electron detaches; host-services and PTY daemons keep running. Agents continue. Next launch re-adopts.
- Stop — SIGTERM cascade.
This decouples agent uptime from app uptime — a meaningful UX win when an agent is mid-run and the user wants to restart Superset.
4. The Agent Wrapper / Hooks Layer
How does Superset know an agent has finished, emitted a diff, or wants attention — without modifying the agent's source code?
PATH rewriting + wrapper scripts. When a workspace terminal is launched, env is set:
PATH=~/.superset/bin:$PATHSUPERSET_HOME_DIR=~/.supersetSUPERSET_PANE_ID=<id>SUPERSET_WORKSPACE_ID=<id>SUPERSET_PORT=<hooks-server-port>
~/.superset/bin/ contains shims for each agent (claude, codex, cursor, gemini, etc.) generated by apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts. Each shim:
- Forwards args to the real agent binary.
- Injects the agent's own hooks config so it'll POST events to
localhost:$SUPERSET_PORTon tool-use, completion, errors, etc.
For Claude Code specifically, the wrapper rewrites ~/.claude/settings.json to add a managed hook block:
[ -n "$SUPERSET_HOME_DIR" ] && [ -x "$SUPERSET_HOME_DIR/hooks/notify" ] && "$SUPERSET_HOME_DIR/hooks/notify" || true(See apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts:68-93.)
This is the mechanism by which Superset becomes a peer to the agent rather than a layer above it. The agent runs unmodified; the hooks ride along.
5. Database Strategy — Cloud + Local Mirrors
Two databases, distinct schemas:
Cloud — Postgres on Neon, Drizzle (packages/db/src/schema/)
- Auth:
users,sessions,organizations,members,accounts,invitations - Tasks:
tasks,task_statuses(Linear-syncable, hasexternal_provider/external_id/external_url) - Devices/Hosts:
device_presence(v1),v2_hosts,v2_clients,v2_users_hosts - Workspaces:
projects,v2_projects,v2_workspaces - Command queue:
agent_commands— synced via Electric - Chat:
chat_sessions,automation_runs,automations,automation_prompt_versions - Integrations:
integration_connections(Linear/GitHub/Slack OAuth) - Billing:
subscriptions - Misc:
secrets,usersSlackUsers,teams,team_keys,team_sequences(in-flight perplans/20260501-linear-team-entity.md)
Local — SQLite, Drizzle (packages/local-db/src/schema/)
projects— local project record (path, default_branch, color, neonProjectId)worktrees— disk paths, branch, baseBranch, git_status JSON, github_status JSON,createdBySupersetboolean (so adoption knows what's an orphan)workspaces— active tabs (project_id, worktree_id, type, branch, name, tabOrder, port_base)workspace_sections— groupingagent_presets,agent_custom_definitions,automation_setups,developer_experience
The command queue is the only place where cloud → desktop sync is essential: an SDK call from a CI pipeline "create a worktree" lands in agent_commands, Electric syncs it down, the desktop runs it, writes status back, the cloud sees the row update.
For chat, the path is different — see 03-llm-integration.md (Durable Streams, event-sourced).
6. The V1 / V2 Coexistence
The codebase is mid-transition. Both generations live side by side:
| V1 | V2 |
|---|---|
| Polling-based chat (4 fps, two sources) | Event-sourced chat with monotonic seq |
| Terminal-host daemon (separate) | PTY daemon supervised by host-service |
| Single-device assumption | Multi-device (host = machine) |
device_presence table |
v2_hosts, v2_users_hosts |
projects, workspaces |
v2_projects, v2_workspaces |
| Workspace creation tightly coupled to renderer | pendingWorkspaces Electric collection + /pending/{id} page |
Both work today. There is no flag-flip cutover — a deliberate "parallel universes" strategy to avoid mid-flight risk. See 05-design-patterns.md for the strategic narrative.
7. Things To Beware (Pitfalls / Notes For Readers)
- Subscriptions must be observables.
trpc-electronrejects async generators withisObservable(). New endpoints regularly trip on this — seeapps/desktop/AGENTS.md. - Don't hardcode
origin/<base>. A repo lint check (scripts/check-git-ref-strings.sh) bans.startsWith("origin/")outsiderefs.ts. Fork-repo contributors have a different upstream; the diff/branch logic must read the branch's tracked upstream. Seeapps/desktop/V2_WORKSPACE_DIFF_VIEWS.md. - Workspace state authority is in the cloud. A locally-cached "this branch already has a workspace" answer can be stale; the picker reflects cloud state. (Old defensive code that auto-deleted "ghost" cloud rows when the host couldn't find them on disk created data loss bugs — fixed in v2 with the adopt intent.)
- Bun ≠ Node. Anything touching
node-pty,tty.ReadStream, or certain native modules belongs in the Node-only PTY daemon. - Next.js 16 renamed middleware.
proxy.ts, nevermiddleware.ts. - Biome lints at root, not per package. Don't add
biometo package configs. packages/db/drizzle/is auto-generated. Edit schema files only; letdrizzle-kit generateproduce migrations.