combobulator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 combobulate contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # combobulate
2
+
3
+ Unify local chat memory across **Claude Code**, **Codex**, and **Cursor**.
4
+
5
+ Each desktop AI coding tool keeps its chat history in its own format on your disk.
6
+ If you ask Codex a question and want to pick the conversation up in Claude Code,
7
+ there's normally no way to do it — no shared history.
8
+
9
+ `combobulate` is a small macOS daemon that watches each tool's session storage
10
+ and mirrors new chats across the others. Open the same project in any of the
11
+ three tools and you'll see the chats you started elsewhere, with proper titles,
12
+ timestamps, and tool-call rendering native to that tool.
13
+
14
+ ## Install
15
+
16
+ Requires **Node.js ≥ 22** (uses the built-in `node:sqlite` module) and **macOS**
17
+ (the daemon runs under launchd; Linux is a future port).
18
+
19
+ ```bash
20
+ # from a clone
21
+ git clone https://github.com/<you>/combobulate ~/combobulate
22
+ cd ~/combobulate
23
+ npm install -g .
24
+ combobulate install
25
+ ```
26
+
27
+ Or, once published:
28
+
29
+ ```bash
30
+ npm install -g combobulator # the npm package name (the `combobulate` bare
31
+ # name was already taken on npm — the CLI
32
+ # binary is still `combobulate`)
33
+ combobulate install
34
+ ```
35
+
36
+ `combobulate install` writes a launchd plist at
37
+ `~/Library/LaunchAgents/com.combobulate.daemon.plist` and loads it. From now on,
38
+ every new chat in any of the three tools mirrors to the others within ~1.5s, and
39
+ the daemon restarts automatically on every login.
40
+
41
+ ## What works
42
+
43
+ | Surface | Sync direction | How |
44
+ |---|---|---|
45
+ | Claude Code CLI (`claude /resume`) | read + write | direct file mirror under `~/.claude/projects/` |
46
+ | Claude Code VS Code extension | read + write | same files |
47
+ | Claude Code Cursor extension | read + write | same files |
48
+ | Codex CLI (`codex resume`) | read + write | direct rollout mirror under `~/.codex/sessions/` |
49
+ | Codex Desktop project sidebar | read + write | rollout + `codex app` IPC + `state_5.sqlite` row |
50
+ | Cursor | **read-only** | `state.vscdb` sqlite read |
51
+ | Claude Desktop "Claude Code" tab | not supported | cloud-backed, see Limitations |
52
+
53
+ ## How it works
54
+
55
+ ```
56
+ ┌──────────────┐
57
+ Claude Code ──► │ │ ──► Claude Code
58
+ │ combobulate │
59
+ Codex ────────► │ daemon │ ──► Codex
60
+ │ │
61
+ Cursor ───────► │ (poll @1.5s)│ Cursor (write deferred)
62
+ └──────────────┘
63
+ ```
64
+
65
+ Every 1.5s the daemon scans:
66
+ - `~/.claude/projects/<encoded-cwd>/*.jsonl` — Claude Code sessions
67
+ - `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` — Codex rollouts
68
+ - `~/Library/Application Support/Cursor/.../state.vscdb` — Cursor's chat DB
69
+ (opened read-only with `mode=ro&immutable=1` so we never contend with the
70
+ running Cursor process)
71
+
72
+ For each session newer than the install epoch the daemon:
73
+
74
+ 1. **Normalizes** it into a tool-agnostic typed event stream: real user prompts,
75
+ assistant text responses, tool calls (with input/output), tool results.
76
+ 2. **Detects mirrors** via a `__combobulate_mirror__` marker baked into every file
77
+ we write, so we never re-mirror our own writes.
78
+ 3. **Fingerprints** the message stream — skips if nothing has changed since the
79
+ last sync. Same source updating? Overwrite the same mirror file in place.
80
+ 4. **Emits** to each target tool's native format, including:
81
+ - Per-turn `task_started` / `turn_context` / `user_message` / `reasoning` /
82
+ `agent_message` / `task_complete` for Codex (the format Codex Desktop's
83
+ renderer requires — wrong `phase` values or missing events make messages
84
+ invisible).
85
+ - Native `function_call` / `exec_command_end` / `function_call_output`
86
+ events for tool calls (Bash → `exec_command`, Edit/Write/MultiEdit →
87
+ `custom_tool_call name=apply_patch` with a unified diff).
88
+ - Real per-message timestamps so the chat sorts under the date the original
89
+ conversation happened.
90
+ - A `[Claude Code]` / `[Codex]` / `[Cursor]` tag prepended to every mirrored
91
+ thread title so you can see at a glance where a chat came from.
92
+
93
+ For Codex Desktop specifically, the daemon also:
94
+ - Calls `codex app <cwd>` so the project appears in Codex Desktop's sidebar.
95
+ - Inserts a row into `state_5.sqlite`'s `threads` table with `source='cli'`,
96
+ non-zero `tokens_used`, and a non-empty `preview` — the threads with all three
97
+ of those missing get filtered out of Codex Desktop's chat list as "empty drafts".
98
+
99
+ ## Commands
100
+
101
+ ```bash
102
+ combobulate install # set up the launchd agent, start the daemon
103
+ combobulate uninstall # remove the launchd agent (state in ~/.combobulate is kept)
104
+ combobulate daemon # run the watcher in the foreground (used by launchd)
105
+ combobulate status # quick state summary
106
+ combobulate doctor # diagnose: daemon, paths, state, recent errors
107
+ combobulate sync # one-shot mirror pass (doesn't need the daemon)
108
+ --all # ignore the install epoch
109
+ --since-hours=N # default 24
110
+ --limit=N # max sessions per source, default 20
111
+ --dry-run # log what would mirror, write nothing
112
+ combobulate fix-codex-projects
113
+ # re-register all mirror cwds with Codex Desktop
114
+ combobulate cleanup # remove broken Codex thread rows we created
115
+ --dry-run # list what would be deleted
116
+ combobulate help
117
+ ```
118
+
119
+ **First-time troubleshooting**: run `combobulate doctor`. If it reports
120
+ problems, the message tells you the exact recovery command.
121
+
122
+ ## Files combobulate touches
123
+
124
+ | Location | What |
125
+ |---|---|
126
+ | `~/.combobulate/state.json` | mirror tracking (source fingerprint → target ids/paths) |
127
+ | `~/.combobulate/daemon.log` | daemon log |
128
+ | `~/.combobulate/synced/` | fallback cwd for sourceless sessions (Cursor) |
129
+ | `~/Library/LaunchAgents/com.combobulate.daemon.plist` | launchd entry |
130
+ | `~/.claude/projects/<cwd>/<uuid>.jsonl` | mirrored Claude sessions |
131
+ | `~/.claude/history.jsonl` | up-arrow entries prefixed `[Codex]` / `[Cursor]` |
132
+ | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | mirrored Codex rollouts |
133
+ | `~/.codex/state_5.sqlite` `threads` table | row per mirror so Codex Desktop renders it |
134
+ | `~/.codex/session_index.jsonl` | thread-index entry |
135
+ | `~/.codex/history.jsonl` | up-arrow entries prefixed `[Claude Code]` / `[Cursor]` |
136
+ | `~/.codex/.codex-global-state.json` | workspace-roots additions (one-time per cwd) |
137
+
138
+ Every mirrored Claude session has a `__combobulate_mirror__` marker on line 1.
139
+ Every mirrored Codex rollout has it nested at `session_meta.payload.combobulate`.
140
+ That's our loop-prevention key. To wipe all mirrors:
141
+
142
+ ```bash
143
+ combobulate uninstall
144
+ grep -rl __combobulate_mirror__ ~/.claude/projects ~/.codex/sessions | xargs rm
145
+ combobulate cleanup # remove dangling DB rows (still requires Node 22+)
146
+ rm -rf ~/.combobulate
147
+ ```
148
+
149
+ ## Known limitations
150
+
151
+ - **macOS only.** Daemon runs under launchd; Linux/systemd is a small port.
152
+ - **Cursor is read-only.** Cursor holds its 1.3GB `state.vscdb` open with a
153
+ write lock while the app is running — injecting writes risks corruption.
154
+ Cursor → others works fully; others → Cursor needs a Cursor extension that
155
+ runs in-process.
156
+ - **Claude Desktop's "Claude Code" tab doesn't show synced chats.** That tab
157
+ is cloud-backed — it lists sessions registered in `bridge-state.json` between
158
+ a local CLI session and a claude.ai cloud session. Our mirrors are local-only
159
+ files, so they never get bridged. The Claude Code CLI, the VS Code extension,
160
+ and the Cursor extension all show synced chats correctly; only the
161
+ Claude Desktop in-app tab is affected.
162
+ - **Codex Desktop's diff card** under an Edit/Write tool call shows
163
+ "initialize a git repo" if the cwd isn't a git repo, and otherwise shows
164
+ no diff if the file is already at the final state on disk. Codex's diff
165
+ renderer reads the live working tree via `git diff`, not the unified diff
166
+ embedded in the rollout — this is a Codex design choice.
167
+ - **Tool fidelity isn't 100% across tools.** Claude's `Read`/`Glob`/`Grep`/etc.
168
+ are translated to Codex `exec_command` with a representative shell command;
169
+ the surface looks right but the tool semantics don't perfectly match. Edit
170
+ operations translate cleanly via `apply_patch`.
171
+
172
+ ## Hacking / testing
173
+
174
+ ```bash
175
+ npm test # runs test-e2e.mjs + test-daemon.mjs in a tmp HOME
176
+ COMBOBULATE_DEBUG=1 combobulate daemon
177
+ tail -f ~/.combobulate/daemon.log
178
+ ```
179
+
180
+ The architecture (sources, sinks, daemon orchestrator) is documented in code.
181
+ The codex sink in particular has long comments capturing every format detail
182
+ we reverse-engineered the hard way — keep them in sync if Codex's format
183
+ shifts.
184
+
185
+ ## License
186
+
187
+ MIT.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+ main(process.argv.slice(2)).catch((e) => {
4
+ console.error(e?.stack || e?.message || e);
5
+ process.exit(1);
6
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "combobulator",
3
+ "version": "0.1.0",
4
+ "description": "Unify local chat memory across Claude Code, Codex, and Cursor — your prompts and assistant replies follow you across tools.",
5
+ "type": "module",
6
+ "bin": {
7
+ "combobulate": "bin/combobulate.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "scripts": {
13
+ "start": "node bin/combobulate.js",
14
+ "daemon": "node bin/combobulate.js daemon",
15
+ "test": "node test-e2e.mjs && node test-daemon.mjs",
16
+ "prepublishOnly": "npm test"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "src/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "claude",
26
+ "claude-code",
27
+ "codex",
28
+ "cursor",
29
+ "chat-history",
30
+ "memory",
31
+ "sync",
32
+ "cli",
33
+ "developer-tools"
34
+ ],
35
+ "os": ["darwin"],
36
+ "license": "MIT",
37
+ "author": "Panchangam18",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Panchangam18/combobulator.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/Panchangam18/combobulator/issues"
44
+ },
45
+ "homepage": "https://github.com/Panchangam18/combobulator#readme"
46
+ }
package/src/cli.js ADDED
@@ -0,0 +1,92 @@
1
+ import { install } from './commands/install.js';
2
+ import { uninstall } from './commands/uninstall.js';
3
+ import { status } from './commands/status.js';
4
+ import { sync } from './commands/sync.js';
5
+ import { fixCodexProjects } from './commands/fix-codex-projects.js';
6
+ import { cleanup } from './commands/cleanup.js';
7
+ import { doctor } from './commands/doctor.js';
8
+ import { runDaemon } from './daemon.js';
9
+
10
+ const HELP = `combobulate — unify chat history across Claude Code, Codex, and Cursor
11
+
12
+ USAGE
13
+ combobulate <command> [options]
14
+
15
+ COMMANDS
16
+ install Set up the background daemon (launchd agent on macOS) and
17
+ start mirroring new sessions from now on.
18
+ uninstall Remove the launchd agent. State at ~/.combobulate is kept.
19
+ daemon Run the watcher in the foreground (used by launchd).
20
+ status Show what's installed, last mirrored sessions, watched paths.
21
+ sync [opts] One-shot mirror pass over recent sessions.
22
+ --all ignore install epoch (mirror anything new-looking)
23
+ --since-hours=N look back N hours (default 24)
24
+ --limit=N max sessions per source (default 20)
25
+ --dry-run log what would be mirrored, write nothing
26
+ fix-codex-projects
27
+ Register every cwd from existing mirrors in Codex Desktop's
28
+ workspace list. Called automatically by install; rerun if
29
+ Codex stopped surfacing a synced project.
30
+ cleanup [--dry-run]
31
+ Remove broken Codex thread rows we created (source='unknown'
32
+ or orphaned). Safe — won't touch your real threads or
33
+ rollout files.
34
+ doctor Diagnose the setup: daemon, paths, state, recent errors.
35
+ Run this first when sync isn't working.
36
+ help Show this message.
37
+
38
+ How it works
39
+ Daemon polls each tool's session storage every 1.5s. New chats get mirrored
40
+ to the other tools' native formats: per-turn replay with proper task_started
41
+ / agent_message / tool calls, real timestamps, and a [Source] tag prepended
42
+ to the title. Mirrored files are tagged so we never replay our own writes.
43
+
44
+ Cursor is read-only — its sqlite chat DB is locked while the app runs.
45
+ Claude Desktop's "Claude Code" tab is cloud-backed and out of scope; the
46
+ Claude CLI, VS Code extension, Cursor extension, Codex CLI, and Codex Desktop
47
+ all see the synced chats. Run \`combobulate doctor\` if something looks off.
48
+ `;
49
+
50
+ function parseArgs(argv) {
51
+ const out = { _: [] };
52
+ for (const arg of argv) {
53
+ if (arg.startsWith('--')) {
54
+ const [k, v] = arg.slice(2).split('=');
55
+ out[k] = v === undefined ? true : v;
56
+ } else {
57
+ out._.push(arg);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export async function main(argv) {
64
+ const args = parseArgs(argv);
65
+ const cmd = args._[0] || 'help';
66
+
67
+ switch (cmd) {
68
+ case 'install': return install();
69
+ case 'uninstall': return uninstall();
70
+ case 'daemon': return runDaemon();
71
+ case 'status': return status();
72
+ case 'fix-codex-projects': return fixCodexProjects();
73
+ case 'cleanup': return cleanup({ dryRun: !!args['dry-run'] });
74
+ case 'doctor': return doctor();
75
+ case 'sync':
76
+ return sync({
77
+ all: !!args.all,
78
+ sinceHours: args['since-hours'] ? Number(args['since-hours']) : 24,
79
+ limit: args.limit ? Number(args.limit) : 20,
80
+ dryRun: !!args['dry-run'],
81
+ });
82
+ case 'help':
83
+ case '-h':
84
+ case '--help':
85
+ console.log(HELP);
86
+ return;
87
+ default:
88
+ console.error(`unknown command: ${cmd}\n`);
89
+ console.log(HELP);
90
+ process.exitCode = 1;
91
+ }
92
+ }
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { HOME } from './config.js';
6
+ import { info, warn } from './log.js';
7
+
8
+ const execFileP = promisify(execFile);
9
+
10
+ const STATE_FILE = path.join(HOME, '.codex', '.codex-global-state.json');
11
+
12
+ // Tell Codex Desktop to treat `cwd` as one of its workspace roots so the mirrored
13
+ // chats become visible in the per-cwd sidebar.
14
+ //
15
+ // We discovered the hard way that editing `.codex-global-state.json` directly is
16
+ // unreliable: Codex Desktop caches the file in memory and writes it back on
17
+ // shutdown, clobbering our additions. The correct mechanism is `codex app <path>`
18
+ // — Codex's own CLI command that sends an IPC to the running app to register the
19
+ // workspace (or launch + register if it isn't running). This persists.
20
+ //
21
+ // Side effect: `codex app` focuses Codex Desktop on the registered workspace.
22
+ // We accept that for one-time registration but skip the call entirely if the
23
+ // cwd is already in workspace-roots — so daemon-driven mirroring of an
24
+ // already-tracked cwd doesn't cause focus theft.
25
+ export async function registerCodexWorkspaceRoot(cwd) {
26
+ if (!cwd || !cwd.startsWith('/')) return false;
27
+ if (!fs.existsSync(cwd)) return false;
28
+ if (isAlreadyRegistered(cwd)) return false;
29
+
30
+ try {
31
+ await execFileP('codex', ['app', cwd], { timeout: 8000 });
32
+ info(`registered Codex workspace root via codex app: ${cwd}`);
33
+ return true;
34
+ } catch (e) {
35
+ warn(`codex app failed for ${cwd}: ${e.message}`);
36
+ // Fallback: try the direct JSON write. May get overwritten on Codex shutdown,
37
+ // but better than nothing for fresh installs where Codex isn't running yet.
38
+ return jsonFallback(cwd);
39
+ }
40
+ }
41
+
42
+ function isAlreadyRegistered(cwd) {
43
+ if (!fs.existsSync(STATE_FILE)) return false;
44
+ try {
45
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
46
+ return Array.isArray(state['electron-saved-workspace-roots']) &&
47
+ state['electron-saved-workspace-roots'].includes(cwd);
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function jsonFallback(cwd) {
54
+ if (!fs.existsSync(STATE_FILE)) return false;
55
+ try {
56
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
57
+ const roots = state['electron-saved-workspace-roots'];
58
+ const order = state['project-order'];
59
+ if (!Array.isArray(roots) || !Array.isArray(order)) return false;
60
+ let changed = false;
61
+ if (!roots.includes(cwd)) { roots.push(cwd); changed = true; }
62
+ if (!order.includes(cwd)) { order.unshift(cwd); changed = true; }
63
+ if (!changed) return false;
64
+ const tmp = STATE_FILE + '.tmp';
65
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
66
+ fs.renameSync(tmp, STATE_FILE);
67
+ info(`registered Codex workspace root via JSON fallback: ${cwd} (may be overwritten by Codex on shutdown)`);
68
+ return true;
69
+ } catch (e) {
70
+ warn(`JSON fallback failed for ${cwd}: ${e.message}`);
71
+ return false;
72
+ }
73
+ }
@@ -0,0 +1,72 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { HOME } from './config.js';
5
+ import { warn } from './log.js';
6
+
7
+ const DB = path.join(HOME, '.codex', 'state_5.sqlite');
8
+
9
+ // Insert or replace a row in Codex Desktop's threads table so the mirrored
10
+ // chat is visible in the per-cwd sidebar. The schema is in state_5.sqlite —
11
+ // idx_threads_archived_cwd_updated_at_ms is the index the sidebar queries.
12
+ // The DB is WAL'd, so concurrent writes alongside a running Codex Desktop are
13
+ // safe (SQLite serializes them at the page level).
14
+ export function upsertCodexThread({
15
+ sessionId,
16
+ rolloutPath,
17
+ cwd,
18
+ title,
19
+ firstUserMessage,
20
+ createdAtMs,
21
+ updatedAtMs,
22
+ approxTokens,
23
+ }) {
24
+ if (!fs.existsSync(DB)) return false;
25
+
26
+ let db;
27
+ try {
28
+ db = new DatabaseSync(DB);
29
+ // Match Codex's pragmas — WAL with NORMAL synchronous so we don't fsync
30
+ // every transaction and slow Codex down.
31
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
32
+
33
+ // IMPORTANT: Codex Desktop's chat sidebar filters out threads with
34
+ // source='cli' AND tokens_used=0 AND preview=''
35
+ // (treating them as empty drafts and hiding them). To make mirrored chats
36
+ // actually show up, we set preview to a snippet of the first user message
37
+ // and tokens_used to a positive estimate. This matches what Codex itself
38
+ // writes for "real" threads.
39
+ const fum = (firstUserMessage || '').slice(0, 2000);
40
+ const preview = fum ? fum.slice(0, 200) : 'Synced from combobulate';
41
+ const tokens = Math.max(1, approxTokens || Math.ceil(fum.length / 4));
42
+
43
+ const stmt = db.prepare(`
44
+ INSERT OR REPLACE INTO threads
45
+ (id, rollout_path, created_at, updated_at, source, model_provider, cwd,
46
+ title, sandbox_policy, approval_mode, has_user_event, archived,
47
+ cli_version, first_user_message, memory_mode, preview, tokens_used)
48
+ VALUES
49
+ (?, ?, ?, ?, 'cli', 'openai', ?,
50
+ ?, '{"type":"danger-full-access"}', 'never', 1, 0,
51
+ '0.1.0', ?, 'enabled', ?, ?)
52
+ `);
53
+
54
+ stmt.run(
55
+ sessionId,
56
+ rolloutPath,
57
+ Math.floor(createdAtMs / 1000),
58
+ Math.floor(updatedAtMs / 1000),
59
+ cwd,
60
+ (title || 'Synced from combobulate').slice(0, 200),
61
+ fum,
62
+ preview,
63
+ tokens,
64
+ );
65
+ return true;
66
+ } catch (e) {
67
+ warn(`codex threads upsert failed: ${e.message}`);
68
+ return false;
69
+ } finally {
70
+ try { db?.close(); } catch {}
71
+ }
72
+ }
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { PATHS, HOME, MIRROR_MARKER } from '../config.js';
5
+ import { info, warn } from '../log.js';
6
+
7
+ const CODEX_DB = path.join(HOME, '.codex', 'state_5.sqlite');
8
+
9
+ // Sweep any combobulate-authored rows that Codex's app-server has flipped to
10
+ // source='unknown' (typically rollouts written before the format was
11
+ // production-correct), plus orphaned thread rows whose rollout_path no longer
12
+ // exists on disk. Safe to run anytime — only touches combobulate-authored rows.
13
+ //
14
+ // What we DON'T touch: rollout files themselves, claude session files, your
15
+ // own non-mirror Codex threads. If you need a truly clean slate use
16
+ // `combobulate uninstall && rm -rf ~/.combobulate`.
17
+ export async function cleanup({ dryRun = false } = {}) {
18
+ if (!fs.existsSync(CODEX_DB)) {
19
+ info('Codex state_5.sqlite not present — nothing to clean up.');
20
+ return;
21
+ }
22
+
23
+ const db = new DatabaseSync(CODEX_DB);
24
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;');
25
+
26
+ // Pull every row that LOOKS like one of ours (we'll confirm by peeking the
27
+ // rollout file). We can't rely on `source` alone because Codex rewrites it.
28
+ const rows = db.prepare(`SELECT id, source, rollout_path FROM threads WHERE archived = 0`).all();
29
+
30
+ const toDelete = [];
31
+ let combobulateConfirmed = 0;
32
+ let orphaned = 0;
33
+ let unknownSource = 0;
34
+
35
+ for (const r of rows) {
36
+ const isOurs = looksLikeCombobulateRollout(r.rollout_path);
37
+ const exists = r.rollout_path && fs.existsSync(r.rollout_path);
38
+
39
+ if (isOurs) combobulateConfirmed++;
40
+
41
+ // Three deletion criteria:
42
+ // (a) Our mirror file is gone — orphaned row
43
+ // (b) Marked unknown source AND the rollout file is missing too
44
+ // (c) Marked unknown source AND the file IS ours (we have a fresh rewrite
45
+ // pending and want Codex to re-import from scratch)
46
+ if (isOurs && !exists) { toDelete.push(r); orphaned++; }
47
+ else if (r.source === 'unknown' && !exists) { toDelete.push(r); orphaned++; }
48
+ else if (r.source === 'unknown' && isOurs) { toDelete.push(r); unknownSource++; }
49
+ }
50
+
51
+ info(`scanned ${rows.length} threads.`);
52
+ info(` combobulate-authored: ${combobulateConfirmed}`);
53
+ info(` to delete: ${toDelete.length} (${orphaned} orphaned, ${unknownSource} unknown-source)`);
54
+
55
+ if (!toDelete.length) {
56
+ db.close();
57
+ return;
58
+ }
59
+
60
+ if (dryRun) {
61
+ info('(dry-run) would delete:');
62
+ for (const r of toDelete.slice(0, 20)) info(` ${r.id} source=${r.source} path=${r.rollout_path}`);
63
+ if (toDelete.length > 20) info(` ... and ${toDelete.length - 20} more`);
64
+ db.close();
65
+ return;
66
+ }
67
+
68
+ const stmt = db.prepare('DELETE FROM threads WHERE id = ?');
69
+ for (const r of toDelete) {
70
+ try { stmt.run(r.id); } catch (e) { warn(`failed to delete ${r.id}: ${e.message}`); }
71
+ }
72
+ db.close();
73
+ info(`deleted ${toDelete.length} row(s).`);
74
+ info('Restart Codex Desktop to refresh its in-memory chat list.');
75
+ }
76
+
77
+ // Cheap check: peek the first ~16KB of a rollout file and look for our marker
78
+ // (either the legacy top-level field or the new nested location). Returns
79
+ // false for any file we didn't author.
80
+ function looksLikeCombobulateRollout(filePath) {
81
+ if (!filePath || !fs.existsSync(filePath)) return false;
82
+ let fd;
83
+ try {
84
+ fd = fs.openSync(filePath, 'r');
85
+ const buf = Buffer.alloc(16 * 1024);
86
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
87
+ const lines = buf.subarray(0, n).toString('utf8').split('\n').slice(0, 3);
88
+ for (const line of lines) {
89
+ if (!line) continue;
90
+ let d;
91
+ try { d = JSON.parse(line); } catch { continue; }
92
+ if (d[MIRROR_MARKER]) return true;
93
+ if (d.type === 'session_meta') {
94
+ if (d.payload?.originator === 'combobulate') return true;
95
+ if (d.payload?.combobulate?.[MIRROR_MARKER]) return true;
96
+ }
97
+ }
98
+ return false;
99
+ } catch {
100
+ return false;
101
+ } finally {
102
+ if (fd !== undefined) try { fs.closeSync(fd); } catch {}
103
+ }
104
+ }