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
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.
|
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
|
+
}
|