@mirrowel/opencode-openshare 0.1.1 → 0.2.0-dev.1

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/dist/core.js CHANGED
@@ -44,6 +44,7 @@
44
44
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
45
  import { join } from "node:path";
46
46
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
47
+ import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
47
48
  const DEFAULT_POLICY = {
48
49
  mode: "persistent",
49
50
  turnGraceMs: 120000,
@@ -93,11 +94,12 @@ function itemFor(type, data, sessionID) {
93
94
  if (type === "prefs")
94
95
  return { key: "prefs", item: { type, data } };
95
96
  if (type === "session")
96
- return { key: `session/${data?.id ?? ""}`, item: { type, data } };
97
+ return { key: `session/${data?.id ?? ""}`, item: { type, sessionID, data } };
98
+ // v2 messages carry no sessionID inside data (v1 infos did); the item
99
+ // envelope stamps it so multi-session folds (parent + subagents) can
100
+ // group without guessing
97
101
  if (type === "message")
98
- return { key: `message/${data?.id ?? ""}`, item: { type, data } };
99
- if (type === "part")
100
- return { key: `part/${data?.messageID ?? ""}/${data?.id ?? ""}`, item: { type, data } };
102
+ return { key: `message/${data?.id ?? ""}`, item: { type, sessionID, data } };
101
103
  if (type === "session_diff")
102
104
  return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } };
103
105
  return undefined;
@@ -380,6 +382,7 @@ export function createEngine(deps) {
380
382
  activated: false,
381
383
  wroteBinding: false,
382
384
  everConnected: false,
385
+ merged: new Map(),
383
386
  });
384
387
  const refreshConfig = () => {
385
388
  // settings may change between activations (the TUI writes the config
@@ -638,6 +641,16 @@ export function createEngine(deps) {
638
641
  logError("spool save failed", e?.message || e);
639
642
  }
640
643
  };
644
+ // hot path: appending one pending frame must not re-serialize the whole
645
+ // queue (O(n^2) during backfill bursts) — append the line instead
646
+ const spoolAppend = (st, r, f) => {
647
+ try {
648
+ appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n");
649
+ }
650
+ catch (e) {
651
+ logError("spool append failed", e?.message || e);
652
+ }
653
+ };
641
654
  const spoolLoad = (st, r) => {
642
655
  try {
643
656
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
@@ -648,10 +661,20 @@ export function createEngine(deps) {
648
661
  r.ackedRev = meta.ackedRev ?? 0;
649
662
  }
650
663
  if (existsSync(queuePath(st, r.url))) {
651
- r.pending = readFileSync(queuePath(st, r.url), "utf8")
652
- .split("\n")
653
- .filter((l) => l.trim())
654
- .map((l) => JSON.parse(l));
664
+ // append-only spool: a crash mid-append can tear the LAST line —
665
+ // keep the valid prefix instead of dropping the whole queue
666
+ r.pending = [];
667
+ for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
668
+ if (!line.trim())
669
+ continue;
670
+ try {
671
+ r.pending.push(JSON.parse(line));
672
+ }
673
+ catch {
674
+ log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
675
+ break;
676
+ }
677
+ }
655
678
  }
656
679
  }
657
680
  catch {
@@ -696,15 +719,37 @@ export function createEngine(deps) {
696
719
  }
697
720
  return true;
698
721
  };
699
- /** Sync frames are chunked (~1MB WS cap on CF; a 5000-message backfill
700
- * must ride several frames — worker apply is keyed-idempotent, chunks
701
- * just bump rev). Status rides each chunk; last write wins. */
702
- const SYNC_CHUNK = 200;
722
+ /** Sync frames are chunked by BOTH item count and serialized bytes.
723
+ * Count-only chunking could pack 200 large tool outputs into a frame
724
+ * that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
725
+ * default) and die wholesale. Budget keeps every frame cheap to apply
726
+ * and rebroadcast; worker apply is keyed-idempotent, so chunks just
727
+ * bump rev. An item larger than the whole budget rides alone in its
728
+ * own frame (the worker fits oversize items to its storage cap).
729
+ * Status rides each chunk; last write wins. */
730
+ const chunkItems = (items, maxItems, maxBytes) => {
731
+ const chunks = [];
732
+ let cur = [];
733
+ let curBytes = 0;
734
+ for (const it of items) {
735
+ const n = (JSON.stringify(it) ?? "null").length;
736
+ if (cur.length && (cur.length >= maxItems || curBytes + n > maxBytes)) {
737
+ chunks.push(cur);
738
+ cur = [];
739
+ curBytes = 0;
740
+ }
741
+ cur.push(it);
742
+ curBytes += n;
743
+ }
744
+ if (cur.length)
745
+ chunks.push(cur);
746
+ return chunks;
747
+ };
703
748
  const sendSync = (r, f) => {
704
749
  if (!f.items.length)
705
750
  return wsSend(r, { op: "sync", items: [], status: f.status });
706
- for (let i = 0; i < f.items.length; i += SYNC_CHUNK) {
707
- wsSend(r, { op: "sync", items: f.items.slice(i, i + SYNC_CHUNK), status: f.status });
751
+ for (const chunk of chunkItems(f.items, 200, 512 * 1024)) {
752
+ wsSend(r, { op: "sync", items: chunk, status: f.status });
708
753
  }
709
754
  return true;
710
755
  };
@@ -1227,6 +1272,10 @@ export function createEngine(deps) {
1227
1272
  };
1228
1273
  // ---- flush -------------------------------------------------------------
1229
1274
  const frameFrom = (st) => {
1275
+ // stamps run at frame time: the whole family's data is indexed by now
1276
+ // (child prompts are created during the task call, well inside the
1277
+ // flush debounce), and any re-enqueued message lands in this frame
1278
+ stampsPass();
1230
1279
  const items = Array.from(st.queue.values());
1231
1280
  st.queue.clear();
1232
1281
  const statusChanged = st.status !== st.lastSentStatus;
@@ -1258,7 +1307,7 @@ export function createEngine(deps) {
1258
1307
  // sync dies as "unknown op" (live-caught: post-drop reconnects
1259
1308
  // lost whole turns). The init-ok handler flushes pending itself.
1260
1309
  r.pending.push(f);
1261
- spoolSave(st, r);
1310
+ spoolAppend(st, r, f);
1262
1311
  if (r.ws && !r.wsReady)
1263
1312
  continue;
1264
1313
  if (r.wsReady) {
@@ -1289,18 +1338,24 @@ export function createEngine(deps) {
1289
1338
  status = f.status;
1290
1339
  }
1291
1340
  const cred = credOf(r.url);
1292
- const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1293
- method: "POST",
1294
- headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1295
- body: JSON.stringify({ secret: r.secret ?? "", data: items, status }),
1296
- signal: AbortSignal.timeout(15000),
1297
- }).catch(() => null);
1298
- if (res && res.ok) {
1341
+ // chunked like WS frames: one unbounded body is a whole-backlog
1342
+ // bomb after a mid-backfill socket drop (backfill = full history).
1343
+ // Failure stops the loop with pending intact — replaying already-
1344
+ // applied chunks is idempotent.
1345
+ for (const chunk of chunkItems(items, 500, 4 * 1024 * 1024)) {
1346
+ const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1347
+ method: "POST",
1348
+ headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1349
+ body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
1350
+ signal: AbortSignal.timeout(15000),
1351
+ }).catch(() => null);
1352
+ if (!res || !res.ok)
1353
+ return;
1299
1354
  const body = (await res.json().catch(() => ({})));
1300
1355
  r.ackedRev = body.rev ?? r.ackedRev;
1301
- r.pending = [];
1302
- spoolSave(st, r);
1303
1356
  }
1357
+ r.pending = [];
1358
+ spoolSave(st, r);
1304
1359
  };
1305
1360
  const scheduleFlush = (st) => {
1306
1361
  if (st.timer)
@@ -1323,12 +1378,288 @@ export function createEngine(deps) {
1323
1378
  if (!st || !entry)
1324
1379
  return;
1325
1380
  st.queue.set(entry.key, entry.item);
1381
+ indexMessage(entry);
1326
1382
  if (debugEvents)
1327
1383
  log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`);
1328
1384
  touch(st);
1329
1385
  ensureConnected(st);
1330
1386
  scheduleFlush(st);
1331
1387
  };
1388
+ const msgIndex = new Map(); // `${sid}:${messageID}`
1389
+ const indexMessage = (entry) => {
1390
+ if (entry.item.type !== "message")
1391
+ return;
1392
+ const d = entry.item.data;
1393
+ if (!d || typeof d.id !== "string")
1394
+ return;
1395
+ const role = String(d.type ?? "");
1396
+ if (role !== "user" && role !== "assistant")
1397
+ return;
1398
+ const key = `${entry.item.sessionID}:${d.id}`;
1399
+ const prev = msgIndex.get(key);
1400
+ msgIndex.set(key, {
1401
+ sid: String(entry.item.sessionID ?? ""),
1402
+ role: role,
1403
+ agent: role === "assistant"
1404
+ ? typeof d.agent === "string"
1405
+ ? d.agent
1406
+ : prev?.agent
1407
+ : typeof d.metadata?.agent === "string"
1408
+ ? d.metadata.agent
1409
+ : prev?.agent,
1410
+ created: Number(d.time?.created ?? prev?.created ?? 0),
1411
+ data: d,
1412
+ });
1413
+ };
1414
+ const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
1415
+ const reEnqueueMessage = (m) => {
1416
+ const st = sessionStateOf(m.sid);
1417
+ if (st)
1418
+ enqueue(st, itemFor("message", m.data, m.sid));
1419
+ };
1420
+ /** segment agent for a user prompt: the prompt's own recorded agent (v1
1421
+ * hosts stamp it on every user message), else the first assistant
1422
+ * response inside the segment (v2 hosts), else carry-forward. */
1423
+ const segmentAgent = (ordered, at) => {
1424
+ const own = ordered[at]?.agent;
1425
+ if (own)
1426
+ return own;
1427
+ for (let i = at + 1; i < ordered.length; i++) {
1428
+ if (ordered[i].role === "assistant" && ordered[i].agent)
1429
+ return ordered[i].agent;
1430
+ if (ordered[i].role === "user")
1431
+ break; // next segment already began
1432
+ }
1433
+ return undefined;
1434
+ };
1435
+ /** user messages whose segment runs a DIFFERENT agent than the previous
1436
+ * segment carry metadata.agentSwitch = <agent> - the viewer renders the
1437
+ * transition chip from the stamp alone. */
1438
+ const stampAgentSwitches = () => {
1439
+ const bySession = new Map();
1440
+ for (const m of msgIndex.values()) {
1441
+ const list = bySession.get(m.sid);
1442
+ if (list)
1443
+ list.push(m);
1444
+ else
1445
+ bySession.set(m.sid, [m]);
1446
+ }
1447
+ for (const [, list] of bySession) {
1448
+ list.sort((a, b) => a.created - b.created);
1449
+ const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
1450
+ let prev;
1451
+ for (let k = 0; k < users.length; k++) {
1452
+ const agent = segmentAgent(list, users[k]);
1453
+ const d = list[users[k]].data;
1454
+ const meta = (d.metadata ??= {});
1455
+ const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
1456
+ if (want !== meta.agentSwitch) {
1457
+ if (want)
1458
+ meta.agentSwitch = want;
1459
+ else
1460
+ delete meta.agentSwitch;
1461
+ reEnqueueMessage(list[users[k]]);
1462
+ }
1463
+ if (agent)
1464
+ prev = agent;
1465
+ }
1466
+ }
1467
+ };
1468
+ /** task/subagent tool calls get metadata.agent = the agent that actually
1469
+ * ran for THAT call: the child's first prompt created at/after the call
1470
+ * started (v1 prompts carry the agent; v2 resolves via the response).
1471
+ * Rank-free pairing - a resumed child's second call lands on its second
1472
+ * prompt by construction time. */
1473
+ const stampTaskAgents = () => {
1474
+ const bySession = new Map();
1475
+ for (const m of msgIndex.values()) {
1476
+ const list = bySession.get(m.sid);
1477
+ if (list)
1478
+ list.push(m);
1479
+ else
1480
+ bySession.set(m.sid, [m]);
1481
+ }
1482
+ for (const list of bySession.values())
1483
+ list.sort((a, b) => a.created - b.created);
1484
+ for (const m of msgIndex.values()) {
1485
+ if (m.role !== "assistant")
1486
+ continue;
1487
+ const content = m.data.content;
1488
+ if (!Array.isArray(content))
1489
+ continue;
1490
+ let mutated = false;
1491
+ for (const part of content) {
1492
+ if (!part || part.type !== "tool")
1493
+ continue;
1494
+ if (part.name !== "task" && part.name !== "subagent")
1495
+ continue;
1496
+ const state = part.state;
1497
+ const meta = state?.metadata;
1498
+ const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined;
1499
+ if (!childSid || typeof meta.agent === "string")
1500
+ continue;
1501
+ const partStart = Number(part.time?.created ?? 0);
1502
+ const child = bySession.get(childSid);
1503
+ if (!child)
1504
+ continue;
1505
+ let agent;
1506
+ for (const c of child) {
1507
+ if (c.created < partStart)
1508
+ continue;
1509
+ if (c.role === "user" && c.agent) {
1510
+ agent = c.agent;
1511
+ break;
1512
+ }
1513
+ }
1514
+ if (!agent) {
1515
+ for (const c of child) {
1516
+ if (c.created < partStart)
1517
+ continue;
1518
+ if (c.role === "assistant" && c.agent) {
1519
+ agent = c.agent;
1520
+ break;
1521
+ }
1522
+ }
1523
+ }
1524
+ if (agent) {
1525
+ meta.agent = agent;
1526
+ mutated = true;
1527
+ }
1528
+ }
1529
+ if (mutated)
1530
+ reEnqueueMessage(m);
1531
+ }
1532
+ };
1533
+ /** file tools get metadata.language (shiki lang id, from the path the
1534
+ * call touched) - the viewer highlights from the stamp and never guesses
1535
+ * (owner directive; the viewer's extension map is an old-share fallback) */
1536
+ const LANG_BY_EXT = {
1537
+ ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1538
+ js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
1539
+ json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
1540
+ py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
1541
+ css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
1542
+ toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
1543
+ sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
1544
+ swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
1545
+ };
1546
+ const FILE_TOOLS = new Set(["read", "write", "edit", "patch"]);
1547
+ const stampFileLanguages = () => {
1548
+ for (const m of msgIndex.values()) {
1549
+ if (m.role !== "assistant")
1550
+ continue;
1551
+ const content = m.data.content;
1552
+ if (!Array.isArray(content))
1553
+ continue;
1554
+ let mutated = false;
1555
+ for (const part of content) {
1556
+ if (!part || part.type !== "tool")
1557
+ continue;
1558
+ if (!FILE_TOOLS.has(part.name))
1559
+ continue;
1560
+ const state = part.state;
1561
+ const meta = state?.metadata;
1562
+ if (!meta)
1563
+ continue;
1564
+ const raw = state.input?.path ?? state.input?.filePath;
1565
+ // language stamp (shiki id from the path)
1566
+ if (typeof meta.language !== "string" && typeof raw === "string") {
1567
+ const ext = raw.split(".").pop()?.toLowerCase() ?? "";
1568
+ const lang = LANG_BY_EXT[ext];
1569
+ if (lang) {
1570
+ meta.language = lang;
1571
+ mutated = true;
1572
+ }
1573
+ }
1574
+ // lineStart stamp: reads sliced from an offset keep REAL file line
1575
+ // numbers in the viewer's unified file card (pierre numbers from 1;
1576
+ // the viewer shifts by this stamp, authoritatively)
1577
+ if (typeof meta.lineStart !== "number" && part.name === "read") {
1578
+ const off = state.input?.offset;
1579
+ if (typeof off === "number" && off >= 0 && off > 0) {
1580
+ meta.lineStart = off + 1;
1581
+ mutated = true;
1582
+ }
1583
+ }
1584
+ }
1585
+ if (mutated)
1586
+ reEnqueueMessage(m);
1587
+ }
1588
+ };
1589
+ const stampsPass = () => {
1590
+ stampAgentSwitches();
1591
+ stampTaskAgents();
1592
+ stampFileLanguages();
1593
+ };
1594
+ // ---- contract v2 message assembly --------------------------------------
1595
+ // v1 hosts stream parts as separate events; the v2 item embeds content.
1596
+ // The engine keeps the assembled view per message and re-emits the
1597
+ // normalized whole (the keyed queue dedupes across a debounce window,
1598
+ // so intermediate re-emissions collapse).
1599
+ const mergeEntry = (st, messageID) => {
1600
+ let e = st.merged.get(messageID);
1601
+ if (!e) {
1602
+ e = { parts: new Map() };
1603
+ st.merged.set(messageID, e);
1604
+ }
1605
+ return e;
1606
+ };
1607
+ /** (Re-)emit the v2 message item for a merged v1 message. */
1608
+ const emitMerged = (st, sid, messageID) => {
1609
+ const e = st.merged.get(messageID);
1610
+ if (!e?.info)
1611
+ return; // parts arriving before their message: wait
1612
+ const res = normalizeV1Message(e.info, [...e.parts.values()]);
1613
+ if (!res)
1614
+ return;
1615
+ if ("refill" in res) {
1616
+ // v1 compaction summary assistant → rewrite the parent compaction
1617
+ // message with the summary text; the summary message itself is not
1618
+ // emitted as its own item
1619
+ const parent = st.merged.get(res.refill);
1620
+ if (parent?.info) {
1621
+ const compaction = normalizeV1Message(parent.info, [...parent.parts.values()]);
1622
+ if (compaction && "message" in compaction) {
1623
+ const msg = compaction.message;
1624
+ msg.summary = res.summary;
1625
+ enqueue(st, itemFor("message", msg, sid));
1626
+ }
1627
+ }
1628
+ return;
1629
+ }
1630
+ enqueue(st, itemFor("message", res.message, sid));
1631
+ };
1632
+ /** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
1633
+ * with v2-native messages (pass-through, no merge). */
1634
+ const ingestMessageInfo = (st, sid, info) => {
1635
+ if (isV2NativeMessage(info)) {
1636
+ enqueue(st, itemFor("message", info, sid));
1637
+ return;
1638
+ }
1639
+ if (typeof info?.id !== "string")
1640
+ return;
1641
+ const e = mergeEntry(st, info.id);
1642
+ e.info = info;
1643
+ emitMerged(st, sid, info.id);
1644
+ };
1645
+ const ingestPart = (st, sid, part) => {
1646
+ if (typeof part?.id !== "string" || typeof part.messageID !== "string")
1647
+ return;
1648
+ const e = mergeEntry(st, part.messageID);
1649
+ e.parts.set(part.id, part);
1650
+ emitMerged(st, sid, part.messageID);
1651
+ };
1652
+ const dropMergedMessage = (st, messageID) => {
1653
+ st.merged.delete(messageID);
1654
+ };
1655
+ const dropMergedPart = (st, sid, messageID, partID) => {
1656
+ const e = st.merged.get(messageID);
1657
+ if (!e)
1658
+ return;
1659
+ e.parts.delete(partID);
1660
+ if (e.info)
1661
+ emitMerged(st, sid, messageID);
1662
+ };
1332
1663
  const remove = (st) => {
1333
1664
  st.closed = true;
1334
1665
  st.activated = false;
@@ -1413,7 +1744,7 @@ export function createEngine(deps) {
1413
1744
  const PAGE_SIZE = 100;
1414
1745
  const MAX_PAGES = 50;
1415
1746
  const query = () => (deps.directory ? { directory: deps.directory } : {});
1416
- async function fetchMessages(client, sid) {
1747
+ async function fetchMessages(client, sid, onPage) {
1417
1748
  if (typeof client.session.messages !== "function")
1418
1749
  return [];
1419
1750
  const out = [];
@@ -1429,11 +1760,29 @@ export function createEngine(deps) {
1429
1760
  if (!Array.isArray(result) || result.length === 0)
1430
1761
  break;
1431
1762
  out.push(...result);
1763
+ // stream pages as they arrive: enqueue (and the rate-limited flush)
1764
+ // starts with page 1 while later pages are still loading — a huge
1765
+ // session backfills progressively instead of after a full collect
1766
+ if (onPage) {
1767
+ try {
1768
+ onPage(result);
1769
+ }
1770
+ catch { }
1771
+ }
1432
1772
  if (result.length < PAGE_SIZE)
1433
1773
  break;
1434
1774
  const last = result[result.length - 1];
1435
- const nextBefore = typeof last?.info?.id === "string" ? last.info.id : typeof last?.id === "string" ? last.id : undefined;
1436
- if (!nextBefore || nextBefore === before)
1775
+ const lastInfo = (last?.info ?? last);
1776
+ const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
1777
+ // `before` is an OPAQUE cursor: base64url({id, time}) of the page
1778
+ // tail (message-v2.ts codec). A bare message id 400s on hosts with
1779
+ // cursor pagination — every v1 backfill silently truncated to one
1780
+ // 100-message page (live-caught).
1781
+ const lastTime = lastInfo?.time?.created;
1782
+ if (!lastID || typeof lastTime !== "number")
1783
+ break;
1784
+ const nextBefore = cursorFor(lastID, lastTime);
1785
+ if (nextBefore === before)
1437
1786
  break;
1438
1787
  before = nextBefore;
1439
1788
  }
@@ -1443,27 +1792,32 @@ export function createEngine(deps) {
1443
1792
  const sessionInfoValue = info ?? (await sessionInfo(client, sid));
1444
1793
  if (sessionInfoValue)
1445
1794
  enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
1446
- const messages = await fetchMessages(client, sid);
1447
1795
  let modelSeen = false;
1448
- for (const message of messages) {
1449
- const m = (message.info ?? message);
1450
- if (!m || typeof m.id !== "string")
1451
- continue;
1452
- enqueue(st, itemFor("message", m, sid));
1453
- if (m.role === "user" && m.model) {
1454
- modelSeen = true;
1455
- const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
1456
- enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
1457
- }
1458
- for (const part of message.parts ?? []) {
1459
- const p = part;
1460
- if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
1796
+ const consume = (messages) => {
1797
+ for (const message of messages) {
1798
+ const m = (message.info ?? message);
1799
+ if (!m || typeof m.id !== "string")
1461
1800
  continue;
1462
- if (p.state?.status === "error")
1463
- st.badSignal = true;
1464
- enqueue(st, itemFor("part", p, sid));
1801
+ // seed the merge state, then emit the assembled v2 message
1802
+ const e = mergeEntry(st, m.id);
1803
+ e.info = m;
1804
+ for (const rawPart of (message.parts ?? [])) {
1805
+ const p = rawPart;
1806
+ if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
1807
+ continue;
1808
+ if (p.state?.status === "error")
1809
+ st.badSignal = true;
1810
+ e.parts.set(p.id, p);
1811
+ }
1812
+ emitMerged(st, sid, m.id);
1813
+ if (m.role === "user" && m.model) {
1814
+ modelSeen = true;
1815
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
1816
+ enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
1817
+ }
1465
1818
  }
1466
- }
1819
+ };
1820
+ const messages = await fetchMessages(client, sid, (page) => consume(page));
1467
1821
  if (!modelSeen && sessionInfoValue?.model) {
1468
1822
  // hosts whose user messages carry no model (v2): label from the
1469
1823
  // session's own model
@@ -1773,9 +2127,9 @@ export function createEngine(deps) {
1773
2127
  // opencode undo: revert() only marks the session (the viewer
1774
2128
  // hides the range); the NEXT prompt hard-deletes the reverted
1775
2129
  // messages/parts via cleanup() and publishes these events.
1776
- // The worker's store is upsert-only, so without an explicit
1777
- // tombstone the deleted items live forever and reappear the
1778
- // moment the revert marker clears (live-caught in production).
2130
+ // A message tombstone removes the whole v2 message (content is
2131
+ // embedded); a part removal re-emits the merged message minus
2132
+ // the part.
1779
2133
  const st = stFor(sid);
1780
2134
  if (!st || !st.activated || st.closed)
1781
2135
  return;
@@ -1783,13 +2137,14 @@ export function createEngine(deps) {
1783
2137
  if (!messageID)
1784
2138
  return;
1785
2139
  if (t === "message.removed") {
1786
- enqueue(st, { key: `message/${messageID}`, item: { type: "message", del: true, data: { id: messageID } } });
2140
+ dropMergedMessage(st, messageID);
2141
+ enqueue(st, { key: `message/${messageID}`, item: messageTombstone(st.sid, messageID) });
1787
2142
  }
1788
2143
  else {
1789
2144
  const partID = p.partID;
1790
2145
  if (!partID)
1791
2146
  return;
1792
- enqueue(st, { key: `part/${messageID}/${partID}`, item: { type: "part", del: true, data: { id: partID, messageID } } });
2147
+ dropMergedPart(st, sid, messageID, partID);
1793
2148
  }
1794
2149
  }
1795
2150
  else if (t === "message.updated") {
@@ -1844,7 +2199,7 @@ export function createEngine(deps) {
1844
2199
  }
1845
2200
  if (!st || !st.activated || st.closed)
1846
2201
  return;
1847
- enqueue(st, itemFor("message", p.info, sid));
2202
+ ingestMessageInfo(st, sid, p.info);
1848
2203
  const info = p.info;
1849
2204
  if (info?.role === "user" && info?.model) {
1850
2205
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
@@ -1853,8 +2208,9 @@ export function createEngine(deps) {
1853
2208
  // an assistant message that DIED (finish:"error") — e.g. all
1854
2209
  // retries exhausted — surfaces as a 200 response with an errored
1855
2210
  // message, NOT as session.error (live-caught in the suite); the
1856
- // share must still say error
1857
- if (info?.role === "assistant" && info.finish === "error") {
2211
+ // share must still say error. Works for v2-native messages too
2212
+ // (finish lives on the message itself).
2213
+ if (info?.finish === "error") {
1858
2214
  st.badSignal = true;
1859
2215
  }
1860
2216
  }
@@ -1865,13 +2221,13 @@ export function createEngine(deps) {
1865
2221
  const part = p.part;
1866
2222
  if (part?.state?.status === "error")
1867
2223
  st.badSignal = true;
1868
- enqueue(st, itemFor("part", part, sid));
2224
+ ingestPart(st, sid, part);
1869
2225
  }
1870
2226
  else if (t === "session.diff") {
1871
2227
  const st = stFor(sid);
1872
2228
  if (!st || !st.activated || st.closed)
1873
2229
  return;
1874
- enqueue(st, itemFor("session_diff", p.diff, sid));
2230
+ enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid));
1875
2231
  }
1876
2232
  else if (t === "session.deleted") {
1877
2233
  // a CHILD session ending (task done, gc) must NEVER take the