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

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
@@ -43,8 +43,11 @@
43
43
  // secret must never reach viewers.
44
44
 
45
45
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
46
+ import { createHash } from "node:crypto"
46
47
  import { join } from "node:path"
47
48
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
49
+ import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
50
+ import { queueBindingWrite } from "./binding-queue.js"
48
51
 
49
52
  // --- minimal structural types (hand-written: the standalone bundle must
50
53
  // not depend on the plugin SDK at runtime; shapes verified against
@@ -106,13 +109,21 @@ type RemoteState = {
106
109
  initTimer: ReturnType<typeof setTimeout> | null
107
110
  initWasAttach: boolean
108
111
  backoff: number
112
+ /** deferred backoff reset: released after 30s of socket stability or a
113
+ * second acked sync — never right after init (init-then-drop servers
114
+ * would cycle at 1/s otherwise) */
115
+ backoffResetTimer: ReturnType<typeof setTimeout> | null
109
116
  reconnectTimer: ReturnType<typeof setTimeout> | null
110
117
  intentionalClose: boolean
111
118
  connectQueued: boolean
112
119
  /** identity dead-end for THIS server (frozen owner, unenrollable
113
120
  * private server): stop reconnecting it; other servers unaffected. */
114
121
  authBlocked: boolean
115
- pending: Array<{ items: unknown[]; status: string }> // frames awaiting ack (spool)
122
+ pending: Array<{ items: unknown[]; status: string; seq?: number; wire?: string }> // chunk-frames awaiting ack (spool; wire = prebuilt frame string)
123
+ frozenRemote?: boolean // server froze the owner mid-session: stop writing
124
+ spoolAppendFailed?: boolean // last append errored: memory-only frame exists
125
+ spoolSaveTimer: ReturnType<typeof setTimeout> | null // debounced ack-path spool rewrite
126
+ authWaiter?: (() => void) | undefined // frame-auth handshake resolver
116
127
  announced: boolean
117
128
  settleTimer: ReturnType<typeof setTimeout> | null
118
129
  idleDropTimer: ReturnType<typeof setTimeout> | null
@@ -148,6 +159,16 @@ type SessionState = {
148
159
  /** any remote completed a handshake — used to detect LATER creates
149
160
  * (fan-out adds, post-mismatch recreates) which need a history backfill */
150
161
  everConnected: boolean
162
+ /** contract v2 merge state: v1 hosts stream parts separately; the v2
163
+ * item embeds content, so the engine holds the assembled view per
164
+ * message and re-emits the normalized whole on every update */
165
+ merged: Map<string, { info?: Record<string, unknown>; parts: Map<string, Record<string, unknown>> }>
166
+ /** content hash of the LAST item enqueued per key: identical re-feeds
167
+ * (v2 hosts re-sync the whole transcript on every refresh; unchanged
168
+ * session info on every session.updated) are skipped instead of being
169
+ * flushed to every remote, bumping rev and re-folding every viewer.
170
+ * Cleared on activation — re-activation re-backfills by design. */
171
+ sentHash: Map<string, string>
151
172
  }
152
173
 
153
174
  type Message = { info?: Record<string, unknown>; parts?: Record<string, unknown>[] }
@@ -239,10 +260,11 @@ type ItemEntry = { key: string; item: Record<string, unknown> }
239
260
  function itemFor(type: string, data: unknown, sessionID: string): ItemEntry | undefined {
240
261
  if (type === "model") return { key: `model/${sessionID}`, item: { type, sessionID, data } }
241
262
  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 } }
263
+ if (type === "session") return { key: `session/${(data as SessionInfo)?.id ?? ""}`, item: { type, sessionID, data } }
264
+ // v2 messages carry no sessionID inside data (v1 infos did); the item
265
+ // envelope stamps it so multi-session folds (parent + subagents) can
266
+ // group without guessing
267
+ if (type === "message") return { key: `message/${(data as MessageInfo)?.id ?? ""}`, item: { type, sessionID, data } }
246
268
  if (type === "session_diff") return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } }
247
269
  return undefined
248
270
  }
@@ -414,6 +436,8 @@ export function createEngine(deps: EngineDeps): Engine {
414
436
  initTimer: null,
415
437
  initWasAttach: false,
416
438
  backoff: 1000,
439
+ backoffResetTimer: null,
440
+ spoolSaveTimer: null,
417
441
  reconnectTimer: null,
418
442
  intentionalClose: false,
419
443
  connectQueued: false,
@@ -479,12 +503,19 @@ export function createEngine(deps: EngineDeps): Engine {
479
503
  if (b && typeof b === "object" && (b as { policy?: unknown }).policy) {
480
504
  applyPolicy((b as { policy: unknown }).policy, url)
481
505
  }
506
+ // frame auth capability: the server accepts the token as the
507
+ // first WS frame — lets us keep it off the query string (proxy
508
+ // and access logs otherwise capture the full credential)
509
+ if (b && typeof b === "object" && (b as { frameAuth?: unknown }).frameAuth) {
510
+ frameAuthUrls.add(url)
511
+ }
482
512
  })
483
513
  .catch(() => {
484
514
  // unreachable worker: defaults stay; the init ack will correct us
485
515
  policyAsked.delete(url)
486
516
  })
487
517
  }
518
+ const frameAuthUrls = new Set<string>()
488
519
  const wsUrl = (url: string) => url.replace(/^http/, "ws")
489
520
 
490
521
  // ---- spool root ---------------------------------------------------------
@@ -527,6 +558,8 @@ export function createEngine(deps: EngineDeps): Engine {
527
558
  activated: false,
528
559
  wroteBinding: false,
529
560
  everConnected: false,
561
+ merged: new Map(),
562
+ sentHash: new Map(),
530
563
  })
531
564
 
532
565
  const refreshConfig = () => {
@@ -565,6 +598,9 @@ export function createEngine(deps: EngineDeps): Engine {
565
598
  }
566
599
  st.activated = true
567
600
  st.opts = opts
601
+ // re-activation re-backfills the whole session (unshare -> share
602
+ // again): the enqueue dedupe must not skip the returning content
603
+ st.sentHash.clear()
568
604
  const gated = mode === "manual" || opts2?.gated === true
569
605
  if (gated) {
570
606
  // seed the share status from the session's CURRENT state before
@@ -640,26 +676,31 @@ export function createEngine(deps: EngineDeps): Engine {
640
676
 
641
677
  async function writeMetadata(st: SessionState, value: Record<string, unknown> | null) {
642
678
  if (!st.client) return
643
- try {
644
- const info = unwrap(
645
- await st.client.session.get({
646
- path: { id: st.sid },
647
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
648
- }),
649
- )
650
- const metadata = { ...(info?.metadata ?? {}) }
651
- if (value === null) delete metadata[METADATA_KEY]
652
- else metadata[METADATA_KEY] = value
653
- unwrap(
654
- await st.client.session.update({
655
- path: { id: st.sid },
656
- body: { metadata },
657
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
658
- }),
659
- )
660
- } catch (e) {
661
- log("metadata write failed (spool still covers reconnects):", (e as Error)?.message || e)
662
- }
679
+ // the read-modify-write runs inside the shared per-session queue: the
680
+ // TUI patches the same metadata (opt-outs, collisions) and interleaved
681
+ // RMWs silently lose one side's update (see binding-queue.ts)
682
+ await queueBindingWrite(st.sid, async () => {
683
+ try {
684
+ const info = unwrap(
685
+ await st.client!.session.get({
686
+ path: { id: st.sid },
687
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
688
+ }),
689
+ )
690
+ const metadata = { ...(info?.metadata ?? {}) }
691
+ if (value === null) delete metadata[METADATA_KEY]
692
+ else metadata[METADATA_KEY] = value
693
+ unwrap(
694
+ await st.client!.session.update({
695
+ path: { id: st.sid },
696
+ body: { metadata },
697
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
698
+ }),
699
+ )
700
+ } catch (e) {
701
+ log("metadata write failed (spool still covers reconnects):", (e as Error)?.message || e)
702
+ }
703
+ })
663
704
  }
664
705
 
665
706
  /** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
@@ -770,12 +811,51 @@ export function createEngine(deps: EngineDeps): Engine {
770
811
  const spoolSave = (st: SessionState, r: RemoteState) => {
771
812
  try {
772
813
  writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }))
773
- writeFileSync(queuePath(st, r.url), r.pending.map((f) => JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""))
814
+ // the queue line IS the wire frame when prebuilt (single
815
+ // serialization end-to-end); a pending frame without wire (legacy
816
+ // spool load) serializes here
817
+ writeFileSync(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""))
818
+ // the migrated content now lives in the new queue (or was acked):
819
+ // retire the single-server-era files so they don't accumulate forever
820
+ rmSync(legacyMetaPath(st), { force: true })
821
+ rmSync(legacyQueuePath(st), { force: true })
774
822
  } catch (e) {
775
823
  logError("spool save failed", (e as Error)?.message || e)
776
824
  }
777
825
  }
826
+ // hot path: appending one pending frame must not re-serialize the whole
827
+ // queue (O(n^2) during backfill bursts) — append the line instead
828
+ const spoolAppend = (st: SessionState, r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
829
+ try {
830
+ appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
831
+ } catch (e) {
832
+ // the frame lives ONLY in memory now — flag the remote so the next
833
+ // spoolLoad does NOT replace in-memory pending with the shorter
834
+ // disk queue (silent loss otherwise; live-caught class in review)
835
+ r.spoolAppendFailed = true
836
+ logError("spool append failed", (e as Error)?.message || e)
837
+ }
838
+ }
839
+ /** Debounced ack-path spool rewrite: one ack per chunk during a
840
+ * multi-chunk replay each rewrote the WHOLE queue file (O(n^2) —
841
+ * ~160MB of sync disk churn on a 25-chunk outage recovery). A crash
842
+ * inside the 300ms window only loses "these frames were acked" — the
843
+ * next connect replays idempotent keyed upserts, which the protocol
844
+ * already tolerates. init-ok keeps the immediate save (binding/
845
+ * recovery info). */
846
+ const scheduleSpoolSave = (st: SessionState, r: RemoteState) => {
847
+ if (r.spoolSaveTimer) return
848
+ r.spoolSaveTimer = setTimeout(() => {
849
+ r.spoolSaveTimer = null
850
+ if (!st.closed) spoolSave(st, r)
851
+ }, 300)
852
+ }
778
853
  const spoolLoad = (st: SessionState, r: RemoteState) => {
854
+ // meta first (share binding recovery), in its OWN try: a missing meta
855
+ // file is normal (it's only written after the first successful
856
+ // handshake) and must NOT touch pending (live-caught class: the old
857
+ // blanket catch wiped in-memory pending AND skipped the queue read,
858
+ // so everything queued before the first successful connect was lost).
779
859
  try {
780
860
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
781
861
  // metadata binding (db) is authoritative when both exist and disagree
@@ -784,15 +864,40 @@ export function createEngine(deps: EngineDeps): Engine {
784
864
  r.secret = meta.secret
785
865
  r.ackedRev = meta.ackedRev ?? 0
786
866
  }
867
+ } catch {}
868
+ try {
787
869
  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))
870
+ // append-only spool: a crash mid-append can tear the LAST line —
871
+ // keep the valid prefix instead of dropping the whole queue.
872
+ // The disk queue is the authority: every in-memory frame was
873
+ // appended before it was tracked, so replacing pending with the
874
+ // parsed queue loses nothing. A FAILED read keeps in-memory
875
+ // pending untouched.
876
+ const disk: Array<{ items: unknown[]; status: string; seq?: number }> = []
877
+ for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
878
+ if (!line.trim()) continue
879
+ try {
880
+ disk.push(JSON.parse(line))
881
+ } catch {
882
+ log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`)
883
+ break
884
+ }
885
+ }
886
+ // seq hygiene: re-stamp pre-seq spool lines (an ack without seq
887
+ // would otherwise clear ALL pending — the H2 wipe, once per
888
+ // upgrade) and seed the counter past every restored seq (a
889
+ // restart resetting to 0 would collide new frames with old ones)
890
+ for (const f of disk) {
891
+ if (typeof f.seq !== "number") f.seq = ++nextSeq
892
+ else if (f.seq >= nextSeq) nextSeq = f.seq + 1
893
+ }
894
+ // a failed append means the disk queue can be SHORTER than
895
+ // memory — the disk is only authoritative when every append
896
+ // landed (otherwise the replace would drop the memory-only frame)
897
+ if (!r.spoolAppendFailed) r.pending = disk
898
+ else r.spoolAppendFailed = false
792
899
  }
793
- } catch {
794
- r.pending = []
795
- }
900
+ } catch {}
796
901
  // single-server-era spool (no url suffix): belongs to the first target
797
902
  if (!r.shareId && targets[0]?.url === r.url) {
798
903
  try {
@@ -814,12 +919,27 @@ export function createEngine(deps: EngineDeps): Engine {
814
919
  }
815
920
  const spoolClear = (st: SessionState, r: RemoteState) => {
816
921
  try {
922
+ if (r.spoolSaveTimer) {
923
+ clearTimeout(r.spoolSaveTimer)
924
+ r.spoolSaveTimer = null
925
+ }
817
926
  rmSync(metaPath(st, r.url), { force: true })
818
927
  rmSync(queuePath(st, r.url), { force: true })
928
+ // single-server-era files die with the session too
929
+ rmSync(legacyMetaPath(st), { force: true })
930
+ rmSync(legacyQueuePath(st), { force: true })
819
931
  } catch {}
820
932
  }
821
933
 
822
934
  // ---- websocket transport (per remote) ----------------------------------
935
+ /** Monotonic client frame sequence: every chunk-frame gets one; the
936
+ * server echoes it in the ack, and the plugin clears exactly that
937
+ * chunk from pending. (Live-caught class: one logical frame split into
938
+ * N chunks — the worker acks EACH chunk, and a blanket "pending = []"
939
+ * on the first ack erased chunks 2..N; a socket death right there lost
940
+ * them permanently.) */
941
+ let nextSeq = 0
942
+
823
943
  const wsSend = (r: RemoteState, obj: unknown) => {
824
944
  if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${(obj as { op?: string })?.op} (ready=${r.wsReady})`)
825
945
  try {
@@ -830,15 +950,66 @@ export function createEngine(deps: EngineDeps): Engine {
830
950
  return true
831
951
  }
832
952
 
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
837
- const sendSync = (r: RemoteState, f: { items: unknown[]; status: string }) => {
838
- 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 })
953
+ /** Sync frames are chunked by BOTH item count and serialized bytes.
954
+ * Count-only chunking could pack 200 large tool outputs into a frame
955
+ * that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
956
+ * default) and die wholesale. Budget keeps every frame cheap to apply
957
+ * and rebroadcast; worker apply is keyed-idempotent, so chunks just
958
+ * bump rev. An item larger than the whole budget rides alone in its
959
+ * own frame (the worker fits oversize items to its storage cap).
960
+ * Status rides each chunk; last write wins.
961
+ * Returns each chunk's PREBUILT items JSON: the wire frame and the
962
+ * spool line are derived from it by concatenation — every item used
963
+ * to be serialized 3-5x per remote per flush (chunk measure, spool
964
+ * append, ws send, sendSync re-chunk). */
965
+ const chunkedFrames = (items: unknown[], maxItems: number, maxBytes: number): Array<{ items: unknown[]; json: string }> => {
966
+ const chunks: Array<{ items: unknown[]; json: string }> = []
967
+ let cur: unknown[] = []
968
+ let curJson: string[] = []
969
+ let curBytes = 0
970
+ for (const it of items) {
971
+ const j = JSON.stringify(it) ?? "null"
972
+ if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
973
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
974
+ cur = []
975
+ curJson = []
976
+ curBytes = 0
977
+ }
978
+ cur.push(it)
979
+ curJson.push(j)
980
+ curBytes += j.length
981
+ }
982
+ if (cur.length) chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" })
983
+ return chunks
984
+ }
985
+ /** legacy chunker (count/byte split without prebuilt JSON) — replay
986
+ * paths that only need the split */
987
+ const chunkItems = (items: unknown[], maxItems: number, maxBytes: number): unknown[][] =>
988
+ chunkedFrames(items, maxItems, maxBytes).map((c) => c.items)
989
+ /** the wire sync frame, built by concatenation over the prebuilt items
990
+ * JSON — byte-identical to JSON.stringify({op,items,status,seq}) */
991
+ const syncFrameStr = (itemsJson: string, status: string, seq: number | undefined) =>
992
+ `{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`
993
+ const wsSendRaw = (r: RemoteState, s: string) => {
994
+ if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`)
995
+ try {
996
+ r.ws?.send(s)
997
+ } catch {
998
+ return false
999
+ }
1000
+ return true
1001
+ }
1002
+ const sendSync = (r: RemoteState, f: { items: unknown[]; status: string; seq?: number; wire?: string }) => {
1003
+ if (!f.items.length) return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq })
1004
+ // prebuilt wire (the hot flush path): one serialization total
1005
+ if (f.wire) return wsSendRaw(r, f.wire)
1006
+ // replayed/legacy frames: chunk-shaped by construction — only an
1007
+ // oversize one (a pre-chunking spool line) needs re-splitting
1008
+ const itemsJson = JSON.stringify(f.items)
1009
+ if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
1010
+ return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq))
841
1011
  }
1012
+ for (const c of chunkedFrames(f.items, 200, 512 * 1024)) wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq))
842
1013
  return true
843
1014
  }
844
1015
 
@@ -1017,7 +1188,12 @@ export function createEngine(deps: EngineDeps): Engine {
1017
1188
  logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`)
1018
1189
  return
1019
1190
  }
1020
- const ws = new WebSocket(`${wsUrl(r.url)}/api/ws?token=${encodeURIComponent(token ?? "")}`)
1191
+ // frame auth: when the server advertises it, keep the token off the
1192
+ // URL (query strings land in proxy/access logs). The auth frame
1193
+ // rides the socket in order — the server sets its identity before
1194
+ // our init arrives. Old servers fall back to ?token=.
1195
+ const useFrameAuth = !!token && frameAuthUrls.has(r.url)
1196
+ const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`)
1021
1197
  r.ws = ws
1022
1198
  await new Promise((resolve, reject) => {
1023
1199
  const t = setTimeout(() => reject(new Error("ws open timeout")), 15000)
@@ -1055,6 +1231,31 @@ export function createEngine(deps: EngineDeps): Engine {
1055
1231
  if (!st.closed) scheduleReconnect(st, r)
1056
1232
  })
1057
1233
 
1234
+ if (useFrameAuth) {
1235
+ wsSend(r, { op: "auth", token })
1236
+ // the auth and init frames can interleave in the worker's
1237
+ // concurrent webSocketMessage invocations — wait for the authed
1238
+ // ack before authenticating anything (timeout -> fall back to a
1239
+ // query-token reconnect)
1240
+ const authed = await new Promise<boolean>((resolve) => {
1241
+ const t = setTimeout(() => resolve(false), 3000)
1242
+ r.authWaiter = () => {
1243
+ clearTimeout(t)
1244
+ resolve(true)
1245
+ }
1246
+ })
1247
+ r.authWaiter = undefined
1248
+ if (!authed) {
1249
+ logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`)
1250
+ frameAuthUrls.delete(r.url)
1251
+ try {
1252
+ ws.close(4000, "auth timeout")
1253
+ } catch {}
1254
+ if (!st.closed) scheduleReconnect(st, r)
1255
+ return
1256
+ }
1257
+ }
1258
+
1058
1259
  if (r.initWasAttach) {
1059
1260
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
1060
1261
  } else {
@@ -1084,7 +1285,9 @@ export function createEngine(deps: EngineDeps): Engine {
1084
1285
 
1085
1286
  const scheduleReconnect = (st: SessionState, r: RemoteState) => {
1086
1287
  if (st.closed || r.reconnectTimer) return
1087
- const wait = r.backoff
1288
+ // jitter (±20%) so fan-out remotes and multiple sessions never sync
1289
+ // their retry storms onto the same second
1290
+ const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)))
1088
1291
  r.backoff = Math.min(r.backoff * 2, 60000)
1089
1292
  r.reconnectTimer = setTimeout(() => {
1090
1293
  r.reconnectTimer = null
@@ -1170,6 +1373,15 @@ export function createEngine(deps: EngineDeps): Engine {
1170
1373
  }
1171
1374
 
1172
1375
  const handleFrame = async (st: SessionState, r: RemoteState, frame: Record<string, unknown>) => {
1376
+ if (frame.op === "authed") {
1377
+ // frame-auth handshake ack: release the init (the auth and init
1378
+ // frames can interleave server-side — webSocketMessage invocations
1379
+ // run concurrently — so the init must WAIT for this, else it
1380
+ // authenticates as anonymous/tokenless)
1381
+ r.authWaiter?.()
1382
+ r.authWaiter = undefined
1383
+ return
1384
+ }
1173
1385
  if (frame.op === "init") {
1174
1386
  if (!frame.ok) {
1175
1387
  const err = String(frame.error ?? "")
@@ -1223,6 +1435,19 @@ export function createEngine(deps: EngineDeps): Engine {
1223
1435
  // bad base (mismatch protocol). Content REPLAYS to the new share —
1224
1436
  // pending is never dropped on a reset (live-caught: the recreate
1225
1437
  // husk had the title but zero messages).
1438
+ if (/limit|too many|rate/i.test(err)) {
1439
+ // quota/429 ("max shares reached", "daily create limit", the
1440
+ // anonymous caps): an immediate re-init on the same socket is a
1441
+ // hot loop against the server — back off through the normal
1442
+ // reconnect cadence instead
1443
+ logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
1444
+ r.connecting = false
1445
+ try {
1446
+ r.ws?.close(4000, "rate limited")
1447
+ } catch {}
1448
+ if (!st.closed) scheduleReconnect(st, r)
1449
+ return
1450
+ }
1226
1451
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
1227
1452
  deleteAbandoned(r)
1228
1453
  r.shareId = null
@@ -1253,11 +1478,23 @@ export function createEngine(deps: EngineDeps): Engine {
1253
1478
  r.connecting = false
1254
1479
  r.initWasAttach = false
1255
1480
  r.collided = null
1481
+ // a successful handshake proves write access: an admin unfreeze
1482
+ // revives the remote from here (frozenRemote is otherwise sticky)
1483
+ if (r.frozenRemote && !frame.error) r.frozenRemote = false
1256
1484
  if (r.initTimer) {
1257
1485
  clearTimeout(r.initTimer)
1258
1486
  r.initTimer = null
1259
1487
  }
1260
- r.backoff = 1000
1488
+ // backoff reset is DEFERRED until the socket proves stable: a
1489
+ // server that accepts init then immediately drops would otherwise
1490
+ // reconnect once per second forever (~1 cycle/s per session-server
1491
+ // pair, live-caught in the optimization audit). 30s alive (socket
1492
+ // still ready, no reconnect pending) or a second acked sync.
1493
+ if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
1494
+ r.backoffResetTimer = setTimeout(() => {
1495
+ r.backoffResetTimer = null
1496
+ if (r.wsReady && !r.reconnectTimer) r.backoff = 1000
1497
+ }, 30000)
1261
1498
  st.everConnected = true
1262
1499
  if (frame.policy) applyPolicy(frame.policy, r.url)
1263
1500
  r.intentionalClose = false
@@ -1279,19 +1516,67 @@ export function createEngine(deps: EngineDeps): Engine {
1279
1516
  if (wasFanoutCreate) {
1280
1517
  // a server joined an ALREADY-live session (fan-out add, or a
1281
1518
  // post-mismatch recreate): its fresh share needs the full history —
1282
- // backfill is keyed-idempotent, other servers just re-upsert
1519
+ // backfill is keyed-idempotent, other servers just re-upsert.
1520
+ // The enqueue dedupe must step aside: those hashes say "already
1521
+ // entered the machinery", but the NEW remote never received any
1522
+ // of it (live-caught by F3: the join's backfill was fully
1523
+ // deduped away and the joined share stayed empty).
1524
+ st.sentHash.clear()
1283
1525
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
1284
1526
  }
1285
1527
  return
1286
1528
  }
1287
1529
  if (frame.op === "ack") {
1288
1530
  r.ackedRev = (frame.rev as number) ?? r.ackedRev
1289
- r.pending = []
1290
- spoolSave(st, r)
1531
+ // a second acked sync also proves the socket stable — release the
1532
+ // backoff reset timer early (complements the 30s-alive timer)
1533
+ if (r.backoffResetTimer) {
1534
+ clearTimeout(r.backoffResetTimer)
1535
+ r.backoffResetTimer = null
1536
+ r.backoff = 1000
1537
+ }
1538
+ // seq-scoped retirement: an ack identifies the chunk it applied.
1539
+ // An ack WITHOUT seq = old server: legacy clear-all (correct there —
1540
+ // old servers acked the whole pending batch together).
1541
+ if (typeof frame.seq === "number") {
1542
+ r.pending = r.pending.filter((p) => p.seq !== frame.seq)
1543
+ } else {
1544
+ r.pending = []
1545
+ }
1546
+ scheduleSpoolSave(st, r)
1291
1547
  scheduleBindingWrite(st)
1292
1548
  return
1293
1549
  }
1294
1550
  if (frame.op === "nack" || frame.op === "error") {
1551
+ const errText = String(frame.error ?? "")
1552
+ if (frame.op === "nack") {
1553
+ // classify: the share dying server-side under a live socket
1554
+ // (sweeper TTL, admin delete, purge) surfaces as nacks — the
1555
+ // socket is "healthy" so nothing else recovers it. Same reset as
1556
+ // the init-refused path: fresh create + content replay.
1557
+ if (/not found|gone/i.test(errText)) {
1558
+ logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`)
1559
+ deleteAbandoned(r)
1560
+ r.shareId = null
1561
+ r.secret = null
1562
+ r.ackedRev = 0
1563
+ try {
1564
+ r.ws?.close(4000, "share gone")
1565
+ } catch {}
1566
+ if (!st.closed) queueConnect(st, r)
1567
+ return
1568
+ }
1569
+ if (/frozen/i.test(errText)) {
1570
+ // owner frozen mid-session: every write is refused. Stop
1571
+ // spooling NEW frames for this remote (unbounded growth) but
1572
+ // KEEP what's already queued — an unfreeze revives the remote
1573
+ // on the next successful handshake (init-ok clears the flag)
1574
+ // and the backlog replays from there.
1575
+ r.frozenRemote = true
1576
+ logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`)
1577
+ return
1578
+ }
1579
+ }
1295
1580
  logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200))
1296
1581
  // the control socket lost its attach state (worker-side eviction of
1297
1582
  // the control DO): force a reconnect — init attach replays pending
@@ -1323,6 +1608,10 @@ export function createEngine(deps: EngineDeps): Engine {
1323
1608
 
1324
1609
  // ---- flush -------------------------------------------------------------
1325
1610
  const frameFrom = (st: SessionState) => {
1611
+ // stamps run at frame time: the whole family's data is indexed by now
1612
+ // (child prompts are created during the task call, well inside the
1613
+ // flush debounce), and any re-enqueued message lands in this frame
1614
+ stampsPass(st)
1326
1615
  const items = Array.from(st.queue.values())
1327
1616
  st.queue.clear()
1328
1617
  const statusChanged = st.status !== st.lastSentStatus
@@ -1344,51 +1633,115 @@ export function createEngine(deps: EngineDeps): Engine {
1344
1633
  if (!f) return
1345
1634
  // fan out to every remote; each carries its own pending copy and acks
1346
1635
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1636
+ // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1637
+ // chunk-frames so a partial-ack can never erase unsent chunks
1638
+ // (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
1639
+ const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
1347
1640
  let httpWork: Promise<unknown> | undefined
1348
1641
  for (const r of st.remotes.values()) {
1642
+ if (r.frozenRemote) continue // refused server-side; stop spooling
1643
+ const fresh: Array<{ items: unknown[]; status: string; seq: number; wire?: string }> = []
1644
+ if (chunks.length) {
1645
+ for (const c of chunks) {
1646
+ const seq = ++nextSeq
1647
+ const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) }
1648
+ fresh.push(cf)
1649
+ r.pending.push(cf)
1650
+ spoolAppend(st, r, cf)
1651
+ }
1652
+ } else {
1653
+ // status-only frame (empty items): one chunk-less frame
1654
+ const seq = ++nextSeq
1655
+ const cf = { items: [] as unknown[], status: f.status, seq }
1656
+ fresh.push(cf)
1657
+ r.pending.push(cf)
1658
+ spoolAppend(st, r, cf)
1659
+ }
1349
1660
  // never send syncs before the init ack: the worker's per-socket
1350
1661
  // ControlDO has no attach until OUR init completes, and a racing
1351
1662
  // sync dies as "unknown op" (live-caught: post-drop reconnects
1352
1663
  // lost whole turns). The init-ok handler flushes pending itself.
1353
- r.pending.push(f)
1354
- spoolSave(st, r)
1355
1664
  if (r.ws && !r.wsReady) continue
1356
1665
  if (r.wsReady) {
1357
- sendSync(r, f)
1666
+ for (const cf of fresh) sendSync(r, cf)
1358
1667
  continue
1359
1668
  }
1360
1669
  const viaHttp = flushViaHttp(st, r)
1361
1670
  if (viaHttp) httpWork = (httpWork ?? Promise.resolve()).then(() => viaHttp)
1362
1671
  }
1363
1672
  if (st.remotes.size) st.lastFlushAt = Date.now()
1673
+ // the HTTP work runs detached from the flush chain (one dead server
1674
+ // must not stall the fan-out); dispose awaits its own final flush
1675
+ if (httpWork) void httpWork.catch((e) => logError("http flush error", (e as Error)?.message || e))
1364
1676
  return httpWork
1365
1677
  }
1366
1678
 
1679
+ /** Client-side key derivation mirroring the server's itemKey — used only
1680
+ * for deduping the HTTP fallback batch (keyed upserts: the LAST item
1681
+ * for a key wins, matching apply() semantics). */
1682
+ const httpDedupeKey = (it: unknown): string | null => {
1683
+ if (!it || typeof it !== "object") return null
1684
+ const o = it as { type?: string; data?: { id?: string; messageID?: string; sessionID?: string }; sessionID?: string }
1685
+ switch (o.type) {
1686
+ case "session": return "session/" + (o.data?.id ?? "")
1687
+ case "session_diff": return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "")
1688
+ case "message": return "message/" + (o.data?.id ?? "")
1689
+ case "part": return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "")
1690
+ case "model": return "model/" + (o.sessionID ?? o.data?.sessionID ?? "")
1691
+ case "prefs": return "prefs"
1692
+ default: return null
1693
+ }
1694
+ }
1695
+
1367
1696
  const flushViaHttp = async (st: SessionState, r: RemoteState) => {
1368
1697
  if (!r.shareId || !r.pending.length) return
1369
1698
  // ALL pending frames, merged (keyed upserts — later wins). Sending
1370
1699
  // only the last frame dropped every earlier batch's items whenever
1371
1700
  // the HTTP fallback carried more than one (live-caught class:
1372
- // content loss on reconnect paths).
1373
- const items: unknown[] = []
1701
+ // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1702
+ // a streaming message appearing in k pending frames shipped k
1703
+ // copies (up to 25x on a replay burst) — every duplicate was also a
1704
+ // server row-write + a rebroadcast frame.
1705
+ const byKey = new Map<string, unknown>()
1706
+ const unkeyed: unknown[] = []
1374
1707
  let status: string | undefined
1708
+ const sentSeqs = new Set<number>()
1375
1709
  for (const f of r.pending) {
1376
- for (const it of f.items as unknown[]) items.push(it)
1710
+ for (const it of f.items as unknown[]) {
1711
+ const k = httpDedupeKey(it)
1712
+ if (k) byKey.set(k, it)
1713
+ else unkeyed.push(it)
1714
+ }
1377
1715
  if (f.status) status = f.status
1716
+ if (typeof f.seq === "number") sentSeqs.add(f.seq)
1378
1717
  }
1718
+ const items = [...byKey.values(), ...unkeyed]
1379
1719
  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) {
1720
+ // chunked like WS frames: one unbounded body is a whole-backlog
1721
+ // bomb after a mid-backfill socket drop (backfill = full history).
1722
+ // Failure stops the loop with pending intact — replaying already-
1723
+ // applied chunks is idempotent.
1724
+ // status-only frames (items empty, e.g. the terminal completed
1725
+ // flush in HTTP-only mode) still need ONE request or the status
1726
+ // never lands (the share stays "live" forever)
1727
+ const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]]
1728
+ for (const chunk of chunks) {
1729
+ const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1730
+ method: "POST",
1731
+ headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1732
+ body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
1733
+ signal: AbortSignal.timeout(15000),
1734
+ }).catch(() => null)
1735
+ if (!res || !res.ok) return
1387
1736
  const body = (await res.json().catch(() => ({}))) as { rev?: number }
1388
1737
  r.ackedRev = body.rev ?? r.ackedRev
1389
- r.pending = []
1390
- spoolSave(st, r)
1391
1738
  }
1739
+ // retire EXACTLY what this run sent: a concurrent flush may have
1740
+ // queued new frames while our fetches were in flight (the HTTP work
1741
+ // is deliberately detached from the flush chain) — a blanket clear
1742
+ // would erase them before anything transmitted them
1743
+ r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq as number))
1744
+ scheduleSpoolSave(st, r)
1392
1745
  }
1393
1746
 
1394
1747
  const scheduleFlush = (st: SessionState) => {
@@ -1403,19 +1756,350 @@ export function createEngine(deps: EngineDeps): Engine {
1403
1756
  st.timer = setTimeout(() => {
1404
1757
  st.timer = null
1405
1758
  if (debugEvents) log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`)
1406
- chain = chain.then(() => flush(st)).catch((e) => logError("flush error", (e as Error)?.message || e))
1759
+ // NOTE: the flush's HTTP-fallback promise is deliberately NOT
1760
+ // chained — one black-holing server (15s timeout per chunk) must
1761
+ // not stall every other remote's flushes behind it. Dispose awaits
1762
+ // its own final flush explicitly.
1763
+ chain = chain
1764
+ .then(() => {
1765
+ flush(st)
1766
+ })
1767
+ .catch((e) => logError("flush error", (e as Error)?.message || e))
1407
1768
  }, wait)
1408
1769
  }
1409
1770
 
1410
1771
  const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
1411
1772
  if (!st || !entry) return
1773
+ // content dedupe at the machinery boundary: an identical re-feed has
1774
+ // either entered the queue already or been flushed into every
1775
+ // remote's pending/spool (the per-remote delivery machinery owns it
1776
+ // from there) — re-sending it bumps rev, rewrites storage and
1777
+ // re-folds every viewer for nothing. This is the engine-side home
1778
+ // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1779
+ // dormant and skipped the flag-time content — live-caught).
1780
+ let h: string
1781
+ try {
1782
+ h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
1783
+ } catch {
1784
+ h = ""
1785
+ }
1786
+ if (h && st.sentHash.get(entry.key) === h) {
1787
+ if (debugEvents) log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`)
1788
+ return
1789
+ }
1790
+ if (h) st.sentHash.set(entry.key, h)
1412
1791
  st.queue.set(entry.key, entry.item)
1792
+ indexMessage(entry)
1413
1793
  if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
1414
1794
  touch(st)
1415
1795
  ensureConnected(st)
1416
1796
  scheduleFlush(st)
1417
1797
  }
1418
1798
 
1799
+ // ---- authoritative stamps ----------------------------------------------
1800
+ // The viewer renders stamped facts and never re-derives them (owner
1801
+ // directive). Both stamps are computed at frame time across the whole
1802
+ // share family - the engine sees every session of the family regardless
1803
+ // of host generation, so this is the only place with the full picture.
1804
+
1805
+ type IndexedMessage = {
1806
+ sid: string
1807
+ role: string
1808
+ agent?: string
1809
+ created: number
1810
+ data: Record<string, unknown>
1811
+ }
1812
+ const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
1813
+ /** message ids tombstoned this run: stampsPass must never re-upsert an
1814
+ * undone message (live-caught class: dropMergedMessage removed the
1815
+ * merged entry but msgIndex kept the corpse; a neighboring-segment
1816
+ * change recomputed its stamps and reEnqueueMessage resurrected the
1817
+ * deleted turn server-side). */
1818
+ const tombstoned = new Set<string>()
1819
+
1820
+ const indexMessage = (entry: ItemEntry) => {
1821
+ if (entry.item.type !== "message") return
1822
+ const d = entry.item.data as Record<string, any> | undefined
1823
+ if (!d || typeof d.id !== "string") return
1824
+ const role = String(d.type ?? "")
1825
+ if (role !== "user" && role !== "assistant") return
1826
+ const key = `${entry.item.sessionID}:${d.id}`
1827
+ const prev = msgIndex.get(key)
1828
+ msgIndex.set(key, {
1829
+ sid: String(entry.item.sessionID ?? ""),
1830
+ role: role as string,
1831
+ agent:
1832
+ role === "assistant"
1833
+ ? typeof d.agent === "string"
1834
+ ? d.agent
1835
+ : prev?.agent
1836
+ : typeof d.metadata?.agent === "string"
1837
+ ? d.metadata.agent
1838
+ : prev?.agent,
1839
+ created: Number(d.time?.created ?? prev?.created ?? 0),
1840
+ data: d as Record<string, unknown>,
1841
+ })
1842
+ }
1843
+
1844
+ const sessionStateOf = (sid: string): SessionState | undefined =>
1845
+ sessions.get(childToParent.get(sid) ?? sid)
1846
+
1847
+ const reEnqueueMessage = (m: IndexedMessage) => {
1848
+ // tombstone guard: an undone message stays dead even if a stamp pass
1849
+ // recomputes its segment metadata
1850
+ if (tombstoned.has(`${m.sid}:${m.data.id}`)) return
1851
+ const st = sessionStateOf(m.sid)
1852
+ if (st) enqueue(st, itemFor("message", m.data, m.sid))
1853
+ }
1854
+
1855
+ /** segment agent for a user prompt: the prompt's own recorded agent (v1
1856
+ * hosts stamp it on every user message), else the first assistant
1857
+ * response inside the segment (v2 hosts), else carry-forward. */
1858
+ const segmentAgent = (ordered: IndexedMessage[], at: number): string | undefined => {
1859
+ const own = ordered[at]?.agent
1860
+ if (own) return own
1861
+ for (let i = at + 1; i < ordered.length; i++) {
1862
+ if (ordered[i].role === "assistant" && ordered[i].agent) return ordered[i].agent
1863
+ if (ordered[i].role === "user") break // next segment already began
1864
+ }
1865
+ return undefined
1866
+ }
1867
+
1868
+ /** user messages whose segment runs a DIFFERENT agent than the previous
1869
+ * segment carry metadata.agentSwitch = <agent> - the viewer renders the
1870
+ * transition chip from the stamp alone. */
1871
+ const stampAgentSwitches = (bySession: Map<string, IndexedMessage[]>) => {
1872
+ for (const [, list] of bySession) {
1873
+ list.sort((a, b) => a.created - b.created)
1874
+ const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0)
1875
+ let prev: string | undefined
1876
+ for (let k = 0; k < users.length; k++) {
1877
+ const agent = segmentAgent(list, users[k])
1878
+ const d = list[users[k]].data as Record<string, any>
1879
+ const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
1880
+ // only touch metadata when the stamp actually CHANGES — a blanket
1881
+ // `d.metadata ??= {}` polluted every user message (including the
1882
+ // host's live objects on v2 pass-through) with an empty metadata
1883
+ // object, which re-emitted the message once per refresh (rev churn
1884
+ // per feed — live-caught by the enqueue-dedupe rev check)
1885
+ if (want !== (d.metadata?.agentSwitch as string | undefined)) {
1886
+ if (want) {
1887
+ const meta = (d.metadata ??= {}) as Record<string, unknown>
1888
+ meta.agentSwitch = want
1889
+ } else if (d.metadata) {
1890
+ delete d.metadata.agentSwitch
1891
+ }
1892
+ reEnqueueMessage(list[users[k]])
1893
+ }
1894
+ if (agent) prev = agent
1895
+ }
1896
+ }
1897
+ }
1898
+
1899
+ /** task/subagent tool calls get metadata.agent = the agent that actually
1900
+ * ran for THAT call: the child's first prompt created at/after the call
1901
+ * started (v1 prompts carry the agent; v2 resolves via the response).
1902
+ * Rank-free pairing - a resumed child's second call lands on its second
1903
+ * prompt by construction time. */
1904
+ const stampTaskAgents = (bySession: Map<string, IndexedMessage[]>) => {
1905
+ for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
1906
+ for (const m of msgIndex.values()) {
1907
+ if (m.role !== "assistant") continue
1908
+ const content = (m.data as any).content
1909
+ if (!Array.isArray(content)) continue
1910
+ let mutated = false
1911
+ for (const part of content) {
1912
+ if (!part || part.type !== "tool") continue
1913
+ if (part.name !== "task" && part.name !== "subagent") continue
1914
+ const state = part.state
1915
+ const meta = state?.metadata
1916
+ const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined
1917
+ if (!childSid || typeof meta.agent === "string") continue
1918
+ const partStart = Number(part.time?.created ?? 0)
1919
+ const child = bySession.get(childSid)
1920
+ if (!child) continue
1921
+ let agent: string | undefined
1922
+ for (const c of child) {
1923
+ if (c.created < partStart) continue
1924
+ if (c.role === "user" && c.agent) {
1925
+ agent = c.agent
1926
+ break
1927
+ }
1928
+ }
1929
+ if (!agent) {
1930
+ for (const c of child) {
1931
+ if (c.created < partStart) continue
1932
+ if (c.role === "assistant" && c.agent) {
1933
+ agent = c.agent
1934
+ break
1935
+ }
1936
+ }
1937
+ }
1938
+ if (agent) {
1939
+ meta.agent = agent
1940
+ mutated = true
1941
+ }
1942
+ }
1943
+ if (mutated) reEnqueueMessage(m)
1944
+ }
1945
+ }
1946
+
1947
+ /** file tools get metadata.language (shiki lang id, from the path the
1948
+ * call touched) - the viewer highlights from the stamp and never guesses
1949
+ * (owner directive; the viewer's extension map is an old-share fallback) */
1950
+ const LANG_BY_EXT: Record<string, string> = {
1951
+ ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1952
+ js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
1953
+ json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
1954
+ py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
1955
+ css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
1956
+ toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
1957
+ sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
1958
+ swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
1959
+ }
1960
+ const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
1961
+ const stampFileLanguages = () => {
1962
+ for (const m of msgIndex.values()) {
1963
+ if (m.role !== "assistant") continue
1964
+ const content = (m.data as any).content
1965
+ if (!Array.isArray(content)) continue
1966
+ let mutated = false
1967
+ for (const part of content) {
1968
+ if (!part || part.type !== "tool") continue
1969
+ if (!FILE_TOOLS.has(part.name)) continue
1970
+ const state = part.state
1971
+ const meta = state?.metadata
1972
+ if (!meta) continue
1973
+ const raw = state.input?.path ?? state.input?.filePath
1974
+ // language stamp (shiki id from the path)
1975
+ if (typeof meta.language !== "string" && typeof raw === "string") {
1976
+ const ext = raw.split(".").pop()?.toLowerCase() ?? ""
1977
+ const lang = LANG_BY_EXT[ext]
1978
+ if (lang) {
1979
+ meta.language = lang
1980
+ mutated = true
1981
+ }
1982
+ }
1983
+ // lineStart stamp: reads sliced from an offset keep REAL file line
1984
+ // numbers in the viewer's unified file card (pierre numbers from 1;
1985
+ // the viewer shifts by this stamp, authoritatively)
1986
+ if (typeof meta.lineStart !== "number" && part.name === "read") {
1987
+ const off = state.input?.offset
1988
+ if (typeof off === "number" && off >= 0 && off > 0) {
1989
+ meta.lineStart = off + 1
1990
+ mutated = true
1991
+ }
1992
+ }
1993
+ }
1994
+ if (mutated) reEnqueueMessage(m)
1995
+ }
1996
+ }
1997
+
1998
+ const stampsPass = (st: SessionState) => {
1999
+ // skip entirely when this flush carries no message items: every
2000
+ // index mutation rides a message item through the queue (inserts via
2001
+ // enqueue, deletes via tombstones — both are type "message"), so no
2002
+ // message in the queue means the stamps' inputs are unchanged. A
2003
+ // status-only/model/prefs/diff flush costs zero stamp work.
2004
+ let any = false
2005
+ for (const it of st.queue.values()) {
2006
+ if ((it as { type?: string }).type === "message") {
2007
+ any = true
2008
+ break
2009
+ }
2010
+ }
2011
+ if (!any) return
2012
+ // one grouping + one sort shared by both passes (each used to build
2013
+ // and sort its own identical bySession map per flush)
2014
+ const bySession = new Map<string, IndexedMessage[]>()
2015
+ for (const m of msgIndex.values()) {
2016
+ const list = bySession.get(m.sid)
2017
+ if (list) list.push(m)
2018
+ else bySession.set(m.sid, [m])
2019
+ }
2020
+ for (const list of bySession.values()) list.sort((a, b) => a.created - b.created)
2021
+ stampAgentSwitches(bySession)
2022
+ stampTaskAgents(bySession)
2023
+ stampFileLanguages()
2024
+ }
2025
+
2026
+ // ---- contract v2 message assembly --------------------------------------
2027
+ // v1 hosts stream parts as separate events; the v2 item embeds content.
2028
+ // The engine keeps the assembled view per message and re-emits the
2029
+ // normalized whole (the keyed queue dedupes across a debounce window,
2030
+ // so intermediate re-emissions collapse).
2031
+
2032
+ const mergeEntry = (st: SessionState, messageID: string) => {
2033
+ let e = st.merged.get(messageID)
2034
+ if (!e) {
2035
+ e = { parts: new Map() }
2036
+ st.merged.set(messageID, e)
2037
+ }
2038
+ return e
2039
+ }
2040
+
2041
+ /** (Re-)emit the v2 message item for a merged v1 message. */
2042
+ const emitMerged = (st: SessionState, sid: string, messageID: string) => {
2043
+ const e = st.merged.get(messageID)
2044
+ if (!e?.info) return // parts arriving before their message: wait
2045
+ const res = normalizeV1Message(e.info, [...e.parts.values()])
2046
+ if (!res) return
2047
+ if ("refill" in res) {
2048
+ // v1 compaction summary assistant → rewrite the parent compaction
2049
+ // message with the summary text; the summary message itself is not
2050
+ // emitted as its own item
2051
+ const parent = st.merged.get(res.refill)
2052
+ if (parent?.info) {
2053
+ const compaction = normalizeV1Message(parent.info, [...parent.parts.values()])
2054
+ if (compaction && "message" in compaction) {
2055
+ const msg = compaction.message as Record<string, unknown>
2056
+ msg.summary = res.summary
2057
+ enqueue(st, itemFor("message", msg, sid))
2058
+ }
2059
+ }
2060
+ return
2061
+ }
2062
+ enqueue(st, itemFor("message", res.message, sid))
2063
+ }
2064
+
2065
+ /** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
2066
+ * with v2-native messages (pass-through, no merge). */
2067
+ const ingestMessageInfo = (st: SessionState, sid: string, info: Record<string, unknown>) => {
2068
+ if (isV2NativeMessage(info)) {
2069
+ enqueue(st, itemFor("message", info, sid))
2070
+ return
2071
+ }
2072
+ if (typeof info?.id !== "string") return
2073
+ const e = mergeEntry(st, info.id)
2074
+ e.info = info
2075
+ emitMerged(st, sid, info.id)
2076
+ }
2077
+
2078
+ const ingestPart = (st: SessionState, sid: string, part: Record<string, unknown>) => {
2079
+ if (typeof part?.id !== "string" || typeof part.messageID !== "string") return
2080
+ const e = mergeEntry(st, part.messageID)
2081
+ e.parts.set(part.id, part)
2082
+ emitMerged(st, sid, part.messageID)
2083
+ }
2084
+
2085
+ const dropMergedMessage = (st: SessionState, messageID: string, evSid?: string) => {
2086
+ // the index corpse must go too: stampsPass iterates msgIndex, and a
2087
+ // re-stamp of a deleted message would re-upsert it (resurrection).
2088
+ // Key by the EVENT's session (children tombstone under their own sid;
2089
+ // the merged map is per main session)
2090
+ const sid = evSid ?? st.sid
2091
+ st.merged.delete(messageID)
2092
+ msgIndex.delete(`${sid}:${messageID}`)
2093
+ tombstoned.add(`${sid}:${messageID}`)
2094
+ }
2095
+
2096
+ const dropMergedPart = (st: SessionState, sid: string, messageID: string, partID: string) => {
2097
+ const e = st.merged.get(messageID)
2098
+ if (!e) return
2099
+ e.parts.delete(partID)
2100
+ if (e.info) emitMerged(st, sid, messageID)
2101
+ }
2102
+
1419
2103
  const remove = (st: SessionState) => {
1420
2104
  st.closed = true
1421
2105
  st.activated = false
@@ -1432,6 +2116,10 @@ export function createEngine(deps: EngineDeps): Engine {
1432
2116
  clearTimeout(r.initTimer)
1433
2117
  r.initTimer = null
1434
2118
  }
2119
+ if (r.backoffResetTimer) {
2120
+ clearTimeout(r.backoffResetTimer)
2121
+ r.backoffResetTimer = null
2122
+ }
1435
2123
  if (r.reconnectTimer) {
1436
2124
  clearTimeout(r.reconnectTimer)
1437
2125
  r.reconnectTimer = null
@@ -1441,8 +2129,28 @@ export function createEngine(deps: EngineDeps): Engine {
1441
2129
  clearTimeout(r.idleDropTimer)
1442
2130
  r.idleDropTimer = null
1443
2131
  }
1444
- if (r.wsReady) wsSend(r, { op: "delete" })
1445
- else if (r.shareId && r.secret) {
2132
+ // wsReady delete only when THIS remote still owns a share: a stray
2133
+ // child teardown clears shareId after adopting into the parent —
2134
+ // the ControlDO's server-side attach would otherwise delete the
2135
+ // very share the parent just inherited (live-caught adoption churn)
2136
+ if (r.wsReady && r.shareId) {
2137
+ wsSend(r, { op: "delete" })
2138
+ // backstop: the WS delete is fire-and-forget on the wire — a
2139
+ // socket dying right after leaves the page public until TTL. The
2140
+ // HTTP goodbye is idempotent (404/ok both fine).
2141
+ if (r.shareId && r.secret) {
2142
+ const sid = r.shareId
2143
+ const sec = r.secret
2144
+ setTimeout(() => {
2145
+ void fetch(`${r.url}/api/share/${sid}`, {
2146
+ method: "DELETE",
2147
+ headers: { "content-type": "application/json" },
2148
+ body: JSON.stringify({ secret: sec }),
2149
+ signal: AbortSignal.timeout(8000),
2150
+ }).catch(() => {})
2151
+ }, 2000)
2152
+ }
2153
+ } else if (r.shareId && r.secret) {
1446
2154
  // turn mode may have dropped the socket after the last settle; the
1447
2155
  // goodbye still must land — say it over HTTP
1448
2156
  void fetch(`${r.url}/api/share/${r.shareId}`, {
@@ -1461,6 +2169,15 @@ export function createEngine(deps: EngineDeps): Engine {
1461
2169
  }
1462
2170
  }, 1000)
1463
2171
  for (const r of st.remotes.values()) spoolClear(st, r)
2172
+ // memory hygiene: the session is gone — its index corpses, tombstone
2173
+ // guards and child routing must not linger (msgIndex/tombstoned are
2174
+ // engine-global and otherwise only grow; sentHash rides the session
2175
+ // state and dies with it)
2176
+ const stSid = st.sid
2177
+ const pfx = `${stSid}:`
2178
+ for (const k of msgIndex.keys()) if (k.startsWith(pfx)) msgIndex.delete(k)
2179
+ for (const k of tombstoned) if (k.startsWith(pfx)) tombstoned.delete(k)
2180
+ for (const [child, parent] of childToParent) if (parent === stSid || child === stSid) childToParent.delete(child)
1464
2181
  sessions.delete(st.sid)
1465
2182
  try {
1466
2183
  chain = chain.then(async () => {
@@ -1496,7 +2213,7 @@ export function createEngine(deps: EngineDeps): Engine {
1496
2213
 
1497
2214
  const query = () => (deps.directory ? { directory: deps.directory } : {})
1498
2215
 
1499
- async function fetchMessages(client: ClientApi, sid: string): Promise<Message[]> {
2216
+ async function fetchMessages(client: ClientApi, sid: string, onPage?: (page: Message[]) => void): Promise<Message[]> {
1500
2217
  if (typeof client.session.messages !== "function") return []
1501
2218
  const out: Message[] = []
1502
2219
  let before: string | undefined
@@ -1511,13 +2228,33 @@ export function createEngine(deps: EngineDeps): Engine {
1511
2228
  )
1512
2229
  if (!Array.isArray(result) || result.length === 0) break
1513
2230
  out.push(...(result as Message[]))
2231
+ // stream pages as they arrive: enqueue (and the rate-limited flush)
2232
+ // starts with page 1 while later pages are still loading — a huge
2233
+ // session backfills progressively instead of after a full collect
2234
+ if (onPage) {
2235
+ try {
2236
+ onPage(result as Message[])
2237
+ } catch {}
2238
+ }
1514
2239
  if (result.length < PAGE_SIZE) break
1515
2240
  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
2241
+ const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
2242
+ const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
2243
+ // `before` is an OPAQUE cursor: base64url({id, time}) of the page
2244
+ // tail (message-v2.ts codec). A bare message id 400s on hosts with
2245
+ // cursor pagination — every v1 backfill silently truncated to one
2246
+ // 100-message page (live-caught).
2247
+ const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
2248
+ if (!lastID || typeof lastTime !== "number") break
2249
+ const nextBefore = cursorFor(lastID, lastTime)
2250
+ if (nextBefore === before) break
1519
2251
  before = nextBefore
1520
2252
  }
2253
+ // the page cap is a hard truncation: surface it instead of silently
2254
+ // sharing a partial history (oldest pages dropped)
2255
+ if (out.length >= MAX_PAGES * PAGE_SIZE) {
2256
+ logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`)
2257
+ }
1521
2258
  return out
1522
2259
  }
1523
2260
 
@@ -1525,27 +2262,32 @@ export function createEngine(deps: EngineDeps): Engine {
1525
2262
  const sessionInfoValue = info ?? (await sessionInfo(client, sid))
1526
2263
  if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
1527
2264
 
1528
- const messages = await fetchMessages(client, sid)
1529
2265
  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))
2266
+ const consume = (messages: Message[]) => {
2267
+ for (const message of messages) {
2268
+ const m = ((message.info ?? message) as MessageInfo) as Record<string, unknown> | undefined
2269
+ if (!m || typeof m.id !== "string") continue
2270
+ // seed the merge state, then emit the assembled v2 message
2271
+ const e = mergeEntry(st, m.id)
2272
+ e.info = m
2273
+ for (const rawPart of (message.parts ?? []) as Array<Record<string, unknown>>) {
2274
+ const p = rawPart as PartInfo
2275
+ if (!p || typeof p.id !== "string" || typeof p.messageID !== "string") continue
2276
+ if (p.state?.status === "error") st.badSignal = true
2277
+ e.parts.set(p.id, p as Record<string, unknown>)
2278
+ }
2279
+ emitMerged(st, sid, m.id)
2280
+ if (m.role === "user" && m.model) {
2281
+ modelSeen = true
2282
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model as SessionModel)
2283
+ enqueue(
2284
+ st,
2285
+ itemFor("model", [{ id: (m.model as SessionModel).modelID, providerID: (m.model as SessionModel).providerID, name: label }], sid),
2286
+ )
2287
+ }
1547
2288
  }
1548
2289
  }
2290
+ const messages = await fetchMessages(client, sid, (page) => consume(page))
1549
2291
  if (!modelSeen && sessionInfoValue?.model) {
1550
2292
  // hosts whose user messages carry no model (v2): label from the
1551
2293
  // session's own model
@@ -1800,8 +2542,13 @@ export function createEngine(deps: EngineDeps): Engine {
1800
2542
  // runs still land their final status.
1801
2543
  if (st.timer) clearTimeout(st.timer)
1802
2544
  st.timer = null
2545
+ // detached flush (HTTP fallback off the shared chain — one
2546
+ // black-holing server must not stall the fan-out; same rule
2547
+ // as scheduleFlush)
1803
2548
  chain = chain
1804
- .then(() => flush(st))
2549
+ .then(() => {
2550
+ flush(st)
2551
+ })
1805
2552
  .catch((e) => logError("idle flush error", (e as Error)?.message || e))
1806
2553
  const epoch = st.statusEpoch
1807
2554
  const settle = () => {
@@ -1810,7 +2557,9 @@ export function createEngine(deps: EngineDeps): Engine {
1810
2557
  if (st.timer) clearTimeout(st.timer)
1811
2558
  st.timer = null
1812
2559
  chain = chain
1813
- .then(() => flush(st))
2560
+ .then(() => {
2561
+ flush(st)
2562
+ })
1814
2563
  .then(() => {
1815
2564
  armSettleDrop(st)
1816
2565
  })
@@ -1842,19 +2591,24 @@ export function createEngine(deps: EngineDeps): Engine {
1842
2591
  // opencode undo: revert() only marks the session (the viewer
1843
2592
  // hides the range); the NEXT prompt hard-deletes the reverted
1844
2593
  // 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).
2594
+ // A message tombstone removes the whole v2 message (content is
2595
+ // embedded); a part removal re-emits the merged message minus
2596
+ // the part.
1848
2597
  const st = stFor(sid)
1849
2598
  if (!st || !st.activated || st.closed) return
1850
2599
  const messageID = p.messageID as string | undefined
1851
2600
  if (!messageID) return
1852
2601
  if (t === "message.removed") {
1853
- enqueue(st, { key: `message/${messageID}`, item: { type: "message", del: true, data: { id: messageID } } })
2602
+ // key/tombstone by the EVENT's session id (a child's message
2603
+ // must not be keyed onto the parent — the worker deletes the
2604
+ // item but the viewer's tombstone key would miss and the
2605
+ // msgIndex corpse could re-upsert it)
2606
+ dropMergedMessage(st, messageID, sid)
2607
+ enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) })
1854
2608
  } else {
1855
2609
  const partID = p.partID as string | undefined
1856
2610
  if (!partID) return
1857
- enqueue(st, { key: `part/${messageID}/${partID}`, item: { type: "part", del: true, data: { id: partID, messageID } } })
2611
+ dropMergedPart(st, sid, messageID, partID)
1858
2612
  }
1859
2613
  } else if (t === "message.updated") {
1860
2614
  // resumed sessions may emit the user message BEFORE any session.*
@@ -1907,7 +2661,7 @@ export function createEngine(deps: EngineDeps): Engine {
1907
2661
  }
1908
2662
  }
1909
2663
  if (!st || !st.activated || st.closed) return
1910
- enqueue(st, itemFor("message", p.info, sid))
2664
+ ingestMessageInfo(st, sid, p.info as Record<string, unknown>)
1911
2665
  const info = p.info as MessageInfo
1912
2666
  if (info?.role === "user" && info?.model) {
1913
2667
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
@@ -1916,8 +2670,9 @@ export function createEngine(deps: EngineDeps): Engine {
1916
2670
  // an assistant message that DIED (finish:"error") — e.g. all
1917
2671
  // retries exhausted — surfaces as a 200 response with an errored
1918
2672
  // 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") {
2673
+ // share must still say error. Works for v2-native messages too
2674
+ // (finish lives on the message itself).
2675
+ if ((info as { finish?: string; role?: string })?.finish === "error") {
1921
2676
  st.badSignal = true
1922
2677
  }
1923
2678
  } else if (t === "message.part.updated") {
@@ -1925,11 +2680,11 @@ export function createEngine(deps: EngineDeps): Engine {
1925
2680
  if (!st || !st.activated || st.closed) return
1926
2681
  const part = p.part as PartInfo
1927
2682
  if (part?.state?.status === "error") st.badSignal = true
1928
- enqueue(st, itemFor("part", part, sid))
2683
+ ingestPart(st, sid, part as Record<string, unknown>)
1929
2684
  } else if (t === "session.diff") {
1930
2685
  const st = stFor(sid)
1931
2686
  if (!st || !st.activated || st.closed) return
1932
- enqueue(st, itemFor("session_diff", p.diff, sid))
2687
+ enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid))
1933
2688
  } else if (t === "session.deleted") {
1934
2689
  // a CHILD session ending (task done, gc) must NEVER take the
1935
2690
  // parent's share down with it — the page keeps the subagent's
@@ -1979,6 +2734,16 @@ export function createEngine(deps: EngineDeps): Engine {
1979
2734
  await flushViaHttp(st, r).catch(() => {})
1980
2735
  }
1981
2736
  }
2737
+ // resolve any pending debounced spool rewrite before exit: the
2738
+ // queue lines are already on disk (appends are sync), but the
2739
+ // meta's ackedRev and the compacted queue should be final
2740
+ for (const r of st.remotes.values()) {
2741
+ if (r.spoolSaveTimer) {
2742
+ clearTimeout(r.spoolSaveTimer)
2743
+ r.spoolSaveTimer = null
2744
+ spoolSave(st, r)
2745
+ }
2746
+ }
1982
2747
  if (st.metaTimer) clearTimeout(st.metaTimer)
1983
2748
  st.metaTimer = null
1984
2749
  await writeBinding(st).catch(() => {})