@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 +47 -2
- package/dist/runtime/index.js +15 -0
- package/dist/runtime/opencode-sessions.js +283 -0
- package/dist/runtime/protocol.js +50 -4
- package/dist/server.js +15 -2
- package/dist/session/event-log.js +37 -4
- package/package.json +1 -1
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(() => {
|
|
436
|
-
|
|
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;
|
package/dist/runtime/index.js
CHANGED
|
@@ -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/dist/runtime/protocol.js
CHANGED
|
@@ -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 (!
|
|
454
|
-
|
|
455
|
-
this.
|
|
456
|
-
|
|
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.
|
|
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
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
|
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