@bivy/bivy 0.10.1-staging.140 → 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.
package/bin/acp-shim.mjs CHANGED
@@ -125,6 +125,31 @@ let pendingModel = null;
125
125
  // right ACP permission request with a concrete optionId.
126
126
  const permissionRequests = new Map();
127
127
 
128
+ // --- trailing-update drain ---------------------------------------------------
129
+ // opencode's ACP server resolves `session/prompt` (the end_turn reply) BEFORE the
130
+ // final `agent_message_chunk` frames are flushed — a known upstream ordering race
131
+ // (opencode#17505). If the shim declared session.done the instant the prompt reply
132
+ // arrived, the turn would seal at ProtocolRuntime with the reply's tail still
133
+ // unstreamed: the trailing text then streams live (message_update) but never
134
+ // reaches getMessages(), so it vanishes the moment the session reopens. So the
135
+ // turn is NOT done at prompt-resolve — hold session.done until the session/update
136
+ // stream has been quiet for TRAILING_DRAIN_MS, letting the late chunks land while
137
+ // the turn is still open and get sealed into history.
138
+ const TRAILING_DRAIN_MS = 250;
139
+ let turnDrainTimer = null;
140
+ let turnDraining = false;
141
+ function scheduleTurnDone() {
142
+ clearTimeout(turnDrainTimer);
143
+ turnDrainTimer = setTimeout(finishTurnDone, TRAILING_DRAIN_MS);
144
+ }
145
+ function finishTurnDone() {
146
+ turnDrainTimer = null;
147
+ if (!turnDraining) return;
148
+ turnDraining = false;
149
+ bivy({ type: "session.status", status: "idle" });
150
+ bivy({ type: "session.done" });
151
+ }
152
+
128
153
  // --- tool-call field normalization -------------------------------------------
129
154
  // ACP's `tool_call`/`tool_call_update` carries a free-text `title` (whatever
130
155
  // prose the agent chose) AND a small fixed `kind` enum (read/edit/delete/move/
@@ -235,6 +260,10 @@ function publishModels(result) {
235
260
  function onSessionUpdate(params) {
236
261
  const u = params?.update;
237
262
  if (!u || typeof u !== "object") return;
263
+ // Any update arriving while the turn is draining means the agent is still
264
+ // emitting the tail of this turn (see the drain note above) — reset the quiet
265
+ // window so session.done waits for it instead of sealing history short.
266
+ if (turnDraining) scheduleTurnDone();
238
267
  const kind = String(u.sessionUpdate || "");
239
268
  const textOf = (content) => {
240
269
  if (!content) return "";
@@ -432,8 +461,19 @@ async function onBivyCommand(msg) {
432
461
  bivy({ replyTo: id, ok: true });
433
462
  bivy({ type: "session.status", status: "working" });
434
463
  agentRequest("session/prompt", { sessionId, prompt: [{ type: "text", text: String(msg.text ?? "") }] })
435
- .then(() => { bivy({ type: "session.status", status: "idle" }); bivy({ type: "session.done" }); })
436
- .catch((e) => bivy({ type: "session.error", error: e instanceof Error ? e.message : String(e) }));
464
+ .then(() => {
465
+ // The prompt reply is NOT the end of the turn for opencode — the last
466
+ // agent_message_chunk frames trail it (see the drain note above). Arm
467
+ // the drain; session.done fires once the update stream goes quiet.
468
+ turnDraining = true;
469
+ scheduleTurnDone();
470
+ })
471
+ .catch((e) => {
472
+ clearTimeout(turnDrainTimer);
473
+ turnDrainTimer = null;
474
+ turnDraining = false;
475
+ bivy({ type: "session.error", error: e instanceof Error ? e.message : String(e) });
476
+ });
437
477
  return;
438
478
  }
439
479
  case "tool.decision": {
@@ -465,6 +505,11 @@ async function onBivyCommand(msg) {
465
505
  return;
466
506
  }
467
507
  case "session.abort": {
508
+ // A pending drain must not fire session.done after the user cancelled —
509
+ // the turn is being torn down, not finishing on its own.
510
+ clearTimeout(turnDrainTimer);
511
+ turnDrainTimer = null;
512
+ turnDraining = false;
468
513
  if (sessionId) agentNotify("session/cancel", { sessionId });
469
514
  if (id !== undefined) bivy({ replyTo: id, ok: true });
470
515
  return;
@@ -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
+ }
@@ -411,6 +411,40 @@ class ProtocolSession {
411
411
  if (pending)
412
412
  this.turnContent.push({ type: "text", text: pending });
413
413
  }
414
+ /**
415
+ * Fold assistant text that arrives after the turn was sealed (session.done)
416
+ * onto the last persisted assistant message — the ACP end_turn race (see the
417
+ * message.delta branch). The daemon's message_end handler re-snapshots the
418
+ * base transcript, so the tail survives a reopen; live viewers catch up via
419
+ * the emitted message_update + message_end. Text with no assistant message to
420
+ * fold onto is dropped (there is nowhere durable for it to go).
421
+ */
422
+ foldLateAssistantDelta(text) {
423
+ let lastIndex = -1;
424
+ for (let i = this.messages.length - 1; i >= 0; i--) {
425
+ if (this.messages[i]?.role === "assistant") {
426
+ lastIndex = i;
427
+ break;
428
+ }
429
+ }
430
+ if (lastIndex < 0)
431
+ return;
432
+ const msg = this.messages[lastIndex];
433
+ const raw = msg.content;
434
+ const content = Array.isArray(raw)
435
+ ? raw
436
+ : typeof raw === "string" && raw
437
+ ? [{ type: "text", text: raw }]
438
+ : [];
439
+ const lastBlock = content[content.length - 1];
440
+ if (lastBlock && lastBlock.type === "text")
441
+ lastBlock.text = `${lastBlock.text ?? ""}${text}`;
442
+ else
443
+ content.push({ type: "text", text });
444
+ msg.content = content;
445
+ this.emit({ type: "message_update", message: { role: "assistant", content } });
446
+ this.emit({ type: "message_end", message: { role: "assistant", content } });
447
+ }
414
448
  handleMessage(msg) {
415
449
  this.emitter.emit("protocol-message", msg);
416
450
  const replyTo = typeof msg.replyTo === "string" ? msg.replyTo : "";
@@ -450,10 +484,22 @@ class ProtocolSession {
450
484
  }
451
485
  if (type === "message.delta") {
452
486
  const text = String(msg.text ?? "");
453
- if (!this.assistantText)
454
- this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
455
- this.assistantText += text;
456
- this.emit({ type: "message_update", message: { role: "assistant", content: this.assistantText } });
487
+ if (!text)
488
+ return;
489
+ if (this.streaming) {
490
+ if (!this.assistantText)
491
+ this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
492
+ this.assistantText += text;
493
+ this.emit({ type: "message_update", message: { role: "assistant", content: this.assistantText } });
494
+ return;
495
+ }
496
+ // The turn was already sealed (session.done) yet the agent is still
497
+ // streaming text — the ACP end_turn race where the final agent_message_chunk
498
+ // frames land after the prompt reply (opencode#17505). The shim drains the
499
+ // tail before declaring done, but this is the net for a chunk that outlives
500
+ // the drain: fold it onto the last assistant message so it survives a reopen
501
+ // instead of opening a fresh draft that is never persisted.
502
+ this.foldLateAssistantDelta(text);
457
503
  return;
458
504
  }
459
505
  if (type === "message.reasoning" || type === "reasoning.delta") {
package/dist/server.js CHANGED
@@ -76,7 +76,7 @@ import { captureDirtyPatch, applyDirtyPatch } from "./session/fork-dirty.js";
76
76
  import { thinkingTextFromContent } from "./session/transcript-merge.js";
77
77
  import { normalizeMessages } from "./session/transcript-normal.js";
78
78
  import { buildNativeImportSeedPrompt } from "./session/native-import.js";
79
- import { EventLog } from "./session/event-log.js";
79
+ import { EventLog, mergeBases } from "./session/event-log.js";
80
80
  import { revertFile } from "./session/revert-file.js";
81
81
  import { buildDiagnosticsReport, activationRecord } from "./diagnostics.js";
82
82
  import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
@@ -2443,12 +2443,25 @@ retireTranscriptsDir();
2443
2443
  * with no build-free `readMessages` and no external rollout. Skips the write when the
2444
2444
  * runtime has nothing yet, so a transient empty read can never clobber a good base
2445
2445
  * with `[]`. The log stores it as a bounded delta (see EventLog.appendBaseSnapshot).
2446
+ *
2447
+ * The snapshot must never SHRINK the log. A protocol runtime that resumes via a
2448
+ * blank reconnect (e.g. opencode through the ACP shim: session/load returns no
2449
+ * message history) starts empty and only reports the turns that ran after the
2450
+ * resume — a transcript SHORTER than what the log already holds, despite being
2451
+ * the same ongoing conversation. `appendBaseSnapshot` reads a shorter snapshot
2452
+ * as a compaction and REPLACES the logged base, silently dropping every prior
2453
+ * turn. Rebase the runtime's snapshot onto the logged history instead (the same
2454
+ * union `EventLog.deriveHistory` applies on read): the merged view is exactly
2455
+ * the prefix-extend case the log already handles, so the log grows monotonically.
2456
+ * A genuine runtime compaction isn't a supported feature, so never treating a
2457
+ * snapshot as a shrink is safe.
2446
2458
  */
2447
2459
  function persistTranscriptSnapshot(record) {
2448
2460
  const base = record.session.getMessages();
2449
2461
  if (!base.length)
2450
2462
  return;
2451
- eventLog.appendBaseSnapshot(record.id, base);
2463
+ const logged = eventLog.readBase(record.id);
2464
+ eventLog.appendBaseSnapshot(record.id, logged.length ? mergeBases(logged, base) : base);
2452
2465
  }
2453
2466
  // In-flight dedupe so two sessions (or two turns) referencing the same remote
2454
2467
  // image URL only ever trigger one outbound fetch. Process-lifetime only — a
@@ -29,6 +29,30 @@
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
31
  import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript } from "./transcript-merge.js";
32
+ /**
33
+ * Union of a persisted base and a runtime's live transcript, such that the result
34
+ * is never shorter than either input alone. A live session's runtime transcript
35
+ * extends the persisted base, so the union is just the runtime's (no duplication).
36
+ * A resumed runtime that reconnected blank and only re-saw post-resume turns
37
+ * reports a strict prefix of the persisted base, so the union keeps the persisted
38
+ * copy. Disjoint inputs (a resumed runtime whose new turns aren't in the log yet)
39
+ * concatenate in log-then-runtime order. Duplicates are collapsed by message
40
+ * identity, so the same conversation serialized twice never double-appears.
41
+ */
42
+ export function mergeBases(logged, runtime) {
43
+ if (!logged.length)
44
+ return runtime;
45
+ const known = new Set(logged.map((m) => JSON.stringify(m)));
46
+ const merged = [...logged];
47
+ for (const m of runtime) {
48
+ const key = JSON.stringify(m);
49
+ if (!known.has(key)) {
50
+ known.add(key);
51
+ merged.push(m);
52
+ }
53
+ }
54
+ return merged;
55
+ }
32
56
  /** Content-block type carried by a folded outbound attachment. MUST match
33
57
  * `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
34
58
  * renderHistory keys on this exact string to render the chip. */
@@ -456,12 +480,21 @@ export class EventLog {
456
480
  }
457
481
  /**
458
482
  * The full derived conversation: overlay detail merged into the base transcript.
459
- * Prefers the runtime's own live transcript when it has one; otherwise replays the
460
- * base persisted in the log (a reopened session on a runtime that can't rebuild it).
461
- * This is the single read path — it absorbs the former `mergeConversation` helper.
483
+ * The base is the UNION of the runtime's live transcript and the base persisted
484
+ * in the log — never one at the other's expense. A live session's runtime
485
+ * transcript extends the log (both are the same conversation, the runtime one
486
+ * message newer), so the union is just the runtime's. But a runtime that resumes
487
+ * via a blank reconnect (e.g. opencode through the ACP shim: session/load
488
+ * returns no message history) reports only the turns that ran AFTER the resume —
489
+ * a truncation of the same conversation, not the whole story. Preferring the
490
+ * runtime base there would mask every prior turn from every history read, so the
491
+ * union keeps the log's fuller copy while still surfacing whatever the runtime
492
+ * alone knows. This is the single read path — it absorbs the former
493
+ * `mergeConversation` helper.
462
494
  */
463
495
  deriveHistory(id, runtimeBase) {
464
- const base = runtimeBase && runtimeBase.length ? runtimeBase : this.readBase(id);
496
+ const logged = this.readBase(id);
497
+ const base = runtimeBase && runtimeBase.length ? mergeBases(logged, runtimeBase) : logged;
465
498
  return mergeTranscript(base, this.read(id));
466
499
  }
467
500
  /** Full ordered record list (already-flushed followed by pending). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.140",
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.",