@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.
@@ -0,0 +1,337 @@
1
+ // src/sync/sync.js
2
+ //
3
+ // Phase 2: bidirectional sync between Claude Code and OpenCode.
4
+ //
5
+ // Given a known session pair (Claude id ↔ OpenCode id) recorded in the ledger,
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.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { convertToOpenCode } from "../converters/claude-to-opencode.js";
14
+ import { convertToClaude } from "../converters/opencode-to-claude.js";
15
+ import { parseSessionFile, toConversation, claudeProjectsDir, encodeProjectPath } from "../readers/claude-reader.js";
16
+ import { exportSession } from "../readers/opencode-reader.js";
17
+ import { writeClaudeSession } from "../writers/claude-writer.js";
18
+ import { importIntoOpenCode } from "../writers/opencode-import.js";
19
+ import { findMapping, readLedgerPair, commitFork } from "../ledger/git-ledger.js";
20
+
21
+ function toEpochMs(isoOrMs) {
22
+ if (typeof isoOrMs === "number") return isoOrMs;
23
+ const t = isoOrMs ? Date.parse(isoOrMs) : NaN;
24
+ return Number.isFinite(t) ? t : 0;
25
+ }
26
+
27
+ function claudeTurnKey(entry) {
28
+ // Use the entry uuid when available; fall back to a stable content hash for
29
+ // matching the same logical turn across re-imports.
30
+ return entry.uuid || `${entry.type}:${entry.timestamp}:${JSON.stringify(entry.message?.content)}`;
31
+ }
32
+
33
+ function opencodeTurnKey(msg) {
34
+ return msg.info?.id || `${msg.info?.role}:${msg.info?.time?.created}:${JSON.stringify(msg.parts)}`;
35
+ }
36
+
37
+ function isNewTurn(currentList, lastKeys, keyFn) {
38
+ return currentList.filter((item) => !lastKeys.has(keyFn(item)));
39
+ }
40
+
41
+ function normalizeCurrent(last) {
42
+ return {
43
+ claude: last.claudeEntries || last.claude || [],
44
+ opencode: last.opencodeMessages || last.opencode?.messages || last.opencode || [],
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Compute the new turns on each side since the last sync.
50
+ *
51
+ * @param {object} current - { claudeEntries, opencodeMessages }
52
+ * @param {object} last - { claudeEntries, opencodeMessages } or { claude, opencode }
53
+ * @returns {{ claudeNew: object[], opencodeNew: object[] }}
54
+ */
55
+ export function diffSync(current, last) {
56
+ const cur = normalizeCurrent(current);
57
+ const lst = normalizeCurrent(last);
58
+
59
+ const lastClaudeKeys = new Set(lst.claude.map(claudeTurnKey));
60
+ const lastOpenCodeKeys = new Set(lst.opencode.map(opencodeTurnKey));
61
+
62
+ return {
63
+ claudeNew: isNewTurn(cur.claude, lastClaudeKeys, claudeTurnKey),
64
+ opencodeNew: isNewTurn(cur.opencode, lastOpenCodeKeys, opencodeTurnKey),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Merge new turns from both sides into a single chronological sequence.
70
+ * Returns the merged data in both tool-specific forms.
71
+ *
72
+ * @param {object} current - current state of both sides
73
+ * @param {object} last - last synced state of both sides
74
+ * @param {string} strategy - "timestamp" or "abort"
75
+ * @param {object} opts - conversion options (directory, etc.)
76
+ * @returns {{claudeEntries: object[], opencodeMessages: object[], claudeNew: object[], opencodeNew: object[]}}
77
+ */
78
+ export function mergeSync(current, last, strategy, opts = {}) {
79
+ const { claudeNew, opencodeNew } = diffSync(current, last);
80
+
81
+ if (strategy === "abort" && claudeNew.length > 0 && opencodeNew.length > 0) {
82
+ const err = new Error(
83
+ `Both sides have new turns since the last sync.\n` +
84
+ `Claude: ${claudeNew.length} new turns. OpenCode: ${opencodeNew.length} new turns.\n` +
85
+ `Run with --strategy timestamp to merge by timestamp, or resolve manually.`
86
+ );
87
+ err.claudeNew = claudeNew;
88
+ err.opencodeNew = opencodeNew;
89
+ throw err;
90
+ }
91
+
92
+ const cur = normalizeCurrent(current);
93
+ const lst = normalizeCurrent(last);
94
+
95
+ // Always build the merged state from the last synced baseline, then add the
96
+ // genuinely new turns. This protects against OpenCode's `export` returning a
97
+ // partial or stale session (e.g. while the server is running, or when an
98
+ // imported session loses its original messages after being continued). Using
99
+ // the current state as the base would silently drop any history that OpenCode
100
+ // failed to include.
101
+ const mergedClaude = [...lst.claude];
102
+ const mergedOpenCode = [...lst.opencode];
103
+
104
+ // Preserve the original session ids across both tools so the merged output
105
+ // continues to be written to the same Claude JSONL and the same OpenCode
106
+ // session. Without this, converted turns would create a new session id and
107
+ // `writeClaudeSession` / `importIntoOpenCode` would fork the conversation
108
+ // instead of updating it in place.
109
+ const claudeSessionId = lst.claude[0]?.sessionId || opts.claudeSessionId;
110
+ const opencodeSessionId = lst.opencode[0]?.info?.sessionID || opts.opencodeId;
111
+
112
+ // Add genuinely new turns reported by the current state.
113
+ const existingClaudeKeys = new Set(mergedClaude.map(claudeTurnKey));
114
+ for (const e of claudeNew) {
115
+ const k = claudeTurnKey(e);
116
+ if (!existingClaudeKeys.has(k)) {
117
+ mergedClaude.push(e);
118
+ existingClaudeKeys.add(k);
119
+ }
120
+ }
121
+ const existingOpenCodeKeys = new Set(mergedOpenCode.map(opencodeTurnKey));
122
+ for (const m of opencodeNew) {
123
+ const k = opencodeTurnKey(m);
124
+ if (!existingOpenCodeKeys.has(k)) {
125
+ mergedOpenCode.push(m);
126
+ existingOpenCodeKeys.add(k);
127
+ }
128
+ }
129
+
130
+ // Fold Claude-only new turns into OpenCode.
131
+ if (claudeNew.length > 0) {
132
+ const newOpenCodeMessages = convertToOpenCode(claudeNew, {
133
+ directory: opts.directory,
134
+ title: opts.title ?? "Synced session",
135
+ providerID: opts.providerID,
136
+ agent: opts.agent,
137
+ opencodeSessionId,
138
+ }).messages;
139
+ for (const m of newOpenCodeMessages) {
140
+ if (!existingOpenCodeKeys.has(opencodeTurnKey(m))) {
141
+ mergedOpenCode.push(m);
142
+ existingOpenCodeKeys.add(opencodeTurnKey(m));
143
+ }
144
+ }
145
+ }
146
+
147
+ // Expand OpenCode-only new turns into Claude entries.
148
+ if (opencodeNew.length > 0) {
149
+ const newClaudeEntries = convertToClaude(
150
+ { info: { id: opts.opencodeId || "placeholder", directory: opts.directory }, messages: opencodeNew },
151
+ { directory: opts.directory, sessionId: claudeSessionId }
152
+ );
153
+ for (const e of newClaudeEntries) {
154
+ if (!existingClaudeKeys.has(claudeTurnKey(e))) {
155
+ mergedClaude.push(e);
156
+ existingClaudeKeys.add(claudeTurnKey(e));
157
+ }
158
+ }
159
+ }
160
+
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) });
172
+ }
173
+ }
174
+ claudeWithGroups.sort((a, b) => a.ts - b.ts);
175
+ mergedClaude.length = 0;
176
+ for (const g of claudeWithGroups) mergedClaude.push(...g.entries);
177
+
178
+ let prevUuid = null;
179
+ for (const e of mergedClaude) {
180
+ e.parentUuid = prevUuid;
181
+ prevUuid = e.uuid;
182
+ }
183
+
184
+ // OpenCode: sort by message creation time.
185
+ mergedOpenCode.sort((a, b) => (a.info?.time?.created || 0) - (b.info?.time?.created || 0));
186
+ let prevOpenCodeId = null;
187
+ for (const m of mergedOpenCode) {
188
+ m.info.parentID = prevOpenCodeId || m.info.sessionID;
189
+ prevOpenCodeId = m.info.id;
190
+ }
191
+
192
+ return { claudeEntries: mergedClaude, opencodeMessages: mergedOpenCode, claudeNew, opencodeNew };
193
+ }
194
+
195
+ /**
196
+ * Load current state from both tools.
197
+ *
198
+ * @param {string} claudeFile - path to Claude JSONL
199
+ * @param {string} opencodeId - OpenCode session id
200
+ * @returns {{claudeEntries: object[], opencodeMessages: object[]}}
201
+ */
202
+ export function loadCurrentState(claudeFile, opencodeId) {
203
+ const claudeEntries = toConversation(parseSessionFile(claudeFile));
204
+ const opencodeMessages = exportSession(opencodeId).messages || [];
205
+ return { claudeEntries, opencodeMessages };
206
+ }
207
+
208
+ /**
209
+ * Resolve a session id (Claude or OpenCode) to the paired ids using the ledger
210
+ * mapping. If no id is provided, use the latest ledger commit.
211
+ *
212
+ * @returns {{claudeId: string, opencodeId: string} | null}
213
+ */
214
+ export function resolveSessionPair(ledgerDir, sessionId, ledgerLog) {
215
+ let sourceId = sessionId;
216
+ if (!sourceId) {
217
+ const entries = ledgerLog(ledgerDir, 1);
218
+ if (entries.length === 0) return null;
219
+ const m = entries[0].message.match(/fork [^:]+: ([^ ]+) /);
220
+ sourceId = m ? m[1] : null;
221
+ }
222
+ if (!sourceId) return null;
223
+ const isOpenCode = sourceId.startsWith("ses_");
224
+ return findMapping(ledgerDir, isOpenCode ? { opencodeId: sourceId } : { claudeId: sourceId });
225
+ }
226
+
227
+ /**
228
+ * Run a full bidirectional sync for a known session pair.
229
+ *
230
+ * @returns {{ok:boolean, changed?:boolean, claudeNew?:number, opencodeNew?:number, message?:string, error?:string, exitCode?:number}}
231
+ */
232
+ export function syncSession({ ledgerDir, dir, claudeId, opencodeId, strategy, dryRun, provider, agent }) {
233
+ const claudeProjectDir = path.join(claudeProjectsDir(), encodeProjectPath(dir));
234
+ const claudeFile = path.join(claudeProjectDir, `${claudeId}.jsonl`);
235
+ if (!fs.existsSync(claudeFile)) {
236
+ return { ok: false, error: `Claude session file not found: ${claudeFile}`, exitCode: 1 };
237
+ }
238
+
239
+ const current = loadCurrentState(claudeFile, opencodeId);
240
+ const last = readLedgerPair(ledgerDir, claudeId, opencodeId);
241
+ if (!last.claude || !last.opencode) {
242
+ return {
243
+ ok: false,
244
+ error: `Last synced state not found in the ledger for ${opencodeId}. Run a fork first.`,
245
+ exitCode: 1,
246
+ };
247
+ }
248
+
249
+ if (strategy !== "timestamp" && strategy !== "abort") {
250
+ return { ok: false, error: `Unknown strategy "${strategy}". Use "timestamp" or "abort".`, exitCode: 1 };
251
+ }
252
+
253
+ let merged;
254
+ try {
255
+ merged = mergeSync(current, last, strategy, {
256
+ directory: dir,
257
+ title: last.opencode.info?.title || "Synced session",
258
+ providerID: provider,
259
+ agent,
260
+ opencodeId,
261
+ });
262
+ } catch (err) {
263
+ if (err.claudeNew && err.opencodeNew) {
264
+ return {
265
+ ok: false,
266
+ error: err.message,
267
+ claudeNew: err.claudeNew.length,
268
+ opencodeNew: err.opencodeNew.length,
269
+ exitCode: 2,
270
+ };
271
+ }
272
+ throw err;
273
+ }
274
+
275
+ const cur = normalizeCurrent(current);
276
+ const baseline = normalizeCurrent(last);
277
+ const curClaudeKeys = new Set(cur.claude.map(claudeTurnKey));
278
+ const curOpenCodeKeys = new Set(cur.opencode.map(opencodeTurnKey));
279
+ const missingClaude = baseline.claude.some((e) => !curClaudeKeys.has(claudeTurnKey(e)));
280
+ const missingOpenCode = baseline.opencode.some((m) => !curOpenCodeKeys.has(opencodeTurnKey(m)));
281
+
282
+ if (merged.claudeNew.length === 0 && merged.opencodeNew.length === 0 && !missingClaude && !missingOpenCode) {
283
+ return { ok: true, changed: false, message: "No changes on either side since the last sync." };
284
+ }
285
+
286
+ const openCodePayload = {
287
+ info: {
288
+ ...last.opencode.info,
289
+ id: opencodeId,
290
+ directory: dir,
291
+ time: {
292
+ created: last.opencode.info?.time?.created || Date.now(),
293
+ updated: Date.now(),
294
+ },
295
+ },
296
+ messages: merged.opencodeMessages,
297
+ };
298
+
299
+ if (dryRun) {
300
+ return {
301
+ ok: true,
302
+ changed: true,
303
+ claudeNew: merged.claudeNew.length,
304
+ opencodeNew: merged.opencodeNew.length,
305
+ message: `Dry run: computed merged state. ${merged.claudeEntries.length} Claude entries, ${merged.opencodeMessages.length} OpenCode messages.`,
306
+ };
307
+ }
308
+
309
+ const { hash, changed } = commitFork(ledgerDir, {
310
+ sessionId: opencodeId,
311
+ claudeId,
312
+ opencodeId,
313
+ claudeSource: { data: merged.claudeEntries },
314
+ opencodeSource: { data: openCodePayload },
315
+ direction: "sync",
316
+ });
317
+
318
+ let message = changed
319
+ ? `Ledger commit ${hash.slice(0, 10)} recorded at ${ledgerDir}`
320
+ : `No changes since last sync (ledger already at ${hash.slice(0, 10)})`;
321
+
322
+ try {
323
+ const { filePath } = writeClaudeSession(merged.claudeEntries, dir);
324
+ message += `\nWrote Claude session to ${filePath}`;
325
+ } catch (err) {
326
+ return { ok: false, error: err.message, exitCode: 1 };
327
+ }
328
+
329
+ try {
330
+ const out = importIntoOpenCode(openCodePayload, { cwd: dir });
331
+ message += `\n${out || `Synced OpenCode session ${opencodeId}`}`;
332
+ } catch (err) {
333
+ return { ok: false, error: err.message, exitCode: 1 };
334
+ }
335
+
336
+ return { ok: true, changed, claudeNew: merged.claudeNew.length, opencodeNew: merged.opencodeNew.length, message };
337
+ }
@@ -0,0 +1,136 @@
1
+ // src/sync/watch.js
2
+ //
3
+ // Phase 2 stretch goal: near-real-time auto-sync for a session pair.
4
+ //
5
+ // Watches the Claude session JSONL file with fs.watch and polls OpenCode's
6
+ // session list every `interval` ms. When either side changes, it debounces
7
+ // briefly and runs a sync. Conflicts (both sides changed) are handled by the
8
+ // chosen strategy: timestamp merge (default) or skip + warn (abort).
9
+
10
+ import fs from "node:fs";
11
+ import { syncSession } from "./sync.js";
12
+ import { exportSession } from "../readers/opencode-reader.js";
13
+
14
+ function hashOpenCode(s) {
15
+ if (!s || !s.messages) return "";
16
+ const lines = [];
17
+ for (const m of s.messages) {
18
+ lines.push(m.info?.id ?? "?");
19
+ for (const p of m.parts ?? []) {
20
+ lines.push(`${p.type}:${p.text ?? ""}`);
21
+ }
22
+ }
23
+ return lines.join("\n");
24
+ }
25
+
26
+ export function watchSession({ claudeFile, claudeId, opencodeId, ledgerDir, dir, strategy, interval, provider, agent, onEvent }) {
27
+ let lastClaudeMtime = 0;
28
+ let lastOpenCodeSnapshot = "";
29
+ let pendingTimer = null;
30
+ let running = false;
31
+ let watcher = null;
32
+ let pollTimer = null;
33
+ let stopped = false;
34
+
35
+ const event = (type, message) => {
36
+ if (onEvent) onEvent(type, message);
37
+ };
38
+
39
+ const getClaudeMtime = () => {
40
+ try {
41
+ return fs.statSync(claudeFile).mtimeMs;
42
+ } catch {
43
+ return 0;
44
+ }
45
+ };
46
+
47
+ const getOpenCodeSnapshot = () => {
48
+ try {
49
+ const s = exportSession(opencodeId);
50
+ return hashOpenCode(s);
51
+ } catch {
52
+ return "";
53
+ }
54
+ };
55
+
56
+ const runSync = () => {
57
+ if (running) return;
58
+ running = true;
59
+ event("sync", "Detecting changes...");
60
+ const result = syncSession({
61
+ ledgerDir,
62
+ dir,
63
+ claudeId,
64
+ opencodeId,
65
+ strategy,
66
+ dryRun: false,
67
+ provider,
68
+ agent,
69
+ });
70
+ running = false;
71
+ if (result.exitCode) {
72
+ event("error", result.error || "Sync failed");
73
+ return;
74
+ }
75
+ if (result.message) {
76
+ event("sync", result.message);
77
+ }
78
+ // Refresh baseline after a successful sync.
79
+ lastClaudeMtime = getClaudeMtime();
80
+ lastOpenCodeSnapshot = getOpenCodeSnapshot();
81
+ };
82
+
83
+ const scheduleSync = () => {
84
+ if (pendingTimer) clearTimeout(pendingTimer);
85
+ pendingTimer = setTimeout(() => {
86
+ pendingTimer = null;
87
+ runSync();
88
+ }, 500);
89
+ };
90
+
91
+ const checkChanges = () => {
92
+ if (stopped) return;
93
+ const claudeMtime = getClaudeMtime();
94
+ const openCodeSnapshot = getOpenCodeSnapshot();
95
+ const claudeChanged = claudeMtime > lastClaudeMtime;
96
+ const openCodeChanged = openCodeSnapshot !== lastOpenCodeSnapshot;
97
+
98
+ if (claudeChanged || openCodeChanged) {
99
+ lastClaudeMtime = claudeMtime;
100
+ if (openCodeChanged) lastOpenCodeSnapshot = openCodeSnapshot;
101
+ if (claudeChanged) event("change", "Claude session file changed");
102
+ if (openCodeChanged) event("change", "OpenCode session updated");
103
+ scheduleSync();
104
+ }
105
+ };
106
+
107
+ // Initial baseline.
108
+ lastClaudeMtime = getClaudeMtime();
109
+ lastOpenCodeSnapshot = getOpenCodeSnapshot();
110
+
111
+ watcher = fs.watch(claudeFile, (eventType) => {
112
+ if (eventType === "change") {
113
+ checkChanges();
114
+ }
115
+ });
116
+
117
+ pollTimer = setInterval(checkChanges, interval);
118
+
119
+ return {
120
+ stop() {
121
+ stopped = true;
122
+ if (watcher) watcher.close();
123
+ if (pollTimer) clearInterval(pollTimer);
124
+ if (pendingTimer) clearTimeout(pendingTimer);
125
+ },
126
+ };
127
+ }
128
+
129
+ export function waitForInterrupt() {
130
+ return new Promise((resolve) => {
131
+ process.once("SIGINT", () => {
132
+ console.log("\nStopping watch mode.");
133
+ resolve();
134
+ });
135
+ });
136
+ }
@@ -0,0 +1,40 @@
1
+ // src/writers/claude-writer.js
2
+ //
3
+ // Writes a Claude Code JSONL transcript to the location Claude Code expects:
4
+ // ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { claudeProjectsDir, encodeProjectPath } from "../readers/claude-reader.js";
9
+
10
+ /**
11
+ * Write an array of Claude Code entries as a JSONL session file.
12
+ *
13
+ * @param {object[]} entries - Claude Code JSONL entries
14
+ * @param {string} [directory] - fallback cwd if the first entry has no cwd
15
+ * @param {object} [opts]
16
+ * @param {string} [opts.baseDir] - override the base `~/.claude/projects` path (used by tests)
17
+ * @returns {{filePath:string, projectDir:string, sessionId:string}}
18
+ */
19
+ export function writeClaudeSession(entries, directory, opts = {}) {
20
+ if (!Array.isArray(entries) || entries.length === 0) {
21
+ throw new Error("No entries to write");
22
+ }
23
+ const sessionId = entries[0]?.sessionId;
24
+ const cwd = entries[0]?.cwd || directory;
25
+ if (!sessionId) {
26
+ throw new Error("Entries are missing sessionId");
27
+ }
28
+ if (!cwd) {
29
+ throw new Error("Entries are missing cwd and no directory was provided");
30
+ }
31
+
32
+ const projectDir = path.join(opts.baseDir || claudeProjectsDir(), encodeProjectPath(cwd));
33
+ fs.mkdirSync(projectDir, { recursive: true });
34
+
35
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
36
+ const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
37
+ fs.writeFileSync(filePath, lines);
38
+
39
+ return { filePath, projectDir, sessionId };
40
+ }
@@ -0,0 +1,27 @@
1
+ // src/writers/opencode-import.js
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /** Run `opencode import <file>` on a converted payload. Returns stdout. */
8
+ export function importIntoOpenCode(payload, { cwd } = {}) {
9
+ const tmpFile = path.join(os.tmpdir(), `agentbridge-import-${Date.now()}.json`);
10
+ fs.writeFileSync(tmpFile, JSON.stringify(payload));
11
+ try {
12
+ const out = execFileSync("opencode", ["import", tmpFile], {
13
+ cwd: cwd ?? process.cwd(),
14
+ stdio: ["ignore", "pipe", "pipe"],
15
+ });
16
+ // Only clean up on success - keep the file around on failure for debugging.
17
+ fs.unlinkSync(tmpFile);
18
+ return out.toString().trim();
19
+ } catch (err) {
20
+ const stderr = err.stderr?.toString() ?? "";
21
+ const stdout = err.stdout?.toString() ?? "";
22
+ throw new Error(
23
+ `opencode import failed: ${stderr || stdout || err.message}\n` +
24
+ `(payload kept at ${tmpFile} for debugging)`
25
+ );
26
+ }
27
+ }