Currently, Emacs is the "brain" — it maintains all session state (turn tree, tool/agent indexes, inbox, tasks, files) in hash tables, while the Node.js bridge is a stateless one-shot forwarder. This tightly couples the state model to Emacs Lisp, making it impossible to build alternative UIs (web, native).
Goal: Move state management to a long-running TypeScript backend. Emacs becomes a thin renderer that receives view model updates and sends user actions back. The terminal protocol is designed so web and native UIs can be built trivially.
Outcome: Any client that speaks the protocol can render the full gravity UI — Emacs, React web app, SwiftUI, terminal TUI.
- Effect library: Keep Effect throughout (consistent with bridge, structured errors, DI)
- Code sharing: npm workspace monorepo (gravity-server + emacs-bridge + shared types)
- Backward compat: Clean break — no standalone Emacs mode, server is required
- Transport: Unix domain socket with newline-delimited JSON (WebSocket added later for web)
Claude Code (11 hooks)
↓
Bridge Shim (one-shot, thin — sends raw hook data)
↓ (hook socket)
gravity-server (TypeScript/Effect, long-running)
├── Enrichment (transcript parsing, agent attribution)
├── State Manager (sessions, turn tree, indexes, inbox)
├── Event Handler (hook → state mutations → patches)
└── Terminal Protocol Server (Unix socket)
├── Emacs (thin renderer)
├── Web dashboard (future, via WebSocket)
└── Native app (future, via WebSocket)
- Hook socket (
~/.local/state/gravity-hooks.sock) — bridge shims connect here, one-shot per event - Terminal socket (
~/.local/state/gravity-terminal.sock) — terminals connect here, long-lived
Unix domain socket with newline-delimited JSON (same as today's bridge→Emacs protocol). WebSocket upgrade path for web terminals later. Emacs connects via make-network-process (no new dependency).
// Full session state (on connect, on session create)
{ type: "session.snapshot", sessionId: string, session: Session }
// Incremental update (on state change)
{ type: "session.update", sessionId: string, patches: Patch[] }
// Session removed
{ type: "session.removed", sessionId: string }
// Inbox item lifecycle
{ type: "inbox.added", item: InboxItem }
{ type: "inbox.removed", itemId: number }
// Overview refresh
{ type: "overview.snapshot", projects: ProjectSummary[] }// Permission response
{ type: "action.permission", itemId: number, decision: "allow"|"deny", message?: string }
// Question response
{ type: "action.question", itemId: number, answers: string[] }
// Plan review response
{ type: "action.plan-review", itemId: number, decision: "allow"|"deny", feedback?: PlanFeedback }
// Turn auto-approve
{ type: "action.turn-auto-approve", sessionId: string }
// Subscribe to session detail
{ type: "request.session", sessionId: string }
// Request overview
{ type: "request.overview" }Instead of JSON Patch (RFC 6902) which is path-based and untyped, we use semantic patches — typed operations that map 1:1 to model mutations:
type Patch =
| { op: "set_status", status: "active" | "ended" }
| { op: "set_claude_status", claudeStatus: "idle" | "responding" }
| { op: "set_token_usage", usage: TokenUsage }
| { op: "set_plan", plan: Plan | null }
| { op: "set_streaming_text", text: string | null }
| { op: "set_meta", slug?: string, branch?: string, pid?: number }
| { op: "add_turn", turn: TurnNode }
| { op: "freeze_turn", turnNumber: number }
| { op: "set_turn_stop", turnNumber: number, stopText?: string, stopThinking?: string }
| { op: "set_turn_tokens", turnNumber: number, tokenIn: number, tokenOut: number }
| { op: "add_step", turnNumber: number, agentId?: string, step: StepNode }
| { op: "add_tool", turnNumber: number, stepIndex: number, agentId?: string, tool: Tool }
| { op: "complete_tool", toolUseId: string, result: unknown, status: "done"|"error", duration?: number, postText?: string }
| { op: "add_agent", agent: Agent }
| { op: "complete_agent", agentId: string, stopText?: string, stopThinking?: string, duration?: number }
| { op: "update_task", taskId: string, task: Task }
| { op: "track_file", path: string, fileOp: string }
| { op: "add_prompt", turnNumber: number, prompt: PromptEntry }
| { op: "set_prompt_answer", turnNumber: number, toolUseId: string, answer: string }Why semantic over JSON Patch:
- Terminals can render incrementally (e.g.,
add_tool→ insert one magit section) - Typed — terminals validate, unknown ops trigger full refresh
- Maps directly to both Emacs plist mutations and React state updates
- Multiple patches from one hook event batched into single
session.update
interface Session {
sessionId: string;
cwd: string; project: string;
status: "active" | "ended";
claudeStatus: "idle" | "responding";
slug: string | null; branch: string | null;
pid: number | null; modelName: string | null;
startTime: number; lastEventTime: number;
tokenUsage: TokenUsage | null;
plan: Plan | null;
streamingText: string | null;
turns: TurnNode[]; // tree: turns → steps → tools
currentTurn: number;
toolIndex: Map<string, Tool>; // O(1) by tool_use_id
agentIndex: Map<string, Agent>; // O(1) by agent_id
tasks: Map<string, Task>;
files: Map<string, FileEntry>;
totalToolCount: number;
}
interface TurnNode {
turnNumber: number;
prompt: PromptEntry | null;
steps: StepNode[];
agents: Agent[];
tasks: Task[];
toolCount: number; agentCount: number;
frozen: boolean;
stopText: string | null; stopThinking: string | null;
tokenIn: number | null; tokenOut: number | null;
}
interface StepNode { thinking: string | null; text: string | null; tools: Tool[] }
interface Tool { toolUseId: string; name: string; input: Record<string, unknown>; status: "running"|"done"|"error"; result: unknown; duration: number | null; assistantText: string | null; assistantThinking: string | null; postText: string | null; postThinking: string | null; parentAgentId: string | null; agent: string | null; /* agentId ref */ }
interface Agent { agentId: string; type: string; status: "running"|"done"; steps: StepNode[]; toolCount: number; stopText: string | null; stopThinking: string | null; duration: number | null; taskToolId: string | null; /* tool_use_id ref */ }Direct 1:1 port of the Emacs plist model. Bidirectional links (Tool↔Agent) use ID references instead of object pointers.
Bridge shim keeps its hook socket connection open for PermissionRequest/AskUserQuestion. The server holds a PendingResponse object mapping inbox item → hook socket connection. When a terminal responds, the server writes the response back to the shim's connection:
Shim →(hook socket)→ Server creates PendingResponse + InboxItem
Server →(terminal socket)→ Terminal shows permission/plan-review UI
Terminal →(terminal socket)→ Server receives action
Server writes response → Shim's hook socket connection
Shim reads response → stdout → Claude Code
Multiple terminals: all see the inbox item, first responder wins. DENY-AS-APPROVE workaround for ExitPlanMode (#15755) lives in the server.
package.json -- npm workspace root
packages/
shared/ -- Shared types and utilities
src/
types.ts -- Session, TurnNode, Tool, Agent, Patch, messages
safe-bash.ts -- (moved from emacs-bridge)
package.json
emacs-bridge/ -- (existing, thinned out)
src/
index.ts -- Stripped to shim: stdin → hook socket → stdout
services/ -- Effect services (ProcessIO, Fs, HookSocket)
package.json
gravity-server/ -- NEW: stateful backend
src/
gravity-server.ts -- Entry, lifecycle, two sockets
state/
session-store.ts -- Map<sessionId, Session>, project grouping
session.ts -- Session class, model mutation methods (emit patches)
inbox.ts -- InboxManager, PendingResponse
enrichment/
enrich.ts -- (moved from emacs-bridge, refactored for in-memory agent state)
enrichment.ts -- (moved from emacs-bridge, pure functions)
agent-state.ts -- (refactored: file I/O → in-memory Map)
protocol/
messages.ts -- Protocol message type defs
terminal-server.ts -- Accept terminal connections, dispatch
patch.ts -- Patch types, emission helpers
handlers/
event-handler.ts -- Hook event → enrichment → state mutations
bidirectional.ts -- Permission/question/plan-review flow
util/
log.ts -- (reuse pattern from emacs-bridge)
package.json
tsconfig.json
claude-gravity-events.el— event handling is server-sideclaude-gravity-socket.elsocket server — replaced by client connectionclaude-gravity-state.elmodel mutation API — replaced by patch applicationclaude-gravity-session.elsession creation/CRUD — server creates sessions
claude-gravity-client.el— Unix socket client connecting to gravity-server- Receives snapshots → deserialize JSON to plist tree (same shape renderer already reads)
- Receives patches → apply to local plist tree (read replica)
- Sends actions → serialize to JSON, write to socket
- Inbox events → wire to existing action buffers
- Server lifecycle: start/stop gravity-server as subprocess
claude-gravity-render.el— reads same plist structure, no changesclaude-gravity-ui.el— buffers, keymaps, transient stay the sameclaude-gravity-text.el,claude-gravity-diff.el— text processing staysclaude-gravity-faces.el— visual styling staysclaude-gravity-actions.el— permission/plan-review UI stays (input source changes)claude-gravity-core.el— utilities, logging, tlistclaude-gravity-tmux.el— tmux management (sends actions to server instead of direct socket)
(defun claude-gravity--apply-patch (session-id patch)
(let ((session (gethash session-id claude-gravity--sessions)))
(pcase (alist-get 'op patch)
("add_tool" (claude-gravity--apply-add-tool session patch))
("complete_tool" (claude-gravity--apply-complete-tool session patch))
("set_claude_status" (plist-put session :claude-status (intern ...)))
;; ... one case per patch type
)))Same plist/hash-table structures the renderer already reads. The renderer doesn't know the difference.
The one-shot bridge stays (Claude Code requires it), but becomes thinner:
- Strip enrichment — raw hook data forwarded to server
- Connect to hook socket instead of Emacs terminal socket
- Bidirectional wait — same as today, but on hook socket
- Fallback — if server unavailable, write
{}to stdout (same as today)
Enrichment code (enrich.ts, enrichment.ts, agent-state.ts) moves to the server where it benefits from in-memory agent state and cached transcript positions.
- Set up npm workspace root with
packages/{shared,emacs-bridge,gravity-server} - Move shared types (Session, TurnNode, Tool, Agent, Patch, messages) to
packages/shared - Move
safe-bash.ts,types.tsto shared - Verify existing bridge still works with workspace imports
- Create
gravity-server/with Effect-based architecture - Port session store, turn tree, indexes from
claude-gravity-state.el+claude-gravity-session.el - Port event handler from
claude-gravity-events.el(~680 lines → TypeScript) - Move enrichment from shim to server (enrich.ts, enrichment.ts, agent-state.ts → in-memory)
- Server accepts hook connections on hook socket
- Validation: Bridge dual-writes to both server and Emacs. Compare server state vs Emacs state via MCP eval.
- Implement terminal socket server (Unix domain socket)
- Implement snapshot serialization (Session → JSON)
- Implement patch emission: each model mutation emits typed patches
- Implement inbox manager with PendingResponse objects for bidirectional flows
- Build test terminal (CLI tool that connects and prints state changes)
- Validation: test terminal receives correct snapshots and patches for all 11 event types
- Create
claude-gravity-client.el— Unix socket client - Implement snapshot → plist/hash-table deserialization
- Implement patch application (one
pcasebranch per patch op) - Wire
inbox.added→ existingclaude-gravity-actions.elbuffers - Wire action responses → server
- Emacs spawns gravity-server as subprocess on
claude-gravity-server-start - Remove:
claude-gravity-events.el, socket server fromclaude-gravity-socket.el, model mutations fromclaude-gravity-state.el - Strip bridge shim to raw forwarding only (no enrichment, no Emacs socket)
- Validation: full Claude Code session renders identically to old standalone mode
- React app connecting via WebSocket (server adds WS endpoint)
- Receives same protocol, renders with whatever framework
| File | Destination | Strategy |
|---|---|---|
emacs-bridge/src/enrichment.ts |
gravity-server/src/enrichment/ |
Move — pure functions, no changes |
emacs-bridge/src/enrich.ts |
gravity-server/src/enrichment/ |
Move — pure functions, no changes |
emacs-bridge/src/agent-state.ts |
gravity-server/src/enrichment/ |
Move + refactor file I/O → in-memory Map |
emacs-bridge/src/safe-bash.ts |
packages/shared/ |
Move — pure function |
emacs-bridge/src/types.ts |
packages/shared/ |
Move + extend with protocol types |
emacs-bridge/src/services/ |
gravity-server/src/ |
Reuse Effect service patterns for hook/terminal sockets |
- Start: Emacs spawns via
start-process(or user starts manually / systemd) - Listen: Hook socket + terminal socket
- Persist: Periodic state dump to
~/.local/state/gravity-server/state.json(30s) - Health: Periodic PID checks, staleness timeout → mark dead sessions ended
- Shutdown: Final state dump, close sockets
-
Phase 1: Run both server and standalone Emacs simultaneously. After N hook events, dump server state as JSON, compare against Emacs state (
claude-gravity--sessionsexported as JSON via MCP eval). States should match. -
Phase 2: Connect test CLI terminal to server. Verify snapshots and patches arrive for all event types. Replay a recorded hook sequence, verify final state matches expected.
-
Phase 3: Run Emacs in client mode. Full UI should work identically — same rendering, same keybindings, same plan review workflow. Test: run a real Claude Code session, verify all turns/tools/agents/tasks render correctly.
-
Bidirectional: Test PermissionRequest, AskUserQuestion, plan review end-to-end with server in the middle. Verify DENY-AS-APPROVE workaround works.
| File | Role in Migration |
|---|---|
claude-gravity-state.el |
Port 17+ model mutation functions to TypeScript |
claude-gravity-events.el |
Port ~680 lines of event handling to TypeScript |
claude-gravity-session.el |
Session creation/cleanup logic moves to server |
claude-gravity-socket.el |
Server becomes client; bidirectional flow moves to server |
emacs-bridge/src/index.ts |
Strip enrichment, forward raw to server's hook socket |
emacs-bridge/src/enrich.ts |
Move to gravity-server, reuse as-is |
emacs-bridge/src/enrichment.ts |
Move to gravity-server, reuse as-is |
emacs-bridge/src/agent-state.ts |
Refactor file I/O → in-memory in server |