How I Recovered a Vanished Cursor Agent Chat
What happened
A long-running agent conversation (~2,700 messages over multiple days) vanished from the Cursor sidebar mid-session, while I was using it. One moment it was there, the next it was gone. Restarting Cursor didn’t bring it back.
What we discovered
After several hours of investigation, we found that Cursor stores conversations across two separate SQLite databases, and the sidebar is controlled by an index in the workspace-specific one — not the global one where the actual conversation data lives.
The two databases
-
Global DB:
~/Library/Application Support/Cursor/User/globalStorage/state.vscdb- Stores actual conversation content:
composerData,bubbleIdentries,checkpointIdentries in thecursorDiskKVtable - This is the big one (ours was ~59GB)
- Stores actual conversation content:
-
Workspace DB:
~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/state.vscdb- Stores a sidebar index in an
ItemTablekey calledcomposer.composerData - This is small (~100MB)
- Stores a sidebar index in an
The sidebar index
The workspace DB’s composer.composerData key contains JSON with:
allComposers: array of{type:"head", composerId:"<uuid>", name:"...", ...}objects — this is what populates the sidebar listselectedComposerIds: array of UUIDs for currently open tabs
What went wrong in our case
Our conversation’s entry was removed from allComposers in the workspace DB. The full conversation data (2,709 messages, all tool calls, file edits, everything) was still intact in the global DB. But without being in allComposers, Cursor never even tried to load it.
We also found that our conversation’s composerData in the global DB had status: "aborted" and fullConversationHeadersOnly: [] (empty bubble list), so we had to fix those too.
How we fixed it
Your situation may differ from ours. These are the steps we went through — adapt as needed.
Step 1: Back up both databases
Do this before touching anything.
# Back up global DB (this is large - 50+ GB, takes a few minutes)
cp ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb /path/to/backup/state.vscdb.backup
cp ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb-wal /path/to/backup/state.vscdb-wal.backup
cp ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb-shm /path/to/backup/state.vscdb-shm.backup
Step 2: Find your workspace hash
Each project has its own workspace storage directory. Find yours:
for dir in ~/Library/Application\ Support/Cursor/User/workspaceStorage/*/; do
if [ -f "$dir/workspace.json" ]; then
if grep -q "YOUR_PROJECT_FOLDER_NAME" "$dir/workspace.json" 2>/dev/null; then
echo "FOUND: $dir"
cat "$dir/workspace.json"
fi
fi
done
Step 3: Find your lost conversation ID
The conversation data likely still exists in the global DB. This script lists all conversations:
# find_lost_conversation.py
import sqlite3, json, os
from datetime import datetime
global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')
conn = sqlite3.connect(global_db)
c = conn.cursor()
# List all conversations sorted by most recent
c.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")
convos = []
for key, val in c.fetchall():
try:
obj = json.loads(val)
convos.append({
'id': key.replace('composerData:', ''),
'name': obj.get('name', '(unnamed)'),
'lastUpdatedAt': obj.get('lastUpdatedAt', 0),
'status': obj.get('status', '?'),
'bubbles': len(obj.get('fullConversationHeadersOnly', [])),
})
except:
pass
convos.sort(key=lambda x: x['lastUpdatedAt'], reverse=True)
for co in convos[:30]:
ts = datetime.fromtimestamp(co['lastUpdatedAt']/1000).strftime('%Y-%m-%d %H:%M') if co['lastUpdatedAt'] else '?'
print(f"{ts} | {co['status']:10s} | {co['bubbles']:5d} bubbles | {co['name'][:60]}")
print(f" ID: {co['id']}")
conn.close()
Note: This query can be slow on a large database. Look for your conversation by name, date, or bubble count.
Step 4: Verify the conversation data exists
# verify_conversation.py
import sqlite3, json, os
CONV_ID = 'YOUR_CONVERSATION_ID_HERE' # <-- Replace this
global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')
conn = sqlite3.connect(global_db)
c = conn.cursor()
c.execute("SELECT length(value) FROM cursorDiskKV WHERE key = ?", (f'composerData:{CONV_ID}',))
r = c.fetchone()
print(f"composerData: {r[0] if r else 'MISSING'} bytes")
c.execute("SELECT COUNT(*) FROM cursorDiskKV WHERE key LIKE ?", (f'bubbleId:{CONV_ID}:%',))
print(f"bubbleId entries: {c.fetchone()[0]}")
conn.close()
If composerData exists and there are bubbleId entries, your conversation data is intact.
Step 5: Check if the conversation is in the sidebar index
# check_sidebar_index.py
import sqlite3, json, os
CONV_ID = 'YOUR_CONVERSATION_ID_HERE'
WORKSPACE_HASH = 'YOUR_WORKSPACE_HASH' # From step 2
ws_db = os.path.expanduser(f'~/Library/Application Support/Cursor/User/workspaceStorage/{WORKSPACE_HASH}/state.vscdb')
conn = sqlite3.connect(ws_db)
c = conn.cursor()
c.execute('SELECT value FROM ItemTable WHERE key = ?', ('composer.composerData',))
obj = json.loads(c.fetchone()[0])
all_composers = obj.get('allComposers', [])
found = any(isinstance(e, dict) and e.get('composerId') == CONV_ID for e in all_composers)
print(f"allComposers has {len(all_composers)} entries")
print(f"Your conversation is in the list: {found}")
conn.close()
If your conversation is NOT in the list, that’s why it vanished. Proceed to step 6.
Step 6: Add the conversation to the sidebar index
Close Cursor completely first (Cmd+Q or pkill -9 -f Cursor). Cursor will revert changes made while it’s running.
# fix_sidebar.py
import sqlite3, json, os
CONV_ID = 'YOUR_CONVERSATION_ID_HERE' # <-- Replace this
WORKSPACE_HASH = 'YOUR_WORKSPACE_HASH' # <-- From step 2
global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')
ws_db = os.path.expanduser(f'~/Library/Application Support/Cursor/User/workspaceStorage/{WORKSPACE_HASH}/state.vscdb')
# Read conversation metadata from global DB
gconn = sqlite3.connect(global_db)
gc = gconn.cursor()
gc.execute('SELECT value FROM cursorDiskKV WHERE key = ?', (f'composerData:{CONV_ID}',))
row = gc.fetchone()
if not row:
print("ERROR: composerData not found in global DB")
exit(1)
conv_data = json.loads(row[0])
gconn.close()
# Build the sidebar head entry
head = {
"type": "head",
"composerId": CONV_ID,
"name": conv_data.get("name", "Recovered conversation"),
"lastUpdatedAt": conv_data.get("lastUpdatedAt", 0),
"createdAt": conv_data.get("createdAt", 0),
"unifiedMode": conv_data.get("unifiedMode", "agent"),
"forceMode": conv_data.get("forceMode", "edit"),
"hasUnreadMessages": False,
"contextUsagePercent": conv_data.get("contextUsagePercent", 0),
"totalLinesAdded": conv_data.get("totalLinesAdded", 0),
"totalLinesRemoved": conv_data.get("totalLinesRemoved", 0),
"filesChangedCount": conv_data.get("filesChangedCount", 0),
"subtitle": conv_data.get("subtitle", ""),
"isAgentic": conv_data.get("isAgentic", True),
"status": conv_data.get("status", "completed"),
}
# Add to workspace sidebar index
conn = sqlite3.connect(ws_db)
c = conn.cursor()
c.execute('SELECT value FROM ItemTable WHERE key = ?', ('composer.composerData',))
ws_data = json.loads(c.fetchone()[0])
all_composers = ws_data.get('allComposers', [])
already_present = any(
isinstance(e, dict) and e.get('composerId') == CONV_ID
for e in all_composers
)
if already_present:
print("Conversation already in sidebar index")
else:
all_composers.insert(0, head)
ws_data['allComposers'] = all_composers
# Also add to selectedComposerIds (open tabs)
selected = ws_data.get('selectedComposerIds', [])
if CONV_ID not in selected:
selected.insert(0, CONV_ID)
ws_data['selectedComposerIds'] = selected
# Write as compact JSON (no spaces) to match Cursor's format
c.execute('UPDATE ItemTable SET value = ? WHERE key = ?',
(json.dumps(ws_data, separators=(',', ':')), 'composer.composerData'))
conn.commit()
print(f"SUCCESS: Added to allComposers ({len(all_composers)} total)")
print(f"Added to selectedComposerIds ({len(selected)} total)")
conn.close()
print("\nReopen Cursor. The conversation should appear in the sidebar.")
Step 7 (if needed): Fix empty bubble headers
In our case, the conversation’s composerData in the global DB had fullConversationHeadersOnly: [] (empty) and status: "aborted". The conversation appeared in the sidebar after step 6 but showed no messages until we fixed this.
If you have the same issue, run this with Cursor closed:
# fix_bubble_headers.py
import sqlite3, json, os
CONV_ID = 'YOUR_CONVERSATION_ID_HERE'
global_db = os.path.expanduser('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb')
conn = sqlite3.connect(global_db)
c = conn.cursor()
# Get all bubble entries
c.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE ?", (f'bubbleId:{CONV_ID}:%',))
rows = c.fetchall()
bubbles = []
for key, value in rows:
try:
obj = json.loads(value)
bubbles.append({
'bubbleId': obj.get('bubbleId', key.split(':')[-1]),
'type': obj.get('type', 2),
'createdAt': obj.get('createdAt', ''),
})
except:
pass
bubbles.sort(key=lambda b: b['createdAt'])
headers = [{'bubbleId': b['bubbleId'], 'type': b['type']} for b in bubbles]
# Update composerData
c.execute('SELECT value FROM cursorDiskKV WHERE key = ?', (f'composerData:{CONV_ID}',))
obj = json.loads(c.fetchone()[0])
obj['fullConversationHeadersOnly'] = headers
obj['status'] = 'completed' # Was 'aborted' in our case
# Write as compact JSON to match Cursor's format
c.execute('UPDATE cursorDiskKV SET value = ? WHERE key = ?',
(json.dumps(obj, separators=(',', ':')), f'composerData:{CONV_ID}'))
conn.commit()
print(f"Updated: {len(headers)} bubble headers, status=completed")
conn.close()
Important notes from our experience
- Always back up before modifying anything. We backed up all three DB files (
.vscdb,-wal,-shm) to an external drive before starting. - Close Cursor completely before writing to the databases. Cursor reverts changes made while it’s running. We learned this the hard way — multiple fixes were silently undone on restart.
- Use compact JSON (
separators=(',', ':')) when writing. Cursor’s internal serialization uses no spaces. We initially wrote with Python’s default spacing and while it shouldn’t matter for a JSON parser, matching the format avoids any potential issues. - The global DB can be 50+ GB. LIKE queries on
cursorDiskKVare extremely slow. Target specific keys when possible. - Cursor actively garbage-collects conversation data. We observed Cursor delete a 28MB composerData entry in real-time while investigating. Don’t wait — back up immediately if a conversation vanishes.
Architecture reference
This is what we mapped out during our investigation. Your Cursor version may differ.
Cursor conversation storage (as of v2.4.28, Feb 2026):
Global DB (globalStorage/state.vscdb):
cursorDiskKV table:
composerData:<conv-id> → full conversation metadata + bubble header list
bubbleId:<conv-id>:<id> → individual message content (JSON)
checkpointId:<conv-id>:<id> → file state snapshots per message
agentKv:checkpoint:<conv-id> → Merkle tree root for LLM context
agentKv:blob:<sha256> → content-addressable blob storage
Workspace DB (workspaceStorage/<hash>/state.vscdb):
ItemTable:
composer.composerData → JSON with:
allComposers: [{type:"head", composerId:"...", name:"...", ...}, ...]
↑ THIS IS THE SIDEBAR INDEX
selectedComposerIds: ["uuid1", "uuid2", ...] ← open tabs
lastFocusedComposerIds: [...]
The sidebar reads ONLY from allComposers in the workspace DB. If a conversation is missing from that list, it’s invisible — regardless of whether the full data exists in the global DB. That was our problem, and adding the entry back to allComposers was the fix.