@mirrowel/opencode-openshare 0.1.0 → 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/src/core.ts CHANGED
@@ -45,6 +45,7 @@
45
45
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
46
46
  import { join } from "node:path"
47
47
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
48
+ import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
48
49
 
49
50
  // --- minimal structural types (hand-written: the standalone bundle must
50
51
  // not depend on the plugin SDK at runtime; shapes verified against
@@ -148,6 +149,10 @@ type SessionState = {
148
149
  /** any remote completed a handshake — used to detect LATER creates
149
150
  * (fan-out adds, post-mismatch recreates) which need a history backfill */
150
151
  everConnected: boolean
152
+ /** contract v2 merge state: v1 hosts stream parts separately; the v2
153
+ * item embeds content, so the engine holds the assembled view per
154
+ * message and re-emits the normalized whole on every update */
155
+ merged: Map<string, { info?: Record<string, unknown>; parts: Map<string, Record<string, unknown>> }>
151
156
  }
152
157
 
153
158
  type Message = { info?: Record<string, unknown>; parts?: Record<string, unknown>[] }
@@ -239,10 +244,11 @@ type ItemEntry = { key: string; item: Record<string, unknown> }
239
244
  function itemFor(type: string, data: unknown, sessionID: string): ItemEntry | undefined {
240
245
  if (type === "model") return { key: `model/${sessionID}`, item: { type, sessionID, data } }
241
246
  if (type === "prefs") return { key: "prefs", item: { type, data } }
242
- if (type === "session") return { key: `session/${(data as SessionInfo)?.id ?? ""}`, item: { type, data } }
243
- if (type === "message") return { key: `message/${(data as MessageInfo)?.id ?? ""}`, item: { type, data } }
244
- if (type === "part")
245
- return { key: `part/${(data as PartInfo)?.messageID ?? ""}/${(data as PartInfo)?.id ?? ""}`, item: { type, data } }
247
+ if (type === "session") return { key: `session/${(data as SessionInfo)?.id ?? ""}`, item: { type, sessionID, data } }
248
+ // v2 messages carry no sessionID inside data (v1 infos did); the item
249
+ // envelope stamps it so multi-session folds (parent + subagents) can
250
+ // group without guessing
251
+ if (type === "message") return { key: `message/${(data as MessageInfo)?.id ?? ""}`, item: { type, sessionID, data } }
246
252
  if (type === "session_diff") return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } }
247
253
  return undefined
248
254
  }
@@ -527,6 +533,7 @@ export function createEngine(deps: EngineDeps): Engine {
527
533
  activated: false,
528
534
  wroteBinding: false,
529
535
  everConnected: false,
536
+ merged: new Map(),
530
537
  })
531
538
 
532
539
  const refreshConfig = () => {
@@ -775,6 +782,15 @@ export function createEngine(deps: EngineDeps): Engine {
775
782
  logError("spool save failed", (e as Error)?.message || e)
776
783
  }
777
784
  }
785
+ // hot path: appending one pending frame must not re-serialize the whole
786
+ // queue (O(n^2) during backfill bursts) — append the line instead
787
+ const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string }) => {
788
+ try {
789
+ appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n")
790
+ } catch (e) {
791
+ logError("spool append failed", (e as Error)?.message || e)
792
+ }
793
+ }
778
794
  const spoolLoad = (st: SessionState, r: RemoteState) => {
779
795
  try {
780
796
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
@@ -785,10 +801,18 @@ export function createEngine(deps: EngineDeps): Engine {
785
801
  r.ackedRev = meta.ackedRev ?? 0
786
802
  }
787
803
  if (existsSync(queuePath(st, r.url))) {
788
- r.pending = readFileSync(queuePath(st, r.url), "utf8")
789
- .split("\n")
790
- .filter((l) => l.trim())
791
- .map((l) => JSON.parse(l))
804
+ // append-only spool: a crash mid-append can tear the LAST line —
805
+ // keep the valid prefix instead of dropping the whole queue
806
+ r.pending = []
807
+ for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
808
+ if (!line.trim()) continue
809
+ try {
810
+ r.pending.push(JSON.parse(line))
811
+ } catch {
812
+ log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`)
813
+ break
814
+ }
815
+ }
792
816
  }
793
817
  } catch {
794
818
  r.pending = []
@@ -830,14 +854,35 @@ export function createEngine(deps: EngineDeps): Engine {
830
854
  return true
831
855
  }
832
856
 
833
- /** Sync frames are chunked (~1MB WS cap on CF; a 5000-message backfill
834
- * must ride several frames — worker apply is keyed-idempotent, chunks
835
- * just bump rev). Status rides each chunk; last write wins. */
836
- const SYNC_CHUNK = 200
857
+ /** Sync frames are chunked by BOTH item count and serialized bytes.
858
+ * Count-only chunking could pack 200 large tool outputs into a frame
859
+ * that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
860
+ * default) and die wholesale. Budget keeps every frame cheap to apply
861
+ * and rebroadcast; worker apply is keyed-idempotent, so chunks just
862
+ * bump rev. An item larger than the whole budget rides alone in its
863
+ * own frame (the worker fits oversize items to its storage cap).
864
+ * Status rides each chunk; last write wins. */
865
+ const chunkItems = (items: unknown[], maxItems: number, maxBytes: number): unknown[][] => {
866
+ const chunks: unknown[][] = []
867
+ let cur: unknown[] = []
868
+ let curBytes = 0
869
+ for (const it of items) {
870
+ const n = (JSON.stringify(it) ?? "null").length
871
+ if (cur.length && (cur.length >= maxItems || curBytes + n > maxBytes)) {
872
+ chunks.push(cur)
873
+ cur = []
874
+ curBytes = 0
875
+ }
876
+ cur.push(it)
877
+ curBytes += n
878
+ }
879
+ if (cur.length) chunks.push(cur)
880
+ return chunks
881
+ }
837
882
  const sendSync = (r: RemoteState, f: { items: unknown[]; status: string }) => {
838
883
  if (!f.items.length) return wsSend(r, { op: "sync", items: [], status: f.status })
839
- for (let i = 0; i < f.items.length; i += SYNC_CHUNK) {
840
- wsSend(r, { op: "sync", items: f.items.slice(i, i + SYNC_CHUNK), status: f.status })
884
+ for (const chunk of chunkItems(f.items, 200, 512 * 1024)) {
885
+ wsSend(r, { op: "sync", items: chunk, status: f.status })
841
886
  }
842
887
  return true
843
888
  }
@@ -1323,6 +1368,10 @@ export function createEngine(deps: EngineDeps): Engine {
1323
1368
 
1324
1369
  // ---- flush -------------------------------------------------------------
1325
1370
  const frameFrom = (st: SessionState) => {
1371
+ // stamps run at frame time: the whole family's data is indexed by now
1372
+ // (child prompts are created during the task call, well inside the
1373
+ // flush debounce), and any re-enqueued message lands in this frame
1374
+ stampsPass()
1326
1375
  const items = Array.from(st.queue.values())
1327
1376
  st.queue.clear()
1328
1377
  const statusChanged = st.status !== st.lastSentStatus
@@ -1351,7 +1400,7 @@ export function createEngine(deps: EngineDeps): Engine {
1351
1400
  // sync dies as "unknown op" (live-caught: post-drop reconnects
1352
1401
  // lost whole turns). The init-ok handler flushes pending itself.
1353
1402
  r.pending.push(f)
1354
- spoolSave(st, r)
1403
+ spoolAppend(st, r, f)
1355
1404
  if (r.ws && !r.wsReady) continue
1356
1405
  if (r.wsReady) {
1357
1406
  sendSync(r, f)
@@ -1377,18 +1426,23 @@ export function createEngine(deps: EngineDeps): Engine {
1377
1426
  if (f.status) status = f.status
1378
1427
  }
1379
1428
  const cred = credOf(r.url)
1380
- const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1381
- method: "POST",
1382
- headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1383
- body: JSON.stringify({ secret: r.secret ?? "", data: items, status }),
1384
- signal: AbortSignal.timeout(15000),
1385
- }).catch(() => null)
1386
- if (res && res.ok) {
1429
+ // chunked like WS frames: one unbounded body is a whole-backlog
1430
+ // bomb after a mid-backfill socket drop (backfill = full history).
1431
+ // Failure stops the loop with pending intact — replaying already-
1432
+ // applied chunks is idempotent.
1433
+ for (const chunk of chunkItems(items, 500, 4 * 1024 * 1024)) {
1434
+ const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1435
+ method: "POST",
1436
+ headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1437
+ body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
1438
+ signal: AbortSignal.timeout(15000),
1439
+ }).catch(() => null)
1440
+ if (!res || !res.ok) return
1387
1441
  const body = (await res.json().catch(() => ({}))) as { rev?: number }
1388
1442
  r.ackedRev = body.rev ?? r.ackedRev
1389
- r.pending = []
1390
- spoolSave(st, r)
1391
1443
  }
1444
+ r.pending = []
1445
+ spoolSave(st, r)
1392
1446
  }
1393
1447
 
1394
1448
  const scheduleFlush = (st: SessionState) => {
@@ -1410,12 +1464,283 @@ export function createEngine(deps: EngineDeps): Engine {
1410
1464
  const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
1411
1465
  if (!st || !entry) return
1412
1466
  st.queue.set(entry.key, entry.item)
1467
+ indexMessage(entry)
1413
1468
  if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
1414
1469
  touch(st)
1415
1470
  ensureConnected(st)
1416
1471
  scheduleFlush(st)
1417
1472
  }
1418
1473
 
1474
+ // ---- authoritative stamps ----------------------------------------------
1475
+ // The viewer renders stamped facts and never re-derives them (owner
1476
+ // directive). Both stamps are computed at frame time across the whole
1477
+ // share family - the engine sees every session of the family regardless
1478
+ // of host generation, so this is the only place with the full picture.
1479
+
1480
+ type IndexedMessage = {
1481
+ sid: string
1482
+ role: string
1483
+ agent?: string
1484
+ created: number
1485
+ data: Record<string, unknown>
1486
+ }
1487
+ const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
1488
+
1489
+ const indexMessage = (entry: ItemEntry) => {
1490
+ if (entry.item.type !== "message") return
1491
+ const d = entry.item.data as Record<string, any> | undefined
1492
+ if (!d || typeof d.id !== "string") return
1493
+ const role = String(d.type ?? "")
1494
+ if (role !== "user" && role !== "assistant") return
1495
+ const key = `${entry.item.sessionID}:${d.id}`
1496
+ const prev = msgIndex.get(key)
1497
+ msgIndex.set(key, {
1498
+ sid: String(entry.item.sessionID ?? ""),
1499
+ role: role as string,
1500
+ agent:
1501
+ role === "assistant"
1502
+ ? typeof d.agent === "string"
1503
+ ? d.agent
1504
+ : prev?.agent
1505
+ : typeof d.metadata?.agent === "string"
1506
+ ? d.metadata.agent
1507
+ : prev?.agent,
1508
+ created: Number(d.time?.created ?? prev?.created ?? 0),
1509
+ data: d as Record<string, unknown>,
1510
+ })
1511
+ }
1512
+
1513
+ const sessionStateOf = (sid: string): SessionState | undefined =>
1514
+ sessions.get(childToParent.get(sid) ?? sid)
1515
+
1516
+ const reEnqueueMessage = (m: IndexedMessage) => {
1517
+ const st = sessionStateOf(m.sid)
1518
+ if (st) enqueue(st, itemFor("message", m.data, m.sid))
1519
+ }
1520
+
1521
+ /** segment agent for a user prompt: the prompt's own recorded agent (v1
1522
+ * hosts stamp it on every user message), else the first assistant
1523
+ * response inside the segment (v2 hosts), else carry-forward. */
1524
+ const segmentAgent = (ordered: IndexedMessage[], at: number): string | undefined => {
1525
+ const own = ordered[at]?.agent
1526
+ if (own) return own
1527
+ for (let i = at + 1; i < ordered.length; i++) {
1528
+ if (ordered[i].role === "assistant" && ordered[i].agent) return ordered[i].agent
1529
+ if (ordered[i].role === "user") break // next segment already began
1530
+ }
1531
+ return undefined
1532
+ }
1533
+
1534
+ /** user messages whose segment runs a DIFFERENT agent than the previous
1535
+ * segment carry metadata.agentSwitch = <agent> - the viewer renders the
1536
+ * transition chip from the stamp alone. */
1537
+ const stampAgentSwitches = () => {
1538
+ const bySession = new Map<string, IndexedMessage[]>()
1539
+ for (const m of msgIndex.values()) {
1540
+ const list = bySession.get(m.sid)
1541
+ if (list) list.push(m)
1542
+ else bySession.set(m.sid, [m])
1543
+ }
1544
+ for (const [, list] of bySession) {
1545
+ list.sort((a, b) => a.created - b.created)
1546
+ const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0)
1547
+ let prev: string | undefined
1548
+ for (let k = 0; k < users.length; k++) {
1549
+ const agent = segmentAgent(list, users[k])
1550
+ const d = list[users[k]].data as Record<string, any>
1551
+ const meta = (d.metadata ??= {}) as Record<string, unknown>
1552
+ const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
1553
+ if (want !== (meta.agentSwitch as string | undefined)) {
1554
+ if (want) meta.agentSwitch = want
1555
+ else delete meta.agentSwitch
1556
+ reEnqueueMessage(list[users[k]])
1557
+ }
1558
+ if (agent) prev = agent
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ /** task/subagent tool calls get metadata.agent = the agent that actually
1564
+ * ran for THAT call: the child's first prompt created at/after the call
1565
+ * started (v1 prompts carry the agent; v2 resolves via the response).
1566
+ * Rank-free pairing - a resumed child's second call lands on its second
1567
+ * prompt by construction time. */
1568
+ const stampTaskAgents = () => {
1569
+ const bySession = new Map<string, IndexedMessage[]>()
1570
+ for (const m of msgIndex.values()) {
1571
+ const list = bySession.get(m.sid)
1572
+ if (list) list.push(m)
1573
+ else bySession.set(m.sid, [m])
1574
+ }
1575
+ for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
1576
+ for (const m of msgIndex.values()) {
1577
+ if (m.role !== "assistant") continue
1578
+ const content = (m.data as any).content
1579
+ if (!Array.isArray(content)) continue
1580
+ let mutated = false
1581
+ for (const part of content) {
1582
+ if (!part || part.type !== "tool") continue
1583
+ if (part.name !== "task" && part.name !== "subagent") continue
1584
+ const state = part.state
1585
+ const meta = state?.metadata
1586
+ const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined
1587
+ if (!childSid || typeof meta.agent === "string") continue
1588
+ const partStart = Number(part.time?.created ?? 0)
1589
+ const child = bySession.get(childSid)
1590
+ if (!child) continue
1591
+ let agent: string | undefined
1592
+ for (const c of child) {
1593
+ if (c.created < partStart) continue
1594
+ if (c.role === "user" && c.agent) {
1595
+ agent = c.agent
1596
+ break
1597
+ }
1598
+ }
1599
+ if (!agent) {
1600
+ for (const c of child) {
1601
+ if (c.created < partStart) continue
1602
+ if (c.role === "assistant" && c.agent) {
1603
+ agent = c.agent
1604
+ break
1605
+ }
1606
+ }
1607
+ }
1608
+ if (agent) {
1609
+ meta.agent = agent
1610
+ mutated = true
1611
+ }
1612
+ }
1613
+ if (mutated) reEnqueueMessage(m)
1614
+ }
1615
+ }
1616
+
1617
+ /** file tools get metadata.language (shiki lang id, from the path the
1618
+ * call touched) - the viewer highlights from the stamp and never guesses
1619
+ * (owner directive; the viewer's extension map is an old-share fallback) */
1620
+ const LANG_BY_EXT: Record<string, string> = {
1621
+ ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1622
+ js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
1623
+ json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
1624
+ py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
1625
+ css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
1626
+ toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
1627
+ sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
1628
+ swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
1629
+ }
1630
+ const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
1631
+ const stampFileLanguages = () => {
1632
+ for (const m of msgIndex.values()) {
1633
+ if (m.role !== "assistant") continue
1634
+ const content = (m.data as any).content
1635
+ if (!Array.isArray(content)) continue
1636
+ let mutated = false
1637
+ for (const part of content) {
1638
+ if (!part || part.type !== "tool") continue
1639
+ if (!FILE_TOOLS.has(part.name)) continue
1640
+ const state = part.state
1641
+ const meta = state?.metadata
1642
+ if (!meta) continue
1643
+ const raw = state.input?.path ?? state.input?.filePath
1644
+ // language stamp (shiki id from the path)
1645
+ if (typeof meta.language !== "string" && typeof raw === "string") {
1646
+ const ext = raw.split(".").pop()?.toLowerCase() ?? ""
1647
+ const lang = LANG_BY_EXT[ext]
1648
+ if (lang) {
1649
+ meta.language = lang
1650
+ mutated = true
1651
+ }
1652
+ }
1653
+ // lineStart stamp: reads sliced from an offset keep REAL file line
1654
+ // numbers in the viewer's unified file card (pierre numbers from 1;
1655
+ // the viewer shifts by this stamp, authoritatively)
1656
+ if (typeof meta.lineStart !== "number" && part.name === "read") {
1657
+ const off = state.input?.offset
1658
+ if (typeof off === "number" && off >= 0 && off > 0) {
1659
+ meta.lineStart = off + 1
1660
+ mutated = true
1661
+ }
1662
+ }
1663
+ }
1664
+ if (mutated) reEnqueueMessage(m)
1665
+ }
1666
+ }
1667
+
1668
+ const stampsPass = () => {
1669
+ stampAgentSwitches()
1670
+ stampTaskAgents()
1671
+ stampFileLanguages()
1672
+ }
1673
+
1674
+ // ---- contract v2 message assembly --------------------------------------
1675
+ // v1 hosts stream parts as separate events; the v2 item embeds content.
1676
+ // The engine keeps the assembled view per message and re-emits the
1677
+ // normalized whole (the keyed queue dedupes across a debounce window,
1678
+ // so intermediate re-emissions collapse).
1679
+
1680
+ const mergeEntry = (st: SessionState, messageID: string) => {
1681
+ let e = st.merged.get(messageID)
1682
+ if (!e) {
1683
+ e = { parts: new Map() }
1684
+ st.merged.set(messageID, e)
1685
+ }
1686
+ return e
1687
+ }
1688
+
1689
+ /** (Re-)emit the v2 message item for a merged v1 message. */
1690
+ const emitMerged = (st: SessionState, sid: string, messageID: string) => {
1691
+ const e = st.merged.get(messageID)
1692
+ if (!e?.info) return // parts arriving before their message: wait
1693
+ const res = normalizeV1Message(e.info, [...e.parts.values()])
1694
+ if (!res) return
1695
+ if ("refill" in res) {
1696
+ // v1 compaction summary assistant → rewrite the parent compaction
1697
+ // message with the summary text; the summary message itself is not
1698
+ // emitted as its own item
1699
+ const parent = st.merged.get(res.refill)
1700
+ if (parent?.info) {
1701
+ const compaction = normalizeV1Message(parent.info, [...parent.parts.values()])
1702
+ if (compaction && "message" in compaction) {
1703
+ const msg = compaction.message as Record<string, unknown>
1704
+ msg.summary = res.summary
1705
+ enqueue(st, itemFor("message", msg, sid))
1706
+ }
1707
+ }
1708
+ return
1709
+ }
1710
+ enqueue(st, itemFor("message", res.message, sid))
1711
+ }
1712
+
1713
+ /** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
1714
+ * with v2-native messages (pass-through, no merge). */
1715
+ const ingestMessageInfo = (st: SessionState, sid: string, info: Record<string, unknown>) => {
1716
+ if (isV2NativeMessage(info)) {
1717
+ enqueue(st, itemFor("message", info, sid))
1718
+ return
1719
+ }
1720
+ if (typeof info?.id !== "string") return
1721
+ const e = mergeEntry(st, info.id)
1722
+ e.info = info
1723
+ emitMerged(st, sid, info.id)
1724
+ }
1725
+
1726
+ const ingestPart = (st: SessionState, sid: string, part: Record<string, unknown>) => {
1727
+ if (typeof part?.id !== "string" || typeof part.messageID !== "string") return
1728
+ const e = mergeEntry(st, part.messageID)
1729
+ e.parts.set(part.id, part)
1730
+ emitMerged(st, sid, part.messageID)
1731
+ }
1732
+
1733
+ const dropMergedMessage = (st: SessionState, messageID: string) => {
1734
+ st.merged.delete(messageID)
1735
+ }
1736
+
1737
+ const dropMergedPart = (st: SessionState, sid: string, messageID: string, partID: string) => {
1738
+ const e = st.merged.get(messageID)
1739
+ if (!e) return
1740
+ e.parts.delete(partID)
1741
+ if (e.info) emitMerged(st, sid, messageID)
1742
+ }
1743
+
1419
1744
  const remove = (st: SessionState) => {
1420
1745
  st.closed = true
1421
1746
  st.activated = false
@@ -1496,7 +1821,7 @@ export function createEngine(deps: EngineDeps): Engine {
1496
1821
 
1497
1822
  const query = () => (deps.directory ? { directory: deps.directory } : {})
1498
1823
 
1499
- async function fetchMessages(client: ClientApi, sid: string): Promise<Message[]> {
1824
+ async function fetchMessages(client: ClientApi, sid: string, onPage?: (page: Message[]) => void): Promise<Message[]> {
1500
1825
  if (typeof client.session.messages !== "function") return []
1501
1826
  const out: Message[] = []
1502
1827
  let before: string | undefined
@@ -1511,11 +1836,26 @@ export function createEngine(deps: EngineDeps): Engine {
1511
1836
  )
1512
1837
  if (!Array.isArray(result) || result.length === 0) break
1513
1838
  out.push(...(result as Message[]))
1839
+ // stream pages as they arrive: enqueue (and the rate-limited flush)
1840
+ // starts with page 1 while later pages are still loading — a huge
1841
+ // session backfills progressively instead of after a full collect
1842
+ if (onPage) {
1843
+ try {
1844
+ onPage(result as Message[])
1845
+ } catch {}
1846
+ }
1514
1847
  if (result.length < PAGE_SIZE) break
1515
1848
  const last = result[result.length - 1] as Message
1516
- const nextBefore =
1517
- typeof last?.info?.id === "string" ? (last.info.id as string) : typeof (last as unknown as { id?: string })?.id === "string" ? ((last as unknown as { id: string }).id as string) : undefined
1518
- if (!nextBefore || nextBefore === before) break
1849
+ const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
1850
+ const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
1851
+ // `before` is an OPAQUE cursor: base64url({id, time}) of the page
1852
+ // tail (message-v2.ts codec). A bare message id 400s on hosts with
1853
+ // cursor pagination — every v1 backfill silently truncated to one
1854
+ // 100-message page (live-caught).
1855
+ const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
1856
+ if (!lastID || typeof lastTime !== "number") break
1857
+ const nextBefore = cursorFor(lastID, lastTime)
1858
+ if (nextBefore === before) break
1519
1859
  before = nextBefore
1520
1860
  }
1521
1861
  return out
@@ -1525,27 +1865,32 @@ export function createEngine(deps: EngineDeps): Engine {
1525
1865
  const sessionInfoValue = info ?? (await sessionInfo(client, sid))
1526
1866
  if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
1527
1867
 
1528
- const messages = await fetchMessages(client, sid)
1529
1868
  let modelSeen = false
1530
- for (const message of messages) {
1531
- const m = (message.info ?? message) as MessageInfo
1532
- if (!m || typeof m.id !== "string") continue
1533
- enqueue(st, itemFor("message", m, sid))
1534
- if (m.role === "user" && m.model) {
1535
- modelSeen = true
1536
- const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model)
1537
- enqueue(
1538
- st,
1539
- itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid),
1540
- )
1541
- }
1542
- for (const part of message.parts ?? []) {
1543
- const p = part as PartInfo
1544
- if (!p || typeof p.id !== "string" || typeof p.messageID !== "string") continue
1545
- if (p.state?.status === "error") st.badSignal = true
1546
- enqueue(st, itemFor("part", p, sid))
1869
+ const consume = (messages: Message[]) => {
1870
+ for (const message of messages) {
1871
+ const m = ((message.info ?? message) as MessageInfo) as Record<string, unknown> | undefined
1872
+ if (!m || typeof m.id !== "string") continue
1873
+ // seed the merge state, then emit the assembled v2 message
1874
+ const e = mergeEntry(st, m.id)
1875
+ e.info = m
1876
+ for (const rawPart of (message.parts ?? []) as Array<Record<string, unknown>>) {
1877
+ const p = rawPart as PartInfo
1878
+ if (!p || typeof p.id !== "string" || typeof p.messageID !== "string") continue
1879
+ if (p.state?.status === "error") st.badSignal = true
1880
+ e.parts.set(p.id, p as Record<string, unknown>)
1881
+ }
1882
+ emitMerged(st, sid, m.id)
1883
+ if (m.role === "user" && m.model) {
1884
+ modelSeen = true
1885
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model as SessionModel)
1886
+ enqueue(
1887
+ st,
1888
+ itemFor("model", [{ id: (m.model as SessionModel).modelID, providerID: (m.model as SessionModel).providerID, name: label }], sid),
1889
+ )
1890
+ }
1547
1891
  }
1548
1892
  }
1893
+ const messages = await fetchMessages(client, sid, (page) => consume(page))
1549
1894
  if (!modelSeen && sessionInfoValue?.model) {
1550
1895
  // hosts whose user messages carry no model (v2): label from the
1551
1896
  // session's own model
@@ -1842,19 +2187,20 @@ export function createEngine(deps: EngineDeps): Engine {
1842
2187
  // opencode undo: revert() only marks the session (the viewer
1843
2188
  // hides the range); the NEXT prompt hard-deletes the reverted
1844
2189
  // messages/parts via cleanup() and publishes these events.
1845
- // The worker's store is upsert-only, so without an explicit
1846
- // tombstone the deleted items live forever and reappear the
1847
- // moment the revert marker clears (live-caught in production).
2190
+ // A message tombstone removes the whole v2 message (content is
2191
+ // embedded); a part removal re-emits the merged message minus
2192
+ // the part.
1848
2193
  const st = stFor(sid)
1849
2194
  if (!st || !st.activated || st.closed) return
1850
2195
  const messageID = p.messageID as string | undefined
1851
2196
  if (!messageID) return
1852
2197
  if (t === "message.removed") {
1853
- enqueue(st, { key: `message/${messageID}`, item: { type: "message", del: true, data: { id: messageID } } })
2198
+ dropMergedMessage(st, messageID)
2199
+ enqueue(st, { key: `message/${messageID}`, item: messageTombstone(st.sid, messageID) })
1854
2200
  } else {
1855
2201
  const partID = p.partID as string | undefined
1856
2202
  if (!partID) return
1857
- enqueue(st, { key: `part/${messageID}/${partID}`, item: { type: "part", del: true, data: { id: partID, messageID } } })
2203
+ dropMergedPart(st, sid, messageID, partID)
1858
2204
  }
1859
2205
  } else if (t === "message.updated") {
1860
2206
  // resumed sessions may emit the user message BEFORE any session.*
@@ -1907,7 +2253,7 @@ export function createEngine(deps: EngineDeps): Engine {
1907
2253
  }
1908
2254
  }
1909
2255
  if (!st || !st.activated || st.closed) return
1910
- enqueue(st, itemFor("message", p.info, sid))
2256
+ ingestMessageInfo(st, sid, p.info as Record<string, unknown>)
1911
2257
  const info = p.info as MessageInfo
1912
2258
  if (info?.role === "user" && info?.model) {
1913
2259
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
@@ -1916,8 +2262,9 @@ export function createEngine(deps: EngineDeps): Engine {
1916
2262
  // an assistant message that DIED (finish:"error") — e.g. all
1917
2263
  // retries exhausted — surfaces as a 200 response with an errored
1918
2264
  // message, NOT as session.error (live-caught in the suite); the
1919
- // share must still say error
1920
- if (info?.role === "assistant" && (info as { finish?: string }).finish === "error") {
2265
+ // share must still say error. Works for v2-native messages too
2266
+ // (finish lives on the message itself).
2267
+ if ((info as { finish?: string; role?: string })?.finish === "error") {
1921
2268
  st.badSignal = true
1922
2269
  }
1923
2270
  } else if (t === "message.part.updated") {
@@ -1925,11 +2272,11 @@ export function createEngine(deps: EngineDeps): Engine {
1925
2272
  if (!st || !st.activated || st.closed) return
1926
2273
  const part = p.part as PartInfo
1927
2274
  if (part?.state?.status === "error") st.badSignal = true
1928
- enqueue(st, itemFor("part", part, sid))
2275
+ ingestPart(st, sid, part as Record<string, unknown>)
1929
2276
  } else if (t === "session.diff") {
1930
2277
  const st = stFor(sid)
1931
2278
  if (!st || !st.activated || st.closed) return
1932
- enqueue(st, itemFor("session_diff", p.diff, sid))
2279
+ enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid))
1933
2280
  } else if (t === "session.deleted") {
1934
2281
  // a CHILD session ending (task done, gc) must NEVER take the
1935
2282
  // parent's share down with it — the page keeps the subagent's