@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 CHANGED
@@ -1,157 +1,208 @@
1
1
  # agentbridge
2
2
 
3
- Phase 1 of a Claude Code ⇄ OpenCode context bridge: fork a chat session from
4
- one agent into the other, and record every fork as a commit in a local git
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>] [--strategy timestamp|abort]
13
- agentbridge watch [session-id] [--dir <path>] [--strategy timestamp|abort] [--interval <ms>]
14
- agentbridge log [--dir <path>] [--limit <n>]
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
- Typical flow:
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
- - `agentbridge list` (or `list -s opencode`) to see what's available.
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
- | Command | Default source | `-s opencode` |
26
- |---|---|---|
27
- | `list` | Claude Code sessions | OpenCode sessions |
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
- Auto-detection: ids starting with `ses_` are OpenCode; everything else is
32
- treated as a Claude Code session id.
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
- Run `agentbridge fork` with no arguments inside a project you've used `claude`
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
- Run `agentbridge fork <ses_...> -s opencode` to go the other way: it exports
40
- the OpenCode session, converts it back to a Claude Code JSONL transcript, and
41
- writes it to `~/.claude/projects/<encoded-dir>/<uuid>.jsonl` so `claude` can
42
- resume it.
36
+ ```
37
+ npx -y @neerajvipparla/agentbridge list
38
+ ```
43
39
 
44
- Re-running `fork` on an unchanged session is a no-op (same target id, no new
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
- `--dry-run` converts and writes the ledger commit, but skips the final
49
- `opencode import` / Claude file write.
42
+ ### From source
50
43
 
51
- ## Bidirectional sync (`sync`)
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
- `agentbridge sync [session-id]` reconciles a conversation that has been edited
54
- in both tools since the last fork/sync.
51
+ ## Usage
55
52
 
56
- - It reads the current Claude transcript and the current OpenCode session.
57
- - It compares both to the last synced state stored in `.agentbridge/ledger/`.
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
- Conflict strategies:
62
+ Preview one in full with: agentbridge show <session-id>
63
+ Fork one to OpenCode with: agentbridge fork <session-id>
64
+ ```
69
65
 
70
- - `timestamp` (default) append new turns from both sides and sort by time.
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
- `sync` also records a ledger commit, so you can roll back or inspect the diff.
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
- ## Live sync (`watch`)
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
- `agentbridge watch [session-id]` monitors the Claude session file and polls
81
- OpenCode, automatically syncing when either side changes.
77
+ ### Forking Claude Code OpenCode
82
78
 
83
- ```bash
84
- agentbridge watch # latest session in the ledger
85
- agentbridge watch <ses_...> # watch a specific OpenCode session
86
- agentbridge watch --interval 1000 # poll OpenCode every 1 second
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
- Press **Ctrl-C** to stop. The default poll interval is 5 seconds.
91
-
92
- **Safety note:** `watch` uses the same merge strategy as `sync`. With the default
93
- `timestamp` strategy it will auto-merge both sides' new turns. If you prefer to
94
- review every conflict, use `--strategy abort`.
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
- ## Install
91
+ ### Forking OpenCode → Claude Code
97
92
 
98
93
  ```
99
- npm install -g @neerajvipparla/agentbridge
94
+ agentbridge fork <ses_...> -s opencode
100
95
  ```
101
96
 
102
- The package is published under a scoped name (npm's automated policy flags
103
- `agentbridge` as too similar to an existing, unrelated `agent-bridge`
104
- package) but the command it installs is still plain `agentbridge`.
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
- Or run it once without installing:
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
- npx -y @neerajvipparla/agentbridge list
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
- Requires `git` and the `opencode` CLI on your `PATH`.
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
- ### From source
162
+ ### Reviewing what's been forked
115
163
 
116
164
  ```
117
- git clone https://github.com/neerajvipparla/Agentbridge.git
118
- cd Agentbridge
119
- npm install
120
- npm link # or: node bin/agentbridge.js ...
165
+ agentbridge log [--limit <n>]
121
166
  ```
122
167
 
123
- ## How the conversion works
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
- Each line is an entry with a `type` (`user`/`assistant`/...), a `message`
131
- object whose `content` is either a string or an array of blocks (`text`,
132
- `thinking`, `tool_use`, `tool_result`), and Claude's own `uuid`/`parentUuid`
133
- chain. `src/readers/claude-reader.js` finds and parses these files; `toConversation()`
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
- `src/converters/claude-to-opencode.js` turns Claude Code into OpenCode's import/export
138
- JSON shape; `src/converters/opencode-to-claude.js` runs the reverse direction. Both are built on the
139
- same import/export JSON contract that was reverse-engineered from
140
- `opencode@1.18.5` by round-tripping synthetic sessions through
141
- `opencode import ...` / `opencode export ...` and reading OpenCode's generated
142
- SDK types in `@opencode-ai/sdk/dist/gen/types.gen.d.ts`.
143
-
144
- Forward (`converter.js`) mapping:
145
-
146
- ```
147
- Claude JSONL → OpenCode { info, messages:[{info, parts}] }
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 separate
154
- message. If no result is found, the part is marked `status: "pending"`.
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). Re-importing the same OpenCode session produces
175
- the same Claude id, so reverse-forking is also idempotent.
176
-
177
- In both directions, IDs and timestamps are **derived deterministically** from
178
- the source tool's own ids/timestamps (sha256-based), not random. This is what
179
- makes re-forking idempotent. Override `--provider` / `--agent` on forward
180
- forks if your OpenCode config names them differently.
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-specific part types besides `text` / `reasoning` / `tool` aren't
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` merges by timestamp; it does not yet de-duplicate semantically
193
- identical turns added independently on both sides.
194
- - OpenCode's `export` command can be lossy while the OpenCode server is running
195
- or when a session was imported and then continued. `agentbridge` now reads
196
- OpenCode's SQLite database directly as a fallback, and `sync` rebuilds the
197
- merged state from the ledger baseline so history is not silently dropped.
198
- However, uncommitted/in-progress turns that OpenCode has not yet flushed to
199
- disk cannot be synced until OpenCode persists them.
200
-
201
- ## Phase 2: bidirectional sync (implemented)
202
-
203
- The `agentbridge sync` command implements steps 1-3:
204
-
205
- 1. **Both directions get a converter.** Done: `converter.js` (Claude → OpenCode)
206
- and `opencode-to-claude.js` (OpenCode Claude). Both write through the
207
- same ledger.
208
- 2. **The ledger is the source of truth for "what did we last sync".**
209
- `sync` reads the last ledger commit for the session pair, diffs it against
210
- the current state of both tools, and only merges the new turns. The merged
211
- state is always rebuilt from the ledger baseline so a partial OpenCode export
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).
@@ -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("-s, --strategy <strategy>", 'merge strategy: "timestamp" (default) or "abort"', "timestamp")
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
- console.log(`Syncing ${result.claudeNew} new Claude turns and ${result.opencodeNew} new OpenCode turns.`);
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.0",
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. The default
8
- // strategy is "timestamp": new turns from both sides are appended and sorted
9
- // by time. The "abort" strategy refuses when both sides have new turns.
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
- * Merge new turns from both sides into a single chronological sequence.
70
- * Returns the merged data in both tool-specific forms.
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 {string} strategy - "timestamp" or "abort"
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
- export function mergeSync(current, last, strategy, opts = {}) {
79
- const { claudeNew, opencodeNew } = diffSync(current, last);
81
+ function mergeAbort(current, last, diff, opts) {
82
+ const { claudeNew, opencodeNew } = diff;
80
83
 
81
- if (strategy === "abort" && claudeNew.length > 0 && opencodeNew.length > 0) {
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 merge by timestamp, or resolve manually.`
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
- // Sort both merged representations by timestamp and recompute parent chains.
162
- // Claude: keep assistant + tool-result pairs together.
163
- const claudeWithGroups = [];
164
- for (let i = 0; i < mergedClaude.length; i++) {
165
- const e = mergedClaude[i];
166
- const next = mergedClaude[i + 1];
167
- if (e.type === "assistant" && next && next.type === "user" && next.parentUuid === e.uuid) {
168
- claudeWithGroups.push({ entries: [e, next], ts: toEpochMs(e.timestamp) });
169
- i++; // skip the paired result entry
170
- } else {
171
- claudeWithGroups.push({ entries: [e], ts: toEpochMs(e.timestamp) });
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
- // OpenCode: sort by message creation time.
185
- mergedOpenCode.sort((a, b) => (a.info?.time?.created || 0) - (b.info?.time?.created || 0));
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 !== "timestamp" && strategy !== "abort") {
250
- return { ok: false, error: `Unknown strategy "${strategy}". Use "timestamp" or "abort".`, exitCode: 1 };
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;