@neerajvipparla/agentbridge 0.1.0 → 0.1.2
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/README.md +200 -142
- package/bin/agentbridge.js +27 -16
- package/package.json +1 -1
- package/src/sync/sync.js +167 -28
package/README.md
CHANGED
|
@@ -1,157 +1,208 @@
|
|
|
1
1
|
# agentbridge
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
one
|
|
5
|
-
ledger.
|
|
3
|
+
A bidirectional Claude Code ⇄ OpenCode context bridge: fork a chat session
|
|
4
|
+
from one tool into the other, keep both sides in sync as the conversation
|
|
5
|
+
continues, and record every fork or sync as a commit in a local git ledger.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
agentbridge list [-s claude|opencode] [--dir <path>] [--all]
|
|
9
|
-
agentbridge show <session-id> [-s claude|opencode] [--dir <path>]
|
|
9
|
+
agentbridge show <session-id> [-s claude|opencode] [--dir <path>] [--all]
|
|
10
10
|
agentbridge fork [session-id] [-s claude|opencode] [--dir <path>] [--dry-run]
|
|
11
11
|
[--title <t>] [--provider <id>] [--agent <name>]
|
|
12
|
-
agentbridge sync [session-id] [--dir <path>] [--
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
agentbridge sync [session-id] [--dir <path>] [--dry-run]
|
|
13
|
+
[--strategy timestamp|abort|persist-claude|persist-opencode]
|
|
14
|
+
[--provider <id>] [--agent <name>]
|
|
15
|
+
agentbridge watch [session-id] [--dir <path>] [--strategy timestamp|abort]
|
|
16
|
+
[--interval <ms>] [--provider <id>] [--agent <name>]
|
|
17
|
+
agentbridge log [--dir <path>] [--limit <n>]
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
Run `agentbridge <command> --help` for the exhaustive flag list with
|
|
21
|
+
defaults - the block above is the shape of the surface, not a full
|
|
22
|
+
reference.
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
- `agentbridge show <id>` to read the full transcript.
|
|
21
|
-
- `agentbridge fork <id>` to clone the session into the *other* tool.
|
|
22
|
-
|
|
23
|
-
`--source` / `-s` selects the source tool:
|
|
24
|
+
## Install
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
| `show` | auto-detect by id | OpenCode session |
|
|
29
|
-
| `fork` | auto-detect by id (or latest Claude) | OpenCode → Claude Code |
|
|
26
|
+
```
|
|
27
|
+
npm install -g @neerajvipparla/agentbridge
|
|
28
|
+
```
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
The package is published under a scoped name (npm's automated policy flags
|
|
31
|
+
`agentbridge` as too similar to an existing, unrelated `agent-bridge`
|
|
32
|
+
package) but the command it installs is still plain `agentbridge`.
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
in, and it will find the most recent Claude Code session, convert it into
|
|
36
|
-
OpenCode's format, commit both representations to `.agentbridge/ledger/`, and
|
|
37
|
-
run `opencode import` so the conversation shows up in OpenCode.
|
|
34
|
+
Or run it once without installing:
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
resume it.
|
|
36
|
+
```
|
|
37
|
+
npx -y @neerajvipparla/agentbridge list
|
|
38
|
+
```
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
ledger commit). If the source session has grown since the last fork, it
|
|
46
|
-
updates the target in place and records a new commit containing only the diff.
|
|
40
|
+
Requires `git` and the `opencode` CLI on your `PATH`.
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
`opencode import` / Claude file write.
|
|
42
|
+
### From source
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
```
|
|
45
|
+
git clone https://github.com/neerajvipparla/Agentbridge.git
|
|
46
|
+
cd Agentbridge
|
|
47
|
+
npm install
|
|
48
|
+
npm link # or: node bin/agentbridge.js ...
|
|
49
|
+
```
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
in both tools since the last fork/sync.
|
|
51
|
+
## Usage
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- It merges only the *new* turns into both tools.
|
|
53
|
+
Typical flow: `list` to see what's available, `show` to read a transcript
|
|
54
|
+
you're unsure about, `fork` once you've picked one.
|
|
59
55
|
|
|
60
|
-
```bash
|
|
61
|
-
agentbridge sync # latest session in the ledger
|
|
62
|
-
agentbridge sync <claude-uuid> # by Claude session id
|
|
63
|
-
agentbridge sync <ses_...> # by OpenCode session id
|
|
64
|
-
agentbridge sync --strategy abort # refuse if both sides changed
|
|
65
|
-
agentbridge sync --strategy timestamp # merge by timestamp (default)
|
|
66
56
|
```
|
|
57
|
+
$ agentbridge list
|
|
58
|
+
ad245505-24b0-478b-9126-b3dcdf79852f
|
|
59
|
+
7/26/2026, 4:34:18 PM · 805 messages
|
|
60
|
+
"<command-message>init</command-message>"
|
|
67
61
|
|
|
68
|
-
|
|
62
|
+
Preview one in full with: agentbridge show <session-id>
|
|
63
|
+
Fork one to OpenCode with: agentbridge fork <session-id>
|
|
64
|
+
```
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
This is the recommended default for conversations because turns are
|
|
72
|
-
append-only and chronological order is usually the right merge.
|
|
73
|
-
- `abort` — stop and show how many new turns each side has, so you can resolve
|
|
74
|
-
manually. Use this when you want full control over the merge.
|
|
66
|
+
`--source` / `-s` picks which tool a command reads from or converts between:
|
|
75
67
|
|
|
76
|
-
|
|
68
|
+
| Command | Default source | `-s opencode` |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `list` | Claude Code sessions | OpenCode sessions |
|
|
71
|
+
| `show` | auto-detect by id | OpenCode session |
|
|
72
|
+
| `fork` | auto-detect by id (or latest Claude session) | OpenCode → Claude Code |
|
|
77
73
|
|
|
78
|
-
|
|
74
|
+
Auto-detection: ids starting with `ses_` are treated as OpenCode; everything
|
|
75
|
+
else is treated as a Claude Code session id.
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
OpenCode, automatically syncing when either side changes.
|
|
77
|
+
### Forking Claude Code → OpenCode
|
|
82
78
|
|
|
83
|
-
```
|
|
84
|
-
agentbridge
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
agentbridge watch --strategy abort # stop and warn on conflicts
|
|
79
|
+
```
|
|
80
|
+
$ agentbridge fork b9a600da-9bf0-4d52-a70b-ff66bade638b --dry-run
|
|
81
|
+
Ledger commit bb0ee7d608 recorded at .../.agentbridge/ledger
|
|
82
|
+
Dry run: skipped `opencode import`. Converted OpenCode session id: ses_d51119061f313e2d7f905e57
|
|
88
83
|
```
|
|
89
84
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
85
|
+
Run it without `--dry-run` inside (or with `--dir` pointing at) a project
|
|
86
|
+
you've used `claude` in, and it will find the most recent Claude Code
|
|
87
|
+
session, convert it into OpenCode's format, commit both representations to
|
|
88
|
+
`.agentbridge/ledger/`, and run `opencode import` so the conversation shows
|
|
89
|
+
up in OpenCode, ready to resume with `opencode --session <id>`.
|
|
95
90
|
|
|
96
|
-
|
|
91
|
+
### Forking OpenCode → Claude Code
|
|
97
92
|
|
|
98
93
|
```
|
|
99
|
-
|
|
94
|
+
agentbridge fork <ses_...> -s opencode
|
|
100
95
|
```
|
|
101
96
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
Exports the OpenCode session, converts it back to a Claude Code JSONL
|
|
98
|
+
transcript, and writes it to `~/.claude/projects/<encoded-dir>/<uuid>.jsonl`
|
|
99
|
+
so `claude` can resume it.
|
|
105
100
|
|
|
106
|
-
|
|
101
|
+
Re-running `fork` on an unchanged session is a no-op (same target id, no new
|
|
102
|
+
ledger commit, and `--dry-run` reports "No changes since last fork"). If the
|
|
103
|
+
source session has grown since the last fork, it updates the target in
|
|
104
|
+
place and records a new commit containing only the diff.
|
|
105
|
+
|
|
106
|
+
### Keeping both sides in sync
|
|
107
107
|
|
|
108
|
+
`agentbridge sync [session-id]` reconciles a conversation that's been edited
|
|
109
|
+
in both tools since the last fork or sync - it reads the current state of
|
|
110
|
+
both, compares each to the last synced state recorded in the ledger, and
|
|
111
|
+
merges only the genuinely new turns.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
agentbridge sync # latest session pair in the ledger
|
|
115
|
+
agentbridge sync <claude-uuid> # by Claude session id
|
|
116
|
+
agentbridge sync <ses_...> # by OpenCode session id
|
|
117
|
+
agentbridge sync --strategy abort # refuse if both sides changed
|
|
118
|
+
agentbridge sync --strategy timestamp # group by origin (default)
|
|
108
119
|
```
|
|
109
|
-
|
|
120
|
+
|
|
121
|
+
- `timestamp` (default) - each side keeps its own new turns first, then the
|
|
122
|
+
other side's new turns appended after (not interleaved by real time). If
|
|
123
|
+
Claude Code gained N+1, N+2 and OpenCode independently gained N+1′, N+2′,
|
|
124
|
+
N+3′ since the last sync, running `sync` produces, on the Claude side,
|
|
125
|
+
`[...baseline, N+1, N+2, N+1′, N+2′, N+3′]`, and on the OpenCode side,
|
|
126
|
+
`[...baseline, N+1′, N+2′, N+3′, N+1, N+2]`.
|
|
127
|
+
- `abort` - stop and report how many new turns each side has, so you resolve
|
|
128
|
+
the conflict manually instead of auto-merging.
|
|
129
|
+
- `persist-claude` / `persist-opencode` - keep only one side's divergent new
|
|
130
|
+
turns; the other side's are discarded entirely (not appended, not
|
|
131
|
+
converted). The common baseline before the divergence is preserved on
|
|
132
|
+
both sides regardless. **This is destructive and not recoverable**: the
|
|
133
|
+
discarded turns are exactly the turns created since the last sync, so
|
|
134
|
+
they were never in any ledger commit - the live Claude JSONL is fully
|
|
135
|
+
overwritten with the new merged state, and the discarded side's turns
|
|
136
|
+
are gone from disk. Run with `--dry-run` first if you're not sure which
|
|
137
|
+
side you want to keep.
|
|
138
|
+
|
|
139
|
+
`sync` records its own ledger commit on a real change, so you can inspect or
|
|
140
|
+
roll back a merge like any other fork. Re-running it with nothing new on
|
|
141
|
+
either side is a no-op, same as `fork`.
|
|
142
|
+
|
|
143
|
+
`agentbridge watch [session-id]` does the same thing continuously: it
|
|
144
|
+
monitors the Claude session file for changes and polls OpenCode's state,
|
|
145
|
+
auto-syncing whenever either side changes.
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
agentbridge watch # latest session pair in the ledger
|
|
149
|
+
agentbridge watch <ses_...> # watch a specific OpenCode session
|
|
150
|
+
agentbridge watch --interval 1000 # poll OpenCode every 1 second
|
|
151
|
+
agentbridge watch --strategy abort # stop and warn on conflicts instead
|
|
110
152
|
```
|
|
111
153
|
|
|
112
|
-
|
|
154
|
+
Press **Ctrl-C** to stop. The default poll interval is 5 seconds.
|
|
155
|
+
`watch` accepts the same `timestamp`/`abort` strategies as `sync` - with the
|
|
156
|
+
default `timestamp` strategy it auto-merges both sides' new turns on every
|
|
157
|
+
poll, so use `--strategy abort` if you'd rather review every conflict by
|
|
158
|
+
hand. `persist-claude`/`persist-opencode` are **not** available on `watch` -
|
|
159
|
+
they're a deliberate, one-shot `sync` action, not something left running
|
|
160
|
+
unattended that repeatedly discards a side's turns.
|
|
113
161
|
|
|
114
|
-
###
|
|
162
|
+
### Reviewing what's been forked
|
|
115
163
|
|
|
116
164
|
```
|
|
117
|
-
|
|
118
|
-
cd Agentbridge
|
|
119
|
-
npm install
|
|
120
|
-
npm link # or: node bin/agentbridge.js ...
|
|
165
|
+
agentbridge log [--limit <n>]
|
|
121
166
|
```
|
|
122
167
|
|
|
123
|
-
|
|
168
|
+
Prints the commit history of the local git ledger for the current project -
|
|
169
|
+
one line per fork or sync, newest first.
|
|
170
|
+
|
|
171
|
+
## How it works
|
|
124
172
|
|
|
125
173
|
Claude Code stores one JSONL file per session at
|
|
126
174
|
`~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`, where `<encoded-cwd>`
|
|
127
175
|
is the absolute working directory with **every non-alphanumeric character**
|
|
128
176
|
replaced by `-` (e.g. `/Users/me/.config/my_app` →
|
|
129
|
-
`-Users-me--config-my-app`), not just the path separators.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
|
|
177
|
+
`-Users-me--config-my-app`), not just the path separators. Each line is an
|
|
178
|
+
entry with a `type` (`user`/`assistant`/...), a `message` object whose
|
|
179
|
+
`content` is either a string or an array of blocks (`text`, `thinking`,
|
|
180
|
+
`tool_use`, `tool_result`), and Claude's own `uuid`/`parentUuid` chain.
|
|
181
|
+
`src/readers/claude-reader.js` finds and parses these files; `toConversation()`
|
|
134
182
|
filters out subagent ("sidechain") and internal ("meta") entries so you get
|
|
135
|
-
the same linear conversation you'd see in the Claude Code TUI.
|
|
136
|
-
|
|
137
|
-
`
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
183
|
+
the same linear conversation you'd see in the Claude Code TUI. OpenCode
|
|
184
|
+
sessions are read via the `opencode` CLI (`session list --format json` and
|
|
185
|
+
`export <id>`) rather than its internal SQLite format directly, since that
|
|
186
|
+
format has changed before and the CLI is the stable surface - `export` falls
|
|
187
|
+
back to reading OpenCode's SQLite database directly (via `node:sqlite`,
|
|
188
|
+
which needs **Node.js 22.5+**) only when the CLI's own export is incomplete,
|
|
189
|
+
e.g. while the OpenCode server is running.
|
|
190
|
+
|
|
191
|
+
`src/converters/claude-to-opencode.js` converts Claude Code into OpenCode's
|
|
192
|
+
import/export JSON shape; `src/converters/opencode-to-claude.js` runs the
|
|
193
|
+
reverse direction. Both are built on the same import/export JSON contract,
|
|
194
|
+
reverse-engineered from `opencode@1.18.5` by round-tripping synthetic
|
|
195
|
+
sessions through `opencode import ...` / `opencode export ...` and reading
|
|
196
|
+
OpenCode's generated SDK types in `@opencode-ai/sdk/dist/gen/types.gen.d.ts`.
|
|
197
|
+
|
|
198
|
+
Forward (`claude-to-opencode.js`) mapping:
|
|
149
199
|
|
|
150
200
|
- Claude's `text` / `thinking` blocks → OpenCode `text` / `reasoning` parts.
|
|
151
201
|
- Claude's `tool_use` blocks → OpenCode `tool` parts. The matching
|
|
152
202
|
`tool_result` (or the raw `toolUseResult` field on the following entry) is
|
|
153
|
-
folded into the tool part's `state` instead of being rendered as a
|
|
154
|
-
message. If no result is found, the part is marked `status:
|
|
203
|
+
folded into the tool part's `state` instead of being rendered as a
|
|
204
|
+
separate message. If no result is found, the part is marked `status:
|
|
205
|
+
"pending"`.
|
|
155
206
|
- Claude's synthetic "here's your tool result" user turn is *not* emitted as
|
|
156
207
|
its own OpenCode message (it would show up as an empty bubble); its content
|
|
157
208
|
is already captured in the tool part above.
|
|
@@ -160,10 +211,6 @@ Claude JSONL → OpenCode { info, messages:[{info, parts}] }
|
|
|
160
211
|
|
|
161
212
|
Reverse (`opencode-to-claude.js`) mapping:
|
|
162
213
|
|
|
163
|
-
```
|
|
164
|
-
OpenCode { info, messages:[{info, parts}] } → Claude JSONL
|
|
165
|
-
```
|
|
166
|
-
|
|
167
214
|
- OpenCode `text` / `reasoning` parts → Claude `text` / `thinking` blocks.
|
|
168
215
|
- OpenCode `tool` parts are expanded back into the two-entry structure Claude
|
|
169
216
|
Code uses: an assistant entry containing a `tool_use` block, followed by a
|
|
@@ -171,13 +218,34 @@ OpenCode { info, messages:[{info, parts}] } → Claude JSONL
|
|
|
171
218
|
`toolUseResult` fallback.
|
|
172
219
|
- A **fresh, deterministic Claude session UUID** is generated from the
|
|
173
220
|
OpenCode session id rather than reusing the `ses_...` id (the formats are
|
|
174
|
-
intentionally different).
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
221
|
+
intentionally different).
|
|
222
|
+
|
|
223
|
+
**IDs are always deterministic** in both directions - sha256 over the source
|
|
224
|
+
tool's own ids, never `crypto.randomUUID()` - which is what makes re-forking
|
|
225
|
+
idempotent: importing the same session twice produces the same target id
|
|
226
|
+
and (for `fork`) byte-identical output, so the ledger records nothing on the
|
|
227
|
+
second run instead of a duplicate. **Timestamps** are derived from the
|
|
228
|
+
source's own recorded times where available; the one exception is a Claude
|
|
229
|
+
entry converted from an OpenCode message that has no `time.completed` or
|
|
230
|
+
`time.created` at all, which falls back to the current wall clock for that
|
|
231
|
+
entry's cosmetic timestamp field only - the entry and session *ids* stay
|
|
232
|
+
deterministic regardless. `sync` additionally stamps the OpenCode session's
|
|
233
|
+
`time.updated` with the real current time on every real merge (a sync
|
|
234
|
+
legitimately happens "now"), but a `sync` or `watch` cycle with nothing new
|
|
235
|
+
on either side still returns before writing anything, so it stays a true
|
|
236
|
+
no-op like `fork`.
|
|
237
|
+
|
|
238
|
+
The ledger (`.agentbridge/ledger/` inside the target project) is its own
|
|
239
|
+
nested git repo, not your project's own repo - `.agentbridge/` is
|
|
240
|
+
`.gitignore`d for you. Each fork or sync writes `claude/<id>.jsonl` and
|
|
241
|
+
`opencode/<id>.json` (raw or converted, depending on direction) plus a
|
|
242
|
+
`mapping.json` recording every known Claude-id ↔ OpenCode-id pair, which is
|
|
243
|
+
how `sync`/`watch`/`log` resolve either id back to its counterpart and to
|
|
244
|
+
the last-synced baseline.
|
|
245
|
+
|
|
246
|
+
Override `--provider` / `--agent` on `fork`/`sync`/`watch` if your OpenCode
|
|
247
|
+
config names its provider or agent differently than the defaults
|
|
248
|
+
(`anthropic` / `build`).
|
|
181
249
|
|
|
182
250
|
## Known limitations
|
|
183
251
|
|
|
@@ -186,35 +254,25 @@ forks if your OpenCode config names them differently.
|
|
|
186
254
|
`tool_use_id`. This is correct for the common one-tool-per-turn case;
|
|
187
255
|
heavily parallel tool use could mis-pair in rare cases.
|
|
188
256
|
- File attachments, permission prompts, Claude Code slash commands, and
|
|
189
|
-
OpenCode
|
|
190
|
-
converted yet.
|
|
257
|
+
OpenCode part types besides `text` / `reasoning` / `tool` aren't converted.
|
|
191
258
|
- One session at a time; no batch mode.
|
|
192
|
-
- `sync`
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
##
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
(e.g. while the server is running) cannot erase history.
|
|
213
|
-
3. **Conflict handling:** `timestamp` (default) appends both sides' new turns
|
|
214
|
-
in chronological order; `abort` refuses and reports the conflict so you
|
|
215
|
-
can resolve manually.
|
|
216
|
-
4. **Live sync:** `agentbridge watch` monitors the Claude JSONL file with
|
|
217
|
-
`fs.watch` and polls OpenCode's state, auto-syncing on change.
|
|
218
|
-
5. **OpenCode export robustness:** `opencode-reader.js` falls back to reading
|
|
219
|
-
OpenCode's SQLite database directly when the CLI `export` is incomplete. The
|
|
220
|
-
fallback requires Node.js 22.5+ (`node:sqlite`).
|
|
259
|
+
- `sync`'s strategies do not de-duplicate semantically identical turns added
|
|
260
|
+
independently on both sides - only exact-match turns (by id, or content
|
|
261
|
+
hash when no id is available) are recognized as "the same" turn.
|
|
262
|
+
- The OpenCode SQLite fallback in `src/readers/opencode-reader.js` needs
|
|
263
|
+
Node.js 22.5+ (`node:sqlite`). On older Node it silently falls back to the
|
|
264
|
+
`opencode` CLI's own `export`, which can itself be incomplete while the
|
|
265
|
+
OpenCode server is running or after an imported session has been
|
|
266
|
+
continued.
|
|
267
|
+
|
|
268
|
+
## Contributing
|
|
269
|
+
|
|
270
|
+
PRs welcome. This repo uses two long-lived branches - `dev` for ordinary
|
|
271
|
+
work, `main` for what's released to npm - and PRs target `dev` by default
|
|
272
|
+
(CI/CD-only changes target `main` directly). See `CLAUDE.md` and
|
|
273
|
+
`agents/skills/` for the full development conventions (tests, code style,
|
|
274
|
+
PR shape, documentation) before opening one.
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT © neerajvipparla. See [LICENSE](LICENSE).
|
package/bin/agentbridge.js
CHANGED
|
@@ -290,7 +290,11 @@ program
|
|
|
290
290
|
.description("Reconcile a conversation that has been edited in both tools")
|
|
291
291
|
.argument("[session-id]", "Claude or OpenCode session id (defaults to the most recently forked session in the ledger)")
|
|
292
292
|
.option("-d, --dir <path>", "project directory (defaults to cwd)", process.cwd())
|
|
293
|
-
.option(
|
|
293
|
+
.option(
|
|
294
|
+
"-s, --strategy <strategy>",
|
|
295
|
+
'merge strategy: "timestamp" (default), "abort", "persist-claude", or "persist-opencode"',
|
|
296
|
+
"timestamp"
|
|
297
|
+
)
|
|
294
298
|
.option("--provider <providerID>", "OpenCode provider id for new Claude turns", "anthropic")
|
|
295
299
|
.option("--agent <agent>", "OpenCode agent name for new Claude turns", "build")
|
|
296
300
|
.option("--dry-run", "compute the merge but skip writing files or importing", false)
|
|
@@ -325,7 +329,13 @@ program
|
|
|
325
329
|
});
|
|
326
330
|
|
|
327
331
|
if (result.claudeNew !== undefined && result.opencodeNew !== undefined && (result.claudeNew > 0 || result.opencodeNew > 0)) {
|
|
328
|
-
|
|
332
|
+
if (opts.strategy === "persist-claude") {
|
|
333
|
+
console.log(`Keeping ${result.claudeNew} new Claude turns; discarding ${result.opencodeNew} new OpenCode turns.`);
|
|
334
|
+
} else if (opts.strategy === "persist-opencode") {
|
|
335
|
+
console.log(`Keeping ${result.opencodeNew} new OpenCode turns; discarding ${result.claudeNew} new Claude turns.`);
|
|
336
|
+
} else {
|
|
337
|
+
console.log(`Syncing ${result.claudeNew} new Claude turns and ${result.opencodeNew} new OpenCode turns.`);
|
|
338
|
+
}
|
|
329
339
|
}
|
|
330
340
|
if (result.message) console.log(result.message);
|
|
331
341
|
if (result.error) console.error(result.error);
|
|
@@ -343,6 +353,21 @@ program
|
|
|
343
353
|
.option("--agent <agent>", "OpenCode agent name for new Claude turns", "build")
|
|
344
354
|
.action(async (sessionId, opts) => {
|
|
345
355
|
const dir = path.resolve(opts.dir);
|
|
356
|
+
|
|
357
|
+
const strategy = opts.strategy;
|
|
358
|
+
if (strategy !== "timestamp" && strategy !== "abort") {
|
|
359
|
+
console.error(`Unknown strategy "${strategy}". Use "timestamp" or "abort".`);
|
|
360
|
+
process.exitCode = 1;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const interval = Number(opts.interval);
|
|
365
|
+
if (!Number.isFinite(interval) || interval < 500) {
|
|
366
|
+
console.error("Interval must be at least 500 ms.");
|
|
367
|
+
process.exitCode = 1;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
346
371
|
const ledgerDir = ledgerPath(dir);
|
|
347
372
|
if (!fs.existsSync(path.join(ledgerDir, ".git"))) {
|
|
348
373
|
console.error(`No ledger found at ${ledgerDir}. Run a fork first.`);
|
|
@@ -369,20 +394,6 @@ program
|
|
|
369
394
|
return;
|
|
370
395
|
}
|
|
371
396
|
|
|
372
|
-
const strategy = opts.strategy;
|
|
373
|
-
if (strategy !== "timestamp" && strategy !== "abort") {
|
|
374
|
-
console.error(`Unknown strategy "${strategy}". Use "timestamp" or "abort".`);
|
|
375
|
-
process.exitCode = 1;
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const interval = Number(opts.interval);
|
|
380
|
-
if (!Number.isFinite(interval) || interval < 500) {
|
|
381
|
-
console.error("Interval must be at least 500 ms.");
|
|
382
|
-
process.exitCode = 1;
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
397
|
console.log(`Watching ${claudeFile} and OpenCode session ${opencodeId} (poll every ${interval}ms). Press Ctrl-C to stop.`);
|
|
387
398
|
const handle = watchSession({
|
|
388
399
|
claudeFile,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neerajvipparla/agentbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Fork Claude Code chat sessions into OpenCode and back, versioned as local git commits. A bidirectional Claude Code <-> OpenCode context bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/sync/sync.js
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Given a known session pair (Claude id ↔ OpenCode id) recorded in the ledger,
|
|
6
6
|
// this module reads the current state of both tools, compares each to the last
|
|
7
|
-
// synced state stored in the ledger, and merges only the new turns
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// synced state stored in the ledger, and merges only the new turns, according
|
|
8
|
+
// to one of four strategies (see STRATEGIES below): "timestamp" (default) -
|
|
9
|
+
// each side's own new turns first, then the other side's, appended - not
|
|
10
|
+
// interleaved by real time; "abort" - refuses when both sides have new turns;
|
|
11
|
+
// "persist-claude" / "persist-opencode" - keep only one side's divergent new
|
|
12
|
+
// turns, discarding the other's entirely (sync-only, not available on watch).
|
|
10
13
|
|
|
11
14
|
import fs from "node:fs";
|
|
12
15
|
import path from "node:path";
|
|
@@ -66,29 +69,46 @@ export function diffSync(current, last) {
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
72
|
+
* Refuse to merge if both sides have new turns; otherwise merge normally
|
|
73
|
+
* (there is nothing to abort over when only one side changed).
|
|
71
74
|
*
|
|
72
75
|
* @param {object} current - current state of both sides
|
|
73
76
|
* @param {object} last - last synced state of both sides
|
|
74
|
-
* @param {
|
|
77
|
+
* @param {{claudeNew: object[], opencodeNew: object[]}} diff - from diffSync
|
|
75
78
|
* @param {object} opts - conversion options (directory, etc.)
|
|
76
79
|
* @returns {{claudeEntries: object[], opencodeMessages: object[], claudeNew: object[], opencodeNew: object[]}}
|
|
77
80
|
*/
|
|
78
|
-
|
|
79
|
-
const { claudeNew, opencodeNew } =
|
|
81
|
+
function mergeAbort(current, last, diff, opts) {
|
|
82
|
+
const { claudeNew, opencodeNew } = diff;
|
|
80
83
|
|
|
81
|
-
if (
|
|
84
|
+
if (claudeNew.length > 0 && opencodeNew.length > 0) {
|
|
82
85
|
const err = new Error(
|
|
83
86
|
`Both sides have new turns since the last sync.\n` +
|
|
84
87
|
`Claude: ${claudeNew.length} new turns. OpenCode: ${opencodeNew.length} new turns.\n` +
|
|
85
|
-
`Run with --strategy timestamp to
|
|
88
|
+
`Run with --strategy timestamp to keep both sides' turns (grouped by origin), ` +
|
|
89
|
+
`--strategy persist-claude / persist-opencode to keep only one side and discard the other, ` +
|
|
90
|
+
`or resolve manually.`
|
|
86
91
|
);
|
|
87
92
|
err.claudeNew = claudeNew;
|
|
88
93
|
err.opencodeNew = opencodeNew;
|
|
89
94
|
throw err;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
// Nothing to abort over - only one side changed (or neither). Merge normally.
|
|
98
|
+
return mergeTimestamp(current, last, diff, opts);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Merge new turns from both sides into a single sequence per side.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} current - current state of both sides
|
|
105
|
+
* @param {object} last - last synced state of both sides
|
|
106
|
+
* @param {{claudeNew: object[], opencodeNew: object[]}} diff - from diffSync
|
|
107
|
+
* @param {object} opts - conversion options (directory, etc.)
|
|
108
|
+
* @returns {{claudeEntries: object[], opencodeMessages: object[], claudeNew: object[], opencodeNew: object[]}}
|
|
109
|
+
*/
|
|
110
|
+
function mergeTimestamp(current, last, diff, opts) {
|
|
111
|
+
const { claudeNew, opencodeNew } = diff;
|
|
92
112
|
const cur = normalizeCurrent(current);
|
|
93
113
|
const lst = normalizeCurrent(last);
|
|
94
114
|
|
|
@@ -158,31 +178,121 @@ export function mergeSync(current, last, strategy, opts = {}) {
|
|
|
158
178
|
}
|
|
159
179
|
}
|
|
160
180
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
// No sort here, deliberately: mergedClaude/mergedOpenCode are already in
|
|
182
|
+
// the wanted order (baseline, then this side's own new turns, then the
|
|
183
|
+
// other side's new turns converted in) from the construction above.
|
|
184
|
+
// Grouping by origin instead of interleaving by real timestamp is the
|
|
185
|
+
// contract `timestamp` promises - see the design doc.
|
|
186
|
+
|
|
187
|
+
let prevUuid = null;
|
|
188
|
+
for (const e of mergedClaude) {
|
|
189
|
+
e.parentUuid = prevUuid;
|
|
190
|
+
prevUuid = e.uuid;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let prevOpenCodeId = null;
|
|
194
|
+
for (const m of mergedOpenCode) {
|
|
195
|
+
m.info.parentID = prevOpenCodeId || m.info.sessionID;
|
|
196
|
+
prevOpenCodeId = m.info.id;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { claudeEntries: mergedClaude, opencodeMessages: mergedOpenCode, claudeNew, opencodeNew };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Keep only Claude's divergent new turns; OpenCode's are discarded entirely
|
|
204
|
+
* (not appended, not converted). The common baseline is preserved on both
|
|
205
|
+
* sides. opencodeNew is still returned (unused in the merge) so the CLI can
|
|
206
|
+
* report how many turns were discarded.
|
|
207
|
+
*/
|
|
208
|
+
function mergePersistClaude(current, last, diff, opts) {
|
|
209
|
+
const { claudeNew, opencodeNew } = diff;
|
|
210
|
+
const lst = normalizeCurrent(last);
|
|
211
|
+
|
|
212
|
+
const mergedClaude = [...lst.claude];
|
|
213
|
+
const existingClaudeKeys = new Set(mergedClaude.map(claudeTurnKey));
|
|
214
|
+
for (const e of claudeNew) {
|
|
215
|
+
const k = claudeTurnKey(e);
|
|
216
|
+
if (!existingClaudeKeys.has(k)) {
|
|
217
|
+
mergedClaude.push(e);
|
|
218
|
+
existingClaudeKeys.add(k);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const mergedOpenCode = [...lst.opencode];
|
|
223
|
+
if (claudeNew.length > 0) {
|
|
224
|
+
const existingOpenCodeKeys = new Set(mergedOpenCode.map(opencodeTurnKey));
|
|
225
|
+
const opencodeSessionId = lst.opencode[0]?.info?.sessionID || opts.opencodeId;
|
|
226
|
+
const newOpenCodeMessages = convertToOpenCode(claudeNew, {
|
|
227
|
+
directory: opts.directory,
|
|
228
|
+
title: opts.title ?? "Synced session",
|
|
229
|
+
providerID: opts.providerID,
|
|
230
|
+
agent: opts.agent,
|
|
231
|
+
opencodeSessionId,
|
|
232
|
+
}).messages;
|
|
233
|
+
for (const m of newOpenCodeMessages) {
|
|
234
|
+
const k = opencodeTurnKey(m);
|
|
235
|
+
if (!existingOpenCodeKeys.has(k)) {
|
|
236
|
+
mergedOpenCode.push(m);
|
|
237
|
+
existingOpenCodeKeys.add(k);
|
|
238
|
+
}
|
|
172
239
|
}
|
|
173
240
|
}
|
|
174
|
-
claudeWithGroups.sort((a, b) => a.ts - b.ts);
|
|
175
|
-
mergedClaude.length = 0;
|
|
176
|
-
for (const g of claudeWithGroups) mergedClaude.push(...g.entries);
|
|
177
241
|
|
|
178
242
|
let prevUuid = null;
|
|
179
243
|
for (const e of mergedClaude) {
|
|
180
244
|
e.parentUuid = prevUuid;
|
|
181
245
|
prevUuid = e.uuid;
|
|
182
246
|
}
|
|
247
|
+
let prevOpenCodeId = null;
|
|
248
|
+
for (const m of mergedOpenCode) {
|
|
249
|
+
m.info.parentID = prevOpenCodeId || m.info.sessionID;
|
|
250
|
+
prevOpenCodeId = m.info.id;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { claudeEntries: mergedClaude, opencodeMessages: mergedOpenCode, claudeNew, opencodeNew };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Mirror of mergePersistClaude: keep only OpenCode's divergent new turns,
|
|
258
|
+
* discard Claude's entirely.
|
|
259
|
+
*/
|
|
260
|
+
function mergePersistOpencode(current, last, diff, opts) {
|
|
261
|
+
const { claudeNew, opencodeNew } = diff;
|
|
262
|
+
const lst = normalizeCurrent(last);
|
|
183
263
|
|
|
184
|
-
|
|
185
|
-
|
|
264
|
+
const mergedOpenCode = [...lst.opencode];
|
|
265
|
+
const existingOpenCodeKeys = new Set(mergedOpenCode.map(opencodeTurnKey));
|
|
266
|
+
for (const m of opencodeNew) {
|
|
267
|
+
const k = opencodeTurnKey(m);
|
|
268
|
+
if (!existingOpenCodeKeys.has(k)) {
|
|
269
|
+
mergedOpenCode.push(m);
|
|
270
|
+
existingOpenCodeKeys.add(k);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const mergedClaude = [...lst.claude];
|
|
275
|
+
if (opencodeNew.length > 0) {
|
|
276
|
+
const existingClaudeKeys = new Set(mergedClaude.map(claudeTurnKey));
|
|
277
|
+
const claudeSessionId = lst.claude[0]?.sessionId || opts.claudeSessionId;
|
|
278
|
+
const newClaudeEntries = convertToClaude(
|
|
279
|
+
{ info: { id: opts.opencodeId || "placeholder", directory: opts.directory }, messages: opencodeNew },
|
|
280
|
+
{ directory: opts.directory, sessionId: claudeSessionId }
|
|
281
|
+
);
|
|
282
|
+
for (const e of newClaudeEntries) {
|
|
283
|
+
const k = claudeTurnKey(e);
|
|
284
|
+
if (!existingClaudeKeys.has(k)) {
|
|
285
|
+
mergedClaude.push(e);
|
|
286
|
+
existingClaudeKeys.add(k);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let prevUuid = null;
|
|
292
|
+
for (const e of mergedClaude) {
|
|
293
|
+
e.parentUuid = prevUuid;
|
|
294
|
+
prevUuid = e.uuid;
|
|
295
|
+
}
|
|
186
296
|
let prevOpenCodeId = null;
|
|
187
297
|
for (const m of mergedOpenCode) {
|
|
188
298
|
m.info.parentID = prevOpenCodeId || m.info.sessionID;
|
|
@@ -192,6 +302,31 @@ export function mergeSync(current, last, strategy, opts = {}) {
|
|
|
192
302
|
return { claudeEntries: mergedClaude, opencodeMessages: mergedOpenCode, claudeNew, opencodeNew };
|
|
193
303
|
}
|
|
194
304
|
|
|
305
|
+
const STRATEGIES = {
|
|
306
|
+
timestamp: mergeTimestamp,
|
|
307
|
+
abort: mergeAbort,
|
|
308
|
+
"persist-claude": mergePersistClaude,
|
|
309
|
+
"persist-opencode": mergePersistOpencode,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Merge new turns from both sides according to the given strategy.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} current - current state of both sides
|
|
316
|
+
* @param {object} last - last synced state of both sides
|
|
317
|
+
* @param {string} strategy - one of the keys of STRATEGIES
|
|
318
|
+
* @param {object} opts - conversion options (directory, etc.)
|
|
319
|
+
* @returns {{claudeEntries: object[], opencodeMessages: object[], claudeNew: object[], opencodeNew: object[]}}
|
|
320
|
+
*/
|
|
321
|
+
export function mergeSync(current, last, strategy, opts = {}) {
|
|
322
|
+
const diff = diffSync(current, last);
|
|
323
|
+
const fn = STRATEGIES[strategy];
|
|
324
|
+
if (!fn) {
|
|
325
|
+
throw new Error(`Unknown strategy "${strategy}". Use one of: ${Object.keys(STRATEGIES).join(", ")}.`);
|
|
326
|
+
}
|
|
327
|
+
return fn(current, last, diff, opts);
|
|
328
|
+
}
|
|
329
|
+
|
|
195
330
|
/**
|
|
196
331
|
* Load current state from both tools.
|
|
197
332
|
*
|
|
@@ -246,8 +381,12 @@ export function syncSession({ ledgerDir, dir, claudeId, opencodeId, strategy, dr
|
|
|
246
381
|
};
|
|
247
382
|
}
|
|
248
383
|
|
|
249
|
-
if (strategy
|
|
250
|
-
return {
|
|
384
|
+
if (!(strategy in STRATEGIES)) {
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
error: `Unknown strategy "${strategy}". Use one of: ${Object.keys(STRATEGIES).join(", ")}.`,
|
|
388
|
+
exitCode: 1,
|
|
389
|
+
};
|
|
251
390
|
}
|
|
252
391
|
|
|
253
392
|
let merged;
|