9. State, UI, and Session Persistence
9.1 State layers
Three different persistence scopes:
| Scope | Mechanism | Representative file |
|---|---|---|
| Module-level singletons | bootstrap/state.ts |
getSessionId(), getCwd(), getModelUsage(), getLastAPIRequest() |
| AppState (React) | Singleton store + useSyncExternalStore |
state/AppStateStore.ts, state/store.ts |
| Session storage | ~/.claude/sessions/<id>/ on disk |
utils/sessionStorage.ts, utils/conversationRecovery.ts |
Bootstrap singletons (bootstrap/state.ts)
The things that need to be callable from anywhere without dependency injection:
originalCwd,cwd,projectRootsessionId,parentSessionIdmodelUsage,initialMainLoopModel,mainLoopModelOverridekairosActive— assistant-daemon flagstrictToolResultPairing— HFI opt-in- Telemetry handles:
meter,loggerProvider,eventLogger,tracerProvider, and a set ofAttributedCounterinstances (sessionCounter,locCounter,prCounter,commitCounter,costCounter,tokenCounter, etc.) agentColorMap— assign colors to named teammateslastAPIRequest— snapshot of the last API params (used by/share)cachedClaudeMdContent— cycle-break for claudemd ↔ permissionssessionBypassPermissionsMode— non-persisted session overridesessionCreatedTeams— cleanup bookkeeping
AppState (state/AppStateStore.ts)
A deeply-structured immutable state. Notable fields:
- Core:
settings,verbose,mainLoopModel,mainLoopModelForSession,statusLineText,expandedView - Permission:
toolPermissionContext, disabled-bypass state - Remote:
remoteSessionUrl,remoteConnectionStatus,remoteBackgroundTaskCount - Repl bridge:
replBridgeEnabled,replBridgeExplicit,replBridgeOutboundOnly,replBridgeConnected,replBridgeSessionActive,replBridgeReconnecting,replBridgeConnectUrl,replBridgeSessionUrl, environment/session IDs - Footer visibility:
Tasks,TMux,Bagel,Teams,Bridge,Companion - Speculation:
speculationState - File/history:
fileHistoryState,attributionState - Hooks:
sessionHooksState,deferredHookMessages - Thinking:
thinkingConfig,thinkingModeUserState - Model picker:
selectedModelIndex,showModelPicker - Agents UI:
selectedIPAgentIndex,coordinatorTaskIndex,viewSelectionMode,footerSelection
Store implementation (state/store.ts)
Tiny:
getState() // current value
setState(updater) // immutable updater, ref-equality check, notify listeners
subscribe(listener) // returns unsubscribeReact components subscribe via useSyncExternalStore(subscribeToAppState, getAppStateSnapshot). The store is wrapped by <AppStateProvider> (state/AppState.tsx) which also handles onChangeAppState callbacks and settings-change reconciliation.
9.2 Ink TUI
Component tree
<App> (components/App.tsx)
├── <FpsMetricsProvider>
├── <StatsProvider>
└── <AppStateProvider onChangeAppState>
└── <MainScreen>
├── <Messages> (components/Messages.tsx)
│ ├── <LogoHeader>
│ └── <VirtualMessageList>
│ └── many <MessageRow> (components/MessageRow.tsx)
│ └── <Message> (components/Message.tsx)
├── <ContextVisualization>
├── <EffortCallout>
├── <Footer> (with many pills: tasks, tmux, teams, bridge…)
├── <StatusLine>
└── <Input> (text input with history, vim mode)
Virtualized rendering
VirtualMessageList renders only the visible window plus a small overscan. MessageRow exports hasContentAfterIndex() which is pre-computed to avoid array-prop pinning in the React fiber cache — a performance trick that keeps memoization stable across scroll updates.
Message collapsing
Tool.isSearchOrReadCommand(input) drives whether a row renders as a compact group summary. renderGroupedToolUse(instances) coalesces parallel calls (three Reads → one collapsed group).
Hook highlights (hooks/)
useTextInput(hooks/useTextInput.ts) — the real cursor / kill-ring / vim / ghost-text / history / multiline engine.useCommandQueue+useQueueProcessor— decouple user input from query execution; the queue supports priority'now'>'next'>'later'and auto-drains when (a) no active query, (b) queue non-empty, (c) no blocking local JSX.useCanUseTool— the permission callback wired intoQueryEngine.useSessionBackgrounding— send the session to the background queue (claude ps).useRemoteSession+useReplBridge— live websocket connections.useInboxPoller— cross-session / cross-device inbox checking.useVoice*— voice input.useIDEIntegration/useIdeConnectionStatus/useIdeSelection— bidirectional IDE sync.
9.3 Text input
useTextInput handles:
- Multi-line editing (Alt+Enter to insert newline vs. Enter to submit).
- Vim-mode full normal/insert/visual operators via
vim/. - History (up/down to recall prior submissions).
@-mention file completion (viahooks/useIdeAtMentioned.tsandhooks/fileSuggestions.ts)./-command typeahead.- Image paste handling.
- Kill ring (Emacs-style).
9.4 Session transcript
Every session gets ~/.claude/sessions/<sessionId>/:
transcript.jsonl # serialized message entries, one per line
metadata.json # title, cost, duration, lines added/removed, token usage, FPS
logs/ # hook summaries, context collapse snapshots, file history,
# attribution, tool result persistence, content replacement
Recording
recordTranscript(messages) writes appending JSONL. In --bare / CLAUDE_CODE_IS_COWORK modes the write is fire-and-forget; otherwise it awaits (QueryEngine.ts:450-463).
Critical ordering: user messages are persisted before entering the query loop so a kill-mid-request still leaves a resumable transcript. Without this, getLastSessionLog filters out queue-only entries and returns null, and --resume fails with "No conversation found" (QueryEngine.ts:438-449 comment).
For assistant messages, the transcript is written fire-and-forget — claude.ts yields one assistant message per content block, then mutates the last one's message.usage/stop_reason on message_delta; awaiting the write would block the generator and prevent message_delta from being seen before the drain timer elapses (QueryEngine.ts:718-731 comment).
Resume path
--resume → loadTranscriptFromFile() → processResumedConversation() → loadConversationForResume(). Message state, cost, duration are reconstructed. If a mid-compact crash occurred, the compactMetadata.preservedSegment.tailUuid is used to re-align the transcript up through the tail.
9.5 Worktree state
saveWorktreeState() (from setup.ts) records worktree path, branch, and optional PR number in session storage. On resume into the same session, we restore cwd into the worktree (setup.ts:174-285).
9.6 Bridge / remote session layer
Two orthogonal modes:
Outbound (bridge)
claude remote-control (and legacy aliases remote / sync / bridge) turns this CLI into a long-lived environment server. It:
- Registers itself with CCR, providing machine name, git repo, max session count, spawn mode.
- Polls for work assignments encrypted as
WorkSecretbundles. - For each assignment, spawns an isolated session —
single-session(teardown on complete),worktree(persistent server, each session isolated), orsame-dir(shared cwd). - Reports status, heartbeats, handles timeouts.
WorkSecret contains session_ingress_token, api_base_url, git info, auth tokens, env vars, MCP config (bridge/types.ts).
Inbound (remote session)
RemoteSessionManager subscribes to a WebSocket (remote/SessionsWebSocket.ts) for messages addressed to a given sessionId. When claude.ai/code/session_… is opened in a browser, the frontend sends user messages over this channel. Callbacks: onMessage, onPermissionRequest, onPermissionCancelled, onConnected, onDisconnected, onReconnecting, onError.
The protocol carries:
SDKMessage— serialized conversation entries.SDKControlRequest— permission checks.SDKControlResponse— permission results.SDKControlCancelRequest— aborts.
Session-ingress tokens authenticate; org UUIDs scope permissions.
9.7 SDK surface
entrypoints/agentSdkTypes.ts is the public re-export. Core types:
SDKMessage,SDKResultMessage,SDKSessionInfo,SDKUserMessage— serializable message shapes.SDKSession,SDKSessionOptions, callbacks, hook matchers — the session surface.ModelUsage,ThinkingConfig,OutputFormat,ApiKeySource,SdkBeta(Zod schemas inentrypoints/sdk/coreSchemas.ts).- Hook event enum:
PreToolUse,PostToolUse,PostToolUseFailure,Notification,UserPromptSubmit,SessionStart,SessionEnd,Stop,StopFailure,SubagentStart,SubagentStop,PreCompact,PostCompact,PermissionRequest,PermissionDenied,Setup,TeammateIdle,TaskCreated,TaskCompleted,Elicitation,ElicitationResult,ConfigChange,WorktreeCreate,WorktreeRemove,InstructionsLoaded,CwdChanged,FileChanged.
Headless / print mode is detected via getIsNonInteractiveSession() (wired from setup.ts:14) and routes to runHeadless() instead of renderAndRun().