zeron
August 15, 2026 · View on GitHub
A ground-up native rewrite of zeron — a multi-device controller for coding agents (Claude Code / Codex) — in Rust, with a gpui UI. Fresh app; no backwards compatibility required.
Pillars (from the goal):
- Optional sync uses Loro CRDT docs (loro-mirror model) through Cloudflare Durable Objects; the same docs persist locally when sync is disabled.
- Durable Objects stay TypeScript (decision + evidence:
docs/research/durable-objects-language.md). Everything device-side is Rust. - Feature parity with zeron except token-usage display (poor fit for CRDTs; excluded).
- Frontend is gpui (pinned Zed rev). Virtualization + markdown techniques ported from
mugen + pretext (
docs/research/mugen-pretext.md). - One binary, headed or headless. Smooth transitions/animations matching the original
(catalog in
docs/research/feature-inventory.md§1.12).
1. Topology (unchanged shape, new materials)
gpui UI ─ in-proc/localhost RPC ─ engine A ══ DeviceRoom DO relay ══ engine B ─ RPC ─ gpui UI
│ optional edge Worker: auth, rooms, R2 │
└── optional chat2 sync ── ChatRoom DO (per chat) ──┘
└─ Workspace registry room ────┘
- Engine = backend (was
@zeron/backend): runs agents, owns auth, terminals, repos/worktrees, diff sync, doc hosting. Pure Rust daemon, fully functional headless. - UI = viewport (was Electron): gpui app rendering engine state. Talks the same typed RPC whether the engine is in-process or a separate daemon. Organized around spaces — (device, folder) pairs, local or synced according to the active profile. The sidebar is the data: an attention-sorted Sessions list, filtered by a searchable spaces dropdown ("All spaces" included) that also hosts space management. The horizontal tabs are a device-local viewport onto that list (
ui-settings.jsonopenTabs, cross-space): closing a tab is local-only — archiving is an explicit sidebar action — and a sidebar click (re)opens a session as a tab. The new-session canvas carries a space picker (defaulting to the sidebar filter, else the last selected space); new sessions are minted onto the picked space's device via relay-forwardable RPCs. - Edge (TypeScript, ported from zeron
apps/edge): Worker + ChatRoom DO (per chat, the chat2 row protocol; the legacy SessionRoom DO remains deployed only for pre-cutover clients — no current client dials it) + DeviceRoom DO (per device) + R2 attachments + WorkOS JWKS auth. Absorbs the oldapps/serverresponsibilities (WorkOS code exchange/refresh, orgs) so Postgres, the Hono server, and the WebRTC/signaling stack are all gone.
Headed / headless
Single binary zeron:
zeron— headed. If a local engine daemon is already listening on the IPC port, connect to it; otherwise run the engine in-process (RPC over an in-memory duplex — same protocol, zero serialization shortcuts, so the boundary stays honest) and serve that same engine on the IPC port. The embedded engine is not private: any other viewport can attach to the running app without it first being restarted as a daemon. Binding is best-effort — if the port is taken the window still opens, having lost only the ability to host peers.zeron headless— engine only. A clean installation immediately serves its local profile over localhost IPC; when a saved account selects the synced profile at startup and a bearer is available, it also hosts its DeviceRoom for remote control. A VPS can run this while a laptop's UI drives it.
Local-first workspace profiles
Authentication and workspace selection are deliberately separate state machines:
AuthStateis live credential state:SignedOut,NeedsOrganization, orSignedIn. It may change after login, refresh, revocation, or logout.WorkspaceScopeis the immutable storage and transport boundary captured once at engine startup:Local,Synced, or explicitDevelopment.
The engine never re-resolves an open store because AuthState changed. This prevents a sign-in, token refresh, or revocation from silently swapping databases or attaching online transports to a runtime that started local-only.
| Startup condition | WorkspaceScope | Online transports |
|---|---|---|
WorkOS enabled, no parseable saved session.json | Local | Disabled |
| Parseable saved WorkOS session | Synced | Enabled when a bearer is available; organization onboarding completes before opening the store when needed |
| WorkOS disabled without a dev bearer | Development | Disabled |
| Explicit non-empty dev bearer | Development | Enabled |
zeron login and zeron logout operate on session.json while the engine is stopped. Login selects Synced for the next start; logout selects Local for the next start. The UI may update live authentication status, but the active WorkspaceScope still changes only after restart.
The resolved profile selects the session snapshots, registry snapshot, run journals, and attachment cache that may contain workspace data:
| Scope | Store and journals | Uploads |
|---|---|---|
Local | {data_dir}/profiles/local/ | {data_dir}/profiles/local/uploads/ |
Synced | {data_dir}/orgs/{org_id}/{user_id}/ | {data_dir}/orgs/{org_id}/{user_id}/uploads/ |
Development | {data_dir}/orgs/{org_id}/{user_id}/ | {data_dir}/orgs/{org_id}/{user_id}/uploads/ |
The synced and development store roots preserve the historical cloud layout while their attachment caches are account-scoped. Local identity lives in {data_dir}/local-profile.json; its UUID is stable across restarts and is not an account or development identity.
Older releases wrote every synced and development attachment to {data_dir}/uploads/, and persisted those absolute paths in transcripts. On upgrade, the first synced or development account that opens this legacy cache claims it in {data_dir}/legacy-uploads-owner.json. That account may read the cache as a compatibility fallback, but all new staging and commits use its account-scoped uploads root; other accounts cannot read or write the legacy cache.
Device identity and machine resources remain device-scoped under the common data directory: device-id, repository registration, managed worktrees, agent credentials/accounts, and UI settings. They are available across profiles, but they do not contain or expose another profile's transcripts or attachments.
Privacy boundary and follow-ups
This first local-first change does not upload, import, link, or delete local sessions when a user signs in. Local attachments remain jailed under the local upload root and are not readable through the synced attachment cache. Returning to local-only mode reopens the same local identity and data.
The following product work is intentionally deferred:
- Explicit session selection and copy between local and synced profiles, including attachment copying, provenance, and conflict behavior.
- Browsing both scopes simultaneously or switching the visible scope without restarting the engine.
- A supported self-hosted backend contract covering authentication modes, room APIs, authorization, persistence, and blob storage. Current endpoint and bearer overrides remain development/deployment seams, not a promised compatibility surface.
2. Data model — all Loro
Two persistent doc kinds. When sync is enabled, session docs ride the chat2 row protocol (loro updates as append-only rows + Range-resumable checkpoints, ChatRoom DO) and the registry rides its own row-frame protocol; local-only profiles persist the same docs without joining rooms:
-
Session doc (per chat) — the transcript + durable command queue. Schema is a Rust port of
packages/session-doc(same container names/shapes so the edge's tail materializer keeps working):metamap,messageslist (parts as list-of-maps with LoroText bodies — the measured 1.03× oplog shape; never LWW value rewrites),commandslist with ledger rules 1–3 (append-only per-device entries; host-only outcomes; dedupe/TTL/supersede evaluation). Continuation splitting at 256KB, render-only tool parts (full inputs stay in the host's local run journal), tail/diff sidecars. Constants carried over (STREAM_COMMIT_MS=120,DO_FLUSH_MS=5s, compaction at 8MB, retain 30d, tail 64). -
Workspace registry doc (per profile) — the
registry1snapshot stores spaces (id, deviceId, path, name?, gitDetected, checkoutId), the chats index (id, deviceId, title, archived, cwd, branch, checkoutId, spaceId, lastSeenAt, lastMessagePreview/At, config), devices, session-status rows, and checkout-diff summary pointers. A space is a device+folder pair in the active profile; the owning device'sSpacesSyncstamps git presence so branch pickers and the diff sidebar can gate without another RPC. Local scope keeps the registry entirely in its profile store. Synced and development scopes join/registry/{orgId}/ws, backed by the private per-user roomreg1/{orgId}/{userId}; rows are never visible to every member of an organization.Writer discipline: each device writes its own device and session-status rows, rows for chats it hosts, and git stamps for spaces it owns. Creates, renames, archives, and seen marks are LWW sets accepted from any device.
deleteSpacetombstones the space and every chat/session row in it in one commit. Presence uses ephemeral room frames rather than durable heartbeat writes.Why one registry and not N tiny docs: the sidebar needs one subscription for the whole list (grouping, resort animations, unseen markers). Its rows contain indexes rather than transcripts, so one local snapshot and, when enabled, one room connection remain bounded and cheap.
-
Mirror layer (
zeron-doccrate) — Rust equivalent of loro-mirror: typed structs for the schema, incremental application ofdoc.subscribediffs into cached state (no full re-hydration per change — this is also what fixes zeron's known O(transcript) re-projection inefficiency, remaining-work item 1a), and a diff-reconcile write path (evaluatelorosurgeon0.2.x as a dep; our schema is small enough to hand-roll if it doesn't fit). The UI renders mirror state directly with per-entry change notifications — the "endgame" the TS implementation documented but never reached.
Command plane
Send/steer/interrupt/respondInput = durable command entries in the session doc (QueueCommand),
executed by the chat's host device (executor gated on chat ownership; mark-processed BEFORE
execute; steer with no live run dispatches as the next turn). Offline sends queue in the doc.
This is zeron's proven design, kept verbatim.
3. Cargo workspace
zeron/
Cargo.toml # workspace
crates/
proto/ zeron-proto # wire types: AgentEvent, ToolCall, RunRequest, Model,
# entities, RPC envelopes (serde; ndjson framing);
# `view` = the pure derivations both frontends share
# (sort orders, staleness gating, grouping, boot gate)
doc/ zeron-doc # session-doc + workspace-registry schemas, mirror layer,
# parts fold, continuations, command ledger, sidecars
sync/ zeron-sync # loro room client (join/VV backfill/fragments/backoff),
# ephemeral presence, DocsStore (SQLite snapshots +
# processed-command ledger)
harness/ zeron-harness # Harness trait + claude-code (stream-json subprocess),
# codex (app-server JSON-RPC), mock; steering mailbox,
# requestInput, models/reasoning/options catalogs
engine/ zeron-engine # sessions engine (pub/sub, run journal, recovery, stall
# watchdog), doc host + command executor, repos/worktrees,
# checkout-diff sync, terminals (portable-pty), uploads,
# agent accounts (cred swap), auth (WorkOS via edge),
# device-room host/peers, identity
rpc/ zeron-rpc # UiRpc/ControlRpc: typed req/resp/stream over WS (tokio-
# tungstenite) + in-memory transport; device-room virtual
# sockets ({s,k,to,from} frames)
ui/ zeron-ui # gpui app: shell, sidebar, conversation, composer,
# terminal view, diff pane, settings, animation kit
apps/
zeron/ # the binary (headed default, `headless` subcommand)
edge/ # TypeScript Worker + DOs (ported from zeron/apps/edge,
# + auth-exchange routes absorbed from apps/server)
docs/ # this file + research reports
Engine async runtime: tokio throughout; the UI bridges via gpui_tokio (Tokio::spawn
futures surfaced as gpui Tasks). In-process mode runs the engine on its own tokio runtime
thread; the UI never blocks on it.
4. UI plan (gpui) — parity + smoothness
Reference: docs/research/gpui.md, docs/research/mugen-pretext.md,
feature spec docs/research/feature-inventory.md §1.
- Deps:
gpui+gpui_platformpinned to one Zed rev (Apache-2.0). We do not use Zed's GPL crates (markdown,ui,theme,editor) — markdown, components, and theme are ours. - Transcript: gpui
list()+ListState::new(n, ListAlignment::Bottom, overdraw)(sum-tree offsets, follow-tail). On top of it, port the mugen behaviors that gpui doesn't give us:- stick-to-bottom spring with feed-forward tracking of streaming growth; interrupt from user input (wheel-up / drag), re-engage within a 70px band; own-send re-engages + smooth scrolls;
- block-granularity rows (one row = one markdown block / tool group, not one message) with
stable ids
msgId#blockId; live turn stays unsplit, re-splits on persist; optimistic echo rows share the client-minted id so persistence never flickers; - row height memoization keyed by (row id, content length, width) so a streamed token re-measures one row;
- scroll-anchor absorption for above-viewport height changes.
- Markdown (
zeron-ui::markdown):pulldown-cmarkparsing onbackground_spawnwith coalescing (Zed's proven pattern), block-level incremental re-parse of the streaming tail (incremark's O(delta) idea: only re-parse from the last stable block boundary), monochrome theme where numbers drive layout, colors are paint. Code blocks: monospace, no wrap ⇒ height = lines × line-height (layout independent of highlight); syntax highlighting viasynoptic/syntect-class tokenizer run time-sliced in the background, colors applied as text runs (paint-only). Streaming fade-in veil on newly appended text viawith_animationopacity (paint-layer, never affects layout).prefers-reduced-motionhonored. - Composer: hand-rolled gpui text input (start from Zed's
examples/input.rs: IME, selection, clipboard, key actions), compact↔expanded auto-flip by measured text width, auto-grow 76–260px, Enter/Shift+Enter, Send→Steer→Stop morph, drafts + attachments per chat, drag-drop/paste images, QuestionPanel (paged, 1-9 keys, 220ms auto-advance) replacing the composer while input is requested. Pickers (harness/model, traits, repo w/ folder browser, branch w/ worktree toggle) as gpui popovers withmenu-inscale/fade. - Terminal:
alacritty_terminal(vte state machine, MIT/Apache) +portable-ptyon the engine side; custom gpui grid element; tabs w/ drag-reorder (150ms sliding transforms), height drag 160px–55vh, 12ms input coalescing / 80ms resize debounce, 1MB replay, detach ≠ close. - Diff pane: unified-patch parser → virtualized file/hunk/line rows, per-file collapse (180ms height tween), time-sliced highlight, 200ms width transition on the pane itself.
- Animation kit (
zeron-ui::motion): small helpers over gpuiAnimationreproducing the zeron catalog —fade-in(0.5s, cubic-bezier(0.16,1,0.3,1), translateY 4→0),splash-out,zeron-pulsestaggered cell wave (boot splash + loaders),gradient-spin-pulsematrix spinner (WorkingIndicator + rotating flavour word),menu-in/dialog-inscale-fades, 200ms ease-out width/height transitions for sidebar/panes, sidebar-resort slide animation (we own the list, so animate row positions directly — the View Transitions equivalent, 260ms cubic-bezier(0.22,1,0.36,1)), reduced-motion switch. - Theme: always-dark monochrome, oklch-derived neutral scale precomputed to Hsla, hairline borders, Geist/Geist Mono bundled fonts.
5. Engine plan
Direct ports of zeron behaviors (spec: feature-inventory §3):
- Sessions engine: per-session broadcast hub; on-disk run journal (resumable
seqreplay, crash auto-resume); persistent steerable sessions (steering mailbox at step/turn boundary; idle reaper; 10min stall watchdog); recovery stampsaborted. - Doc host: per-chat handle (join room, VV backfill, write user entries + stream assistant segments at 120ms commits, drain commands host-only with processed-ledger idempotence, publish diff sidecar, presence); warm-open recent chats (14d/cap 30); nudge-driven cold open; SQLite snapshot store.
- Harness (research pending —
docs/research/harness.md): trait mirroring zeron'sHarnessShape; Claude Code viaclaudeCLI stream-json in/out (control protocol for permissions/AskUserQuestion→requestInput, resume, steering); Codex via app-server JSON-RPC orcodex exec --json; model/reasoning/option catalogs ported frompackages/harness. - Repos/diffs: git2 or
gitsubprocess (subprocess — matches zeron, avoids libgit2 edge cases); worktrees under~/.zeron/worktrees; fs watchers (notify) + 2min repair; diff capture (patch + numstat + untracked, 3MiB cap, sha256) → workspace registry summary + DO diff sidecar. - Agent accounts: credential-slot swap (macOS Keychain via
security-framework, files elsewhere), plan labels, usage probes, paste-code/browser-poll OAuth flows. - Auth: WorkOS through edge routes (
/auth/exchange,/auth/refresh, orgs); loopback callback server headed, paste-code headless; dev mode (no key ⇒ bearer = configured user id).
6. Edge plan (TypeScript, edge/)
Port zeron/apps/edge nearly verbatim (it is already Loro-native and smoke-tested: session room
w/ hibernation + two-level compaction + daily alarm backups, device room byte relay + nudges +
sidecar slots, R2 attachments, JWKS auth). Additions:
- Private per-user registry rooms (
/registry/{orgId}/ws→reg1/{orgId}/{userId}) with authenticated row sync and ephemeral device presence. /auth/*routes absorbed fromapps/server(WorkOS API key in Worker secret).- Drop
/seedmigration path and legacy sync anything (fresh app). Hibernation hygiene: no idle timers (flush timer only while dirty), auto-response ping/pong — perdocs/research/durable-objects-language.md.
7. Parity exclusions & deliberate changes
- Excluded: token-usage display (profile heatmap, lifetime stats, per-message token columns,
WatchUsage). Rate-limit meters on agent accounts are kept (separate concern; probed from CLIs, not CRDT-synced). - Changed: Postgres entity sync/server → workspace registry + edge; Electron/React/mugen → gpui with ported techniques; Node harness SDKs → subprocess protocols; WebRTC → device-room relay (zeron had already made this move); mobile app → out of scope for this repo.
- Kept verbatim: session-doc schema shape + constants, command ledger rules, edge DO design, render-parts privacy policy, UX behaviors and animation timings.
8. Milestones
Status legend: ✅ shipped · 🟡 shipped with named gaps (see docs/PARITY.md).
- ✅ M0 Scaffold — workspace builds;
proto/doccrates with ledger + parts + continuation unit tests; gpui hello-window runs. - ✅ M1 Doc + sync core —
zeron-docmirror over loro 1.13; room client syncs with the edge running underwrangler dev; Rust⇄edge⇄Rust convergence test (M1 exit: two Rust peers converge through a real SessionRoom DO, tail endpoint serves). - ✅ M2 Engine core — Claude harness end-to-end headless:
zeron headless+ dev auth runs a turn, journal + doc writes, recovery test. - ✅ M3 UI core — shell (sidebar/panes/header), transcript (virtualized, markdown, streaming, stick-to-bottom), composer (send/steer/stop, question panel); local chat fully usable headed.
- ✅ M4 Multi-device — device-room host/client virtual sockets, remote device control, workspace
registry sync, WorkOS auth + org gate, presence. Proven live by
scripts/e2e-smoke.sh: two headless engines against a real edge — B queues a run into the chat doc, the durable nudge wakes host A, A executes (mock harness), transcript + session status sync back to B. - 🟡 M5 Full surface — terminals, diff pane, repo/branch/folder pickers + worktrees, agent accounts UI, settings (devices/shortcuts/archived), Codex harness. Gaps: composer attachment UI (engine upload RPCs exist), Cursor harness.
- 🟡 M6 Polish — wire reconciliation (proto AuthState on the wire,
LocalDevice), two-device e2e smoke, keyboard map, clippy/fmt sweep, Linux packaging (scripts/package-linux.sh+ release profile), macOS bundling config (dist/macos/, not executed — needs a Mac). Gaps: prefers-reduced-motion, engine hardening (instance lock, watchdogs), edge production deploy.
9. Open questions (tracked, non-blocking)
- loro-protocol Rust client ⇄ TS edge interop — verify at M1; fallback is a ~300-line hand-rolled client (the frame protocol is small and we control both ends).
lorosurgeonfit for the mirror write path vs hand-rolled reconcile.- Cursor harness (zeron has it; CLI surface for Rust TBD) — parity item, scheduled after Codex.
- Text shaping performance for analytic row heights: gpui measures shaped text natively (Rust ⇒
cheap), so we start with gpui
list()measurement + memoization rather than porting pretext's full analytic kernel; revisit only if cold-open of huge transcripts measures slow.