@mirrowel/opencode-openshare 0.2.0-dev.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,9 +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";
47
48
  import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
49
+ import { queueBindingWrite } from "./binding-queue.js";
48
50
  const DEFAULT_POLICY = {
49
51
  mode: "persistent",
50
52
  turnGraceMs: 120000,
@@ -271,6 +273,8 @@ export function createEngine(deps) {
271
273
  initTimer: null,
272
274
  initWasAttach: false,
273
275
  backoff: 1000,
276
+ backoffResetTimer: null,
277
+ spoolSaveTimer: null,
274
278
  reconnectTimer: null,
275
279
  intentionalClose: false,
276
280
  connectQueued: false,
@@ -336,12 +340,19 @@ export function createEngine(deps) {
336
340
  if (b && typeof b === "object" && b.policy) {
337
341
  applyPolicy(b.policy, url);
338
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
+ }
339
349
  })
340
350
  .catch(() => {
341
351
  // unreachable worker: defaults stay; the init ack will correct us
342
352
  policyAsked.delete(url);
343
353
  });
344
354
  };
355
+ const frameAuthUrls = new Set();
345
356
  const wsUrl = (url) => url.replace(/^http/, "ws");
346
357
  // ---- spool root ---------------------------------------------------------
347
358
  let spoolRoot = spoolDirFor(cfg);
@@ -383,6 +394,7 @@ export function createEngine(deps) {
383
394
  wroteBinding: false,
384
395
  everConnected: false,
385
396
  merged: new Map(),
397
+ sentHash: new Map(),
386
398
  });
387
399
  const refreshConfig = () => {
388
400
  // settings may change between activations (the TUI writes the config
@@ -422,6 +434,9 @@ export function createEngine(deps) {
422
434
  }
423
435
  st.activated = true;
424
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();
425
440
  const gated = mode === "manual" || opts2?.gated === true;
426
441
  if (gated) {
427
442
  // seed the share status from the session's CURRENT state before
@@ -496,25 +511,30 @@ export function createEngine(deps) {
496
511
  async function writeMetadata(st, value) {
497
512
  if (!st.client)
498
513
  return;
499
- try {
500
- const info = unwrap(await st.client.session.get({
501
- path: { id: st.sid },
502
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
503
- }));
504
- const metadata = { ...(info?.metadata ?? {}) };
505
- if (value === null)
506
- delete metadata[METADATA_KEY];
507
- else
508
- metadata[METADATA_KEY] = value;
509
- unwrap(await st.client.session.update({
510
- path: { id: st.sid },
511
- body: { metadata },
512
- ...(deps.directory ? { query: { directory: deps.directory } } : {}),
513
- }));
514
- }
515
- catch (e) {
516
- log("metadata write failed (spool still covers reconnects):", e?.message || e);
517
- }
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
+ });
518
538
  }
519
539
  /** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
520
540
  * rev,url,enabled?}}}) or legacy flat ({id,secret,rev,url}) which maps
@@ -635,7 +655,14 @@ export function createEngine(deps) {
635
655
  const spoolSave = (st, r) => {
636
656
  try {
637
657
  writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
638
- 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 });
639
666
  }
640
667
  catch (e) {
641
668
  logError("spool save failed", e?.message || e);
@@ -645,13 +672,38 @@ export function createEngine(deps) {
645
672
  // queue (O(n^2) during backfill bursts) — append the line instead
646
673
  const spoolAppend = (st, r, f) => {
647
674
  try {
648
- appendFileSync(queuePath(st, r.url), JSON.stringify(f) + "\n");
675
+ appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
649
676
  }
650
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;
651
682
  logError("spool append failed", e?.message || e);
652
683
  }
653
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
+ };
654
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).
655
707
  try {
656
708
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
657
709
  // metadata binding (db) is authoritative when both exist and disagree
@@ -660,26 +712,48 @@ export function createEngine(deps) {
660
712
  r.secret = meta.secret;
661
713
  r.ackedRev = meta.ackedRev ?? 0;
662
714
  }
715
+ }
716
+ catch { }
717
+ try {
663
718
  if (existsSync(queuePath(st, r.url))) {
664
719
  // append-only spool: a crash mid-append can tear the LAST line —
665
- // keep the valid prefix instead of dropping the whole queue
666
- r.pending = [];
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 = [];
667
726
  for (const line of readFileSync(queuePath(st, r.url), "utf8").split("\n")) {
668
727
  if (!line.trim())
669
728
  continue;
670
729
  try {
671
- r.pending.push(JSON.parse(line));
730
+ disk.push(JSON.parse(line));
672
731
  }
673
732
  catch {
674
733
  log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
675
734
  break;
676
735
  }
677
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;
678
754
  }
679
755
  }
680
- catch {
681
- r.pending = [];
682
- }
756
+ catch { }
683
757
  // single-server-era spool (no url suffix): belongs to the first target
684
758
  if (!r.shareId && targets[0]?.url === r.url) {
685
759
  try {
@@ -702,12 +776,26 @@ export function createEngine(deps) {
702
776
  };
703
777
  const spoolClear = (st, r) => {
704
778
  try {
779
+ if (r.spoolSaveTimer) {
780
+ clearTimeout(r.spoolSaveTimer);
781
+ r.spoolSaveTimer = null;
782
+ }
705
783
  rmSync(metaPath(st, r.url), { force: true });
706
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 });
707
788
  }
708
789
  catch { }
709
790
  };
710
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;
711
799
  const wsSend = (r, obj) => {
712
800
  if (debugEvents)
713
801
  log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
@@ -726,31 +814,63 @@ export function createEngine(deps) {
726
814
  * and rebroadcast; worker apply is keyed-idempotent, so chunks just
727
815
  * bump rev. An item larger than the whole budget rides alone in its
728
816
  * own frame (the worker fits oversize items to its storage cap).
729
- * Status rides each chunk; last write wins. */
730
- const chunkItems = (items, maxItems, maxBytes) => {
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) => {
731
823
  const chunks = [];
732
824
  let cur = [];
825
+ let curJson = [];
733
826
  let curBytes = 0;
734
827
  for (const it of items) {
735
- const n = (JSON.stringify(it) ?? "null").length;
736
- if (cur.length && (cur.length >= maxItems || curBytes + n > maxBytes)) {
737
- chunks.push(cur);
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(",") + "]" });
738
831
  cur = [];
832
+ curJson = [];
739
833
  curBytes = 0;
740
834
  }
741
835
  cur.push(it);
742
- curBytes += n;
836
+ curJson.push(j);
837
+ curBytes += j.length;
743
838
  }
744
839
  if (cur.length)
745
- chunks.push(cur);
840
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
746
841
  return chunks;
747
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
+ };
748
860
  const sendSync = (r, f) => {
749
861
  if (!f.items.length)
750
- return wsSend(r, { op: "sync", items: [], status: f.status });
751
- for (const chunk of chunkItems(f.items, 200, 512 * 1024)) {
752
- wsSend(r, { op: "sync", items: chunk, status: f.status });
753
- }
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));
754
874
  return true;
755
875
  };
756
876
  const announceUrl = (st, r) => {
@@ -944,7 +1064,12 @@ export function createEngine(deps) {
944
1064
  logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
945
1065
  return;
946
1066
  }
947
- 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 ?? "")}`}`);
948
1073
  r.ws = ws;
949
1074
  await new Promise((resolve, reject) => {
950
1075
  const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
@@ -985,6 +1110,32 @@ export function createEngine(deps) {
985
1110
  if (!st.closed)
986
1111
  scheduleReconnect(st, r);
987
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
+ }
988
1139
  if (r.initWasAttach) {
989
1140
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
990
1141
  }
@@ -1018,7 +1169,9 @@ export function createEngine(deps) {
1018
1169
  const scheduleReconnect = (st, r) => {
1019
1170
  if (st.closed || r.reconnectTimer)
1020
1171
  return;
1021
- 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)));
1022
1175
  r.backoff = Math.min(r.backoff * 2, 60000);
1023
1176
  r.reconnectTimer = setTimeout(() => {
1024
1177
  r.reconnectTimer = null;
@@ -1110,6 +1263,15 @@ export function createEngine(deps) {
1110
1263
  }
1111
1264
  };
1112
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
+ }
1113
1275
  if (frame.op === "init") {
1114
1276
  if (!frame.ok) {
1115
1277
  const err = String(frame.error ?? "");
@@ -1168,6 +1330,21 @@ export function createEngine(deps) {
1168
1330
  // bad base (mismatch protocol). Content REPLAYS to the new share —
1169
1331
  // pending is never dropped on a reset (live-caught: the recreate
1170
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
+ }
1171
1348
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1172
1349
  deleteAbandoned(r);
1173
1350
  r.shareId = null;
@@ -1199,11 +1376,26 @@ export function createEngine(deps) {
1199
1376
  r.connecting = false;
1200
1377
  r.initWasAttach = false;
1201
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;
1202
1383
  if (r.initTimer) {
1203
1384
  clearTimeout(r.initTimer);
1204
1385
  r.initTimer = null;
1205
1386
  }
1206
- 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);
1207
1399
  st.everConnected = true;
1208
1400
  if (frame.policy)
1209
1401
  applyPolicy(frame.policy, r.url);
@@ -1229,19 +1421,70 @@ export function createEngine(deps) {
1229
1421
  if (wasFanoutCreate) {
1230
1422
  // a server joined an ALREADY-live session (fan-out add, or a
1231
1423
  // post-mismatch recreate): its fresh share needs the full history —
1232
- // 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();
1233
1430
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1234
1431
  }
1235
1432
  return;
1236
1433
  }
1237
1434
  if (frame.op === "ack") {
1238
1435
  r.ackedRev = frame.rev ?? r.ackedRev;
1239
- r.pending = [];
1240
- 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);
1241
1453
  scheduleBindingWrite(st);
1242
1454
  return;
1243
1455
  }
1244
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
+ }
1245
1488
  logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
1246
1489
  // the control socket lost its attach state (worker-side eviction of
1247
1490
  // the control DO): force a reconnect — init attach replays pending
@@ -1275,7 +1518,7 @@ export function createEngine(deps) {
1275
1518
  // stamps run at frame time: the whole family's data is indexed by now
1276
1519
  // (child prompts are created during the task call, well inside the
1277
1520
  // flush debounce), and any re-enqueued message lands in this frame
1278
- stampsPass();
1521
+ stampsPass(st);
1279
1522
  const items = Array.from(st.queue.values());
1280
1523
  st.queue.clear();
1281
1524
  const statusChanged = st.status !== st.lastSentStatus;
@@ -1300,18 +1543,41 @@ export function createEngine(deps) {
1300
1543
  return;
1301
1544
  // fan out to every remote; each carries its own pending copy and acks
1302
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) : [];
1303
1550
  let httpWork;
1304
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
+ }
1305
1572
  // never send syncs before the init ack: the worker's per-socket
1306
1573
  // ControlDO has no attach until OUR init completes, and a racing
1307
1574
  // sync dies as "unknown op" (live-caught: post-drop reconnects
1308
1575
  // lost whole turns). The init-ok handler flushes pending itself.
1309
- r.pending.push(f);
1310
- spoolAppend(st, r, f);
1311
1576
  if (r.ws && !r.wsReady)
1312
1577
  continue;
1313
1578
  if (r.wsReady) {
1314
- sendSync(r, f);
1579
+ for (const cf of fresh)
1580
+ sendSync(r, cf);
1315
1581
  continue;
1316
1582
  }
1317
1583
  const viaHttp = flushViaHttp(st, r);
@@ -1320,29 +1586,67 @@ export function createEngine(deps) {
1320
1586
  }
1321
1587
  if (st.remotes.size)
1322
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));
1323
1593
  return httpWork;
1324
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
+ };
1325
1612
  const flushViaHttp = async (st, r) => {
1326
1613
  if (!r.shareId || !r.pending.length)
1327
1614
  return;
1328
1615
  // ALL pending frames, merged (keyed upserts — later wins). Sending
1329
1616
  // only the last frame dropped every earlier batch's items whenever
1330
1617
  // the HTTP fallback carried more than one (live-caught class:
1331
- // content loss on reconnect paths).
1332
- 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 = [];
1333
1624
  let status;
1625
+ const sentSeqs = new Set();
1334
1626
  for (const f of r.pending) {
1335
- for (const it of f.items)
1336
- 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
+ }
1337
1634
  if (f.status)
1338
1635
  status = f.status;
1636
+ if (typeof f.seq === "number")
1637
+ sentSeqs.add(f.seq);
1339
1638
  }
1639
+ const items = [...byKey.values(), ...unkeyed];
1340
1640
  const cred = credOf(r.url);
1341
1641
  // chunked like WS frames: one unbounded body is a whole-backlog
1342
1642
  // bomb after a mid-backfill socket drop (backfill = full history).
1343
1643
  // Failure stops the loop with pending intact — replaying already-
1344
1644
  // applied chunks is idempotent.
1345
- for (const chunk of chunkItems(items, 500, 4 * 1024 * 1024)) {
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) {
1346
1650
  const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1347
1651
  method: "POST",
1348
1652
  headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
@@ -1354,8 +1658,12 @@ export function createEngine(deps) {
1354
1658
  const body = (await res.json().catch(() => ({})));
1355
1659
  r.ackedRev = body.rev ?? r.ackedRev;
1356
1660
  }
1357
- r.pending = [];
1358
- spoolSave(st, r);
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);
1359
1667
  };
1360
1668
  const scheduleFlush = (st) => {
1361
1669
  if (st.timer)
@@ -1371,12 +1679,41 @@ export function createEngine(deps) {
1371
1679
  st.timer = null;
1372
1680
  if (debugEvents)
1373
1681
  log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
1374
- 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));
1375
1691
  }, wait);
1376
1692
  };
1377
1693
  const enqueue = (st, entry) => {
1378
1694
  if (!st || !entry)
1379
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);
1380
1717
  st.queue.set(entry.key, entry.item);
1381
1718
  indexMessage(entry);
1382
1719
  if (debugEvents)
@@ -1386,6 +1723,12 @@ export function createEngine(deps) {
1386
1723
  scheduleFlush(st);
1387
1724
  };
1388
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();
1389
1732
  const indexMessage = (entry) => {
1390
1733
  if (entry.item.type !== "message")
1391
1734
  return;
@@ -1413,6 +1756,10 @@ export function createEngine(deps) {
1413
1756
  };
1414
1757
  const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
1415
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;
1416
1763
  const st = sessionStateOf(m.sid);
1417
1764
  if (st)
1418
1765
  enqueue(st, itemFor("message", m.data, m.sid));
@@ -1435,15 +1782,7 @@ export function createEngine(deps) {
1435
1782
  /** user messages whose segment runs a DIFFERENT agent than the previous
1436
1783
  * segment carry metadata.agentSwitch = <agent> - the viewer renders the
1437
1784
  * transition chip from the stamp alone. */
1438
- const stampAgentSwitches = () => {
1439
- const bySession = new Map();
1440
- for (const m of msgIndex.values()) {
1441
- const list = bySession.get(m.sid);
1442
- if (list)
1443
- list.push(m);
1444
- else
1445
- bySession.set(m.sid, [m]);
1446
- }
1785
+ const stampAgentSwitches = (bySession) => {
1447
1786
  for (const [, list] of bySession) {
1448
1787
  list.sort((a, b) => a.created - b.created);
1449
1788
  const users = list.map((m, i) => (m.role === "user" ? i : -1)).filter((i) => i >= 0);
@@ -1451,13 +1790,20 @@ export function createEngine(deps) {
1451
1790
  for (let k = 0; k < users.length; k++) {
1452
1791
  const agent = segmentAgent(list, users[k]);
1453
1792
  const d = list[users[k]].data;
1454
- const meta = (d.metadata ??= {});
1455
1793
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
1456
- if (want !== meta.agentSwitch) {
1457
- if (want)
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 ??= {});
1458
1802
  meta.agentSwitch = want;
1459
- else
1460
- delete meta.agentSwitch;
1803
+ }
1804
+ else if (d.metadata) {
1805
+ delete d.metadata.agentSwitch;
1806
+ }
1461
1807
  reEnqueueMessage(list[users[k]]);
1462
1808
  }
1463
1809
  if (agent)
@@ -1470,15 +1816,7 @@ export function createEngine(deps) {
1470
1816
  * started (v1 prompts carry the agent; v2 resolves via the response).
1471
1817
  * Rank-free pairing - a resumed child's second call lands on its second
1472
1818
  * prompt by construction time. */
1473
- const stampTaskAgents = () => {
1474
- const bySession = new Map();
1475
- for (const m of msgIndex.values()) {
1476
- const list = bySession.get(m.sid);
1477
- if (list)
1478
- list.push(m);
1479
- else
1480
- bySession.set(m.sid, [m]);
1481
- }
1819
+ const stampTaskAgents = (bySession) => {
1482
1820
  for (const list of bySession.values())
1483
1821
  list.sort((a, b) => a.created - b.created);
1484
1822
  for (const m of msgIndex.values()) {
@@ -1586,9 +1924,35 @@ export function createEngine(deps) {
1586
1924
  reEnqueueMessage(m);
1587
1925
  }
1588
1926
  };
1589
- const stampsPass = () => {
1590
- stampAgentSwitches();
1591
- stampTaskAgents();
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);
1592
1956
  stampFileLanguages();
1593
1957
  };
1594
1958
  // ---- contract v2 message assembly --------------------------------------
@@ -1649,8 +2013,15 @@ export function createEngine(deps) {
1649
2013
  e.parts.set(part.id, part);
1650
2014
  emitMerged(st, sid, part.messageID);
1651
2015
  };
1652
- const dropMergedMessage = (st, messageID) => {
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;
1653
2022
  st.merged.delete(messageID);
2023
+ msgIndex.delete(`${sid}:${messageID}`);
2024
+ tombstoned.add(`${sid}:${messageID}`);
1654
2025
  };
1655
2026
  const dropMergedPart = (st, sid, messageID, partID) => {
1656
2027
  const e = st.merged.get(messageID);
@@ -1678,6 +2049,10 @@ export function createEngine(deps) {
1678
2049
  clearTimeout(r.initTimer);
1679
2050
  r.initTimer = null;
1680
2051
  }
2052
+ if (r.backoffResetTimer) {
2053
+ clearTimeout(r.backoffResetTimer);
2054
+ r.backoffResetTimer = null;
2055
+ }
1681
2056
  if (r.reconnectTimer) {
1682
2057
  clearTimeout(r.reconnectTimer);
1683
2058
  r.reconnectTimer = null;
@@ -1687,8 +2062,28 @@ export function createEngine(deps) {
1687
2062
  clearTimeout(r.idleDropTimer);
1688
2063
  r.idleDropTimer = null;
1689
2064
  }
1690
- 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) {
1691
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
+ }
1692
2087
  else if (r.shareId && r.secret) {
1693
2088
  // turn mode may have dropped the socket after the last settle; the
1694
2089
  // goodbye still must land — say it over HTTP
@@ -1710,6 +2105,21 @@ export function createEngine(deps) {
1710
2105
  }, 1000);
1711
2106
  for (const r of st.remotes.values())
1712
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);
1713
2123
  sessions.delete(st.sid);
1714
2124
  try {
1715
2125
  chain = chain.then(async () => {
@@ -1786,6 +2196,11 @@ export function createEngine(deps) {
1786
2196
  break;
1787
2197
  before = nextBefore;
1788
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
+ }
1789
2204
  return out;
1790
2205
  }
1791
2206
  const backfillSession = async (st, client, sid, info) => {
@@ -2079,8 +2494,13 @@ export function createEngine(deps) {
2079
2494
  if (st.timer)
2080
2495
  clearTimeout(st.timer);
2081
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)
2082
2500
  chain = chain
2083
- .then(() => flush(st))
2501
+ .then(() => {
2502
+ flush(st);
2503
+ })
2084
2504
  .catch((e) => logError("idle flush error", e?.message || e));
2085
2505
  const epoch = st.statusEpoch;
2086
2506
  const settle = () => {
@@ -2090,7 +2510,9 @@ export function createEngine(deps) {
2090
2510
  clearTimeout(st.timer);
2091
2511
  st.timer = null;
2092
2512
  chain = chain
2093
- .then(() => flush(st))
2513
+ .then(() => {
2514
+ flush(st);
2515
+ })
2094
2516
  .then(() => {
2095
2517
  armSettleDrop(st);
2096
2518
  })
@@ -2137,8 +2559,12 @@ export function createEngine(deps) {
2137
2559
  if (!messageID)
2138
2560
  return;
2139
2561
  if (t === "message.removed") {
2140
- dropMergedMessage(st, messageID);
2141
- enqueue(st, { key: `message/${messageID}`, item: messageTombstone(st.sid, 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) });
2142
2568
  }
2143
2569
  else {
2144
2570
  const partID = p.partID;
@@ -2281,6 +2707,16 @@ export function createEngine(deps) {
2281
2707
  await flushViaHttp(st, r).catch(() => { });
2282
2708
  }
2283
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
+ }
2284
2720
  if (st.metaTimer)
2285
2721
  clearTimeout(st.metaTimer);
2286
2722
  st.metaTimer = null;