@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/README.md +36 -30
- package/ci/openshare.config.json +7 -2
- package/ci/openshare.js +625 -197
- package/dist/config.d.ts +5 -1
- package/dist/config.js +9 -2
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +411 -55
- package/dist/dialogs.js +38 -0
- package/dist/share-flow.js +2 -2
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -17
- package/dist/tui.js +73 -121
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +6 -121
- package/openshare.config.json +1 -1
- package/package.json +4 -2
- package/src/banner.txt +7 -4
- package/src/config.ts +8 -2
- package/src/contract.ts +320 -0
- package/src/core.ts +403 -56
- package/src/dialogs.tsx +34 -0
- package/src/share-flow.ts +2 -2
- package/src/sidebar.tsx +4 -21
- package/src/tui.tsx +1 -1
- package/src/v2-server.ts +7 -110
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
|
|
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 (
|
|
840
|
-
wsSend(r, { op: "sync", items:
|
|
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
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
if (
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
-
//
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|