@bivy/bivy 0.10.1-staging.141 → 0.10.1-staging.142

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.
@@ -8,6 +8,7 @@ import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled } from "./claude-code.js";
10
10
  import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout } from "./codex-sessions.js";
11
+ import { deleteOpenCodeSession, loadOpenCodeTranscript, writeOpenCodeHistory } from "./opencode-sessions.js";
11
12
  import { createCredentialStore } from "./credentials.js";
12
13
  // Args that continue an existing Codex session each prompt. Codex assigns its own
13
14
  // session id (no launch-time pin), so a resumed run threads it via `exec resume`.
@@ -1314,6 +1315,20 @@ function acpRuntimeOptions(opts) {
1314
1315
  // ACP handshake doesn't carry them); a bare ACP agent has none.
1315
1316
  ...(slashCommands ? { slashCommands } : {}),
1316
1317
  ...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
1318
+ // OpenCode's own store is SQLite under $XDG_DATA_HOME, so an ACP-promoted
1319
+ // opencode can both read a resumed session's transcript back for history
1320
+ // preload (loadHistory), drop it on delete (deleteHistory), and — the fork
1321
+ // win — materialise a cross-runtime fork's portable history as a REAL session
1322
+ // in that store (writeHistory → capabilities.forkHistoryImport → fidelity
1323
+ // "replayed" instead of a seeded summary prompt). Only opencode has this
1324
+ // layout; a bare ACP agent gets none of these hooks.
1325
+ ...(opts.id === "opencode"
1326
+ ? {
1327
+ loadHistory: (sessionRef) => loadOpenCodeTranscript(sessionRef),
1328
+ deleteHistory: (sessionRef) => void deleteOpenCodeSession(sessionRef),
1329
+ writeHistory: (history, ctx) => writeOpenCodeHistory(history, ctx),
1330
+ }
1331
+ : {}),
1317
1332
  };
1318
1333
  }
1319
1334
  function acpRuntimeFromEnv(credsDir) {
@@ -0,0 +1,283 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // OpenCode on-disk session store — the write/read side of materialising a
4
+ // **cross-runtime** fork as a real session in OpenCode's own store (fidelity
5
+ // "replayed", see docs/session-fork-plan.md), the same role codex-sessions.ts
6
+ // plays for Codex.
7
+ //
8
+ // OpenCode (opencode-ai) persists every session in SQLite at
9
+ // $XDG_DATA_HOME/opencode/opencode.db (XDG_DATA_HOME → ~/.local/share)
10
+ // in three tables that are the source of truth for a conversation:
11
+ // - `session` one row per session (id, slug, title, project_id, model, times)
12
+ // - `message` one row per turn (data is JSON: role + model + time)
13
+ // - `part` one row per content block (data is JSON: `{type:"text",text}`)
14
+ // The `event`/`event_sequence` tables are OpenCode's event-sourcing log for live
15
+ // sync/streaming; Bivy's governed path (`opencode acp` via bin/acp-shim.mjs)
16
+ // loads a session purely from `session`/`message`/`part` — `session/load` and
17
+ // `session/prompt` resume a hand-written session and replay the full transcript
18
+ // to the model — and `opencode session list` / `opencode export` read the same
19
+ // snapshot, so those three rows are all a replay fork needs.
20
+ //
21
+ // IMPORTANT — best-effort, verified against opencode 1.18.13 (the version
22
+ // pin in CLI_AGENT_SPECS): the schema is version-variable, so the writer checks
23
+ // for the `session`/`message`/`part` tables and throws (never corrupts) when a
24
+ // node's OpenCode layout doesn't match. The fork engine calls this only as its
25
+ // "replayed" tier and falls back to a seeded continuation prompt if it throws;
26
+ // a node can force that fallback outright with `BIVY_OPENCODE_NO_FORK_REPLAY=1`.
27
+ // Uses node:sqlite (Node ≥22.19 per engines) — no external dependency.
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { randomUUID } from "node:crypto";
32
+ import { DatabaseSync } from "node:sqlite";
33
+ /** Fallback when the DB has no session row to learn the running version from. */
34
+ const FALLBACK_VERSION = "1.18.13";
35
+ /** The project every OpenCode install seeds; used for fork sessions so no
36
+ * per-directory project row has to be synthesised. */
37
+ const GLOBAL_PROJECT_ID = "global";
38
+ /** The model recorded on real OpenCode sessions when none is requested. */
39
+ const DEFAULT_MODEL_ID = "big-pickle";
40
+ const DEFAULT_MODEL_PROVIDER = "opencode";
41
+ /** OpenCode's data dir: $XDG_DATA_HOME/opencode (XDG_DATA_HOME → ~/.local/share). */
42
+ export function opencodeDataDir() {
43
+ const base = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
44
+ return path.join(base, "opencode");
45
+ }
46
+ export function opencodeDbPath() {
47
+ return path.join(opencodeDataDir(), "opencode.db");
48
+ }
49
+ /** A cuid2-style id (`ses_<24 hex>`) like OpenCode's own ids. */
50
+ function openCodeId(prefix) {
51
+ return `${prefix}${randomUUID().replace(/-/g, "").slice(0, 24)}`;
52
+ }
53
+ /**
54
+ * Open the node's OpenCode SQLite store read-write. Throws when the store
55
+ * doesn't exist or lacks the conversation tables (OpenCode never run, or a
56
+ * version/schema we don't know) — the caller must treat this as "no replay".
57
+ */
58
+ function openOpenCodeDb(readWrite) {
59
+ const file = opencodeDbPath();
60
+ const present = (() => {
61
+ try {
62
+ return fs.existsSync(file);
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ })();
68
+ if (!present)
69
+ throw new Error(`OpenCode store not found at ${file} (run OpenCode once to create it)`);
70
+ const db = readWrite ? new DatabaseSync(file) : new DatabaseSync(file, { readOnly: true });
71
+ if (readWrite) {
72
+ // Cascade deletes (message/part/event rows under a session) need FK enforcement.
73
+ db.exec("PRAGMA foreign_keys = ON");
74
+ // OpenCode runs in WAL mode; a briefly-held writer (a live TUI/server) would
75
+ // otherwise reject the insert instantly instead of waiting a moment.
76
+ db.exec("PRAGMA busy_timeout = 5000");
77
+ }
78
+ for (const table of ["session", "message", "part"]) {
79
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
80
+ if (!row) {
81
+ db.close();
82
+ throw new Error(`OpenCode store at ${file} is missing the "${table}" table; fork replay requires a compatible OpenCode layout`);
83
+ }
84
+ }
85
+ return db;
86
+ }
87
+ /** The OpenCode version string to stamp on a fork session — learn it from the
88
+ * newest session in the store (matches the installed binary), else a fallback. */
89
+ function openCodeVersion(db) {
90
+ const row = db.prepare("SELECT version FROM session ORDER BY time_created DESC, id LIMIT 1").get();
91
+ return (row?.version && row.version.trim()) || FALLBACK_VERSION;
92
+ }
93
+ /** Resolve the model to stamp on the fork session from the fork context. */
94
+ function resolveModel(ctx) {
95
+ const requested = ctx.model?.id?.trim();
96
+ if (requested) {
97
+ return {
98
+ modelID: requested,
99
+ providerID: ctx.model?.provider?.trim() || (requested.includes("/") ? requested.split("/")[0] : "opencode"),
100
+ };
101
+ }
102
+ return { modelID: DEFAULT_MODEL_ID, providerID: DEFAULT_MODEL_PROVIDER };
103
+ }
104
+ /**
105
+ * Materialise a **cross-runtime** fork's portable `{role, text}` history as a
106
+ * real session in OpenCode's own SQLite store — the write-side counterpart to
107
+ * `loadOpenCodeTranscript`, and OpenCode's `importHistoryForFork` (fidelity
108
+ * "replayed"). Inserts one `session` row, one `message` row per turn (JSON data
109
+ * mirroring OpenCode's own), and one `text` `part` row per message, keyed by the
110
+ * same `ses_`/`msg_`/`prt_` ids OpenCode uses — the exact layout `opencode acp`
111
+ * `session/load` resumes and `session/prompt` replays as real prior turns.
112
+ * Returns the new session id, which is both the resume ref and the id. Throws on
113
+ * any mismatch so the fork engine falls back to a seeded continuation prompt.
114
+ */
115
+ export function writeOpenCodeHistory(history, ctx) {
116
+ if (process.env.BIVY_OPENCODE_NO_FORK_REPLAY === "1") {
117
+ throw new Error("OpenCode fork replay disabled (BIVY_OPENCODE_NO_FORK_REPLAY=1)");
118
+ }
119
+ if (history.length === 0)
120
+ throw new Error("Nothing to replay: fork history is empty");
121
+ const db = openOpenCodeDb(true);
122
+ try {
123
+ const version = openCodeVersion(db);
124
+ const { modelID, providerID } = resolveModel(ctx);
125
+ const sessionId = openCodeId("ses_");
126
+ const now = Date.now();
127
+ const created = now - history.length; // slightly in the past so the stream orders below now
128
+ const slug = `fork-${randomUUID().slice(0, 8)}`;
129
+ const title = "Forked session";
130
+ const cwd = ctx.cwd;
131
+ const directory = cwd;
132
+ const openCodePath = directory.replace(/^\/+/, "");
133
+ db.exec("BEGIN");
134
+ try {
135
+ // project_id → project.id. OpenCode seeds a `global` project only once a
136
+ // session needs it, so a fresh store may not have one yet — create it (same
137
+ // row OpenCode itself writes) so the fork session's FK always resolves.
138
+ db.prepare(`INSERT OR IGNORE INTO project (id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
139
+ time_created, time_updated, time_initialized, sandboxes, commands)
140
+ VALUES ('global', '/', NULL, NULL, NULL, NULL, NULL, ?, ?, NULL, '[]', NULL)`).run(now, now);
141
+ db.prepare(`INSERT INTO session (id, project_id, workspace_id, parent_id, slug, directory, path, title, version,
142
+ cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
143
+ model, time_created, time_updated)
144
+ VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?, ?)`).run(sessionId, GLOBAL_PROJECT_ID, slug, directory, openCodePath, title, version, JSON.stringify({ id: modelID, providerID }), created, now);
145
+ const insertMessage = db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`);
146
+ const insertPart = db.prepare(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`);
147
+ let previousId = null;
148
+ history.forEach((turn, index) => {
149
+ const messageId = openCodeId("msg_");
150
+ const timeCreated = created + index;
151
+ const timeCompleted = timeCreated + 1;
152
+ let data;
153
+ if (turn.role === "user") {
154
+ data = {
155
+ role: "user",
156
+ time: { created: timeCreated },
157
+ agent: "build",
158
+ model: { providerID, modelID },
159
+ };
160
+ }
161
+ else {
162
+ data = {
163
+ ...(previousId ? { parentID: previousId } : {}),
164
+ role: "assistant",
165
+ mode: "build",
166
+ agent: "build",
167
+ path: { cwd, root: "/" },
168
+ cost: 0,
169
+ tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { write: 0, read: 0 } },
170
+ modelID,
171
+ providerID,
172
+ time: { created: timeCreated, completed: timeCompleted },
173
+ finish: "end_turn",
174
+ };
175
+ }
176
+ insertMessage.run(messageId, sessionId, timeCreated, timeCompleted, JSON.stringify(data));
177
+ insertPart.run(openCodeId("prt_"), messageId, sessionId, timeCreated, timeCompleted, JSON.stringify({ type: "text", text: turn.text }));
178
+ previousId = messageId;
179
+ });
180
+ db.exec("COMMIT");
181
+ }
182
+ catch (error) {
183
+ db.exec("ROLLBACK");
184
+ throw error;
185
+ }
186
+ return { sessionFile: sessionId, id: sessionId };
187
+ }
188
+ finally {
189
+ db.close();
190
+ }
191
+ }
192
+ /**
193
+ * Reconstruct a session's conversation from OpenCode's store as normalized
194
+ * RuntimeMessages (role + content + timestamp), the same shape loadCodexTranscript
195
+ * produces — the fast path behind reopening a resumed session (see
196
+ * ProtocolRuntimeOptions.loadHistory). Best-effort: a missing/unreadable store or
197
+ * unknown session yields an empty list, never a throw.
198
+ */
199
+ export function loadOpenCodeTranscript(sessionRef) {
200
+ const messages = [];
201
+ let db;
202
+ try {
203
+ db = openOpenCodeDb(false);
204
+ }
205
+ catch {
206
+ return messages;
207
+ }
208
+ try {
209
+ const rows = db
210
+ .prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
211
+ .all(sessionRef);
212
+ const partRows = db.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id").all(sessionRef);
213
+ const partsByMessage = new Map();
214
+ for (const part of partRows) {
215
+ let text = "";
216
+ try {
217
+ const parsed = JSON.parse(part.data);
218
+ if (parsed.type === "text" && typeof parsed.text === "string")
219
+ text = parsed.text;
220
+ }
221
+ catch {
222
+ // unreadable part — skip its text
223
+ }
224
+ if (!text)
225
+ continue;
226
+ partsByMessage.set(part.message_id, (partsByMessage.get(part.message_id) ?? "") + text);
227
+ }
228
+ for (const row of rows) {
229
+ let parsed;
230
+ try {
231
+ parsed = JSON.parse(row.data);
232
+ }
233
+ catch {
234
+ continue;
235
+ }
236
+ if (parsed.role !== "user" && parsed.role !== "assistant")
237
+ continue;
238
+ const content = partsByMessage.get(row.id);
239
+ if (!content)
240
+ continue;
241
+ messages.push({ role: parsed.role, content, timestamp: row.time_created });
242
+ }
243
+ }
244
+ finally {
245
+ db.close();
246
+ }
247
+ return messages;
248
+ }
249
+ /**
250
+ * Remove a session from OpenCode's own store so a user-initiated delete in the
251
+ * app actually sticks. Children (message/part rows) are removed explicitly so the
252
+ * cleanup works regardless of the FK action a given OpenCode version declares;
253
+ * `event`/`event_sequence` rows are left to OpenCode's own reaper (a fork session
254
+ * never writes them). Best-effort: returns true when a session row was removed; a
255
+ * missing store or unknown id is not an error. The write-side counterpart to
256
+ * loadOpenCodeTranscript.
257
+ */
258
+ export function deleteOpenCodeSession(sessionRef) {
259
+ let db;
260
+ try {
261
+ db = openOpenCodeDb(true);
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ try {
267
+ db.exec("BEGIN");
268
+ try {
269
+ db.prepare("DELETE FROM part WHERE session_id = ?").run(sessionRef);
270
+ db.prepare("DELETE FROM message WHERE session_id = ?").run(sessionRef);
271
+ const result = db.prepare("DELETE FROM session WHERE id = ?").run(sessionRef);
272
+ db.exec("COMMIT");
273
+ return result.changes > 0;
274
+ }
275
+ catch (error) {
276
+ db.exec("ROLLBACK");
277
+ throw error;
278
+ }
279
+ }
280
+ finally {
281
+ db.close();
282
+ }
283
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.141",
3
+ "version": "0.10.1-staging.142",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",