MODULO 1.3

📦 State Management

Claude Code implementa seu proprio store em 35 linhas de TypeScript sem Redux, Zustand ou Context. A arquitetura abrange tres camadas: store primitivo framework-agnostico, AppState e factory domain-specific, e integracao React via hooks e providers.

7
Topicos
~100
Minutos
Deep
Nivel
Source
Tipo
1

🏗️ The createStore Pattern

O store primitivo fornece tres operacoes em apenas 35 linhas:

export function createStore<T>(
  initialState: T,
  onChange?: OnChange<T>,
): Store<T> {
  let state = initialState
  const listeners = new Set<Listener>()

  return {
    getState: () => state,

    setState: (updater) => {
      const prev = state
      const next = updater(prev)
      if (Object.is(next, prev)) return   // bail if no change
      state = next
      onChange?.({ newState: next, oldState: prev })  // side-effect hook
      for (const listener of listeners) listener()  // notify React
    },

    subscribe: (listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)  // unsubscribe
    },
  }
}

💡 Por que nao useState / useReducer?

React hooks vinculam o lifetime do estado a arvores de componentes. O Claude Code precisa de estado acessivel de codigo nao-React: headless mode, SDK print layer, sessoes de teammate per-process e a cadeia de side-effects onChangeAppState. Um objeto JavaScript simples com um Set de listeners evita essa restricao.

💡 Por que nao Zustand / Jotai?

Zero dependencia de bundle. A interface necessaria -- getState, setState, subscribe -- mapeia exatamente para os requisitos de useSyncExternalStore sem nada a mais.

⚠️ Invariante-chave

setState recebe uma updater function (prev) => next, nunca um objeto parcial. Isso forca imutabilidade no call site: callers devem spread o estado anterior e retornar uma nova referencia. Object.is equality checking significa que re-renders so disparam quando a referencia muda.

2

📊 The AppState Shape - 90+ Fields

AppState definido em state/AppStateStore.ts contem mais de 90 campos distintos organizados em seis categorias logicas. O tipo e DeepImmutable<{...}> para a porcao serializavel, com certos campos escapando o wrapper de imutabilidade.

Session Core

settings, verbose, mainLoopModel, thinkingEnabled, effortValue, fastMode

UI State

expandedView, footerSelection, spinnerTip, activeOverlays, statusLineText

Permissions

toolPermissionContext (mode, bypass flags), denialTracking, pendingPlanVerification

Agent & Tasks

tasks (keyed by taskId), agentNameRegistry, foregroundedTaskId, teamContext

Remote & Bridge

remoteSessionUrl, remoteConnectionStatus, replBridgeEnabled/Connected/Active

Subsystem State

mcp, plugins, speculation, promptSuggestion, notifications, todos, inbox

DeepImmutable e o Escape Hatch

export type AppState = DeepImmutable<{
  settings: SettingsJson
  verbose: boolean
  // ... ~60 serializable fields ...
}> & {
  // Excluded from DeepImmutable -- contain function types
  tasks: { [taskId: string]: TaskState }
  agentNameRegistry: Map<string, AgentId>
  mcp: { clients: MCPServerConnection[]; /* ... */ }
  // ...
}

tasks, agentNameRegistry, sessionHooks, activeOverlays e replContext nao sao wrappados em DeepImmutable porque contem tipos function que a transformacao recursiva readonly do TypeScript nao lida. Trate-os como logicamente imutaveis (sempre spread antes de mutar).

3

🔍 onChangeAppState - The Side-Effect Chokepoint

📊 O Problema que Resolveu

Antes deste padrao, mudancas de permission-mode eram relayed ao dashboard remoto (CCR) por apenas 2 de 8+ caminhos de mutacao. Os outros 6 mutavam AppState silenciosamente, deixando a web UI desatualizada. Centralizar o diff aqui significa que qualquer setState que muda o mode automaticamente sincroniza -- sem mudancas em call sites individuais.

Os 6 Diff Blocks Atuais

export function onChangeAppState({ newState, oldState }) {
  // 1. Permission mode -- sync to CCR + SDK status stream
  if (prevMode !== newMode) {
    const prevExternal = toExternalPermissionMode(prevMode)
    const newExternal  = toExternalPermissionMode(newMode)
    if (prevExternal !== newExternal) {
      notifySessionMetadataChanged({ permission_mode: newExternal })
    }
    notifyPermissionModeChanged(newMode)
  }

  // 2. mainLoopModel -- persist to settings + bootstrap override
  if (newState.mainLoopModel !== oldState.mainLoopModel) { ... }

  // 3. expandedView -- persist to globalConfig
  if (newState.expandedView !== oldState.expandedView) { ... }

  // 4. verbose -- persist to globalConfig
  if (newState.verbose !== oldState.verbose) { ... }

  // 5. tungstenPanelVisible -- ant-only, persist to globalConfig
  if (process.env.USER_TYPE === 'ant' && ...) { ... }

  // 6. settings -- clear auth caches + re-apply env vars
  if (newState.settings !== oldState.settings) {
    clearApiKeyHelperCache()
    clearAwsCredentialsCache()
    clearGcpCredentialsCache()
  }
}

💡 Externalization Guard

Nem todos os modos de permissao internos tem equivalentes externos. toExternalPermissionMode colapsa nomes internal-only como 'bubble' e 'ungated-auto' para 'default' antes de enviar ao CCR. Sem isso, o dashboard remoto receberia nomes de modo sem significado.

4

⚙️ React Hooks Layer

state/AppState.tsx exporta tres hooks e um provider:

// Read a slice -- re-renders only when selected value changes
const verbose = useAppState(s => s.verbose)
const model   = useAppState(s => s.mainLoopModel)

// Write without subscribing -- stable reference, never causes re-renders
const setAppState = useSetAppState()
setAppState(prev => ({ ...prev, verbose: true }))

// Get the raw store -- for passing to non-React helpers
const store = useAppStateStore()
doSomethingOutsideReact(store.getState, store.setState)

Selector Rules

✅ Correto

// Good -- returns existing reference
const { text, promptId } =
  useAppState(s => s.promptSuggestion)

❌ Incorreto

// Bad -- new object every render
const { text, promptId } =
  useAppState(s => ({
    text: s.promptSuggestion.text,
    promptId: s.promptSuggestion.promptId
  }))

NAO retorne novos objetos do selector. useSyncExternalStore compara snapshots com Object.is. Um inline s => ({ a: s.a, b: s.b }) cria um novo objeto a cada render, disparando um infinite re-render loop.

5

📁 Selectors & Transition Helpers

Selectors em state/selectors.ts aceitam Pick<AppState, ...> (nao o estado completo) para testabilidade:

export function getViewedTeammateTask(
  appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>
): InProcessTeammateTaskState | undefined { ... }

export type ActiveAgentForInput =
  | { type: 'leader' }
  | { type: 'viewed';     task: InProcessTeammateTaskState }
  | { type: 'named_agent'; task: LocalAgentTaskState       }

export function getActiveAgentForInput(appState: AppState):
  ActiveAgentForInput { ... }

Transition Helpers - teammateViewHelpers.ts

Transicoes de estado complexas recebem setAppState como argumento, mantendo-se framework-agnosticas e testaveis fora do React:

// Enter teammate transcript view
export function enterTeammateView(
  taskId: string,
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void

// Exit back to leader's view
export function exitTeammateView(
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void

// Context-sensitive x button: abort if running, dismiss if terminal
export function stopOrDismissAgent(
  taskId: string,
  setAppState: (updater: (prev: AppState) => AppState) => void,
): void

📊 Retain/Evict Lifecycle

  • Stub: retain: false, messages: undefined. Row aparece no painel sem transcript.
  • Retained: retain: true, messages carregadas. Acionado por enterTeammateView.
  • Eviction pending: task terminal, evictAfter = Date.now() + 30_000. Row permanece 30s (PANEL_GRACE_MS).
  • Immediate dismiss: evictAfter = 0. Filter esconde row imediatamente.
6

🔀 Full Data Flow

Diagrama: Do Componente ao Re-Render

flowchart TD
    A["Component calls useSetAppState()"] -->|"returns store.setState"| B["store.setState(updater)"]
    B --> C{"Object.is(next, prev)?"}
    C -->|"same"| Z["Return - no-op"]
    C -->|"changed"| D["state = next"]
    D --> E["onChangeAppState({ newState, oldState })"]
    E --> F1["permission mode?"]
    E --> F2["mainLoopModel?"]
    E --> F3["expandedView?"]
    E --> F4["settings?"]
    F1 -->|"yes"| G1["notifySessionMetadataChanged"]
    F2 -->|"yes"| G2["updateSettingsForSource"]
    F3 -->|"yes"| G3["saveGlobalConfig"]
    F4 -->|"yes"| G4["clearAuthCaches"]
    D --> H["for listener: listener()"]
    H --> I["useSyncExternalStore re-render"]
    I --> J["selector compares with Object.is"]
    J -->|"changed"| K["Component re-renders"]
    J -->|"same"| L["Render skipped"]
                    
7

🗂️ Context vs State

Nem tudo no diretorio context/ e React Context. O diretorio contem um mix de padroes:

Padrao Exemplos Descricao
React Context (thin)modalContext, overlayContextValores compartilhados na arvore de componentes
Hooks over AppStatenotifications.tsxLe/escreve AppState via useAppState + useSetAppState
External StorefpsMetrics, statsDados proprios fora do AppState (metricas de perf)
Side-Effect Managermailbox, voiceWebSocket/IPC connections

⚠️ context.ts e Diferente

O arquivo top-level context.ts (nao context/) e inteiramente nao-relacionado a React Context. Ele constroi o system prompt injetado em cada API call: getSystemContext() (git status, cache breaker) e getUserContext() (CLAUDE.md files, current date).

📋 Resumo do Modulo

createStore<T> em 35 linhas implementa exatamente a interface que useSyncExternalStore precisa -- sem biblioteca
setState recebe updater function, nao partial. Object.is bail-out previne re-renders espurios
AppState e enorme de proposito -- single source of truth para toda a sessao
onChangeAppState centraliza todos os side effects em um unico diff observer
Selectors usam Pick<AppState, ...> para testabilidade; transition helpers recebem setAppState como argumento
Retain/evict lifecycle para agent tasks e gerenciado inteiramente via campos no AppState
Modulo Anterior Proximo Modulo