@neerajvipparla/agentbridge 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neerajvipparla
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,220 @@
1
+ # agentbridge
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.
6
+
7
+ ```
8
+ agentbridge list [-s claude|opencode] [--dir <path>] [--all]
9
+ agentbridge show <session-id> [-s claude|opencode] [--dir <path>]
10
+ agentbridge fork [session-id] [-s claude|opencode] [--dir <path>] [--dry-run]
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>]
15
+ ```
16
+
17
+ Typical flow:
18
+
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
+
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 |
30
+
31
+ Auto-detection: ids starting with `ses_` are OpenCode; everything else is
32
+ treated as a Claude Code session id.
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.
38
+
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.
43
+
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.
47
+
48
+ `--dry-run` converts and writes the ledger commit, but skips the final
49
+ `opencode import` / Claude file write.
50
+
51
+ ## Bidirectional sync (`sync`)
52
+
53
+ `agentbridge sync [session-id]` reconciles a conversation that has been edited
54
+ in both tools since the last fork/sync.
55
+
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.
59
+
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
+ ```
67
+
68
+ Conflict strategies:
69
+
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.
75
+
76
+ `sync` also records a ledger commit, so you can roll back or inspect the diff.
77
+
78
+ ## Live sync (`watch`)
79
+
80
+ `agentbridge watch [session-id]` monitors the Claude session file and polls
81
+ OpenCode, automatically syncing when either side changes.
82
+
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
88
+ ```
89
+
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`.
95
+
96
+ ## Install
97
+
98
+ ```
99
+ npm install -g @neerajvipparla/agentbridge
100
+ ```
101
+
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`.
105
+
106
+ Or run it once without installing:
107
+
108
+ ```
109
+ npx -y @neerajvipparla/agentbridge list
110
+ ```
111
+
112
+ Requires `git` and the `opencode` CLI on your `PATH`.
113
+
114
+ ### From source
115
+
116
+ ```
117
+ git clone https://github.com/neerajvipparla/Agentbridge.git
118
+ cd Agentbridge
119
+ npm install
120
+ npm link # or: node bin/agentbridge.js ...
121
+ ```
122
+
123
+ ## How the conversion works
124
+
125
+ Claude Code stores one JSONL file per session at
126
+ `~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`, where `<encoded-cwd>`
127
+ is the absolute working directory with **every non-alphanumeric character**
128
+ 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()`
134
+ 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
+ ```
149
+
150
+ - Claude's `text` / `thinking` blocks → OpenCode `text` / `reasoning` parts.
151
+ - Claude's `tool_use` blocks → OpenCode `tool` parts. The matching
152
+ `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"`.
155
+ - Claude's synthetic "here's your tool result" user turn is *not* emitted as
156
+ its own OpenCode message (it would show up as an empty bubble); its content
157
+ is already captured in the tool part above.
158
+ - Empty assistant turns (e.g. redacted thinking blocks that keep only a
159
+ `signature` and no text) are also skipped to avoid blank bubbles.
160
+
161
+ Reverse (`opencode-to-claude.js`) mapping:
162
+
163
+ ```
164
+ OpenCode { info, messages:[{info, parts}] } → Claude JSONL
165
+ ```
166
+
167
+ - OpenCode `text` / `reasoning` parts → Claude `text` / `thinking` blocks.
168
+ - OpenCode `tool` parts are expanded back into the two-entry structure Claude
169
+ Code uses: an assistant entry containing a `tool_use` block, followed by a
170
+ synthetic user entry containing the matching `tool_result` block plus a
171
+ `toolUseResult` fallback.
172
+ - A **fresh, deterministic Claude session UUID** is generated from the
173
+ 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.
181
+
182
+ ## Known limitations
183
+
184
+ - Parallel tool calls within a single assistant turn are matched to their
185
+ results positionally (FIFO) when Claude Code doesn't tag the result with a
186
+ `tool_use_id`. This is correct for the common one-tool-per-turn case;
187
+ heavily parallel tool use could mis-pair in rare cases.
188
+ - File attachments, permission prompts, Claude Code slash commands, and
189
+ OpenCode-specific part types besides `text` / `reasoning` / `tool` aren't
190
+ converted yet.
191
+ - 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`).
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ findLatestSession as findLatestClaudeSession,
8
+ findSessionById as findClaudeSessionById,
9
+ findProjectDir,
10
+ listSessions as listClaudeSessions,
11
+ listAllProjectDirs,
12
+ parseSessionFile,
13
+ toConversation,
14
+ summarizeSession as summarizeClaudeSession,
15
+ renderTranscript as renderClaudeTranscript,
16
+ claudeProjectsDir,
17
+ encodeProjectPath,
18
+ } from "../src/readers/claude-reader.js";
19
+ import {
20
+ findLatestSession as findLatestOpenCodeSession,
21
+ findSessionById as findOpenCodeSessionById,
22
+ listSessions as listOpenCodeSessions,
23
+ listSessionsForDir as listOpenCodeSessionsForDir,
24
+ summarizeSession as summarizeOpenCodeSession,
25
+ renderTranscript as renderOpenCodeTranscript,
26
+ } from "../src/readers/opencode-reader.js";
27
+ import { convertToOpenCode } from "../src/converters/claude-to-opencode.js";
28
+ import { convertToClaude } from "../src/converters/opencode-to-claude.js";
29
+ import { importIntoOpenCode } from "../src/writers/opencode-import.js";
30
+ import { writeClaudeSession } from "../src/writers/claude-writer.js";
31
+ import { ledgerPath, commitFork, log as ledgerLog } from "../src/ledger/git-ledger.js";
32
+ import { syncSession, resolveSessionPair } from "../src/sync/sync.js";
33
+ import { watchSession, waitForInterrupt } from "../src/sync/watch.js";
34
+
35
+ const program = new Command();
36
+
37
+ program
38
+ .name("agentbridge")
39
+ .description("Fork chat sessions between Claude Code and OpenCode, recorded as local git commits.")
40
+ .version("0.1.0");
41
+
42
+ function resolveSource(sessionId, explicit) {
43
+ if (explicit) {
44
+ if (explicit !== "claude" && explicit !== "opencode") {
45
+ throw new Error(`Unknown source "${explicit}". Use "claude" or "opencode".`);
46
+ }
47
+ return explicit;
48
+ }
49
+ // Auto-detect: OpenCode session ids start with "ses_".
50
+ if (sessionId && sessionId.startsWith("ses_")) return "opencode";
51
+ return "claude";
52
+ }
53
+
54
+ function renderClaudeList(rows, showCwd) {
55
+ if (rows.length === 0) {
56
+ console.log("No non-empty sessions found.");
57
+ return;
58
+ }
59
+ for (const r of rows) {
60
+ const when = new Date(r.mtime).toLocaleString();
61
+ console.log(`${r.id}`);
62
+ console.log(` ${when} · ${r.messageCount} messages${showCwd ? ` · ${r.cwd}` : ""}`);
63
+ console.log(` "${r.firstMessage}"`);
64
+ console.log("");
65
+ }
66
+ }
67
+
68
+ function renderOpenCodeList(rows) {
69
+ if (rows.length === 0) {
70
+ console.log("No OpenCode sessions found.");
71
+ return;
72
+ }
73
+ for (const r of rows) {
74
+ const when = new Date(r.updated).toLocaleString();
75
+ console.log(`${r.id}`);
76
+ console.log(` ${when} · ${r.messageCount} messages · ${r.cwd}`);
77
+ console.log(` "${r.firstMessage}"`);
78
+ console.log("");
79
+ }
80
+ }
81
+
82
+ program
83
+ .command("list")
84
+ .alias("ls")
85
+ .description("List sessions you can fork, newest first")
86
+ .option("-d, --dir <path>", "project directory (defaults to cwd for Claude; omitted = all for OpenCode)")
87
+ .option("-a, --all", "list sessions across every project Claude Code has seen, not just this one", false)
88
+ .option("-s, --source <tool>", 'source tool: "claude" or "opencode" (defaults to claude)')
89
+ .action((opts) => {
90
+ const source = opts.source || "claude";
91
+ if (source === "opencode") {
92
+ const sessions = opts.dir
93
+ ? listOpenCodeSessionsForDir(path.resolve(opts.dir))
94
+ : listOpenCodeSessions();
95
+ const rows = sessions
96
+ .map((s) => ({
97
+ ...s,
98
+ ...summarizeOpenCodeSession(s.session),
99
+ }))
100
+ .filter((r) => r.messageCount > 0);
101
+ renderOpenCodeList(rows);
102
+ console.log(`Preview one in full with: agentbridge show <session-id> -s opencode`);
103
+ console.log(`Fork one to Claude Code with: agentbridge fork <session-id> -s opencode`);
104
+ return;
105
+ }
106
+
107
+ const searchDir = opts.dir ? path.resolve(opts.dir) : process.cwd();
108
+ const targets = opts.all
109
+ ? listAllProjectDirs()
110
+ : (() => {
111
+ const dir = findProjectDir(searchDir);
112
+ return dir ? [dir] : [];
113
+ })();
114
+
115
+ if (targets.length === 0) {
116
+ console.log(
117
+ opts.all
118
+ ? "No Claude Code sessions found anywhere under ~/.claude/projects."
119
+ : `No Claude Code sessions found for ${searchDir}. Try --all to search every project, or run \`claude\` here first.`
120
+ );
121
+ return;
122
+ }
123
+
124
+ const rows = [];
125
+ for (const dir of targets) {
126
+ for (const session of listClaudeSessions(dir)) {
127
+ const entries = parseSessionFile(session.file);
128
+ const summary = summarizeClaudeSession(entries);
129
+ if (summary.messageCount === 0) continue;
130
+ rows.push({ ...session, ...summary });
131
+ }
132
+ }
133
+ rows.sort((a, b) => b.mtime - a.mtime);
134
+
135
+ renderClaudeList(rows, opts.all);
136
+ console.log(`Preview one in full with: agentbridge show <session-id>${opts.all ? " --all" : ""}`);
137
+ console.log(`Fork one to OpenCode with: agentbridge fork <session-id>`);
138
+ });
139
+
140
+ program
141
+ .command("show")
142
+ .description("Print the full transcript of a session")
143
+ .argument("<session-id>", "session id")
144
+ .option("-d, --dir <path>", "project directory (defaults to cwd)", process.cwd())
145
+ .option("-a, --all", "search every project Claude Code has seen, not just --dir", false)
146
+ .option("-s, --source <tool>", 'source tool: "claude" or "opencode" (auto-detect)')
147
+ .action((sessionId, opts) => {
148
+ const source = resolveSource(sessionId, opts.source);
149
+ if (source === "opencode") {
150
+ const session = findOpenCodeSessionById(sessionId);
151
+ if (!session) {
152
+ console.error(`Could not find OpenCode session "${sessionId}".`);
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ console.log(renderOpenCodeTranscript(session.session));
157
+ return;
158
+ }
159
+
160
+ const dir = path.resolve(opts.dir);
161
+ const session = findClaudeSessionById(sessionId, dir);
162
+ if (!session) {
163
+ console.error(`Could not find Claude Code session "${sessionId}" (looked under ~/.claude/projects).`);
164
+ process.exitCode = 1;
165
+ return;
166
+ }
167
+ const entries = parseSessionFile(session.file);
168
+ console.log(renderClaudeTranscript(entries));
169
+ });
170
+
171
+ program
172
+ .command("fork")
173
+ .description("Fork a session into the other tool and record it in the local git ledger")
174
+ .argument("[session-id]", "session id to fork (defaults to the most recent one)")
175
+ .option("-d, --dir <path>", "project directory (forward: source dir; reverse: target dir)", process.cwd())
176
+ .option("-s, --source <tool>", 'source tool: "claude" or "opencode" (auto-detect)')
177
+ .option("--title <title>", "title for the new OpenCode session (forward only)")
178
+ .option("--provider <providerID>", "OpenCode provider id to attribute assistant turns to (forward only)", "anthropic")
179
+ .option("--agent <agent>", "OpenCode agent name (forward only)", "build")
180
+ .option("--dry-run", "convert and write the ledger commit, but skip the final import/write", false)
181
+ .action((sessionId, opts) => {
182
+ const source = resolveSource(sessionId, opts.source);
183
+
184
+ if (source === "opencode") {
185
+ const session = sessionId ? findOpenCodeSessionById(sessionId) : findLatestOpenCodeSession();
186
+ if (!session) {
187
+ console.error(
188
+ sessionId
189
+ ? `Could not find OpenCode session "${sessionId}".`
190
+ : `No OpenCode sessions found. Have you run \`opencode\` yet?`
191
+ );
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+
196
+ const dir = path.resolve(opts.dir || session.session.info?.directory || process.cwd());
197
+ const entries = convertToClaude(session.session, { directory: dir });
198
+ if (entries.length === 0) {
199
+ console.error(`Session ${session.id} has no user/assistant messages to fork.`);
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+
204
+ const ledgerDir = ledgerPath(dir);
205
+ const { hash, changed } = commitFork(ledgerDir, {
206
+ sessionId: session.id,
207
+ claudeSource: { data: entries },
208
+ opencodeSource: { data: session.session },
209
+ direction: "opencode -> claude-code",
210
+ });
211
+ console.log(
212
+ changed
213
+ ? `Ledger commit ${hash.slice(0, 10)} recorded at ${ledgerDir}`
214
+ : `No changes since last fork (ledger already at ${hash.slice(0, 10)})`
215
+ );
216
+
217
+ if (opts.dryRun) {
218
+ console.log(`Dry run: skipped writing Claude file. Claude session id: ${entries[0].sessionId}`);
219
+ return;
220
+ }
221
+
222
+ try {
223
+ const { filePath } = writeClaudeSession(entries, dir);
224
+ console.log(`Wrote Claude session to ${filePath}`);
225
+ console.log(`Resume it with: run \`claude\` in ${dir}`);
226
+ } catch (err) {
227
+ console.error(err.message);
228
+ process.exitCode = 1;
229
+ }
230
+ return;
231
+ }
232
+
233
+ const dir = path.resolve(opts.dir);
234
+ const session = sessionId ? findClaudeSessionById(sessionId, dir) : findLatestClaudeSession(dir);
235
+ if (!session) {
236
+ console.error(
237
+ sessionId
238
+ ? `Could not find Claude Code session "${sessionId}" (looked under ~/.claude/projects).`
239
+ : `No Claude Code sessions found for ${dir}. Have you run \`claude\` here yet?`
240
+ );
241
+ process.exitCode = 1;
242
+ return;
243
+ }
244
+
245
+ const entries = toConversation(parseSessionFile(session.file));
246
+ if (entries.length === 0) {
247
+ console.error(`Session ${session.id} has no user/assistant messages to fork.`);
248
+ process.exitCode = 1;
249
+ return;
250
+ }
251
+
252
+ const title = opts.title ?? `Forked from Claude Code (${session.id.slice(0, 8)})`;
253
+ const converted = convertToOpenCode(entries, {
254
+ directory: dir,
255
+ title,
256
+ providerID: opts.provider,
257
+ agent: opts.agent,
258
+ });
259
+
260
+ const ledgerDir = ledgerPath(dir);
261
+ const { hash, changed } = commitFork(ledgerDir, {
262
+ sessionId: session.id,
263
+ claudeSource: { path: session.file },
264
+ opencodeSource: { data: converted },
265
+ direction: "claude-code -> opencode",
266
+ });
267
+ console.log(
268
+ changed
269
+ ? `Ledger commit ${hash.slice(0, 10)} recorded at ${ledgerDir}`
270
+ : `No changes since last fork (ledger already at ${hash.slice(0, 10)})`
271
+ );
272
+
273
+ if (opts.dryRun) {
274
+ console.log(`Dry run: skipped \`opencode import\`. Converted OpenCode session id: ${converted.info.id}`);
275
+ return;
276
+ }
277
+
278
+ try {
279
+ const out = importIntoOpenCode(converted, { cwd: dir });
280
+ console.log(out || `Imported into OpenCode as ${converted.info.id}`);
281
+ console.log(`Resume it with: opencode --session ${converted.info.id}`);
282
+ } catch (err) {
283
+ console.error(err.message);
284
+ process.exitCode = 1;
285
+ }
286
+ });
287
+
288
+ program
289
+ .command("sync")
290
+ .description("Reconcile a conversation that has been edited in both tools")
291
+ .argument("[session-id]", "Claude or OpenCode session id (defaults to the most recently forked session in the ledger)")
292
+ .option("-d, --dir <path>", "project directory (defaults to cwd)", process.cwd())
293
+ .option("-s, --strategy <strategy>", 'merge strategy: "timestamp" (default) or "abort"', "timestamp")
294
+ .option("--provider <providerID>", "OpenCode provider id for new Claude turns", "anthropic")
295
+ .option("--agent <agent>", "OpenCode agent name for new Claude turns", "build")
296
+ .option("--dry-run", "compute the merge but skip writing files or importing", false)
297
+ .action((sessionId, opts) => {
298
+ const dir = path.resolve(opts.dir);
299
+ const ledgerDir = ledgerPath(dir);
300
+ if (!fs.existsSync(path.join(ledgerDir, ".git"))) {
301
+ console.error(`No ledger found at ${ledgerDir}. Run a fork first.`);
302
+ process.exitCode = 1;
303
+ return;
304
+ }
305
+
306
+ const mapping = resolveSessionPair(ledgerDir, sessionId, ledgerLog);
307
+ if (!mapping) {
308
+ console.error(
309
+ `No mapping found for "${sessionId || "the latest ledger commit"}" in the ledger. ` +
310
+ `You may need to run a fork first so the session pair is recorded.`
311
+ );
312
+ process.exitCode = 1;
313
+ return;
314
+ }
315
+
316
+ const result = syncSession({
317
+ ledgerDir,
318
+ dir,
319
+ claudeId: mapping.claudeId,
320
+ opencodeId: mapping.opencodeId,
321
+ strategy: opts.strategy,
322
+ dryRun: opts.dryRun,
323
+ provider: opts.provider,
324
+ agent: opts.agent,
325
+ });
326
+
327
+ 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.`);
329
+ }
330
+ if (result.message) console.log(result.message);
331
+ if (result.error) console.error(result.error);
332
+ if (result.exitCode) process.exitCode = result.exitCode;
333
+ });
334
+
335
+ program
336
+ .command("watch")
337
+ .description("Auto-sync a conversation when either tool changes (press Ctrl-C to stop)")
338
+ .argument("[session-id]", "Claude or OpenCode session id (defaults to the most recently forked session in the ledger)")
339
+ .option("-d, --dir <path>", "project directory (defaults to cwd)", process.cwd())
340
+ .option("-s, --strategy <strategy>", 'merge strategy: "timestamp" (default) or "abort"', "timestamp")
341
+ .option("-i, --interval <ms>", "OpenCode poll interval in milliseconds", "5000")
342
+ .option("--provider <providerID>", "OpenCode provider id for new Claude turns", "anthropic")
343
+ .option("--agent <agent>", "OpenCode agent name for new Claude turns", "build")
344
+ .action(async (sessionId, opts) => {
345
+ const dir = path.resolve(opts.dir);
346
+ const ledgerDir = ledgerPath(dir);
347
+ if (!fs.existsSync(path.join(ledgerDir, ".git"))) {
348
+ console.error(`No ledger found at ${ledgerDir}. Run a fork first.`);
349
+ process.exitCode = 1;
350
+ return;
351
+ }
352
+
353
+ const mapping = resolveSessionPair(ledgerDir, sessionId, ledgerLog);
354
+ if (!mapping) {
355
+ console.error(
356
+ `No mapping found for "${sessionId || "the latest ledger commit"}" in the ledger. ` +
357
+ `You may need to run a fork first so the session pair is recorded.`
358
+ );
359
+ process.exitCode = 1;
360
+ return;
361
+ }
362
+
363
+ const { claudeId, opencodeId } = mapping;
364
+ const claudeProjectDir = path.join(claudeProjectsDir(), encodeProjectPath(dir));
365
+ const claudeFile = path.join(claudeProjectDir, `${claudeId}.jsonl`);
366
+ if (!fs.existsSync(claudeFile)) {
367
+ console.error(`Claude session file not found: ${claudeFile}`);
368
+ process.exitCode = 1;
369
+ return;
370
+ }
371
+
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
+ console.log(`Watching ${claudeFile} and OpenCode session ${opencodeId} (poll every ${interval}ms). Press Ctrl-C to stop.`);
387
+ const handle = watchSession({
388
+ claudeFile,
389
+ claudeId,
390
+ opencodeId,
391
+ ledgerDir,
392
+ dir,
393
+ strategy,
394
+ interval,
395
+ provider: opts.provider,
396
+ agent: opts.agent,
397
+ onEvent: (type, message) => {
398
+ if (type === "error") console.error(message);
399
+ else console.log(message);
400
+ },
401
+ });
402
+
403
+ await waitForInterrupt();
404
+ handle.stop();
405
+ });
406
+
407
+ program
408
+ .command("log")
409
+ .description("Show the fork history recorded in the local git ledger")
410
+ .option("-d, --dir <path>", "project directory (defaults to cwd)", process.cwd())
411
+ .option("-n, --limit <count>", "number of entries to show", "20")
412
+ .action((opts) => {
413
+ const dir = path.resolve(opts.dir);
414
+ const entries = ledgerLog(ledgerPath(dir), Number(opts.limit));
415
+ if (entries.length === 0) {
416
+ console.log("No forks recorded yet. Run `agentbridge fork` first.");
417
+ return;
418
+ }
419
+ for (const e of entries) {
420
+ console.log(`${e.hash.slice(0, 10)} ${e.date} ${e.message}`);
421
+ }
422
+ });
423
+
424
+ program.parse();