@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/dist/core.js CHANGED
@@ -42,8 +42,11 @@
42
42
  // - The plugin strips this key from everything it syncs — the write
43
43
  // secret must never reach viewers.
44
44
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { createHash } from "node:crypto";
45
46
  import { join } from "node:path";
46
47
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
48
+ import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
49
+ import { queueBindingWrite } from "./binding-queue.js";
47
50
  const DEFAULT_POLICY = {
48
51
  mode: "persistent",
49
52
  turnGraceMs: 120000,
@@ -93,11 +96,12 @@ function itemFor(type, data, sessionID) {
93
96
  if (type === "prefs")
94
97
  return { key: "prefs", item: { type, data } };
95
98
  if (type === "session")
96
- return { key: `session/${data?.id ?? ""}`, item: { type, data } };
99
+ return { key: `session/${data?.id ?? ""}`, item: { type, sessionID, data } };
100
+ // v2 messages carry no sessionID inside data (v1 infos did); the item
101
+ // envelope stamps it so multi-session folds (parent + subagents) can
102
+ // group without guessing
97
103
  if (type === "message")
98
- return { key: `message/${data?.id ?? ""}`, item: { type, data } };
99
- if (type === "part")
100
- return { key: `part/${data?.messageID ?? ""}/${data?.id ?? ""}`, item: { type, data } };
104
+ return { key: `message/${data?.id ?? ""}`, item: { type, sessionID, data } };
101
105
  if (type === "session_diff")
102
106
  return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } };
103
107
  return undefined;
@@ -269,6 +273,8 @@ export function createEngine(deps) {
269
273
  initTimer: null,
270
274
  initWasAttach: false,
271
275
  backoff: 1000,
276
+ backoffResetTimer: null,
277
+ spoolSaveTimer: null,
272
278
  reconnectTimer: null,
273
279
  intentionalClose: false,
274
280
  connectQueued: false,
@@ -334,12 +340,19 @@ export function createEngine(deps) {
334
340
  if (b && typeof b === "object" && b.policy) {
335
341
  applyPolicy(b.policy, url);
336
342
  }
343
+ // frame auth capability: the server accepts the token as the
344
+ // first WS frame — lets us keep it off the query string (proxy
345
+ // and access logs otherwise capture the full credential)
346
+ if (b && typeof b === "object" && b.frameAuth) {
347
+ frameAuthUrls.add(url);
348
+ }
337
349
  })
338
350
  .catch(() => {
339
351
  // unreachable worker: defaults stay; the init ack will correct us
340
352
  policyAsked.delete(url);
341
353
  });
342
354
  };
355
+ const frameAuthUrls = new Set();
343
356
  const wsUrl = (url) => url.replace(/^http/, "ws");
344
357
  // ---- spool root ---------------------------------------------------------
345
358
  let spoolRoot = spoolDirFor(cfg);
@@ -380,6 +393,8 @@ export function createEngine(deps) {
380
393
  activated: false,
381
394
  wroteBinding: false,
382
395
  everConnected: false,
396
+ merged: new Map(),
397
+ sentHash: new Map(),
383
398
  });
384
399
  const refreshConfig = () => {
385
400
  // settings may change between activations (the TUI writes the config
@@ -419,6 +434,9 @@ export function createEngine(deps) {
419
434
  }
420
435
  st.activated = true;
421
436
  st.opts = opts;
437
+ // re-activation re-backfills the whole session (unshare -> share
438
+ // again): the enqueue dedupe must not skip the returning content
439
+ st.sentHash.clear();
422
440
  const gated = mode === "manual" || opts2?.gated === true;
423
441
  if (gated) {
424
442
  // seed the share status from the session's CURRENT state before
@@ -493,25 +511,30 @@ export function createEngine(deps) {
493
511
  async function writeMetadata(st, value) {
494
512
  if (!st.client)
495
513
  return;
496
- try {
497
- const info = unwrap(await st.client.session.get({
498
- path: { id: st.sid },
499
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
500
- }));
501
- const metadata = { ...(info?.metadata ?? {}) };
502
- if (value === null)
503
- delete metadata[METADATA_KEY];
504
- else
505
- metadata[METADATA_KEY] = value;
506
- unwrap(await st.client.session.update({
507
- path: { id: st.sid },
508
- body: { metadata },
509
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
510
- }));
511
- }
512
- catch (e) {
513
- log("metadata write failed (spool still covers reconnects):", e?.message || e);
514
- }
514
+ // the read-modify-write runs inside the shared per-session queue: the
515
+ // TUI patches the same metadata (opt-outs, collisions) and interleaved
516
+ // RMWs silently lose one side's update (see binding-queue.ts)
517
+ await queueBindingWrite(st.sid, async () => {
518
+ try {
519
+ const info = unwrap(await st.client.session.get({
520
+ path: { id: st.sid },
521
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
522
+ }));
523
+ const metadata = { ...(info?.metadata ?? {}) };
524
+ if (value === null)
525
+ delete metadata[METADATA_KEY];
526
+ else
527
+ metadata[METADATA_KEY] = value;
528
+ unwrap(await st.client.session.update({
529
+ path: { id: st.sid },
530
+ body: { metadata },
531
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
532
+ }));
533
+ }
534
+ catch (e) {
535
+ log("metadata write failed (spool still covers reconnects):", e?.message || e);
536
+ }
537
+ });
515
538
  }
516
539
  /** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
517
540
  * rev,url,enabled?}}}) or legacy flat ({id,secret,rev,url}) which maps
@@ -632,13 +655,55 @@ export function createEngine(deps) {
632
655
  const spoolSave = (st, r) => {
633
656
  try {
634
657
  writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
635
- writeFileSync(queuePath(st, r.url), r.pending.map((f) => JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""));
658
+ // the queue line IS the wire frame when prebuilt (single
659
+ // serialization end-to-end); a pending frame without wire (legacy
660
+ // spool load) serializes here
661
+ writeFileSync(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""));
662
+ // the migrated content now lives in the new queue (or was acked):
663
+ // retire the single-server-era files so they don't accumulate forever
664
+ rmSync(legacyMetaPath(st), { force: true });
665
+ rmSync(legacyQueuePath(st), { force: true });
636
666
  }
637
667
  catch (e) {
638
668
  logError("spool save failed", e?.message || e);
639
669
  }
640
670
  };
671
+ // hot path: appending one pending frame must not re-serialize the whole
672
+ // queue (O(n^2) during backfill bursts) — append the line instead
673
+ const spoolAppend = (st, r, f) => {
674
+ try {
675
+ appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
676
+ }
677
+ catch (e) {
678
+ // the frame lives ONLY in memory now — flag the remote so the next
679
+ // spoolLoad does NOT replace in-memory pending with the shorter
680
+ // disk queue (silent loss otherwise; live-caught class in review)
681
+ r.spoolAppendFailed = true;
682
+ logError("spool append failed", e?.message || e);
683
+ }
684
+ };
685
+ /** Debounced ack-path spool rewrite: one ack per chunk during a
686
+ * multi-chunk replay each rewrote the WHOLE queue file (O(n^2) —
687
+ * ~160MB of sync disk churn on a 25-chunk outage recovery). A crash
688
+ * inside the 300ms window only loses "these frames were acked" — the
689
+ * next connect replays idempotent keyed upserts, which the protocol
690
+ * already tolerates. init-ok keeps the immediate save (binding/
691
+ * recovery info). */
692
+ const scheduleSpoolSave = (st, r) => {
693
+ if (r.spoolSaveTimer)
694
+ return;
695
+ r.spoolSaveTimer = setTimeout(() => {
696
+ r.spoolSaveTimer = null;
697
+ if (!st.closed)
698
+ spoolSave(st, r);
699
+ }, 300);
700
+ };
641
701
  const spoolLoad = (st, r) => {
702
+ // meta first (share binding recovery), in its OWN try: a missing meta
703
+ // file is normal (it's only written after the first successful
704
+ // handshake) and must NOT touch pending (live-caught class: the old
705
+ // blanket catch wiped in-memory pending AND skipped the queue read,
706
+ // so everything queued before the first successful connect was lost).
642
707
  try {
643
708
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
644
709
  // metadata binding (db) is authoritative when both exist and disagree
@@ -647,16 +712,48 @@ export function createEngine(deps) {
647
712
  r.secret = meta.secret;
648
713
  r.ackedRev = meta.ackedRev ?? 0;
649
714
  }
715
+ }
716
+ catch { }
717
+ try {
650
718
  if (existsSync(queuePath(st, r.url))) {
651
- r.pending = readFileSync(queuePath(st, r.url), "utf8")
652
- .split("\n")
653
- .filter((l) => l.trim())
654
- .map((l) => JSON.parse(l));
719
+ // append-only spool: a crash mid-append can tear the LAST line —
720
+ // keep the valid prefix instead of dropping the whole queue.
721
+ // The disk queue is the authority: every in-memory frame was
722
+ // appended before it was tracked, so replacing pending with the
723
+ // parsed queue loses nothing. A FAILED read keeps in-memory
724
+ // pending untouched.
725
+ const disk = [];
726
+ for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
727
+ if (!line.trim())
728
+ continue;
729
+ try {
730
+ disk.push(JSON.parse(line));
731
+ }
732
+ catch {
733
+ log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
734
+ break;
735
+ }
736
+ }
737
+ // seq hygiene: re-stamp pre-seq spool lines (an ack without seq
738
+ // would otherwise clear ALL pending — the H2 wipe, once per
739
+ // upgrade) and seed the counter past every restored seq (a
740
+ // restart resetting to 0 would collide new frames with old ones)
741
+ for (const f of disk) {
742
+ if (typeof f.seq !== "number")
743
+ f.seq = ++nextSeq;
744
+ else if (f.seq >= nextSeq)
745
+ nextSeq = f.seq + 1;
746
+ }
747
+ // a failed append means the disk queue can be SHORTER than
748
+ // memory — the disk is only authoritative when every append
749
+ // landed (otherwise the replace would drop the memory-only frame)
750
+ if (!r.spoolAppendFailed)
751
+ r.pending = disk;
752
+ else
753
+ r.spoolAppendFailed = false;
655
754
  }
656
755
  }
657
- catch {
658
- r.pending = [];
659
- }
756
+ catch { }
660
757
  // single-server-era spool (no url suffix): belongs to the first target
661
758
  if (!r.shareId && targets[0]?.url === r.url) {
662
759
  try {
@@ -679,12 +776,26 @@ export function createEngine(deps) {
679
776
  };
680
777
  const spoolClear = (st, r) => {
681
778
  try {
779
+ if (r.spoolSaveTimer) {
780
+ clearTimeout(r.spoolSaveTimer);
781
+ r.spoolSaveTimer = null;
782
+ }
682
783
  rmSync(metaPath(st, r.url), { force: true });
683
784
  rmSync(queuePath(st, r.url), { force: true });
785
+ // single-server-era files die with the session too
786
+ rmSync(legacyMetaPath(st), { force: true });
787
+ rmSync(legacyQueuePath(st), { force: true });
684
788
  }
685
789
  catch { }
686
790
  };
687
791
  // ---- websocket transport (per remote) ----------------------------------
792
+ /** Monotonic client frame sequence: every chunk-frame gets one; the
793
+ * server echoes it in the ack, and the plugin clears exactly that
794
+ * chunk from pending. (Live-caught class: one logical frame split into
795
+ * N chunks — the worker acks EACH chunk, and a blanket "pending = []"
796
+ * on the first ack erased chunks 2..N; a socket death right there lost
797
+ * them permanently.) */
798
+ let nextSeq = 0;
688
799
  const wsSend = (r, obj) => {
689
800
  if (debugEvents)
690
801
  log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
@@ -696,16 +807,70 @@ export function createEngine(deps) {
696
807
  }
697
808
  return true;
698
809
  };
699
- /** Sync frames are chunked (~1MB WS cap on CF; a 5000-message backfill
700
- * must ride several frames — worker apply is keyed-idempotent, chunks
701
- * just bump rev). Status rides each chunk; last write wins. */
702
- const SYNC_CHUNK = 200;
810
+ /** Sync frames are chunked by BOTH item count and serialized bytes.
811
+ * Count-only chunking could pack 200 large tool outputs into a frame
812
+ * that exceeds the server's WS receive cap (CF 32 MiB, Bun 16 MB
813
+ * default) and die wholesale. Budget keeps every frame cheap to apply
814
+ * and rebroadcast; worker apply is keyed-idempotent, so chunks just
815
+ * bump rev. An item larger than the whole budget rides alone in its
816
+ * own frame (the worker fits oversize items to its storage cap).
817
+ * Status rides each chunk; last write wins.
818
+ * Returns each chunk's PREBUILT items JSON: the wire frame and the
819
+ * spool line are derived from it by concatenation — every item used
820
+ * to be serialized 3-5x per remote per flush (chunk measure, spool
821
+ * append, ws send, sendSync re-chunk). */
822
+ const chunkedFrames = (items, maxItems, maxBytes) => {
823
+ const chunks = [];
824
+ let cur = [];
825
+ let curJson = [];
826
+ let curBytes = 0;
827
+ for (const it of items) {
828
+ const j = JSON.stringify(it) ?? "null";
829
+ if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
830
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
831
+ cur = [];
832
+ curJson = [];
833
+ curBytes = 0;
834
+ }
835
+ cur.push(it);
836
+ curJson.push(j);
837
+ curBytes += j.length;
838
+ }
839
+ if (cur.length)
840
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
841
+ return chunks;
842
+ };
843
+ /** legacy chunker (count/byte split without prebuilt JSON) — replay
844
+ * paths that only need the split */
845
+ const chunkItems = (items, maxItems, maxBytes) => chunkedFrames(items, maxItems, maxBytes).map((c) => c.items);
846
+ /** the wire sync frame, built by concatenation over the prebuilt items
847
+ * JSON — byte-identical to JSON.stringify({op,items,status,seq}) */
848
+ const syncFrameStr = (itemsJson, status, seq) => `{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`;
849
+ const wsSendRaw = (r, s) => {
850
+ if (debugEvents)
851
+ log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`);
852
+ try {
853
+ r.ws?.send(s);
854
+ }
855
+ catch {
856
+ return false;
857
+ }
858
+ return true;
859
+ };
703
860
  const sendSync = (r, f) => {
704
861
  if (!f.items.length)
705
- return wsSend(r, { op: "sync", items: [], status: f.status });
706
- for (let i = 0; i < f.items.length; i += SYNC_CHUNK) {
707
- wsSend(r, { op: "sync", items: f.items.slice(i, i + SYNC_CHUNK), status: f.status });
708
- }
862
+ return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq });
863
+ // prebuilt wire (the hot flush path): one serialization total
864
+ if (f.wire)
865
+ return wsSendRaw(r, f.wire);
866
+ // replayed/legacy frames: chunk-shaped by construction — only an
867
+ // oversize one (a pre-chunking spool line) needs re-splitting
868
+ const itemsJson = JSON.stringify(f.items);
869
+ if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
870
+ return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq));
871
+ }
872
+ for (const c of chunkedFrames(f.items, 200, 512 * 1024))
873
+ wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq));
709
874
  return true;
710
875
  };
711
876
  const announceUrl = (st, r) => {
@@ -899,7 +1064,12 @@ export function createEngine(deps) {
899
1064
  logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
900
1065
  return;
901
1066
  }
902
- const ws = new WebSocket(`${wsUrl(r.url)}/api/ws?token=${encodeURIComponent(token ?? "")}`);
1067
+ // frame auth: when the server advertises it, keep the token off the
1068
+ // URL (query strings land in proxy/access logs). The auth frame
1069
+ // rides the socket in order — the server sets its identity before
1070
+ // our init arrives. Old servers fall back to ?token=.
1071
+ const useFrameAuth = !!token && frameAuthUrls.has(r.url);
1072
+ const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`);
903
1073
  r.ws = ws;
904
1074
  await new Promise((resolve, reject) => {
905
1075
  const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
@@ -940,6 +1110,32 @@ export function createEngine(deps) {
940
1110
  if (!st.closed)
941
1111
  scheduleReconnect(st, r);
942
1112
  });
1113
+ if (useFrameAuth) {
1114
+ wsSend(r, { op: "auth", token });
1115
+ // the auth and init frames can interleave in the worker's
1116
+ // concurrent webSocketMessage invocations — wait for the authed
1117
+ // ack before authenticating anything (timeout -> fall back to a
1118
+ // query-token reconnect)
1119
+ const authed = await new Promise((resolve) => {
1120
+ const t = setTimeout(() => resolve(false), 3000);
1121
+ r.authWaiter = () => {
1122
+ clearTimeout(t);
1123
+ resolve(true);
1124
+ };
1125
+ });
1126
+ r.authWaiter = undefined;
1127
+ if (!authed) {
1128
+ logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`);
1129
+ frameAuthUrls.delete(r.url);
1130
+ try {
1131
+ ws.close(4000, "auth timeout");
1132
+ }
1133
+ catch { }
1134
+ if (!st.closed)
1135
+ scheduleReconnect(st, r);
1136
+ return;
1137
+ }
1138
+ }
943
1139
  if (r.initWasAttach) {
944
1140
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
945
1141
  }
@@ -973,7 +1169,9 @@ export function createEngine(deps) {
973
1169
  const scheduleReconnect = (st, r) => {
974
1170
  if (st.closed || r.reconnectTimer)
975
1171
  return;
976
- const wait = r.backoff;
1172
+ // jitter (±20%) so fan-out remotes and multiple sessions never sync
1173
+ // their retry storms onto the same second
1174
+ const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)));
977
1175
  r.backoff = Math.min(r.backoff * 2, 60000);
978
1176
  r.reconnectTimer = setTimeout(() => {
979
1177
  r.reconnectTimer = null;
@@ -1065,6 +1263,15 @@ export function createEngine(deps) {
1065
1263
  }
1066
1264
  };
1067
1265
  const handleFrame = async (st, r, frame) => {
1266
+ if (frame.op === "authed") {
1267
+ // frame-auth handshake ack: release the init (the auth and init
1268
+ // frames can interleave server-side — webSocketMessage invocations
1269
+ // run concurrently — so the init must WAIT for this, else it
1270
+ // authenticates as anonymous/tokenless)
1271
+ r.authWaiter?.();
1272
+ r.authWaiter = undefined;
1273
+ return;
1274
+ }
1068
1275
  if (frame.op === "init") {
1069
1276
  if (!frame.ok) {
1070
1277
  const err = String(frame.error ?? "");
@@ -1123,6 +1330,21 @@ export function createEngine(deps) {
1123
1330
  // bad base (mismatch protocol). Content REPLAYS to the new share —
1124
1331
  // pending is never dropped on a reset (live-caught: the recreate
1125
1332
  // husk had the title but zero messages).
1333
+ if (/limit|too many|rate/i.test(err)) {
1334
+ // quota/429 ("max shares reached", "daily create limit", the
1335
+ // anonymous caps): an immediate re-init on the same socket is a
1336
+ // hot loop against the server — back off through the normal
1337
+ // reconnect cadence instead
1338
+ logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
1339
+ r.connecting = false;
1340
+ try {
1341
+ r.ws?.close(4000, "rate limited");
1342
+ }
1343
+ catch { }
1344
+ if (!st.closed)
1345
+ scheduleReconnect(st, r);
1346
+ return;
1347
+ }
1126
1348
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1127
1349
  deleteAbandoned(r);
1128
1350
  r.shareId = null;
@@ -1154,11 +1376,26 @@ export function createEngine(deps) {
1154
1376
  r.connecting = false;
1155
1377
  r.initWasAttach = false;
1156
1378
  r.collided = null;
1379
+ // a successful handshake proves write access: an admin unfreeze
1380
+ // revives the remote from here (frozenRemote is otherwise sticky)
1381
+ if (r.frozenRemote && !frame.error)
1382
+ r.frozenRemote = false;
1157
1383
  if (r.initTimer) {
1158
1384
  clearTimeout(r.initTimer);
1159
1385
  r.initTimer = null;
1160
1386
  }
1161
- r.backoff = 1000;
1387
+ // backoff reset is DEFERRED until the socket proves stable: a
1388
+ // server that accepts init then immediately drops would otherwise
1389
+ // reconnect once per second forever (~1 cycle/s per session-server
1390
+ // pair, live-caught in the optimization audit). 30s alive (socket
1391
+ // still ready, no reconnect pending) or a second acked sync.
1392
+ if (r.backoffResetTimer)
1393
+ clearTimeout(r.backoffResetTimer);
1394
+ r.backoffResetTimer = setTimeout(() => {
1395
+ r.backoffResetTimer = null;
1396
+ if (r.wsReady && !r.reconnectTimer)
1397
+ r.backoff = 1000;
1398
+ }, 30000);
1162
1399
  st.everConnected = true;
1163
1400
  if (frame.policy)
1164
1401
  applyPolicy(frame.policy, r.url);
@@ -1184,19 +1421,70 @@ export function createEngine(deps) {
1184
1421
  if (wasFanoutCreate) {
1185
1422
  // a server joined an ALREADY-live session (fan-out add, or a
1186
1423
  // post-mismatch recreate): its fresh share needs the full history —
1187
- // backfill is keyed-idempotent, other servers just re-upsert
1424
+ // backfill is keyed-idempotent, other servers just re-upsert.
1425
+ // The enqueue dedupe must step aside: those hashes say "already
1426
+ // entered the machinery", but the NEW remote never received any
1427
+ // of it (live-caught by F3: the join's backfill was fully
1428
+ // deduped away and the joined share stayed empty).
1429
+ st.sentHash.clear();
1188
1430
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1189
1431
  }
1190
1432
  return;
1191
1433
  }
1192
1434
  if (frame.op === "ack") {
1193
1435
  r.ackedRev = frame.rev ?? r.ackedRev;
1194
- r.pending = [];
1195
- spoolSave(st, r);
1436
+ // a second acked sync also proves the socket stable — release the
1437
+ // backoff reset timer early (complements the 30s-alive timer)
1438
+ if (r.backoffResetTimer) {
1439
+ clearTimeout(r.backoffResetTimer);
1440
+ r.backoffResetTimer = null;
1441
+ r.backoff = 1000;
1442
+ }
1443
+ // seq-scoped retirement: an ack identifies the chunk it applied.
1444
+ // An ack WITHOUT seq = old server: legacy clear-all (correct there —
1445
+ // old servers acked the whole pending batch together).
1446
+ if (typeof frame.seq === "number") {
1447
+ r.pending = r.pending.filter((p) => p.seq !== frame.seq);
1448
+ }
1449
+ else {
1450
+ r.pending = [];
1451
+ }
1452
+ scheduleSpoolSave(st, r);
1196
1453
  scheduleBindingWrite(st);
1197
1454
  return;
1198
1455
  }
1199
1456
  if (frame.op === "nack" || frame.op === "error") {
1457
+ const errText = String(frame.error ?? "");
1458
+ if (frame.op === "nack") {
1459
+ // classify: the share dying server-side under a live socket
1460
+ // (sweeper TTL, admin delete, purge) surfaces as nacks — the
1461
+ // socket is "healthy" so nothing else recovers it. Same reset as
1462
+ // the init-refused path: fresh create + content replay.
1463
+ if (/not found|gone/i.test(errText)) {
1464
+ logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`);
1465
+ deleteAbandoned(r);
1466
+ r.shareId = null;
1467
+ r.secret = null;
1468
+ r.ackedRev = 0;
1469
+ try {
1470
+ r.ws?.close(4000, "share gone");
1471
+ }
1472
+ catch { }
1473
+ if (!st.closed)
1474
+ queueConnect(st, r);
1475
+ return;
1476
+ }
1477
+ if (/frozen/i.test(errText)) {
1478
+ // owner frozen mid-session: every write is refused. Stop
1479
+ // spooling NEW frames for this remote (unbounded growth) but
1480
+ // KEEP what's already queued — an unfreeze revives the remote
1481
+ // on the next successful handshake (init-ok clears the flag)
1482
+ // and the backlog replays from there.
1483
+ r.frozenRemote = true;
1484
+ logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`);
1485
+ return;
1486
+ }
1487
+ }
1200
1488
  logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
1201
1489
  // the control socket lost its attach state (worker-side eviction of
1202
1490
  // the control DO): force a reconnect — init attach replays pending
@@ -1227,6 +1515,10 @@ export function createEngine(deps) {
1227
1515
  };
1228
1516
  // ---- flush -------------------------------------------------------------
1229
1517
  const frameFrom = (st) => {
1518
+ // stamps run at frame time: the whole family's data is indexed by now
1519
+ // (child prompts are created during the task call, well inside the
1520
+ // flush debounce), and any re-enqueued message lands in this frame
1521
+ stampsPass(st);
1230
1522
  const items = Array.from(st.queue.values());
1231
1523
  st.queue.clear();
1232
1524
  const statusChanged = st.status !== st.lastSentStatus;
@@ -1251,18 +1543,41 @@ export function createEngine(deps) {
1251
1543
  return;
1252
1544
  // fan out to every remote; each carries its own pending copy and acks
1253
1545
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1546
+ // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1547
+ // chunk-frames so a partial-ack can never erase unsent chunks
1548
+ // (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
1549
+ const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
1254
1550
  let httpWork;
1255
1551
  for (const r of st.remotes.values()) {
1552
+ if (r.frozenRemote)
1553
+ continue; // refused server-side; stop spooling
1554
+ const fresh = [];
1555
+ if (chunks.length) {
1556
+ for (const c of chunks) {
1557
+ const seq = ++nextSeq;
1558
+ const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) };
1559
+ fresh.push(cf);
1560
+ r.pending.push(cf);
1561
+ spoolAppend(st, r, cf);
1562
+ }
1563
+ }
1564
+ else {
1565
+ // status-only frame (empty items): one chunk-less frame
1566
+ const seq = ++nextSeq;
1567
+ const cf = { items: [], status: f.status, seq };
1568
+ fresh.push(cf);
1569
+ r.pending.push(cf);
1570
+ spoolAppend(st, r, cf);
1571
+ }
1256
1572
  // never send syncs before the init ack: the worker's per-socket
1257
1573
  // ControlDO has no attach until OUR init completes, and a racing
1258
1574
  // sync dies as "unknown op" (live-caught: post-drop reconnects
1259
1575
  // lost whole turns). The init-ok handler flushes pending itself.
1260
- r.pending.push(f);
1261
- spoolSave(st, r);
1262
1576
  if (r.ws && !r.wsReady)
1263
1577
  continue;
1264
1578
  if (r.wsReady) {
1265
- sendSync(r, f);
1579
+ for (const cf of fresh)
1580
+ sendSync(r, cf);
1266
1581
  continue;
1267
1582
  }
1268
1583
  const viaHttp = flushViaHttp(st, r);
@@ -1271,36 +1586,84 @@ export function createEngine(deps) {
1271
1586
  }
1272
1587
  if (st.remotes.size)
1273
1588
  st.lastFlushAt = Date.now();
1589
+ // the HTTP work runs detached from the flush chain (one dead server
1590
+ // must not stall the fan-out); dispose awaits its own final flush
1591
+ if (httpWork)
1592
+ void httpWork.catch((e) => logError("http flush error", e?.message || e));
1274
1593
  return httpWork;
1275
1594
  };
1595
+ /** Client-side key derivation mirroring the server's itemKey — used only
1596
+ * for deduping the HTTP fallback batch (keyed upserts: the LAST item
1597
+ * for a key wins, matching apply() semantics). */
1598
+ const httpDedupeKey = (it) => {
1599
+ if (!it || typeof it !== "object")
1600
+ return null;
1601
+ const o = it;
1602
+ switch (o.type) {
1603
+ case "session": return "session/" + (o.data?.id ?? "");
1604
+ case "session_diff": return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "");
1605
+ case "message": return "message/" + (o.data?.id ?? "");
1606
+ case "part": return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "");
1607
+ case "model": return "model/" + (o.sessionID ?? o.data?.sessionID ?? "");
1608
+ case "prefs": return "prefs";
1609
+ default: return null;
1610
+ }
1611
+ };
1276
1612
  const flushViaHttp = async (st, r) => {
1277
1613
  if (!r.shareId || !r.pending.length)
1278
1614
  return;
1279
1615
  // ALL pending frames, merged (keyed upserts — later wins). Sending
1280
1616
  // only the last frame dropped every earlier batch's items whenever
1281
1617
  // the HTTP fallback carried more than one (live-caught class:
1282
- // content loss on reconnect paths).
1283
- const items = [];
1618
+ // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1619
+ // a streaming message appearing in k pending frames shipped k
1620
+ // copies (up to 25x on a replay burst) — every duplicate was also a
1621
+ // server row-write + a rebroadcast frame.
1622
+ const byKey = new Map();
1623
+ const unkeyed = [];
1284
1624
  let status;
1625
+ const sentSeqs = new Set();
1285
1626
  for (const f of r.pending) {
1286
- for (const it of f.items)
1287
- items.push(it);
1627
+ for (const it of f.items) {
1628
+ const k = httpDedupeKey(it);
1629
+ if (k)
1630
+ byKey.set(k, it);
1631
+ else
1632
+ unkeyed.push(it);
1633
+ }
1288
1634
  if (f.status)
1289
1635
  status = f.status;
1636
+ if (typeof f.seq === "number")
1637
+ sentSeqs.add(f.seq);
1290
1638
  }
1639
+ const items = [...byKey.values(), ...unkeyed];
1291
1640
  const cred = credOf(r.url);
1292
- const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1293
- method: "POST",
1294
- headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1295
- body: JSON.stringify({ secret: r.secret ?? "", data: items, status }),
1296
- signal: AbortSignal.timeout(15000),
1297
- }).catch(() => null);
1298
- if (res && res.ok) {
1641
+ // chunked like WS frames: one unbounded body is a whole-backlog
1642
+ // bomb after a mid-backfill socket drop (backfill = full history).
1643
+ // Failure stops the loop with pending intact — replaying already-
1644
+ // applied chunks is idempotent.
1645
+ // status-only frames (items empty, e.g. the terminal completed
1646
+ // flush in HTTP-only mode) still need ONE request or the status
1647
+ // never lands (the share stays "live" forever)
1648
+ const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]];
1649
+ for (const chunk of chunks) {
1650
+ const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1651
+ method: "POST",
1652
+ headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1653
+ body: JSON.stringify({ secret: r.secret ?? "", data: chunk, status }),
1654
+ signal: AbortSignal.timeout(15000),
1655
+ }).catch(() => null);
1656
+ if (!res || !res.ok)
1657
+ return;
1299
1658
  const body = (await res.json().catch(() => ({})));
1300
1659
  r.ackedRev = body.rev ?? r.ackedRev;
1301
- r.pending = [];
1302
- spoolSave(st, r);
1303
1660
  }
1661
+ // retire EXACTLY what this run sent: a concurrent flush may have
1662
+ // queued new frames while our fetches were in flight (the HTTP work
1663
+ // is deliberately detached from the flush chain) — a blanket clear
1664
+ // would erase them before anything transmitted them
1665
+ r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq));
1666
+ scheduleSpoolSave(st, r);
1304
1667
  };
1305
1668
  const scheduleFlush = (st) => {
1306
1669
  if (st.timer)
@@ -1316,19 +1679,358 @@ export function createEngine(deps) {
1316
1679
  st.timer = null;
1317
1680
  if (debugEvents)
1318
1681
  log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
1319
- chain = chain.then(() => flush(st)).catch((e) => logError("flush error", e?.message || e));
1682
+ // NOTE: the flush's HTTP-fallback promise is deliberately NOT
1683
+ // chained — one black-holing server (15s timeout per chunk) must
1684
+ // not stall every other remote's flushes behind it. Dispose awaits
1685
+ // its own final flush explicitly.
1686
+ chain = chain
1687
+ .then(() => {
1688
+ flush(st);
1689
+ })
1690
+ .catch((e) => logError("flush error", e?.message || e));
1320
1691
  }, wait);
1321
1692
  };
1322
1693
  const enqueue = (st, entry) => {
1323
1694
  if (!st || !entry)
1324
1695
  return;
1696
+ // content dedupe at the machinery boundary: an identical re-feed has
1697
+ // either entered the queue already or been flushed into every
1698
+ // remote's pending/spool (the per-remote delivery machinery owns it
1699
+ // from there) — re-sending it bumps rev, rewrites storage and
1700
+ // re-folds every viewer for nothing. This is the engine-side home
1701
+ // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1702
+ // dormant and skipped the flag-time content — live-caught).
1703
+ let h;
1704
+ try {
1705
+ h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
1706
+ }
1707
+ catch {
1708
+ h = "";
1709
+ }
1710
+ if (h && st.sentHash.get(entry.key) === h) {
1711
+ if (debugEvents)
1712
+ log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`);
1713
+ return;
1714
+ }
1715
+ if (h)
1716
+ st.sentHash.set(entry.key, h);
1325
1717
  st.queue.set(entry.key, entry.item);
1718
+ indexMessage(entry);
1326
1719
  if (debugEvents)
1327
1720
  log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`);
1328
1721
  touch(st);
1329
1722
  ensureConnected(st);
1330
1723
  scheduleFlush(st);
1331
1724
  };
1725
+ const msgIndex = new Map(); // `${sid}:${messageID}`
1726
+ /** message ids tombstoned this run: stampsPass must never re-upsert an
1727
+ * undone message (live-caught class: dropMergedMessage removed the
1728
+ * merged entry but msgIndex kept the corpse; a neighboring-segment
1729
+ * change recomputed its stamps and reEnqueueMessage resurrected the
1730
+ * deleted turn server-side). */
1731
+ const tombstoned = new Set();
1732
+ const indexMessage = (entry) => {
1733
+ if (entry.item.type !== "message")
1734
+ return;
1735
+ const d = entry.item.data;
1736
+ if (!d || typeof d.id !== "string")
1737
+ return;
1738
+ const role = String(d.type ?? "");
1739
+ if (role !== "user" && role !== "assistant")
1740
+ return;
1741
+ const key = `${entry.item.sessionID}:${d.id}`;
1742
+ const prev = msgIndex.get(key);
1743
+ msgIndex.set(key, {
1744
+ sid: String(entry.item.sessionID ?? ""),
1745
+ role: role,
1746
+ agent: role === "assistant"
1747
+ ? typeof d.agent === "string"
1748
+ ? d.agent
1749
+ : prev?.agent
1750
+ : typeof d.metadata?.agent === "string"
1751
+ ? d.metadata.agent
1752
+ : prev?.agent,
1753
+ created: Number(d.time?.created ?? prev?.created ?? 0),
1754
+ data: d,
1755
+ });
1756
+ };
1757
+ const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
1758
+ const reEnqueueMessage = (m) => {
1759
+ // tombstone guard: an undone message stays dead even if a stamp pass
1760
+ // recomputes its segment metadata
1761
+ if (tombstoned.has(`${m.sid}:${m.data.id}`))
1762
+ return;
1763
+ const st = sessionStateOf(m.sid);
1764
+ if (st)
1765
+ enqueue(st, itemFor("message", m.data, m.sid));
1766
+ };
1767
+ /** segment agent for a user prompt: the prompt's own recorded agent (v1
1768
+ * hosts stamp it on every user message), else the first assistant
1769
+ * response inside the segment (v2 hosts), else carry-forward. */
1770
+ const segmentAgent = (ordered, at) => {
1771
+ const own = ordered[at]?.agent;
1772
+ if (own)
1773
+ return own;
1774
+ for (let i = at + 1; i < ordered.length; i++) {
1775
+ if (ordered[i].role === "assistant" && ordered[i].agent)
1776
+ return ordered[i].agent;
1777
+ if (ordered[i].role === "user")
1778
+ break; // next segment already began
1779
+ }
1780
+ return undefined;
1781
+ };
1782
+ /** user messages whose segment runs a DIFFERENT agent than the previous
1783
+ * segment carry metadata.agentSwitch = <agent> - the viewer renders the
1784
+ * transition chip from the stamp alone. */
1785
+ const stampAgentSwitches = (bySession) => {
1786
+ for (const [, list] of bySession) {
1787
+ list.sort((a, b) => a.created - b.created);
1788
+ const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
1789
+ let prev;
1790
+ for (let k = 0; k < users.length; k++) {
1791
+ const agent = segmentAgent(list, users[k]);
1792
+ const d = list[users[k]].data;
1793
+ const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
1794
+ // only touch metadata when the stamp actually CHANGES — a blanket
1795
+ // `d.metadata ??= {}` polluted every user message (including the
1796
+ // host's live objects on v2 pass-through) with an empty metadata
1797
+ // object, which re-emitted the message once per refresh (rev churn
1798
+ // per feed — live-caught by the enqueue-dedupe rev check)
1799
+ if (want !== d.metadata?.agentSwitch) {
1800
+ if (want) {
1801
+ const meta = (d.metadata ??= {});
1802
+ meta.agentSwitch = want;
1803
+ }
1804
+ else if (d.metadata) {
1805
+ delete d.metadata.agentSwitch;
1806
+ }
1807
+ reEnqueueMessage(list[users[k]]);
1808
+ }
1809
+ if (agent)
1810
+ prev = agent;
1811
+ }
1812
+ }
1813
+ };
1814
+ /** task/subagent tool calls get metadata.agent = the agent that actually
1815
+ * ran for THAT call: the child's first prompt created at/after the call
1816
+ * started (v1 prompts carry the agent; v2 resolves via the response).
1817
+ * Rank-free pairing - a resumed child's second call lands on its second
1818
+ * prompt by construction time. */
1819
+ const stampTaskAgents = (bySession) => {
1820
+ for (const list of bySession.values())
1821
+ list.sort((a, b) => a.created - b.created);
1822
+ for (const m of msgIndex.values()) {
1823
+ if (m.role !== "assistant")
1824
+ continue;
1825
+ const content = m.data.content;
1826
+ if (!Array.isArray(content))
1827
+ continue;
1828
+ let mutated = false;
1829
+ for (const part of content) {
1830
+ if (!part || part.type !== "tool")
1831
+ continue;
1832
+ if (part.name !== "task" && part.name !== "subagent")
1833
+ continue;
1834
+ const state = part.state;
1835
+ const meta = state?.metadata;
1836
+ const childSid = typeof meta?.sessionID === "string" ? meta.sessionID : undefined;
1837
+ if (!childSid || typeof meta.agent === "string")
1838
+ continue;
1839
+ const partStart = Number(part.time?.created ?? 0);
1840
+ const child = bySession.get(childSid);
1841
+ if (!child)
1842
+ continue;
1843
+ let agent;
1844
+ for (const c of child) {
1845
+ if (c.created < partStart)
1846
+ continue;
1847
+ if (c.role === "user" && c.agent) {
1848
+ agent = c.agent;
1849
+ break;
1850
+ }
1851
+ }
1852
+ if (!agent) {
1853
+ for (const c of child) {
1854
+ if (c.created < partStart)
1855
+ continue;
1856
+ if (c.role === "assistant" && c.agent) {
1857
+ agent = c.agent;
1858
+ break;
1859
+ }
1860
+ }
1861
+ }
1862
+ if (agent) {
1863
+ meta.agent = agent;
1864
+ mutated = true;
1865
+ }
1866
+ }
1867
+ if (mutated)
1868
+ reEnqueueMessage(m);
1869
+ }
1870
+ };
1871
+ /** file tools get metadata.language (shiki lang id, from the path the
1872
+ * call touched) - the viewer highlights from the stamp and never guesses
1873
+ * (owner directive; the viewer's extension map is an old-share fallback) */
1874
+ const LANG_BY_EXT = {
1875
+ ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1876
+ js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
1877
+ json: "json", jsonc: "json", md: "markdown", mdx: "mdx",
1878
+ py: "python", rs: "rust", go: "go", java: "java", rb: "ruby",
1879
+ css: "css", scss: "scss", html: "html", xml: "xml", yaml: "yaml", yml: "yaml",
1880
+ toml: "toml", sh: "shellscript", bash: "shellscript", ps1: "powershell",
1881
+ sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
1882
+ swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
1883
+ };
1884
+ const FILE_TOOLS = new Set(["read", "write", "edit", "patch"]);
1885
+ const stampFileLanguages = () => {
1886
+ for (const m of msgIndex.values()) {
1887
+ if (m.role !== "assistant")
1888
+ continue;
1889
+ const content = m.data.content;
1890
+ if (!Array.isArray(content))
1891
+ continue;
1892
+ let mutated = false;
1893
+ for (const part of content) {
1894
+ if (!part || part.type !== "tool")
1895
+ continue;
1896
+ if (!FILE_TOOLS.has(part.name))
1897
+ continue;
1898
+ const state = part.state;
1899
+ const meta = state?.metadata;
1900
+ if (!meta)
1901
+ continue;
1902
+ const raw = state.input?.path ?? state.input?.filePath;
1903
+ // language stamp (shiki id from the path)
1904
+ if (typeof meta.language !== "string" && typeof raw === "string") {
1905
+ const ext = raw.split(".").pop()?.toLowerCase() ?? "";
1906
+ const lang = LANG_BY_EXT[ext];
1907
+ if (lang) {
1908
+ meta.language = lang;
1909
+ mutated = true;
1910
+ }
1911
+ }
1912
+ // lineStart stamp: reads sliced from an offset keep REAL file line
1913
+ // numbers in the viewer's unified file card (pierre numbers from 1;
1914
+ // the viewer shifts by this stamp, authoritatively)
1915
+ if (typeof meta.lineStart !== "number" && part.name === "read") {
1916
+ const off = state.input?.offset;
1917
+ if (typeof off === "number" && off >= 0 && off > 0) {
1918
+ meta.lineStart = off + 1;
1919
+ mutated = true;
1920
+ }
1921
+ }
1922
+ }
1923
+ if (mutated)
1924
+ reEnqueueMessage(m);
1925
+ }
1926
+ };
1927
+ const stampsPass = (st) => {
1928
+ // skip entirely when this flush carries no message items: every
1929
+ // index mutation rides a message item through the queue (inserts via
1930
+ // enqueue, deletes via tombstones — both are type "message"), so no
1931
+ // message in the queue means the stamps' inputs are unchanged. A
1932
+ // status-only/model/prefs/diff flush costs zero stamp work.
1933
+ let any = false;
1934
+ for (const it of st.queue.values()) {
1935
+ if (it.type === "message") {
1936
+ any = true;
1937
+ break;
1938
+ }
1939
+ }
1940
+ if (!any)
1941
+ return;
1942
+ // one grouping + one sort shared by both passes (each used to build
1943
+ // and sort its own identical bySession map per flush)
1944
+ const bySession = new Map();
1945
+ for (const m of msgIndex.values()) {
1946
+ const list = bySession.get(m.sid);
1947
+ if (list)
1948
+ list.push(m);
1949
+ else
1950
+ bySession.set(m.sid, [m]);
1951
+ }
1952
+ for (const list of bySession.values())
1953
+ list.sort((a, b) => a.created - b.created);
1954
+ stampAgentSwitches(bySession);
1955
+ stampTaskAgents(bySession);
1956
+ stampFileLanguages();
1957
+ };
1958
+ // ---- contract v2 message assembly --------------------------------------
1959
+ // v1 hosts stream parts as separate events; the v2 item embeds content.
1960
+ // The engine keeps the assembled view per message and re-emits the
1961
+ // normalized whole (the keyed queue dedupes across a debounce window,
1962
+ // so intermediate re-emissions collapse).
1963
+ const mergeEntry = (st, messageID) => {
1964
+ let e = st.merged.get(messageID);
1965
+ if (!e) {
1966
+ e = { parts: new Map() };
1967
+ st.merged.set(messageID, e);
1968
+ }
1969
+ return e;
1970
+ };
1971
+ /** (Re-)emit the v2 message item for a merged v1 message. */
1972
+ const emitMerged = (st, sid, messageID) => {
1973
+ const e = st.merged.get(messageID);
1974
+ if (!e?.info)
1975
+ return; // parts arriving before their message: wait
1976
+ const res = normalizeV1Message(e.info, [...e.parts.values()]);
1977
+ if (!res)
1978
+ return;
1979
+ if ("refill" in res) {
1980
+ // v1 compaction summary assistant → rewrite the parent compaction
1981
+ // message with the summary text; the summary message itself is not
1982
+ // emitted as its own item
1983
+ const parent = st.merged.get(res.refill);
1984
+ if (parent?.info) {
1985
+ const compaction = normalizeV1Message(parent.info, [...parent.parts.values()]);
1986
+ if (compaction && "message" in compaction) {
1987
+ const msg = compaction.message;
1988
+ msg.summary = res.summary;
1989
+ enqueue(st, itemFor("message", msg, sid));
1990
+ }
1991
+ }
1992
+ return;
1993
+ }
1994
+ enqueue(st, itemFor("message", res.message, sid));
1995
+ };
1996
+ /** message.updated / part.updated ingestion for v1 hosts; v2 hosts call
1997
+ * with v2-native messages (pass-through, no merge). */
1998
+ const ingestMessageInfo = (st, sid, info) => {
1999
+ if (isV2NativeMessage(info)) {
2000
+ enqueue(st, itemFor("message", info, sid));
2001
+ return;
2002
+ }
2003
+ if (typeof info?.id !== "string")
2004
+ return;
2005
+ const e = mergeEntry(st, info.id);
2006
+ e.info = info;
2007
+ emitMerged(st, sid, info.id);
2008
+ };
2009
+ const ingestPart = (st, sid, part) => {
2010
+ if (typeof part?.id !== "string" || typeof part.messageID !== "string")
2011
+ return;
2012
+ const e = mergeEntry(st, part.messageID);
2013
+ e.parts.set(part.id, part);
2014
+ emitMerged(st, sid, part.messageID);
2015
+ };
2016
+ const dropMergedMessage = (st, messageID, evSid) => {
2017
+ // the index corpse must go too: stampsPass iterates msgIndex, and a
2018
+ // re-stamp of a deleted message would re-upsert it (resurrection).
2019
+ // Key by the EVENT's session (children tombstone under their own sid;
2020
+ // the merged map is per main session)
2021
+ const sid = evSid ?? st.sid;
2022
+ st.merged.delete(messageID);
2023
+ msgIndex.delete(`${sid}:${messageID}`);
2024
+ tombstoned.add(`${sid}:${messageID}`);
2025
+ };
2026
+ const dropMergedPart = (st, sid, messageID, partID) => {
2027
+ const e = st.merged.get(messageID);
2028
+ if (!e)
2029
+ return;
2030
+ e.parts.delete(partID);
2031
+ if (e.info)
2032
+ emitMerged(st, sid, messageID);
2033
+ };
1332
2034
  const remove = (st) => {
1333
2035
  st.closed = true;
1334
2036
  st.activated = false;
@@ -1347,6 +2049,10 @@ export function createEngine(deps) {
1347
2049
  clearTimeout(r.initTimer);
1348
2050
  r.initTimer = null;
1349
2051
  }
2052
+ if (r.backoffResetTimer) {
2053
+ clearTimeout(r.backoffResetTimer);
2054
+ r.backoffResetTimer = null;
2055
+ }
1350
2056
  if (r.reconnectTimer) {
1351
2057
  clearTimeout(r.reconnectTimer);
1352
2058
  r.reconnectTimer = null;
@@ -1356,8 +2062,28 @@ export function createEngine(deps) {
1356
2062
  clearTimeout(r.idleDropTimer);
1357
2063
  r.idleDropTimer = null;
1358
2064
  }
1359
- if (r.wsReady)
2065
+ // wsReady delete only when THIS remote still owns a share: a stray
2066
+ // child teardown clears shareId after adopting into the parent —
2067
+ // the ControlDO's server-side attach would otherwise delete the
2068
+ // very share the parent just inherited (live-caught adoption churn)
2069
+ if (r.wsReady && r.shareId) {
1360
2070
  wsSend(r, { op: "delete" });
2071
+ // backstop: the WS delete is fire-and-forget on the wire — a
2072
+ // socket dying right after leaves the page public until TTL. The
2073
+ // HTTP goodbye is idempotent (404/ok both fine).
2074
+ if (r.shareId && r.secret) {
2075
+ const sid = r.shareId;
2076
+ const sec = r.secret;
2077
+ setTimeout(() => {
2078
+ void fetch(`${r.url}/api/share/${sid}`, {
2079
+ method: "DELETE",
2080
+ headers: { "content-type": "application/json" },
2081
+ body: JSON.stringify({ secret: sec }),
2082
+ signal: AbortSignal.timeout(8000),
2083
+ }).catch(() => { });
2084
+ }, 2000);
2085
+ }
2086
+ }
1361
2087
  else if (r.shareId && r.secret) {
1362
2088
  // turn mode may have dropped the socket after the last settle; the
1363
2089
  // goodbye still must land — say it over HTTP
@@ -1379,6 +2105,21 @@ export function createEngine(deps) {
1379
2105
  }, 1000);
1380
2106
  for (const r of st.remotes.values())
1381
2107
  spoolClear(st, r);
2108
+ // memory hygiene: the session is gone — its index corpses, tombstone
2109
+ // guards and child routing must not linger (msgIndex/tombstoned are
2110
+ // engine-global and otherwise only grow; sentHash rides the session
2111
+ // state and dies with it)
2112
+ const stSid = st.sid;
2113
+ const pfx = `${stSid}:`;
2114
+ for (const k of msgIndex.keys())
2115
+ if (k.startsWith(pfx))
2116
+ msgIndex.delete(k);
2117
+ for (const k of tombstoned)
2118
+ if (k.startsWith(pfx))
2119
+ tombstoned.delete(k);
2120
+ for (const [child, parent] of childToParent)
2121
+ if (parent === stSid || child === stSid)
2122
+ childToParent.delete(child);
1382
2123
  sessions.delete(st.sid);
1383
2124
  try {
1384
2125
  chain = chain.then(async () => {
@@ -1413,7 +2154,7 @@ export function createEngine(deps) {
1413
2154
  const PAGE_SIZE = 100;
1414
2155
  const MAX_PAGES = 50;
1415
2156
  const query = () => (deps.directory ? { directory: deps.directory } : {});
1416
- async function fetchMessages(client, sid) {
2157
+ async function fetchMessages(client, sid, onPage) {
1417
2158
  if (typeof client.session.messages !== "function")
1418
2159
  return [];
1419
2160
  const out = [];
@@ -1429,41 +2170,69 @@ export function createEngine(deps) {
1429
2170
  if (!Array.isArray(result) || result.length === 0)
1430
2171
  break;
1431
2172
  out.push(...result);
2173
+ // stream pages as they arrive: enqueue (and the rate-limited flush)
2174
+ // starts with page 1 while later pages are still loading — a huge
2175
+ // session backfills progressively instead of after a full collect
2176
+ if (onPage) {
2177
+ try {
2178
+ onPage(result);
2179
+ }
2180
+ catch { }
2181
+ }
1432
2182
  if (result.length < PAGE_SIZE)
1433
2183
  break;
1434
2184
  const last = result[result.length - 1];
1435
- const nextBefore = typeof last?.info?.id === "string" ? last.info.id : typeof last?.id === "string" ? last.id : undefined;
1436
- if (!nextBefore || nextBefore === before)
2185
+ const lastInfo = (last?.info ?? last);
2186
+ const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
2187
+ // `before` is an OPAQUE cursor: base64url({id, time}) of the page
2188
+ // tail (message-v2.ts codec). A bare message id 400s on hosts with
2189
+ // cursor pagination — every v1 backfill silently truncated to one
2190
+ // 100-message page (live-caught).
2191
+ const lastTime = lastInfo?.time?.created;
2192
+ if (!lastID || typeof lastTime !== "number")
2193
+ break;
2194
+ const nextBefore = cursorFor(lastID, lastTime);
2195
+ if (nextBefore === before)
1437
2196
  break;
1438
2197
  before = nextBefore;
1439
2198
  }
2199
+ // the page cap is a hard truncation: surface it instead of silently
2200
+ // sharing a partial history (oldest pages dropped)
2201
+ if (out.length >= MAX_PAGES * PAGE_SIZE) {
2202
+ logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`);
2203
+ }
1440
2204
  return out;
1441
2205
  }
1442
2206
  const backfillSession = async (st, client, sid, info) => {
1443
2207
  const sessionInfoValue = info ?? (await sessionInfo(client, sid));
1444
2208
  if (sessionInfoValue)
1445
2209
  enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
1446
- const messages = await fetchMessages(client, sid);
1447
2210
  let modelSeen = false;
1448
- for (const message of messages) {
1449
- const m = (message.info ?? message);
1450
- if (!m || typeof m.id !== "string")
1451
- continue;
1452
- enqueue(st, itemFor("message", m, sid));
1453
- if (m.role === "user" && m.model) {
1454
- modelSeen = true;
1455
- const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
1456
- enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
1457
- }
1458
- for (const part of message.parts ?? []) {
1459
- const p = part;
1460
- if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
2211
+ const consume = (messages) => {
2212
+ for (const message of messages) {
2213
+ const m = (message.info ?? message);
2214
+ if (!m || typeof m.id !== "string")
1461
2215
  continue;
1462
- if (p.state?.status === "error")
1463
- st.badSignal = true;
1464
- enqueue(st, itemFor("part", p, sid));
2216
+ // seed the merge state, then emit the assembled v2 message
2217
+ const e = mergeEntry(st, m.id);
2218
+ e.info = m;
2219
+ for (const rawPart of (message.parts ?? [])) {
2220
+ const p = rawPart;
2221
+ if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
2222
+ continue;
2223
+ if (p.state?.status === "error")
2224
+ st.badSignal = true;
2225
+ e.parts.set(p.id, p);
2226
+ }
2227
+ emitMerged(st, sid, m.id);
2228
+ if (m.role === "user" && m.model) {
2229
+ modelSeen = true;
2230
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
2231
+ enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
2232
+ }
1465
2233
  }
1466
- }
2234
+ };
2235
+ const messages = await fetchMessages(client, sid, (page) => consume(page));
1467
2236
  if (!modelSeen && sessionInfoValue?.model) {
1468
2237
  // hosts whose user messages carry no model (v2): label from the
1469
2238
  // session's own model
@@ -1725,8 +2494,13 @@ export function createEngine(deps) {
1725
2494
  if (st.timer)
1726
2495
  clearTimeout(st.timer);
1727
2496
  st.timer = null;
2497
+ // detached flush (HTTP fallback off the shared chain — one
2498
+ // black-holing server must not stall the fan-out; same rule
2499
+ // as scheduleFlush)
1728
2500
  chain = chain
1729
- .then(() => flush(st))
2501
+ .then(() => {
2502
+ flush(st);
2503
+ })
1730
2504
  .catch((e) => logError("idle flush error", e?.message || e));
1731
2505
  const epoch = st.statusEpoch;
1732
2506
  const settle = () => {
@@ -1736,7 +2510,9 @@ export function createEngine(deps) {
1736
2510
  clearTimeout(st.timer);
1737
2511
  st.timer = null;
1738
2512
  chain = chain
1739
- .then(() => flush(st))
2513
+ .then(() => {
2514
+ flush(st);
2515
+ })
1740
2516
  .then(() => {
1741
2517
  armSettleDrop(st);
1742
2518
  })
@@ -1773,9 +2549,9 @@ export function createEngine(deps) {
1773
2549
  // opencode undo: revert() only marks the session (the viewer
1774
2550
  // hides the range); the NEXT prompt hard-deletes the reverted
1775
2551
  // messages/parts via cleanup() and publishes these events.
1776
- // The worker's store is upsert-only, so without an explicit
1777
- // tombstone the deleted items live forever and reappear the
1778
- // moment the revert marker clears (live-caught in production).
2552
+ // A message tombstone removes the whole v2 message (content is
2553
+ // embedded); a part removal re-emits the merged message minus
2554
+ // the part.
1779
2555
  const st = stFor(sid);
1780
2556
  if (!st || !st.activated || st.closed)
1781
2557
  return;
@@ -1783,13 +2559,18 @@ export function createEngine(deps) {
1783
2559
  if (!messageID)
1784
2560
  return;
1785
2561
  if (t === "message.removed") {
1786
- enqueue(st, { key: `message/${messageID}`, item: { type: "message", del: true, data: { id: messageID } } });
2562
+ // key/tombstone by the EVENT's session id (a child's message
2563
+ // must not be keyed onto the parent — the worker deletes the
2564
+ // item but the viewer's tombstone key would miss and the
2565
+ // msgIndex corpse could re-upsert it)
2566
+ dropMergedMessage(st, messageID, sid);
2567
+ enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) });
1787
2568
  }
1788
2569
  else {
1789
2570
  const partID = p.partID;
1790
2571
  if (!partID)
1791
2572
  return;
1792
- enqueue(st, { key: `part/${messageID}/${partID}`, item: { type: "part", del: true, data: { id: partID, messageID } } });
2573
+ dropMergedPart(st, sid, messageID, partID);
1793
2574
  }
1794
2575
  }
1795
2576
  else if (t === "message.updated") {
@@ -1844,7 +2625,7 @@ export function createEngine(deps) {
1844
2625
  }
1845
2626
  if (!st || !st.activated || st.closed)
1846
2627
  return;
1847
- enqueue(st, itemFor("message", p.info, sid));
2628
+ ingestMessageInfo(st, sid, p.info);
1848
2629
  const info = p.info;
1849
2630
  if (info?.role === "user" && info?.model) {
1850
2631
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
@@ -1853,8 +2634,9 @@ export function createEngine(deps) {
1853
2634
  // an assistant message that DIED (finish:"error") — e.g. all
1854
2635
  // retries exhausted — surfaces as a 200 response with an errored
1855
2636
  // message, NOT as session.error (live-caught in the suite); the
1856
- // share must still say error
1857
- if (info?.role === "assistant" && info.finish === "error") {
2637
+ // share must still say error. Works for v2-native messages too
2638
+ // (finish lives on the message itself).
2639
+ if (info?.finish === "error") {
1858
2640
  st.badSignal = true;
1859
2641
  }
1860
2642
  }
@@ -1865,13 +2647,13 @@ export function createEngine(deps) {
1865
2647
  const part = p.part;
1866
2648
  if (part?.state?.status === "error")
1867
2649
  st.badSignal = true;
1868
- enqueue(st, itemFor("part", part, sid));
2650
+ ingestPart(st, sid, part);
1869
2651
  }
1870
2652
  else if (t === "session.diff") {
1871
2653
  const st = stFor(sid);
1872
2654
  if (!st || !st.activated || st.closed)
1873
2655
  return;
1874
- enqueue(st, itemFor("session_diff", p.diff, sid));
2656
+ enqueue(st, itemFor("session_diff", normalizeV1Diff(p.diff), sid));
1875
2657
  }
1876
2658
  else if (t === "session.deleted") {
1877
2659
  // a CHILD session ending (task done, gc) must NEVER take the
@@ -1925,6 +2707,16 @@ export function createEngine(deps) {
1925
2707
  await flushViaHttp(st, r).catch(() => { });
1926
2708
  }
1927
2709
  }
2710
+ // resolve any pending debounced spool rewrite before exit: the
2711
+ // queue lines are already on disk (appends are sync), but the
2712
+ // meta's ackedRev and the compacted queue should be final
2713
+ for (const r of st.remotes.values()) {
2714
+ if (r.spoolSaveTimer) {
2715
+ clearTimeout(r.spoolSaveTimer);
2716
+ r.spoolSaveTimer = null;
2717
+ spoolSave(st, r);
2718
+ }
2719
+ }
1928
2720
  if (st.metaTimer)
1929
2721
  clearTimeout(st.metaTimer);
1930
2722
  st.metaTimer = null;