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.
@@ -0,0 +1,119 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { PATHS, HOME, LAUNCHD_LABEL } from '../config.js';
6
+ import { loadState } from '../state.js';
7
+
8
+ // Diagnose the combobulate setup end-to-end. Walks every moving piece and
9
+ // prints OK / WARN / FAIL for each, plus a one-line "what to do" hint.
10
+ //
11
+ // Designed to be the first thing you run when "sync isn't working." Detects:
12
+ // - daemon not running
13
+ // - watched paths missing
14
+ // - epoch in the future (clock skew or fresh install)
15
+ // - state file inconsistency
16
+ // - broken mirror rows in Codex
17
+ // - the daemon log mentioning recent errors
18
+ export async function doctor() {
19
+ const checks = [];
20
+ let ok = 0, warn = 0, fail = 0;
21
+
22
+ const add = (status, label, detail = '') => {
23
+ checks.push({ status, label, detail });
24
+ if (status === 'OK') ok++;
25
+ else if (status === 'WARN') warn++;
26
+ else fail++;
27
+ };
28
+
29
+ // 1. launchd agent
30
+ if (fs.existsSync(PATHS.launchdPlist)) {
31
+ let listed = '';
32
+ try { listed = execFileSync('launchctl', ['list'], { encoding: 'utf8' }); } catch {}
33
+ const row = listed.split('\n').find((l) => l.endsWith(LAUNCHD_LABEL));
34
+ if (row) {
35
+ const [pid] = row.trim().split(/\s+/);
36
+ if (pid !== '-' && Number.isFinite(Number(pid))) {
37
+ add('OK', 'daemon', `running, pid=${pid}`);
38
+ } else {
39
+ add('WARN', 'daemon', `loaded but not running — try: launchctl kickstart -k gui/$(id -u)/${LAUNCHD_LABEL}`);
40
+ }
41
+ } else {
42
+ add('FAIL', 'daemon', 'plist present but not loaded — try: combobulate install');
43
+ }
44
+ } else {
45
+ add('FAIL', 'daemon', 'not installed — run: combobulate install');
46
+ }
47
+
48
+ // 2. watched source paths
49
+ add(fs.existsSync(PATHS.claudeProjects) ? 'OK' : 'WARN', 'claude path', PATHS.claudeProjects);
50
+ add(fs.existsSync(PATHS.codexSessions) ? 'OK' : 'WARN', 'codex path', PATHS.codexSessions);
51
+ add(fs.existsSync(PATHS.cursorDb) ? 'OK' : 'WARN', 'cursor path', PATHS.cursorDb);
52
+
53
+ // 3. state file
54
+ let state;
55
+ try {
56
+ state = loadState();
57
+ const mirrorCount = Object.keys(state.mirrors || {}).length;
58
+ const epoch = new Date(state.epoch);
59
+ const now = Date.now();
60
+ if (epoch.getTime() > now + 60000) {
61
+ add('WARN', 'epoch', `${epoch.toISOString()} is in the future — clock skew?`);
62
+ } else {
63
+ add('OK', 'epoch', `${epoch.toISOString()} (${mirrorCount} mirrors tracked)`);
64
+ }
65
+ const sources = {};
66
+ for (const k of Object.keys(state.mirrors || {})) {
67
+ const s = k.split('/')[0];
68
+ sources[s] = (sources[s] || 0) + 1;
69
+ }
70
+ add('OK', 'sources', Object.entries(sources).map(([k, v]) => `${k}:${v}`).join(', ') || '(none yet)');
71
+ } catch (e) {
72
+ add('FAIL', 'state file', `${PATHS.combobulateState}: ${e.message}`);
73
+ }
74
+
75
+ // 4. Codex thread-row health
76
+ const codexDb = path.join(HOME, '.codex', 'state_5.sqlite');
77
+ if (fs.existsSync(codexDb)) {
78
+ try {
79
+ const db = new DatabaseSync(codexDb);
80
+ const unknown = db.prepare(`SELECT COUNT(*) AS n FROM threads WHERE source='unknown' AND archived=0`).get();
81
+ if (unknown.n > 0) {
82
+ add('WARN', 'codex threads', `${unknown.n} row(s) flagged 'unknown' — run: combobulate cleanup`);
83
+ } else {
84
+ add('OK', 'codex threads', 'all rows look healthy');
85
+ }
86
+ db.close();
87
+ } catch (e) {
88
+ add('WARN', 'codex threads', `couldn't read state_5.sqlite: ${e.message}`);
89
+ }
90
+ } else {
91
+ add('OK', 'codex threads', '(Codex not installed)');
92
+ }
93
+
94
+ // 5. recent log errors
95
+ if (fs.existsSync(PATHS.combobulateLog)) {
96
+ try {
97
+ const tail = fs.readFileSync(PATHS.combobulateLog, 'utf8').split('\n').slice(-100);
98
+ const errors = tail.filter((l) => l.includes(' ERROR ')).slice(-3);
99
+ if (errors.length) {
100
+ add('WARN', 'recent log', `${errors.length} error(s) in last 100 lines — see ${PATHS.combobulateLog}`);
101
+ } else {
102
+ add('OK', 'recent log', 'no errors in last 100 lines');
103
+ }
104
+ } catch (e) {
105
+ add('WARN', 'recent log', e.message);
106
+ }
107
+ }
108
+
109
+ // print results
110
+ console.log('\ncombobulate doctor');
111
+ console.log('==================');
112
+ for (const c of checks) {
113
+ const tag = c.status === 'OK' ? '\x1b[32mOK \x1b[0m' : c.status === 'WARN' ? '\x1b[33mWARN\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
114
+ const pad = c.label.padEnd(16);
115
+ console.log(` ${tag} ${pad} ${c.detail}`);
116
+ }
117
+ console.log(`\n${ok} ok, ${warn} warn, ${fail} fail.`);
118
+ if (fail > 0) process.exitCode = 1;
119
+ }
@@ -0,0 +1,164 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PATHS } from '../config.js';
4
+ import { info, warn } from '../log.js';
5
+ import { registerCodexWorkspaceRoot } from '../codex-registry.js';
6
+ import { upsertCodexThread } from '../codex-thread-db.js';
7
+
8
+ // Walk every combobulate-mirrored rollout file and ensure it's wired up to
9
+ // Codex Desktop's UI: a row in state_5.sqlite's `threads` table (so the chat
10
+ // shows in the per-cwd sidebar) AND the cwd in the JSON workspace-roots list
11
+ // (belt-and-suspenders for fresh installs). Idempotent.
12
+ export async function fixCodexProjects() {
13
+ if (!fs.existsSync(PATHS.codexSessions)) {
14
+ info('no codex sessions directory; nothing to do.');
15
+ return;
16
+ }
17
+
18
+ const rollouts = [];
19
+ walk(PATHS.codexSessions, rollouts);
20
+
21
+ let mirrorCount = 0;
22
+ let threadsUpserted = 0;
23
+ const cwds = new Set();
24
+
25
+ for (const f of rollouts) {
26
+ let meta;
27
+ try {
28
+ meta = peek(f);
29
+ } catch { continue; }
30
+ if (!meta?.isMirror) continue;
31
+ mirrorCount++;
32
+
33
+ if (meta.cwd) cwds.add(meta.cwd);
34
+
35
+ if (meta.sessionId && meta.cwd) {
36
+ const stat = fs.statSync(f);
37
+ const ok = upsertCodexThread({
38
+ sessionId: meta.sessionId,
39
+ rolloutPath: f,
40
+ cwd: meta.cwd,
41
+ title: meta.title || 'Synced from combobulate',
42
+ firstUserMessage: meta.firstUserMessage || '',
43
+ createdAtMs: meta.createdAtMs || stat.birthtimeMs || stat.mtimeMs,
44
+ updatedAtMs: stat.mtimeMs,
45
+ });
46
+ if (ok) threadsUpserted++;
47
+ }
48
+ }
49
+
50
+ info(`scanned ${rollouts.length} rollout file(s); ${mirrorCount} are combobulate mirrors.`);
51
+ info(`upserted ${threadsUpserted} thread row(s) into Codex Desktop's DB.`);
52
+
53
+ // Register each cwd as a Codex Desktop workspace root via `codex app <path>`.
54
+ // This is what makes the project appear in Codex's sidebar persistently.
55
+ // Note: each call may briefly steal focus to Codex Desktop as it switches
56
+ // to the registered workspace. Cwds already in workspace-roots are skipped.
57
+ let registered = 0;
58
+ for (const cwd of cwds) {
59
+ if (await registerCodexWorkspaceRoot(cwd)) registered++;
60
+ }
61
+ if (registered) {
62
+ info(`registered ${registered} new Codex Desktop workspace root(s).`);
63
+ } else if (cwds.size) {
64
+ info(`all ${cwds.size} cwd(s) were already registered with Codex Desktop.`);
65
+ }
66
+ }
67
+
68
+ function walk(dir, out) {
69
+ let entries;
70
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
71
+ for (const e of entries) {
72
+ const full = path.join(dir, e.name);
73
+ if (e.isDirectory()) walk(full, out);
74
+ else if (e.isFile() && e.name.endsWith('.jsonl') && e.name.startsWith('rollout-')) {
75
+ out.push(full);
76
+ }
77
+ }
78
+ }
79
+
80
+ // Read the first few lines of a rollout to detect if it's our mirror and
81
+ // pull out the metadata we stuffed in the marker line + session_meta payload.
82
+ // For older mirrors that didn't store title/firstUserMessage in the marker,
83
+ // fall back to parsing the flattened transcript on line 3 (the response_item
84
+ // user message) for the first **User:** chunk.
85
+ function peek(filePath) {
86
+ const fd = fs.openSync(filePath, 'r');
87
+ try {
88
+ // Read enough to capture the marker + session_meta + (often) the flattened
89
+ // transcript. 64KB is plenty for a small chat; long chats truncate and we
90
+ // just fall back to the generic title.
91
+ const buf = Buffer.alloc(64 * 1024);
92
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
93
+ const lines = buf.subarray(0, n).toString('utf8').split('\n');
94
+ let isMirror = false;
95
+ let cwd = null;
96
+ let sessionId = null;
97
+ let title = null;
98
+ let firstUserMessage = null;
99
+ let createdAtMs = null;
100
+ let flattenedText = null;
101
+ for (let i = 0; i < Math.min(lines.length, 5); i++) {
102
+ const line = lines[i];
103
+ if (!line) continue;
104
+ let d;
105
+ try { d = JSON.parse(line); } catch { continue; }
106
+ // Legacy top-level marker (pre-v0.2 rollouts)
107
+ if (d.__combobulate_mirror__) {
108
+ isMirror = true;
109
+ if (d.title) title = d.title;
110
+ if (d.firstUserMessage) firstUserMessage = d.firstUserMessage;
111
+ if (d.cwd && !cwd) cwd = d.cwd;
112
+ }
113
+ if (d.type === 'session_meta') {
114
+ if (d.payload?.originator === 'combobulate') isMirror = true;
115
+ if (d.payload?.cwd) cwd = d.payload.cwd;
116
+ if (d.payload?.id) sessionId = d.payload.id;
117
+ if (d.payload?.timestamp) createdAtMs = Date.parse(d.payload.timestamp);
118
+ // New marker location: nested under session_meta.payload.combobulate
119
+ const c = d.payload?.combobulate;
120
+ if (c?.__combobulate_mirror__) {
121
+ isMirror = true;
122
+ if (c.title && !title) title = c.title;
123
+ if (c.firstUserMessage && !firstUserMessage) firstUserMessage = c.firstUserMessage;
124
+ }
125
+ }
126
+ if (d.type === 'response_item' && d.payload?.role === 'user' && d.payload?.content?.[0]?.text) {
127
+ flattenedText = d.payload.content[0].text;
128
+ }
129
+ }
130
+ if (isMirror && (!title || !firstUserMessage) && flattenedText) {
131
+ const extracted = extractFirstUserLine(flattenedText);
132
+ if (extracted) {
133
+ if (!firstUserMessage) firstUserMessage = extracted.slice(0, 2000);
134
+ if (!title || title === 'Synced from combobulate') title = extracted.split('\n')[0].slice(0, 200);
135
+ }
136
+ }
137
+ return { isMirror, cwd, sessionId, title, firstUserMessage, createdAtMs };
138
+ } finally {
139
+ fs.closeSync(fd);
140
+ }
141
+ }
142
+
143
+ // The flattened transcript looks like:
144
+ // [Synced from claude via combobulate]
145
+ // ... header ...
146
+ // ---
147
+ // **User:** <first message body — may span multiple lines until the next \n\n>
148
+ //
149
+ // **Assistant:** ...
150
+ // Iterate every **User:** chunk and return the first that looks like a real
151
+ // human prompt — not a tool result, not an IDE injection.
152
+ function extractFirstUserLine(text) {
153
+ const re = /\*\*User:\*\*\s+([\s\S]*?)(?=\n\n\*\*(?:User|Assistant):\*\*|$)/g;
154
+ let m;
155
+ while ((m = re.exec(text)) !== null) {
156
+ const body = m[1].trim();
157
+ if (!body) continue;
158
+ if (/^\[tool result\]/i.test(body)) continue;
159
+ if (/^<(ide_opened_file|system-reminder|command-(name|message)|environment_context)\b/i.test(body)) continue;
160
+ if (/^\[Pasted text/.test(body)) continue;
161
+ return body;
162
+ }
163
+ return null;
164
+ }
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PATHS, LAUNCHD_LABEL } from '../config.js';
6
+ import { resetEpoch } from '../state.js';
7
+ import { info, warn } from '../log.js';
8
+ import { fixCodexProjects } from './fix-codex-projects.js';
9
+
10
+ function findEntrypoint() {
11
+ // bin/combobulate.js relative to this file (src/commands/install.js -> ../../bin/combobulate.js)
12
+ return path.resolve(fileURLToPath(import.meta.url), '..', '..', '..', 'bin', 'combobulate.js');
13
+ }
14
+
15
+ function plistXml({ nodeBin, scriptPath, logPath }) {
16
+ return `<?xml version="1.0" encoding="UTF-8"?>
17
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
18
+ <plist version="1.0">
19
+ <dict>
20
+ <key>Label</key>
21
+ <string>${LAUNCHD_LABEL}</string>
22
+ <key>ProgramArguments</key>
23
+ <array>
24
+ <string>${nodeBin}</string>
25
+ <string>${scriptPath}</string>
26
+ <string>daemon</string>
27
+ </array>
28
+ <key>RunAtLoad</key>
29
+ <true/>
30
+ <key>KeepAlive</key>
31
+ <true/>
32
+ <key>StandardOutPath</key>
33
+ <string>${logPath}</string>
34
+ <key>StandardErrorPath</key>
35
+ <string>${logPath}</string>
36
+ <key>ProcessType</key>
37
+ <string>Background</string>
38
+ </dict>
39
+ </plist>
40
+ `;
41
+ }
42
+
43
+ export async function install() {
44
+ fs.mkdirSync(PATHS.combobulateDir, { recursive: true });
45
+ fs.mkdirSync(PATHS.combobulateSynced, { recursive: true });
46
+ fs.mkdirSync(path.dirname(PATHS.launchdPlist), { recursive: true });
47
+
48
+ const nodeBin = process.execPath;
49
+ const scriptPath = findEntrypoint();
50
+
51
+ if (!fs.existsSync(scriptPath)) {
52
+ throw new Error(`Cannot find CLI entrypoint at ${scriptPath}`);
53
+ }
54
+
55
+ // Reset the "epoch" — sessions from before install are considered pre-existing.
56
+ // Daemon only mirrors sessions newer than this.
57
+ resetEpoch();
58
+
59
+ const xml = plistXml({ nodeBin, scriptPath, logPath: PATHS.combobulateLog });
60
+ fs.writeFileSync(PATHS.launchdPlist, xml);
61
+ info(`wrote launchd plist at ${PATHS.launchdPlist}`);
62
+
63
+ // Reload via launchctl. We unload first (ignore failure if not loaded), then load.
64
+ try {
65
+ execFileSync('launchctl', ['unload', PATHS.launchdPlist], { stdio: 'ignore' });
66
+ } catch {}
67
+ try {
68
+ execFileSync('launchctl', ['load', PATHS.launchdPlist], { stdio: 'inherit' });
69
+ info('launchd agent loaded — daemon will start now and on every login.');
70
+ } catch (e) {
71
+ warn(`launchctl load failed: ${e.message}`);
72
+ warn('You can run the daemon manually with: combobulate daemon');
73
+ }
74
+
75
+ // Backfill any pre-existing mirrors into Codex Desktop's workspace registry,
76
+ // so a fresh install on top of prior state immediately shows up.
77
+ try {
78
+ await fixCodexProjects();
79
+ } catch (e) {
80
+ warn(`fix-codex-projects failed: ${e.message}`);
81
+ }
82
+
83
+ info('install complete.');
84
+ info(` state: ${PATHS.combobulateState}`);
85
+ info(` log: ${PATHS.combobulateLog}`);
86
+ info(` synced: ${PATHS.combobulateSynced}`);
87
+ }
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { PATHS, LAUNCHD_LABEL } from '../config.js';
4
+ import { loadState } from '../state.js';
5
+
6
+ export async function status() {
7
+ console.log('combobulate status');
8
+ console.log('==================');
9
+ console.log(`state file: ${PATHS.combobulateState}`);
10
+ console.log(`log file: ${PATHS.combobulateLog}`);
11
+ console.log(`synced cwd: ${PATHS.combobulateSynced}`);
12
+ console.log(`plist: ${PATHS.launchdPlist} ${fs.existsSync(PATHS.launchdPlist) ? '(present)' : '(missing)'}`);
13
+
14
+ // launchctl list returns the PID if loaded
15
+ let listed = '';
16
+ try {
17
+ listed = execFileSync('launchctl', ['list'], { encoding: 'utf8' });
18
+ } catch {}
19
+ const row = listed.split('\n').find((l) => l.endsWith(LAUNCHD_LABEL));
20
+ if (row) {
21
+ const [pid, _exit, label] = row.trim().split(/\s+/);
22
+ console.log(`launchd: loaded as ${label}, pid=${pid === '-' ? 'not running' : pid}`);
23
+ } else {
24
+ console.log('launchd: not loaded');
25
+ }
26
+
27
+ let state;
28
+ try { state = loadState(); } catch { state = null; }
29
+ if (state) {
30
+ const mirrors = Object.entries(state.mirrors || {});
31
+ console.log(`epoch: ${new Date(state.epoch).toISOString()}`);
32
+ console.log(`mirrors: ${mirrors.length} session(s) tracked`);
33
+ if (mirrors.length) {
34
+ const recent = mirrors
35
+ .map(([k, v]) => ({ k, ...v }))
36
+ .sort((a, b) => (b.lastSyncedAt || 0) - (a.lastSyncedAt || 0))
37
+ .slice(0, 5);
38
+ console.log('recent syncs:');
39
+ for (const r of recent) {
40
+ const targets = Object.keys(r.targets || {}).join('+');
41
+ console.log(` ${new Date(r.lastSyncedAt || 0).toISOString()} ${r.k} -> ${targets}`);
42
+ }
43
+ }
44
+ }
45
+
46
+ console.log('\nWatched paths:');
47
+ console.log(` claude: ${PATHS.claudeProjects} ${fs.existsSync(PATHS.claudeProjects) ? '✓' : '✗'}`);
48
+ console.log(` codex: ${PATHS.codexSessions} ${fs.existsSync(PATHS.codexSessions) ? '✓' : '✗'}`);
49
+ console.log(` cursor: ${PATHS.cursorDb} ${fs.existsSync(PATHS.cursorDb) ? '✓' : '✗ (cursor not installed)'}`);
50
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import { PATHS } from '../config.js';
3
+ import { info } from '../log.js';
4
+ import { loadState, saveState, getMirror, setMirror, fingerprintMessages } from '../state.js';
5
+ import { listClaudeSessions, readClaudeSession } from '../sources/claude.js';
6
+ import { listCodexSessions, readCodexSession } from '../sources/codex.js';
7
+ import { listCursorComposers, readCursorComposer } from '../sources/cursor.js';
8
+ import { writeClaudeMirror } from '../sinks/claude.js';
9
+ import { writeCodexMirror } from '../sinks/codex.js';
10
+
11
+ // One-shot sync. Doesn't require the daemon. Useful for testing or running on demand.
12
+ // `--all` ignores the epoch and tries every session newer than --since (default: 24h).
13
+ export async function sync({ all = false, sinceHours = 24, limit = 20, dryRun = false } = {}) {
14
+ fs.mkdirSync(PATHS.combobulateDir, { recursive: true });
15
+ fs.mkdirSync(PATHS.combobulateSynced, { recursive: true });
16
+
17
+ const cutoff = all ? 0 : Date.now() - sinceHours * 3600 * 1000;
18
+ const state = loadState();
19
+
20
+ const sessions = [];
21
+
22
+ for (const meta of listClaudeSessions().slice(0, limit)) {
23
+ if (meta.mtime < cutoff) continue;
24
+ try {
25
+ const s = await readClaudeSession(meta.path);
26
+ if (!s.isMirror && s.messages.length) sessions.push(s);
27
+ } catch (e) { /* ignore */ }
28
+ }
29
+ for (const meta of listCodexSessions().slice(0, limit)) {
30
+ if (meta.mtime < cutoff) continue;
31
+ try {
32
+ const s = await readCodexSession(meta.path);
33
+ if (!s.isMirror && s.messages.length) sessions.push(s);
34
+ } catch (e) { /* ignore */ }
35
+ }
36
+ if (fs.existsSync(PATHS.cursorDb)) {
37
+ try {
38
+ const composers = await listCursorComposers();
39
+ for (const c of composers.slice(0, limit)) {
40
+ if (c.updatedAt < cutoff) continue;
41
+ const s = await readCursorComposer(c.id);
42
+ if (s && !s.isMirror && s.messages.length) sessions.push(s);
43
+ }
44
+ } catch (e) { info(`cursor scan failed: ${e.message}`); }
45
+ }
46
+
47
+ info(`found ${sessions.length} candidate session(s) to mirror`);
48
+
49
+ for (const s of sessions) {
50
+ const key = `${s.source}/${s.sessionId}`;
51
+ const fp = fingerprintMessages(s.messages);
52
+ const prev = getMirror(key);
53
+ if (prev && prev.sourceFingerprint === fp) {
54
+ info(`skip ${key} (no changes)`);
55
+ continue;
56
+ }
57
+ info(`mirror ${key} (${s.messages.length} msgs)`);
58
+ if (dryRun) continue;
59
+ const targets = prev?.targets || {};
60
+ if (s.source !== 'claude') {
61
+ const r = writeClaudeMirror(s, {
62
+ existingSessionId: targets.claude?.sessionId,
63
+ existingFilePath: targets.claude?.filePath,
64
+ });
65
+ targets.claude = r;
66
+ info(` -> claude:${r.sessionId}`);
67
+ }
68
+ if (s.source !== 'codex') {
69
+ const r = writeCodexMirror(s, {
70
+ existingSessionId: targets.codex?.sessionId,
71
+ existingFilePath: targets.codex?.filePath,
72
+ });
73
+ targets.codex = r;
74
+ info(` -> codex:${r.sessionId}`);
75
+ }
76
+ setMirror(key, { sourceFingerprint: fp, targets, lastSyncedAt: Date.now() });
77
+ }
78
+ saveState();
79
+ info('done.');
80
+ }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { PATHS } from '../config.js';
4
+ import { info } from '../log.js';
5
+
6
+ export async function uninstall() {
7
+ if (fs.existsSync(PATHS.launchdPlist)) {
8
+ try {
9
+ execFileSync('launchctl', ['unload', PATHS.launchdPlist], { stdio: 'ignore' });
10
+ } catch {}
11
+ fs.unlinkSync(PATHS.launchdPlist);
12
+ info(`removed launchd plist at ${PATHS.launchdPlist}`);
13
+ } else {
14
+ info('no launchd plist to remove.');
15
+ }
16
+ info('uninstall complete. State at ~/.combobulate is preserved — delete it manually if you want a clean slate.');
17
+ }
package/src/config.js ADDED
@@ -0,0 +1,38 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const HOME = os.homedir();
5
+
6
+ export const PATHS = {
7
+ claudeDir: path.join(HOME, '.claude'),
8
+ claudeProjects: path.join(HOME, '.claude', 'projects'),
9
+ claudeHistory: path.join(HOME, '.claude', 'history.jsonl'),
10
+
11
+ codexDir: path.join(HOME, '.codex'),
12
+ codexSessions: path.join(HOME, '.codex', 'sessions'),
13
+ codexSessionIndex: path.join(HOME, '.codex', 'session_index.jsonl'),
14
+ codexHistory: path.join(HOME, '.codex', 'history.jsonl'),
15
+
16
+ cursorDb: path.join(HOME, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
17
+
18
+ combobulateDir: path.join(HOME, '.combobulate'),
19
+ combobulateState: path.join(HOME, '.combobulate', 'state.json'),
20
+ combobulateLog: path.join(HOME, '.combobulate', 'daemon.log'),
21
+ combobulatePid: path.join(HOME, '.combobulate', 'daemon.pid'),
22
+ combobulateSynced: path.join(HOME, '.combobulate', 'synced'),
23
+ launchdPlist: path.join(HOME, 'Library', 'LaunchAgents', 'com.combobulate.daemon.plist'),
24
+ };
25
+
26
+ export const LAUNCHD_LABEL = 'com.combobulate.daemon';
27
+
28
+ export const POLL_INTERVAL_MS = 1500;
29
+
30
+ // Mirror marker — embedded in every synced session so we don't mirror our own writes.
31
+ export const MIRROR_MARKER = '__combobulate_mirror__';
32
+
33
+ // Encode an absolute filesystem path the way Claude Code does for ~/.claude/projects/<encoded>/.
34
+ // Claude replaces every '/' (and leading slash) with '-' and drops the leading dash on root.
35
+ // E.g. /Users/madhavan/foo -> -Users-madhavan-foo
36
+ export function encodeClaudeProjectDir(cwd) {
37
+ return cwd.replace(/[/.]/g, '-');
38
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import { PATHS, POLL_INTERVAL_MS } from './config.js';
3
+ import { info, warn, error, debug } from './log.js';
4
+ import { loadState, saveState, getMirror, setMirror, fingerprintMessages } from './state.js';
5
+ import { listClaudeSessions, readClaudeSession } from './sources/claude.js';
6
+ import { listCodexSessions, readCodexSession } from './sources/codex.js';
7
+ import { listCursorComposers, readCursorComposer } from './sources/cursor.js';
8
+ import { writeClaudeMirror } from './sinks/claude.js';
9
+ import { writeCodexMirror } from './sinks/codex.js';
10
+
11
+ // Mirror a single normalized session to every other tool. Skip self and skip mirrors.
12
+ async function mirrorSession(session) {
13
+ if (session.isMirror) return;
14
+ if (!session.messages.length) return;
15
+
16
+ const sourceKey = `${session.source}/${session.sessionId}`;
17
+ const fp = fingerprintMessages(session.messages);
18
+ const prev = getMirror(sourceKey);
19
+ if (prev && prev.sourceFingerprint === fp) return; // nothing new
20
+
21
+ const targets = prev?.targets || {};
22
+
23
+ if (session.source !== 'claude') {
24
+ try {
25
+ const result = writeClaudeMirror(session, {
26
+ existingSessionId: targets.claude?.sessionId,
27
+ existingFilePath: targets.claude?.filePath,
28
+ });
29
+ targets.claude = result;
30
+ info(`mirrored ${sourceKey} -> claude:${result.sessionId}`);
31
+ } catch (e) {
32
+ error(`claude mirror failed for ${sourceKey}: ${e.message}`);
33
+ }
34
+ }
35
+
36
+ if (session.source !== 'codex') {
37
+ try {
38
+ const result = writeCodexMirror(session, {
39
+ existingSessionId: targets.codex?.sessionId,
40
+ existingFilePath: targets.codex?.filePath,
41
+ });
42
+ targets.codex = result;
43
+ info(`mirrored ${sourceKey} -> codex:${result.sessionId}`);
44
+ } catch (e) {
45
+ error(`codex mirror failed for ${sourceKey}: ${e.message}`);
46
+ }
47
+ }
48
+
49
+ setMirror(sourceKey, { sourceFingerprint: fp, targets, lastSyncedAt: Date.now() });
50
+ }
51
+
52
+ async function scanClaude(state) {
53
+ const sessions = listClaudeSessions();
54
+ for (const meta of sessions) {
55
+ if (meta.mtime < state.epoch) continue; // pre-existing
56
+ try {
57
+ const session = await readClaudeSession(meta.path);
58
+ await mirrorSession(session);
59
+ } catch (e) {
60
+ debug(`skip claude ${meta.path}: ${e.message}`);
61
+ }
62
+ }
63
+ }
64
+
65
+ async function scanCodex(state) {
66
+ const sessions = listCodexSessions();
67
+ for (const meta of sessions) {
68
+ if (meta.mtime < state.epoch) continue;
69
+ try {
70
+ const session = await readCodexSession(meta.path);
71
+ await mirrorSession(session);
72
+ } catch (e) {
73
+ debug(`skip codex ${meta.path}: ${e.message}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ async function scanCursor(state) {
79
+ if (!fs.existsSync(PATHS.cursorDb)) return;
80
+ let composers;
81
+ try {
82
+ composers = await listCursorComposers();
83
+ } catch (e) {
84
+ debug(`cursor list failed: ${e.message}`);
85
+ return;
86
+ }
87
+ for (const c of composers) {
88
+ if (c.updatedAt < state.epoch) continue;
89
+ try {
90
+ const session = await readCursorComposer(c.id);
91
+ if (session) await mirrorSession(session);
92
+ } catch (e) {
93
+ debug(`skip cursor ${c.id}: ${e.message}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ let running = false;
99
+ async function tick() {
100
+ if (running) return;
101
+ running = true;
102
+ try {
103
+ const state = loadState();
104
+ await scanClaude(state);
105
+ await scanCodex(state);
106
+ await scanCursor(state);
107
+ saveState();
108
+ } catch (e) {
109
+ error(`tick failed: ${e.message}`);
110
+ } finally {
111
+ running = false;
112
+ }
113
+ }
114
+
115
+ export async function runDaemon() {
116
+ fs.mkdirSync(PATHS.combobulateDir, { recursive: true });
117
+ fs.mkdirSync(PATHS.combobulateSynced, { recursive: true });
118
+ fs.writeFileSync(PATHS.combobulatePid, String(process.pid));
119
+
120
+ process.on('SIGTERM', () => { info('SIGTERM, exiting'); process.exit(0); });
121
+ process.on('SIGINT', () => { info('SIGINT, exiting'); process.exit(0); });
122
+
123
+ info(`daemon started pid=${process.pid} interval=${POLL_INTERVAL_MS}ms`);
124
+ await tick();
125
+ setInterval(tick, POLL_INTERVAL_MS);
126
+ }