This document describes the overall system design, component interactions, and module structure.
Claude Code
↓ (hooks: PreToolUse, PostToolUse, PostToolUseFailure, Stop,
│ UserPromptSubmit, SubagentStart, SubagentStop,
│ SessionStart, SessionEnd, Notification,
│ PermissionRequest, AskUserQuestionIntercept)
emacs-bridge (Node.js, one-shot shim)
↓ hook socket (~/.local/state/gravity-hooks.sock)
gravity-server (TypeScript/Effect, long-running)
├── state manager (sessions, turn tree, indexes, inbox)
├── event handler (hook → enrichment → state mutations → semantic patches)
↓ terminal socket (~/.local/state/gravity-terminal.sock)
Terminal clients
├── Emacs client (claude-gravity-client.el)
│ ↓ read-replica plist tree → magit-section renderer
└── macOS menu bar (gravity-menubar, Swift)
↓ colored status dots + session dropdown
-
Bridge shim (one-shot): Receives hook data from Claude Code via stdin, forwards raw event to gravity-server's hook socket, exits. For bidirectional events (PermissionRequest, AskUserQuestion), keeps the socket open and waits for the server's response.
-
gravity-server (long-running): The stateful backend. Enriches events (transcript parsing, agent attribution), manages session state (turn tree, tool/agent indexes, inbox), and broadcasts semantic patches to all connected terminals.
-
Terminal clients (long-lived connections): Connect to the terminal socket, receive session snapshots and incremental patches.
- Emacs client: Maintains a read-replica as plists, renders via magit-section. Sends user actions (permission responses, plan review feedback) back to the server.
- macOS menu bar (
gravity-menubar): Lightweight Swift app showing colored status dots per active session (green=idle, yellow=responding, orange=waiting) and a dropdown with session/inbox details. Read-only — no actions sent back to the server.
Hook socket (~/.local/state/gravity-hooks.sock):
- Bridge shims connect here, one connection per hook event
- Newline-delimited JSON:
{ event, session_id, cwd, pid, data, needs_response } - Fire-and-forget for most events; bidirectional for PermissionRequest/AskUserQuestion
- Override:
GRAVITY_HOOK_SOCKenvironment variable
Terminal socket (~/.local/state/gravity-terminal.sock):
- Emacs and gravity-menubar (macOS menu bar) connect here, persistent connection
- Server → terminal: snapshots, patches, inbox events, overview refreshes
- Terminal → server: permission/question/plan-review actions, session/overview requests
- Override:
GRAVITY_TERMINAL_SOCKenvironment variable
Hook scripts source _ensure-server which:
- Checks if hook socket exists (fast path)
- Spawns gravity-server if missing (atomic lock prevents duplicate spawns)
- Waits up to 2s for socket to appear
package.json -- npm workspace root
packages/
shared/ -- Shared types and utilities
src/
types.ts -- Session, TurnNode, Tool, Agent, Patch, messages
index.ts -- Re-exports
package.json
emacs-bridge/ -- Claude Code plugin (thin shim)
src/
index.ts -- stdin → hook socket → stdout
services/ -- Effect services (ProcessIO, Fs, HookSocket)
hooks/ -- Shell scripts (one per hook event)
_ensure-server -- Auto-start gravity-server
package.json
gravity-server/ -- Stateful backend
src/
gravity-server.ts -- Entry, two sockets, message routing
state/
session-store.ts -- Map<sessionId, Session>, project grouping
session.ts -- Session factory, mutation methods (emit patches)
inbox.ts -- InboxManager, PendingResponse
protocol/
messages.ts -- Protocol message types
terminal-server.ts -- Terminal connections, broadcast
patch.ts -- Patch types, collector
handlers/
event-handler.ts -- Hook event → enrichment → state mutations
bidirectional.ts -- Permission/question/plan-review flow
util/
log.ts -- Logging
build.mjs -- esbuild → dist/gravity-server.mjs
package.json
gravity-menubar/ -- macOS menu bar app (Swift)
GravityMenuBar/
GravityMenuBarApp.swift -- SwiftUI app, MenuBarLabel (status dots)
GravityMonitor.swift -- Terminal socket client, NDJSON parser
Models.swift -- View models, JSON protocol types
Package.swift
Makefile -- Build orchestration
{ type: "session.snapshot", sessionId: string, session: Session }
{ type: "session.update", sessionId: string, patches: Patch[] }
{ type: "session.removed", sessionId: string }
{ type: "inbox.added", item: InboxItem }
{ type: "inbox.removed", itemId: number }
{ type: "inbox.snapshot", items: InboxItem[] }
{ type: "overview.snapshot", projects: ProjectSummary[] }{ type: "hello", capabilities: string[] }
{ type: "action.permission", itemId: number, decision: "allow"|"deny", message?: string, updatedPermissions?: unknown[] }
{ type: "action.question", itemId: number, answers: string[] }
{ type: "action.plan-review", itemId: number, decision: "allow"|"deny", feedback?: PlanFeedback }
{ type: "action.turn-auto-approve", sessionId: string }
{ type: "request.session", sessionId: string }
{ type: "request.overview" }
{ type: "request.resync" }
{ type: "hint.session-dead", sessionId: string }Instead of JSON Patch (RFC 6902), the server emits typed semantic 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_permission_mode"; mode: string | null }
| { op: "set_meta"; slug?: string; displayName?: string; branch?: string; pid?: number; modelName?: string; tmuxSession?: string }
| { 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; postThinking?: string }
| { op: "add_agent"; agent: Agent }
| { op: "complete_agent"; agentId: string; stopText?: string; stopThinking?: string; duration?: number; transcriptPath?: string }
| { 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
Hook scripts in packages/emacs-bridge/hooks/ are registered via hooks.json. Each hook sources _ensure-server (to auto-start gravity-server) then invokes tsx src/index.ts <EventName>. The system handles 12 event types:
- SessionStart: User starts a new Claude Code conversation or runs
/clear - SessionEnd: Claude Code conversation terminates (user exit, crash, timeout)
- PreToolUse: Called before tool execution (can validate/block)
- PostToolUse: Called after successful tool completion
- PostToolUseFailure: Called when tool execution fails
- SubagentStart: User or Claude launches a specialized agent
- SubagentStop: Agent completes (success or failure)
- UserPromptSubmit: User sends a message to Claude (captured for display)
- Stop: Claude's generation completes or is interrupted
- Notification: Informational messages (e.g., permission granted)
- PermissionRequest: Bridge keeps socket open, waits for server response
- Matcher:
ExitPlanMode(when Claude Code exits plan mode) - Timeout: 96 hours
- Response: routed through gravity-server inbox → terminal → user action → server → bridge
- Matcher:
- AskUserQuestionIntercept: Bridge keeps socket open, waits for user answer
- Routes through inbox like PermissionRequest
The Emacs package is split into 15 modular files loaded via claude-gravity.el (thin loader):
| Module | Lines | Purpose | Key Functions |
|---|---|---|---|
claude-gravity-core.el |
~280 | defgroup, defcustom, logging, tlist | claude-gravity-log, claude-gravity--tlist-* |
claude-gravity-faces.el |
~270 | 37 defface declarations + fringe bitmaps | claude-gravity-face-* |
claude-gravity-session.el |
~285 | Session hash table, CRUD operations | claude-gravity--session-get, claude-gravity--session-set |
claude-gravity-discovery.el |
~1000 | Plugin/skill/agent/MCP capability discovery | claude-gravity--discover-capabilities |
claude-gravity-state.el |
~475 | Session state helpers, inbox, tool/agent lookup | claude-gravity--session-get, claude-gravity--apply-patch |
claude-gravity-text.el |
~480 | Text utilities: dividers, tables, markdown, wrapping | claude-gravity--wrap-text, claude-gravity--render-plan |
claude-gravity-diff.el |
~685 | Inline diffs, tool display, plan revision diff | claude-gravity--render-tool-diff |
claude-gravity-render.el |
~855 | Section renderers, turn grouping, agent/tool/task UI | claude-gravity--insert-turn-section, claude-gravity--insert-tool |
claude-gravity-ui.el |
~2610 | Overview/session buffers, modes, keymaps, transient | claude-gravity-status (main entry point) |
claude-gravity-plan-review.el |
~550 | Plan review buffer, comment overlays, feedback flow | claude-gravity-plan-review-approve, claude-gravity-plan-review-deny |
claude-gravity-actions.el |
~920 | Permission/question action buffers, inbox handling | claude-gravity--show-permission-buffer |
claude-gravity-client.el |
~1135 | Terminal socket client to gravity-server | claude-gravity-server-start, claude-gravity--apply-patch |
claude-gravity-tmux.el |
~1295 | Tmux session management, compose buffer | claude-gravity--tmux-start-session |
claude-gravity-daemon.el |
~695 | Agent SDK daemon bridge (ON HOLD) | claude-gravity-daemon-start |
claude-gravity-debug.el |
~750 | Terminal protocol debug viewer | claude-gravity-debug-open |
claude-gravity.el |
~35 | Thin loader: requires all modules | Entry point |
core → {faces, session, discovery} → state → {text, diff} → render → ui
↓
plan-review
↓
actions
↓
client
↓
{tmux, daemon, debug}
coredefines utilities, custom vars, tlist (no dependencies)faces,session,discoverydepend oncoreonlystatedepends oncoreandsession(stores data in session hash table)textanddiffdepend oncore(pure text transformation)renderdepends on all above (renders turns, tools, tasks, agents)uidepends onrender(main UI buffers and keymaps)plan-reviewdepends onui(plan review buffer)actionsdepends onplan-reviewandui(permission/question action buffers)clientdepends onactions(terminal socket, patch application, server lifecycle)tmux,daemon, anddebugdepend onclient(interactive buffers, server actions)
Cross-module forward references:
- Use
declare-functionfor functions called before they're defined - Use bare
defvarfor variables (will be defined elsewhere)
gravity-server maintains the authoritative session state. Each mutation method returns Patch[]:
interface Session {
sessionId: string; cwd: string; project: string;
status: "active" | "ended";
claudeStatus: "idle" | "responding";
turns: TurnNode[];
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>;
plan: Plan | null;
tokenUsage: TokenUsage | null;
totalToolCount: number;
// ... more fields
}Emacs maintains a read-replica as plists in claude-gravity--sessions hash table. The shape mirrors the server's Session object:
Session (plist)
├── :session-id, :cwd, :project, :slug, :status, :claude-status
├── :turns (tlist of turn-node alists)
├── :tool-index (hash-table: tool_use_id → tool alist)
├── :agent-index (hash-table: agent_id → agent alist)
├── :files, :tasks, :plan, :token-usage
└── ...
Patch application (claude-gravity--apply-patch): Each patch op maps to a pcase branch that mutates the local plist tree. The renderer reads the same plist structure as before — it doesn't know the data comes from patches.
Patch application logic lives in claude-gravity-client.el (claude-gravity--apply-patch), with helper functions in claude-gravity-state.el for session state lookups, inbox management, and tool/agent index operations.
See @docs/session-data-model.md for the complete plist reference.
Bridge shim →(hook socket)→ gravity-server creates PendingResponse + InboxItem
gravity-server →(terminal socket)→ all terminals see inbox.added
Terminal (Emacs) →(terminal socket)→ server receives action (first responder wins)
Server writes response →(hook socket)→ bridge shim reads → stdout → Claude Code
Multiple terminals can view the same inbox item. First terminal to respond wins.
Enrichment (now in gravity-server) handles tool-to-agent attribution:
- Scans the transcript backward from each tool to find the most recent agent context
- For single-agent cases, uses optimized path (no scanning)
- Attributes tool to the correct agent's nested step
PostToolUse events extract both preceding and following assistant text:
- Preceding content (
extractPrecedingContent): text before the tool in the same turn - Following content (
extractFollowingContent): text after the tool but before the next turn
Agent transcripts are stored in "sidechain" format (separate files):
- Claude Code creates
agent_transcript_pathfile during agent execution - SubagentStop event provides path but no
stop_text/stop_thinking - gravity-server's enrichment layer detects sidechain format, extracts trailing content
- Falls back to main transcript extraction if sidechain is empty
- Emacs displays agent completion at turn level AND in nested agent section
Phase 1 (Completed): Extract model API
- Split monolithic Emacs code into modular files
- Define clean session state model in
claude-gravity-state.el - Adapt all renderers to read from model state
Phase 2 (Completed): Managed Claude Code subprocess
docs/emacs-driven-sessions.md: spawningclaude -pas managed subprocess- Hooks adapter + managed process coexist
Phase 3 (Completed — v3): gravity-server backend
- Moved state management to long-running TypeScript backend
- Emacs becomes thin terminal client (read-replica + patch application)
- Bridge thinned to raw hook forwarder
- Enrichment moved from bridge to server (benefits from in-memory state)
- Two-socket architecture: hook socket + terminal socket
- Inbox manager for bidirectional flows
claude-gravity-socket.elsplit intoclaude-gravity-plan-review.el+claude-gravity-client.el- See @docs/refactor-implementation.md for full design rationale
- Dead code cleanup: removed
claude-gravity-events.el(event dispatcher, 846 lines) and ~270 lines of unusedclaude-gravity-model-*functions fromclaude-gravity-state.el— all event handling now lives in gravity-server
Daemon Bridge (ON HOLD — 2026-02): Agent SDK daemon (daemon.ts, daemon-session.ts)
- BLOCKED: Agent SDK requires pay-per-use API key. See #6536.
- Code exists but is not usable. Revisit if Anthropic changes policy.
- @DEVELOPMENT.md — Build commands, dependencies, debugging, testing
- @UI-SPEC.md — Visual specification for all UI states and keybindings
- @docs/refactor-implementation.md — v3 design: gravity-server architecture and terminal protocol
- @docs/session-data-model.md — Session plist structure and turn tree reference
- @docs/emacs-driven-sessions.md — Managed sessions research (historical)
- @docs/tmux-interactive-sessions.md — Tmux integration approach