@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 +21 -0
- package/README.md +220 -0
- package/bin/agentbridge.js +424 -0
- package/package.json +45 -0
- package/src/converters/claude-to-opencode.js +283 -0
- package/src/converters/opencode-to-claude.js +170 -0
- package/src/ledger/git-ledger.js +220 -0
- package/src/readers/claude-reader.js +186 -0
- package/src/readers/opencode-reader.js +270 -0
- package/src/sync/sync.js +337 -0
- package/src/sync/watch.js +136 -0
- package/src/writers/claude-writer.js +40 -0
- package/src/writers/opencode-import.js +27 -0
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();
|