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 +21 -0
- package/README.md +187 -0
- package/bin/combobulate.js +6 -0
- package/package.json +46 -0
- package/src/cli.js +92 -0
- package/src/codex-registry.js +73 -0
- package/src/codex-thread-db.js +72 -0
- package/src/commands/cleanup.js +104 -0
- package/src/commands/doctor.js +119 -0
- package/src/commands/fix-codex-projects.js +164 -0
- package/src/commands/install.js +87 -0
- package/src/commands/status.js +50 -0
- package/src/commands/sync.js +80 -0
- package/src/commands/uninstall.js +17 -0
- package/src/config.js +38 -0
- package/src/daemon.js +126 -0
- package/src/log.js +29 -0
- package/src/sinks/claude.js +166 -0
- package/src/sinks/codex.js +577 -0
- package/src/sources/claude.js +144 -0
- package/src/sources/codex.js +93 -0
- package/src/sources/cursor.js +102 -0
- package/src/state.js +70 -0
|
@@ -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
|
+
}
|