Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ const VSCODE_APP_DATA = process.platform === 'darwin'
const VSCODE_WORKSPACE_STORAGE = path.join(VSCODE_APP_DATA, 'User', 'workspaceStorage');
const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl');
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
const SAFE_LOCAL_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;

// Scan Claude desktop app's local-agent-mode-sessions for embedded .claude dirs
// Structure: ~/Library/Application Support/Claude/local-agent-mode-sessions/<id>/<id>/local_<id>/.claude/
Expand Down Expand Up @@ -2577,6 +2578,187 @@ function parseCodexSessionFile(sessionFile) {
};
}

function getCodexDeleteBackupRoot() {
if (process.env.CODEBASH_DELETE_BACKUP_DIR) {
return path.resolve(process.env.CODEBASH_DELETE_BACKUP_DIR);
}

const userBackup = path.join(ALL_HOMES[0], 'backup', 'codex');
if (fs.existsSync(userBackup)) return path.join(userBackup, 'codbash-deleted');

return path.join(CODEX_DIR, 'backups', 'codbash-deleted');
}
Comment on lines +2581 to +2590

function codexLineSessionId(line) {
try {
const entry = JSON.parse(line);
return entry.session_id || entry.sessionId || entry.id || '';
} catch {
return '';
}
}

function collectJsonlLinesForSession(filePath, sessionId) {
if (!fs.existsSync(filePath)) return [];
const matches = [];
for (const line of readLines(filePath)) {
if (codexLineSessionId(line) === sessionId) matches.push(line);
}
return matches;
}

function removeJsonlLinesForSession(filePath, sessionId) {
if (!fs.existsSync(filePath)) return 0;
const lines = readLines(filePath);
const filtered = lines.filter(line => codexLineSessionId(line) !== sessionId);
const removed = lines.length - filtered.length;
if (removed > 0) {
fs.writeFileSync(filePath, filtered.length ? filtered.join('\n') + '\n' : '');
}
return removed;
}
Comment on lines +2610 to +2619

function codexSessionReferencesExist(sessionId) {
return collectJsonlLinesForSession(path.join(CODEX_DIR, 'history.jsonl'), sessionId).length > 0 ||
collectJsonlLinesForSession(path.join(CODEX_DIR, 'session_index.jsonl'), sessionId).length > 0;
}

function writeLinesIfAny(filePath, lines) {
if (!lines || lines.length === 0) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, lines.join('\n') + '\n', { mode: 0o600 });
}

function copyFileIfExists(source, target) {
if (!source || !fs.existsSync(source)) return false;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
try { fs.chmodSync(target, 0o600); } catch {}
return true;
}

function safeSqlString(value) {
if (!SAFE_LOCAL_SESSION_ID.test(String(value || ''))) return '';
return String(value);
}

function backupCodexThreadRow(sessionId, backupDir) {
const safeId = safeSqlString(sessionId);
if (!safeId) return false;
const stateDb = path.join(CODEX_DIR, 'state_5.sqlite');
if (!fs.existsSync(stateDb)) return false;
try {
const rows = execFileSync('sqlite3', ['-json', stateDb, `SELECT * FROM threads WHERE id = '${safeId}';`], {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (!rows || rows === '[]') return false;
fs.writeFileSync(path.join(backupDir, 'state-thread.json'), rows + '\n', { mode: 0o600 });
return true;
} catch {
return false;
}
}

function backupCodexDeleteArtifacts(sessionId, sessionFile, project) {
const root = getCodexDeleteBackupRoot();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = path.join(root, `${stamp}-${sessionId.slice(0, 8)}`);
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });

const manifest = {
sessionId,
project: project || '',
deletedAt: new Date().toISOString(),
codexDir: CODEX_DIR,
artifacts: [],
};

if (copyFileIfExists(sessionFile, path.join(backupDir, 'session.jsonl'))) {
manifest.artifacts.push({ type: 'session', source: sessionFile, backup: 'session.jsonl' });
}

const historyFile = path.join(CODEX_DIR, 'history.jsonl');
const historyLines = collectJsonlLinesForSession(historyFile, sessionId);
if (historyLines.length) {
writeLinesIfAny(path.join(backupDir, 'history.jsonl'), historyLines);
manifest.artifacts.push({ type: 'history', source: historyFile, backup: 'history.jsonl', lines: historyLines.length });
}

const indexFile = path.join(CODEX_DIR, 'session_index.jsonl');
const indexLines = collectJsonlLinesForSession(indexFile, sessionId);
if (indexLines.length) {
writeLinesIfAny(path.join(backupDir, 'session_index.jsonl'), indexLines);
manifest.artifacts.push({ type: 'session_index', source: indexFile, backup: 'session_index.jsonl', lines: indexLines.length });
}

if (backupCodexThreadRow(sessionId, backupDir)) {
manifest.artifacts.push({ type: 'state_thread', source: path.join(CODEX_DIR, 'state_5.sqlite'), backup: 'state-thread.json' });
}

atomicWriteJson(path.join(backupDir, 'manifest.json'), manifest, { mode: 0o600 });
return backupDir;
}

function pruneEmptyDirsUntil(dir, stopDir) {
let current = dir;
const stop = path.resolve(stopDir);
while (current && path.resolve(current) !== stop && path.resolve(current).startsWith(stop + path.sep)) {
try {
if (!fs.existsSync(current) || fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}

function deleteCodexSession(sessionId, project, found) {
const deleted = [];
if (!SAFE_LOCAL_SESSION_ID.test(String(sessionId || ''))) return deleted;

const sessionFile = found && found.file && fs.existsSync(found.file) ? found.file : '';
const backupDir = backupCodexDeleteArtifacts(sessionId, sessionFile, project);
deleted.push('backup: ' + backupDir);

if (sessionFile && fs.existsSync(sessionFile)) {
fs.unlinkSync(sessionFile);
deleted.push('codex session file');
pruneEmptyDirsUntil(path.dirname(sessionFile), path.join(CODEX_DIR, 'sessions'));
}

const historyRemoved = removeJsonlLinesForSession(path.join(CODEX_DIR, 'history.jsonl'), sessionId);
if (historyRemoved > 0) deleted.push(`${historyRemoved} codex history entries`);

const indexRemoved = removeJsonlLinesForSession(path.join(CODEX_DIR, 'session_index.jsonl'), sessionId);
if (indexRemoved > 0) deleted.push(`${indexRemoved} codex index entries`);

const safeId = safeSqlString(sessionId);
const stateDb = path.join(CODEX_DIR, 'state_5.sqlite');
if (safeId && fs.existsSync(stateDb)) {
try {
execFileSync('sqlite3', [stateDb, `DELETE FROM threads WHERE id = '${safeId}';`], {
timeout: 5000,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
deleted.push('codex state thread');
} catch {}
}
Comment on lines +2739 to +2750

_sessionsCache = null;
_sessionsCacheTs = 0;
_sessionFileIndex = null;
_sessionFileIndexTs = 0;
_codexDayDirMtimesPending = null;
_codexSessionsDirMtimes = {};

return deleted;
}

function scanCodexSessions() {
const sessions = [];
const codexTitles = parseCodexSessionIndex(CODEX_DIR);
Expand Down Expand Up @@ -3644,6 +3826,10 @@ function deleteSession(sessionId, project) {
const deleted = [];
let found = findSessionFile(sessionId, project);

if ((found && found.format === 'codex') || (!found && codexSessionReferencesExist(sessionId))) {
return deleteCodexSession(sessionId, project, found);
}

if (found && found.format === 'qwen' && fs.existsSync(found.file)) {
fs.unlinkSync(found.file);
deleted.push('session file');
Expand Down
98 changes: 98 additions & 0 deletions test/codex-delete.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');

function tmpDir() {
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-codex-delete-')));
}
Comment on lines +7 to +9

function writeJsonl(filePath, entries) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
}

function freshDataModule(home, backupDir) {
const dataPath = require.resolve('../src/data');
delete require.cache[dataPath];
const oldHome = os.homedir;
const oldBackup = process.env.CODEBASH_DELETE_BACKUP_DIR;
os.homedir = () => home;
process.env.CODEBASH_DELETE_BACKUP_DIR = backupDir;
try {
return require('../src/data');
} finally {
os.homedir = oldHome;
if (oldBackup === undefined) delete process.env.CODEBASH_DELETE_BACKUP_DIR;
else process.env.CODEBASH_DELETE_BACKUP_DIR = oldBackup;
}
}

test('deleteSession removes Codex artifacts after creating a backup', () => {
const home = tmpDir();
const backupRoot = path.join(home, 'backup', 'codex');
const project = path.join(home, 'work', 'demo');
fs.mkdirSync(project, { recursive: true });

const sid = '019e1234-1234-7000-8000-123456789abc';
const otherSid = '019e9999-1234-7000-8000-123456789abc';
const codexDir = path.join(home, '.codex');
const sessionFile = path.join(codexDir, 'sessions', '2026', '05', '26', `rollout-20260526-${sid}.jsonl`);
writeJsonl(sessionFile, [
{ type: 'session_meta', payload: { id: sid, cwd: project, timestamp: '2026-05-26T12:00:00Z' } },
{ type: 'response_item', payload: { role: 'user', content: [{ type: 'input_text', text: '整理 Codex 记忆' }] } },
]);
writeJsonl(path.join(codexDir, 'history.jsonl'), [
{ session_id: sid, ts: 1779796800, text: '整理 Codex 记忆', cwd: project },
{ session_id: otherSid, ts: 1779796900, text: 'keep me', cwd: project },
]);
writeJsonl(path.join(codexDir, 'session_index.jsonl'), [
{ id: sid, thread_name: 'messy generated title', updated_at: 1779796800000 },
{ id: otherSid, thread_name: 'keep title', updated_at: 1779796900000 },
]);

const data = freshDataModule(home, backupRoot);
const deleted = data.deleteSession(sid, project);

assert.equal(fs.existsSync(sessionFile), false);
assert.match(deleted.join('\n'), /backup:/);
assert.match(deleted.join('\n'), /codex session file/);
assert.match(deleted.join('\n'), /1 codex history entries/);
assert.match(deleted.join('\n'), /1 codex index entries/);

const history = fs.readFileSync(path.join(codexDir, 'history.jsonl'), 'utf8');
assert.equal(history.includes(sid), false);
assert.equal(history.includes(otherSid), true);

const index = fs.readFileSync(path.join(codexDir, 'session_index.jsonl'), 'utf8');
assert.equal(index.includes(sid), false);
assert.equal(index.includes(otherSid), true);

const backupLine = deleted.find(x => x.startsWith('backup: '));
const backupDir = backupLine.slice('backup: '.length);
assert.equal(fs.existsSync(path.join(backupDir, 'manifest.json')), true);
assert.equal(fs.existsSync(path.join(backupDir, 'session.jsonl')), true);
assert.equal(fs.readFileSync(path.join(backupDir, 'history.jsonl'), 'utf8').includes(sid), true);
assert.equal(fs.readFileSync(path.join(backupDir, 'session_index.jsonl'), 'utf8').includes(sid), true);
});

test('deleteSession handles Codex history-only sessions', () => {
const home = tmpDir();
const backupRoot = path.join(home, 'backup', 'codex');
const project = path.join(home, 'work', 'demo');
fs.mkdirSync(project, { recursive: true });

const sid = '019e2234-1234-7000-8000-123456789abc';
const codexDir = path.join(home, '.codex');
writeJsonl(path.join(codexDir, 'history.jsonl'), [
{ session_id: sid, ts: 1779796800, text: 'history only', cwd: project },
]);

const data = freshDataModule(home, backupRoot);
const deleted = data.deleteSession(sid, project);

assert.equal(fs.readFileSync(path.join(codexDir, 'history.jsonl'), 'utf8'), '');
assert.match(deleted.join('\n'), /backup:/);
assert.match(deleted.join('\n'), /1 codex history entries/);
});