This document captures what we've learned about where Cursor stores composer/chat data and how the Deadhand extension reads and forwards it.
The intent is to be a living reference as we continue debugging and reverse‑engineering. Storage formats may change with Cursor releases.
- Transcripts can contain sensitive data. We avoid copying actual conversation content into this doc.
- Observations here were gathered on macOS with Cursor, using SQLite inspection and extension runtime behavior.
- Where we say "observed", it means "confirmed with runtime evidence"; where we say "likely", it's an inference.
Cursor uses multiple SQLite "state" databases with a simple KV-table shape:
-
Workspace-scoped state DB:
- Path pattern (macOS):
~/Library/Application Support/Cursor/User/workspaceStorage/<workspaceId>/state.vscdb - Tables:
ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)cursorDiskKV (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)
- Path pattern (macOS):
-
Global state DB:
- Path (macOS):
~/Library/Application Support/Cursor/User/globalStorage/state.vscdb - Tables: same (
ItemTable,cursorDiskKV)
- Path (macOS):
- Composer/session metadata is primarily available via workspace
state.vscdb→ItemTable. - Actual transcript bubbles (messages, tools, "Questions" UI) are primarily stored in global
state.vscdb→cursorDiskKV.
In the workspace DB, ItemTable.key = 'composer.composerData' contains a JSON blob with an allComposers array.
We parse this to build session metadata:
composerId→ oursessionIdname→ session titleunifiedMode→agent|chat|plan|debug|backgroundlastUpdatedAt,createdAtcontextUsagePercent,totalLinesAdded,totalLinesRemoved,filesChangedCountsubtitle, etc.
This is implemented in:
packages/extension/src/storage-reader.ts(getAllComposers(),parseComposerValue())packages/extension/src/adapters/official.ts(buildEnrichedSession())
In the global DB, cursorDiskKV.key = 'composerData:<composerId>' contains a JSON object representing the composer's state.
Important fields we rely on:
fullConversationHeadersOnly: Array<{ bubbleId: string; type: number }>typeis observed as:1→ user bubble2→ assistant bubble
modelConfig.modelName(e.g.gpt-5.2)modelConfig.maxMode(boolean): whether "max mode" is enabled for this sessionunifiedMode(string): the session mode (agent,chat,plan,debug,background)text/richText(string): current input box draft content (not yet submitted)
Each header's bubbleId points to an individual bubble record stored under:
cursorDiskKV.key = 'bubbleId:<composerId>:<bubbleId>'
Observed bubble fields of interest:
-
Content
text(string): primary visible text shown in Cursor.richText(string): sometimes present as an alternative representation.- Some bubbles have
text = ""but still contain structured data (tools, thinking, etc.).
-
Model
modelInfo.modelName(string): observed to contain the model label (e.g.gpt-5.2).modelInfo.modelmay exist but is not consistently populated.
-
Timestamps / IDs
createdAt(ISO string)bubbleId(UUID)
-
Tool bubbles
toolFormerDataobject indicates a tool invocation recorded as a bubble.- Example:
toolFormerData.name = 'ask_question' toolFormerData.rawArgsis a JSON string (not an object) containing tool arguments.
Cursor's "Questions" UI (the multiple choice prompts in the composer) is stored as:
- Bubble contains:
toolFormerData.name = 'ask_question' - Tool args are in:
toolFormerData.rawArgs(stringified JSON)
Observed schema:
rawArgs.title: stringrawArgs.questions: Array<{ id: string; prompt: string; options: Array<{ id: string; label: string }> }>
flowchart TD
Cursor[Cursor_ExtHost] -->|composer.getOrderedSelectedComposerIds| OfficialAdapter
OfficialAdapter -->|read workspace state.vscdb ItemTable| ComposerMetadata
OfficialAdapter -->|read global state.vscdb cursorDiskKV| ComposerBubbles
OfficialAdapter -->|session_start/update + transcript_event| DaemonWS[Daemon_/ws/extension]
DaemonWS --> Persistence[~/.deadhand JSONL]
WebUI -->|REST + WS subscribe_session| DaemonAPI
DaemonAPI --> WebUI
The extension chooses a data source adapter in packages/extension/src/cursor-adapter.ts:
- Official adapter (
packages/extension/src/adapters/official.ts) is preferred.- Polls
composer.getOrderedSelectedComposerIdsevery 2s. - Reads rich metadata + transcript data from SQLite.
- Polls
- Logs adapter is a fallback (disabled by default, can be enabled via
deadhand.enableLogsAdapter).
CursorComposerStorageReader.getConversationTranscript(composerId):
- Legacy / workspace attempts:
- Looks for various
ItemTablekeys (historical guesses likecomposer.conversation.<id>). - Tries pattern matching on
ItemTablewhere key/value contains the composerId.
- Looks for various
- Global cursorDiskKV fallback (current working path):
- Loads
composerData:<composerId>from globalcursorDiskKV. - Walks
fullConversationHeadersOnlyand reads eachbubbleId:<composerId>:<bubbleId>. - Extracts:
text/richText→ message contentcreatedAt→ timestampmodelInfo.modelName(ormodelInfo.model) → model labeltoolFormerData→ tool call (e.g.,ask_question)
- Loads
This fallback is implemented in:
packages/extension/src/storage-reader.ts→loadTranscriptFromGlobalCursorDiskKV()
The global DB also stores Cursor's reactive UI state under:
ItemTable.key = 'src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.applicationUser'
This JSON blob contains user preferences and available models:
availableDefaultModels2: Array<ModelConfig>— list of models configured in Cursor
Each ModelConfig object includes:
| Field | Type | Description |
|---|---|---|
name |
string | Internal model ID (e.g., gpt-5.2, claude-3.5-sonnet) |
clientDisplayName |
string | UI display label |
serverModelName |
string | Server-side identifier |
defaultOn |
boolean | Whether this model is enabled by the user |
supportsMaxMode |
boolean | Whether model supports "max" reasoning mode |
supportsThinking |
boolean | Whether model supports thinking/chain-of-thought |
We filter to defaultOn === true to get the user's enabled models.
This is implemented in:
packages/extension/src/storage-reader.ts→getApplicationUserState(),getEnabledModels()
When the extension emits transcript events:
- Each message/tool bubble has a stable ID (
bubbleIdUUID). - We use that as
sourceIdso the daemon can safely dedupe on resync/reconnect.
Composer model name isn't reliably available from workspace composer.composerData, so we also read:
global cursorDiskKV composerData:<composerId>.modelConfig.modelName
and send session_update { model } best-effort.
This is implemented in:
packages/extension/src/storage-reader.ts→getComposerModelName()packages/extension/src/adapters/official.ts(callsgetComposerModelName()andclient.updateSession({ model }))
- Extension connects to daemon websocket:
/ws/extension - Daemon persists:
- Sessions:
~/.deadhand/sessions.jsonl - Transcript events:
~/.deadhand/transcripts/<sessionId>.jsonl
- Sessions:
Relevant daemon code:
packages/daemon/src/websocket.ts(handles extension messages)packages/daemon/src/registry.ts(in-memory state, dedupe, retention)packages/daemon/src/persistence.ts(JSONL append + reload)
The web UI:
- Subscribes over WS (
subscribe_session) for live events - Fetches history over REST (
GET /api/v1/sessions/:id) - Renders messages + tool events in the "Soviet terminal" style.
Relevant web files:
packages/web/src/components/Dashboard.tsxpackages/web/src/components/TranscriptView.tsx
This section documents what we've learned about sending user messages to a Cursor composer session programmatically.
Cursor exposes many internal commands via VS Code's command API. We discovered these by:
- Extracting command IDs from Cursor's bundled workbench JS (
/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js) - Running Deadhand's debug commands (
deadhand.debug.listComposerCommands,deadhand.debug.probeAllCursorAPIs)
Submit/send commands:
composer.submit— main submit command (requires text to already be in the input)composer.startComposerPrompt— may start a new composer with a prompt (parameter: prompt string)composer.sendToAgent— possibly sends current composer state to agent modeworkbench.action.chat.submit— VS Code workbench chat submitcomposer.triggerCreateWorktreeButton— submits the current composer input (used aftercreateNew)
Create/navigation commands:
composer.createNew— create a new composer with optionalpartialState(see below)composer.openComposer— open/focus a specific composer by ID (parameter:composerId)composer.focusComposer— focus the current composerworkbench.action.chat.focusInput— focus the chat input fieldcomposer.getOrderedSelectedComposerIds— returns array of active composer IDs (used for detection)
Mode commands:
composerMode.agent,composerMode.chat,composerMode.plan,composerMode.debug, etc.
Working approaches:
-
composer.startComposerPrompt: Can be called with a message string. This appears to populate the composer input with the provided text and may start a new conversation. However, it doesn't reliably target a specificcomposerId. -
composer.openComposer+ focus: Can open a specific composer by ID, but there's no direct command to inject text into the input field and submit it.
Not working (yet):
- There's no discovered command that accepts
(composerId, messageText)and directly submits a message to a specific session. - The internal composer input is managed by a webview/custom editor, not accessible via standard VS Code text document APIs.
The send message functionality is implemented in:
-
packages/extension/src/debug.ts:- Debug commands for testing:
deadhand.debug.sendMessageToComposer— tries multiple strategiesdeadhand.debug.execCommand— generic command executor for exploration
- Debug commands for testing:
-
packages/daemon/src/websocket.ts— handlessend_messagefrom web UI, forwards to extension -
packages/daemon/src/types.ts— message types for send_message request/result -
packages/extension/src/daemon-client.ts— handles incomingsend_message_requestfrom daemon -
packages/extension/src/extension.ts— wires up the message sending handler
- Open Cursor with the Deadhand extension installed
- Enable debug mode:
"deadhand.debugMode": true - Run
Deadhand Debug: List Composer Commandsfrom command palette - Run
Deadhand Debug: Execute Arbitrary Commandto test individual commands
- Monitor for new Cursor commands that might accept message text directly
- Explore Cursor's extension host process for additional IPC mechanisms
- Consider writing a companion extension that registers as an MCP server to receive prompts
This section documents how we create new chat sessions remotely with a specified mode and model.
The composer.createNew command accepts an options object with a partialState field that can pre-populate the new composer:
await vscode.commands.executeCommand('composer.createNew', {
openInNewTab: true,
partialState: {
unifiedMode: 'agent', // 'agent' | 'chat' | 'plan' | 'debug' | 'background'
text: 'Your prompt here', // Pre-fill input text
richText: 'Your prompt here', // Alternative text representation
modelConfig: {
modelName: 'gpt-5.2', // Model to use
maxMode: false // Whether to use max mode
}
}
});After calling createNew, the new composer becomes selected. We detect its ID by:
- Before: Call
composer.getOrderedSelectedComposerIdsto get current IDs - Create: Call
composer.createNewwith desired state - After: Call
composer.getOrderedSelectedComposerIdsagain - Diff: The new ID is the one not in the "before" list
Creating a composer with text populated doesn't automatically submit it. To submit:
- Optionally call
composer.openComposer(composerId, { focusMainInputBox: true })to ensure focus - Call
composer.triggerCreateWorktreeButtonto submit the input
After submission, we poll the transcript to verify the message was delivered:
- Read transcript from
cursorDiskKV bubbleId:<composerId>:* - Hash the expected message content
- Check if a user bubble with matching content hash appears
- Timeout after ~8 seconds if not found
The create chat flow is implemented in:
packages/extension/src/extension.ts→onCreateChatRequesthandlerpackages/daemon/src/websocket.ts→ handlescreate_chatfrom web UIpackages/web/src/components/SessionList.tsx→ UI for creating new chats
- Thinking blocks: some bubbles include
thinking.signatureJSON containingencrypted_content+summary. Decryption and schema are unknown. - Tool results: not all tools store output uniformly; some data appears in large nested arrays (
toolResults,diffHistories,codeBlocks, etc.). - Non-active / archived sessions: confirm how Cursor persists old conversations and whether
fullConversationHeadersOnlyis always sufficient. - Cross-platform: validate Windows/Linux paths and any schema differences.
- Direct message injection: find a cleaner way to submit messages to a specific composerId.