@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/ci/openshare.js CHANGED
@@ -345,6 +345,7 @@ function resolveShareMode(cfg, input) {
345
345
 
346
346
  // src/core.ts
347
347
  import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
348
+ import { createHash } from "node:crypto";
348
349
  import { join as join2 } from "node:path";
349
350
 
350
351
  // src/contract.ts
@@ -554,6 +555,15 @@ function cursorFor(id, time) {
554
555
  return Buffer.from(JSON.stringify({ id, time })).toString("base64url");
555
556
  }
556
557
 
558
+ // src/binding-queue.ts
559
+ var queues = new Map;
560
+ function queueBindingWrite(sid, fn) {
561
+ const prev = queues.get(sid) ?? Promise.resolve();
562
+ const run = prev.then(fn, fn);
563
+ queues.set(sid, run.catch(() => {}));
564
+ return run;
565
+ }
566
+
557
567
  // src/core.ts
558
568
  var DEFAULT_POLICY = {
559
569
  mode: "persistent",
@@ -749,6 +759,8 @@ function createEngine(deps) {
749
759
  initTimer: null,
750
760
  initWasAttach: false,
751
761
  backoff: 1000,
762
+ backoffResetTimer: null,
763
+ spoolSaveTimer: null,
752
764
  reconnectTimer: null,
753
765
  intentionalClose: false,
754
766
  connectQueued: false,
@@ -806,10 +818,14 @@ function createEngine(deps) {
806
818
  if (b && typeof b === "object" && b.policy) {
807
819
  applyPolicy(b.policy, url);
808
820
  }
821
+ if (b && typeof b === "object" && b.frameAuth) {
822
+ frameAuthUrls.add(url);
823
+ }
809
824
  }).catch(() => {
810
825
  policyAsked.delete(url);
811
826
  });
812
827
  };
828
+ const frameAuthUrls = new Set;
813
829
  const wsUrl = (url) => url.replace(/^http/, "ws");
814
830
  let spoolRoot = spoolDirFor(cfg);
815
831
  try {
@@ -845,7 +861,8 @@ function createEngine(deps) {
845
861
  activated: false,
846
862
  wroteBinding: false,
847
863
  everConnected: false,
848
- merged: new Map
864
+ merged: new Map,
865
+ sentHash: new Map
849
866
  });
850
867
  const refreshConfig = () => {
851
868
  const next = loadConfig(deps.pluginDir, deps.options);
@@ -878,6 +895,7 @@ function createEngine(deps) {
878
895
  }
879
896
  st.activated = true;
880
897
  st.opts = opts;
898
+ st.sentHash.clear();
881
899
  const gated = mode === "manual" || opts2?.gated === true;
882
900
  if (gated) {
883
901
  chain = chain.then(() => seedManualStatus(st)).then(() => backfill(st, info)).then(() => queueConnectAll(st)).catch((e) => logError("activation error", e?.message || e));
@@ -929,24 +947,26 @@ function createEngine(deps) {
929
947
  async function writeMetadata(st, value) {
930
948
  if (!st.client)
931
949
  return;
932
- try {
933
- const info = unwrap(await st.client.session.get({
934
- path: { id: st.sid },
935
- ...deps.directory ? { query: { directory: deps.directory } } : {}
936
- }));
937
- const metadata = { ...info?.metadata ?? {} };
938
- if (value === null)
939
- delete metadata[METADATA_KEY];
940
- else
941
- metadata[METADATA_KEY] = value;
942
- unwrap(await st.client.session.update({
943
- path: { id: st.sid },
944
- body: { metadata },
945
- ...deps.directory ? { query: { directory: deps.directory } } : {}
946
- }));
947
- } catch (e) {
948
- log("metadata write failed (spool still covers reconnects):", e?.message || e);
949
- }
950
+ await queueBindingWrite(st.sid, async () => {
951
+ try {
952
+ const info = unwrap(await st.client.session.get({
953
+ path: { id: st.sid },
954
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
955
+ }));
956
+ const metadata = { ...info?.metadata ?? {} };
957
+ if (value === null)
958
+ delete metadata[METADATA_KEY];
959
+ else
960
+ metadata[METADATA_KEY] = value;
961
+ unwrap(await st.client.session.update({
962
+ path: { id: st.sid },
963
+ body: { metadata },
964
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
965
+ }));
966
+ } catch (e) {
967
+ log("metadata write failed (spool still covers reconnects):", e?.message || e);
968
+ }
969
+ });
950
970
  }
951
971
  const parseBinding = (record) => {
952
972
  const out = new Map;
@@ -1050,21 +1070,33 @@ function createEngine(deps) {
1050
1070
  const spoolSave = (st, r) => {
1051
1071
  try {
1052
1072
  writeFileSync2(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
1053
- writeFileSync2(queuePath(st, r.url), r.pending.map((f) => JSON.stringify(f)).join(`
1073
+ writeFileSync2(queuePath(st, r.url), r.pending.map((f) => f.wire ?? JSON.stringify(f)).join(`
1054
1074
  `) + (r.pending.length ? `
1055
1075
  ` : ""));
1076
+ rmSync(legacyMetaPath(st), { force: true });
1077
+ rmSync(legacyQueuePath(st), { force: true });
1056
1078
  } catch (e) {
1057
1079
  logError("spool save failed", e?.message || e);
1058
1080
  }
1059
1081
  };
1060
1082
  const spoolAppend = (st, r, f) => {
1061
1083
  try {
1062
- appendFileSync(queuePath(st, r.url), JSON.stringify(f) + `
1084
+ appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + `
1063
1085
  `);
1064
1086
  } catch (e) {
1087
+ r.spoolAppendFailed = true;
1065
1088
  logError("spool append failed", e?.message || e);
1066
1089
  }
1067
1090
  };
1091
+ const scheduleSpoolSave = (st, r) => {
1092
+ if (r.spoolSaveTimer)
1093
+ return;
1094
+ r.spoolSaveTimer = setTimeout(() => {
1095
+ r.spoolSaveTimer = null;
1096
+ if (!st.closed)
1097
+ spoolSave(st, r);
1098
+ }, 300);
1099
+ };
1068
1100
  const spoolLoad = (st, r) => {
1069
1101
  try {
1070
1102
  const meta = JSON.parse(readFileSync2(metaPath(st, r.url), "utf8"));
@@ -1073,23 +1105,33 @@ function createEngine(deps) {
1073
1105
  r.secret = meta.secret;
1074
1106
  r.ackedRev = meta.ackedRev ?? 0;
1075
1107
  }
1108
+ } catch {}
1109
+ try {
1076
1110
  if (existsSync2(queuePath(st, r.url))) {
1077
- r.pending = [];
1111
+ const disk = [];
1078
1112
  for (const line of readFileSync2(queuePath(st, r.url), "utf8").split(`
1079
1113
  `)) {
1080
1114
  if (!line.trim())
1081
1115
  continue;
1082
1116
  try {
1083
- r.pending.push(JSON.parse(line));
1117
+ disk.push(JSON.parse(line));
1084
1118
  } catch {
1085
1119
  log(`spool load: dropped torn tail line for ${st.sid.slice(0, 18)}`);
1086
1120
  break;
1087
1121
  }
1088
1122
  }
1123
+ for (const f of disk) {
1124
+ if (typeof f.seq !== "number")
1125
+ f.seq = ++nextSeq;
1126
+ else if (f.seq >= nextSeq)
1127
+ nextSeq = f.seq + 1;
1128
+ }
1129
+ if (!r.spoolAppendFailed)
1130
+ r.pending = disk;
1131
+ else
1132
+ r.spoolAppendFailed = false;
1089
1133
  }
1090
- } catch {
1091
- r.pending = [];
1092
- }
1134
+ } catch {}
1093
1135
  if (!r.shareId && targets[0]?.url === r.url) {
1094
1136
  try {
1095
1137
  const meta = JSON.parse(readFileSync2(legacyMetaPath(st), "utf8"));
@@ -1108,10 +1150,17 @@ function createEngine(deps) {
1108
1150
  };
1109
1151
  const spoolClear = (st, r) => {
1110
1152
  try {
1153
+ if (r.spoolSaveTimer) {
1154
+ clearTimeout(r.spoolSaveTimer);
1155
+ r.spoolSaveTimer = null;
1156
+ }
1111
1157
  rmSync(metaPath(st, r.url), { force: true });
1112
1158
  rmSync(queuePath(st, r.url), { force: true });
1159
+ rmSync(legacyMetaPath(st), { force: true });
1160
+ rmSync(legacyQueuePath(st), { force: true });
1113
1161
  } catch {}
1114
1162
  };
1163
+ let nextSeq = 0;
1115
1164
  const wsSend = (r, obj) => {
1116
1165
  if (debugEvents)
1117
1166
  log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
@@ -1122,30 +1171,50 @@ function createEngine(deps) {
1122
1171
  }
1123
1172
  return true;
1124
1173
  };
1125
- const chunkItems = (items, maxItems, maxBytes) => {
1174
+ const chunkedFrames = (items, maxItems, maxBytes) => {
1126
1175
  const chunks = [];
1127
1176
  let cur = [];
1177
+ let curJson = [];
1128
1178
  let curBytes = 0;
1129
1179
  for (const it of items) {
1130
- const n = (JSON.stringify(it) ?? "null").length;
1131
- if (cur.length && (cur.length >= maxItems || curBytes + n > maxBytes)) {
1132
- chunks.push(cur);
1180
+ const j = JSON.stringify(it) ?? "null";
1181
+ if (cur.length && (cur.length >= maxItems || curBytes + j.length > maxBytes)) {
1182
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
1133
1183
  cur = [];
1184
+ curJson = [];
1134
1185
  curBytes = 0;
1135
1186
  }
1136
1187
  cur.push(it);
1137
- curBytes += n;
1188
+ curJson.push(j);
1189
+ curBytes += j.length;
1138
1190
  }
1139
1191
  if (cur.length)
1140
- chunks.push(cur);
1192
+ chunks.push({ items: cur, json: "[" + curJson.join(",") + "]" });
1141
1193
  return chunks;
1142
1194
  };
1195
+ const chunkItems = (items, maxItems, maxBytes) => chunkedFrames(items, maxItems, maxBytes).map((c) => c.items);
1196
+ const syncFrameStr = (itemsJson, status, seq) => `{"op":"sync","items":${itemsJson},"status":${JSON.stringify(status)}${seq !== undefined ? `,"seq":${seq}` : ""}}`;
1197
+ const wsSendRaw = (r, s) => {
1198
+ if (debugEvents)
1199
+ log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send raw (${Math.round(s.length / 1024)}KB, ready=${r.wsReady})`);
1200
+ try {
1201
+ r.ws?.send(s);
1202
+ } catch {
1203
+ return false;
1204
+ }
1205
+ return true;
1206
+ };
1143
1207
  const sendSync = (r, f) => {
1144
1208
  if (!f.items.length)
1145
- return wsSend(r, { op: "sync", items: [], status: f.status });
1146
- for (const chunk of chunkItems(f.items, 200, 512 * 1024)) {
1147
- wsSend(r, { op: "sync", items: chunk, status: f.status });
1148
- }
1209
+ return wsSend(r, { op: "sync", items: [], status: f.status, seq: f.seq });
1210
+ if (f.wire)
1211
+ return wsSendRaw(r, f.wire);
1212
+ const itemsJson = JSON.stringify(f.items);
1213
+ if (f.items.length <= 200 && itemsJson.length <= 512 * 1024) {
1214
+ return wsSendRaw(r, syncFrameStr(itemsJson, f.status, f.seq));
1215
+ }
1216
+ for (const c of chunkedFrames(f.items, 200, 512 * 1024))
1217
+ wsSendRaw(r, syncFrameStr(c.json, f.status, f.seq));
1149
1218
  return true;
1150
1219
  };
1151
1220
  const announceUrl = (st, r) => {
@@ -1287,7 +1356,8 @@ function createEngine(deps) {
1287
1356
  logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
1288
1357
  return;
1289
1358
  }
1290
- const ws = new WebSocket(`${wsUrl(r.url)}/api/ws?token=${encodeURIComponent(token ?? "")}`);
1359
+ const useFrameAuth = !!token && frameAuthUrls.has(r.url);
1360
+ const ws = new WebSocket(`${wsUrl(r.url)}/api/ws${useFrameAuth ? "" : `?token=${encodeURIComponent(token ?? "")}`}`);
1291
1361
  r.ws = ws;
1292
1362
  await new Promise((resolve, reject) => {
1293
1363
  const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
@@ -1323,6 +1393,27 @@ function createEngine(deps) {
1323
1393
  if (!st.closed)
1324
1394
  scheduleReconnect(st, r);
1325
1395
  });
1396
+ if (useFrameAuth) {
1397
+ wsSend(r, { op: "auth", token });
1398
+ const authed = await new Promise((resolve) => {
1399
+ const t = setTimeout(() => resolve(false), 3000);
1400
+ r.authWaiter = () => {
1401
+ clearTimeout(t);
1402
+ resolve(true);
1403
+ };
1404
+ });
1405
+ r.authWaiter = undefined;
1406
+ if (!authed) {
1407
+ logError(`ws ${r.url.slice(0, 24)}: frame-auth ack timeout - reconnecting with query token`);
1408
+ frameAuthUrls.delete(r.url);
1409
+ try {
1410
+ ws.close(4000, "auth timeout");
1411
+ } catch {}
1412
+ if (!st.closed)
1413
+ scheduleReconnect(st, r);
1414
+ return;
1415
+ }
1416
+ }
1326
1417
  if (r.initWasAttach) {
1327
1418
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
1328
1419
  } else {
@@ -1351,7 +1442,7 @@ function createEngine(deps) {
1351
1442
  const scheduleReconnect = (st, r) => {
1352
1443
  if (st.closed || r.reconnectTimer)
1353
1444
  return;
1354
- const wait = r.backoff;
1445
+ const wait = Math.max(250, Math.round(r.backoff * (0.8 + Math.random() * 0.4)));
1355
1446
  r.backoff = Math.min(r.backoff * 2, 60000);
1356
1447
  r.reconnectTimer = setTimeout(() => {
1357
1448
  r.reconnectTimer = null;
@@ -1433,6 +1524,11 @@ function createEngine(deps) {
1433
1524
  }
1434
1525
  };
1435
1526
  const handleFrame = async (st, r, frame) => {
1527
+ if (frame.op === "authed") {
1528
+ r.authWaiter?.();
1529
+ r.authWaiter = undefined;
1530
+ return;
1531
+ }
1436
1532
  if (frame.op === "init") {
1437
1533
  if (!frame.ok) {
1438
1534
  const err = String(frame.error ?? "");
@@ -1471,6 +1567,16 @@ function createEngine(deps) {
1471
1567
  queueConnect(st, r);
1472
1568
  return;
1473
1569
  }
1570
+ if (/limit|too many|rate/i.test(err)) {
1571
+ logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
1572
+ r.connecting = false;
1573
+ try {
1574
+ r.ws?.close(4000, "rate limited");
1575
+ } catch {}
1576
+ if (!st.closed)
1577
+ scheduleReconnect(st, r);
1578
+ return;
1579
+ }
1474
1580
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1475
1581
  deleteAbandoned(r);
1476
1582
  r.shareId = null;
@@ -1499,11 +1605,19 @@ function createEngine(deps) {
1499
1605
  r.connecting = false;
1500
1606
  r.initWasAttach = false;
1501
1607
  r.collided = null;
1608
+ if (r.frozenRemote && !frame.error)
1609
+ r.frozenRemote = false;
1502
1610
  if (r.initTimer) {
1503
1611
  clearTimeout(r.initTimer);
1504
1612
  r.initTimer = null;
1505
1613
  }
1506
- r.backoff = 1000;
1614
+ if (r.backoffResetTimer)
1615
+ clearTimeout(r.backoffResetTimer);
1616
+ r.backoffResetTimer = setTimeout(() => {
1617
+ r.backoffResetTimer = null;
1618
+ if (r.wsReady && !r.reconnectTimer)
1619
+ r.backoff = 1000;
1620
+ }, 30000);
1507
1621
  st.everConnected = true;
1508
1622
  if (frame.policy)
1509
1623
  applyPolicy(frame.policy, r.url);
@@ -1523,18 +1637,49 @@ function createEngine(deps) {
1523
1637
  flush(st);
1524
1638
  }
1525
1639
  if (wasFanoutCreate) {
1640
+ st.sentHash.clear();
1526
1641
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1527
1642
  }
1528
1643
  return;
1529
1644
  }
1530
1645
  if (frame.op === "ack") {
1531
1646
  r.ackedRev = frame.rev ?? r.ackedRev;
1532
- r.pending = [];
1533
- spoolSave(st, r);
1647
+ if (r.backoffResetTimer) {
1648
+ clearTimeout(r.backoffResetTimer);
1649
+ r.backoffResetTimer = null;
1650
+ r.backoff = 1000;
1651
+ }
1652
+ if (typeof frame.seq === "number") {
1653
+ r.pending = r.pending.filter((p) => p.seq !== frame.seq);
1654
+ } else {
1655
+ r.pending = [];
1656
+ }
1657
+ scheduleSpoolSave(st, r);
1534
1658
  scheduleBindingWrite(st);
1535
1659
  return;
1536
1660
  }
1537
1661
  if (frame.op === "nack" || frame.op === "error") {
1662
+ const errText = String(frame.error ?? "");
1663
+ if (frame.op === "nack") {
1664
+ if (/not found|gone/i.test(errText)) {
1665
+ logError(`${r.url}: share vanished server-side (nack: ${errText}) - recreating`);
1666
+ deleteAbandoned(r);
1667
+ r.shareId = null;
1668
+ r.secret = null;
1669
+ r.ackedRev = 0;
1670
+ try {
1671
+ r.ws?.close(4000, "share gone");
1672
+ } catch {}
1673
+ if (!st.closed)
1674
+ queueConnect(st, r);
1675
+ return;
1676
+ }
1677
+ if (/frozen/i.test(errText)) {
1678
+ r.frozenRemote = true;
1679
+ logError(`${r.url}: share frozen by the hoster - edits refused; contact the server admin`);
1680
+ return;
1681
+ }
1682
+ }
1538
1683
  logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
1539
1684
  if (frame.op === "error" && r.wsReady && !st.closed) {
1540
1685
  r.wsReady = false;
@@ -1557,7 +1702,7 @@ function createEngine(deps) {
1557
1702
  queueConnect(st, r);
1558
1703
  };
1559
1704
  const frameFrom = (st) => {
1560
- stampsPass();
1705
+ stampsPass(st);
1561
1706
  const items = Array.from(st.queue.values());
1562
1707
  st.queue.clear();
1563
1708
  const statusChanged = st.status !== st.lastSentStatus;
@@ -1577,14 +1722,32 @@ function createEngine(deps) {
1577
1722
  const f = frameFrom(st);
1578
1723
  if (!f)
1579
1724
  return;
1725
+ const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
1580
1726
  let httpWork;
1581
1727
  for (const r of st.remotes.values()) {
1582
- r.pending.push(f);
1583
- spoolAppend(st, r, f);
1728
+ if (r.frozenRemote)
1729
+ continue;
1730
+ const fresh = [];
1731
+ if (chunks.length) {
1732
+ for (const c of chunks) {
1733
+ const seq = ++nextSeq;
1734
+ const cf = { items: c.items, status: f.status, seq, wire: syncFrameStr(c.json, f.status, seq) };
1735
+ fresh.push(cf);
1736
+ r.pending.push(cf);
1737
+ spoolAppend(st, r, cf);
1738
+ }
1739
+ } else {
1740
+ const seq = ++nextSeq;
1741
+ const cf = { items: [], status: f.status, seq };
1742
+ fresh.push(cf);
1743
+ r.pending.push(cf);
1744
+ spoolAppend(st, r, cf);
1745
+ }
1584
1746
  if (r.ws && !r.wsReady)
1585
1747
  continue;
1586
1748
  if (r.wsReady) {
1587
- sendSync(r, f);
1749
+ for (const cf of fresh)
1750
+ sendSync(r, cf);
1588
1751
  continue;
1589
1752
  }
1590
1753
  const viaHttp = flushViaHttp(st, r);
@@ -1593,21 +1756,55 @@ function createEngine(deps) {
1593
1756
  }
1594
1757
  if (st.remotes.size)
1595
1758
  st.lastFlushAt = Date.now();
1759
+ if (httpWork)
1760
+ httpWork.catch((e) => logError("http flush error", e?.message || e));
1596
1761
  return httpWork;
1597
1762
  };
1763
+ const httpDedupeKey = (it) => {
1764
+ if (!it || typeof it !== "object")
1765
+ return null;
1766
+ const o = it;
1767
+ switch (o.type) {
1768
+ case "session":
1769
+ return "session/" + (o.data?.id ?? "");
1770
+ case "session_diff":
1771
+ return "session_diff/" + (o.sessionID ?? o.data?.sessionID ?? "");
1772
+ case "message":
1773
+ return "message/" + (o.data?.id ?? "");
1774
+ case "part":
1775
+ return "part/" + (o.data?.messageID ?? "") + "/" + (o.data?.id ?? "");
1776
+ case "model":
1777
+ return "model/" + (o.sessionID ?? o.data?.sessionID ?? "");
1778
+ case "prefs":
1779
+ return "prefs";
1780
+ default:
1781
+ return null;
1782
+ }
1783
+ };
1598
1784
  const flushViaHttp = async (st, r) => {
1599
1785
  if (!r.shareId || !r.pending.length)
1600
1786
  return;
1601
- const items = [];
1787
+ const byKey = new Map;
1788
+ const unkeyed = [];
1602
1789
  let status;
1790
+ const sentSeqs = new Set;
1603
1791
  for (const f of r.pending) {
1604
- for (const it of f.items)
1605
- items.push(it);
1792
+ for (const it of f.items) {
1793
+ const k = httpDedupeKey(it);
1794
+ if (k)
1795
+ byKey.set(k, it);
1796
+ else
1797
+ unkeyed.push(it);
1798
+ }
1606
1799
  if (f.status)
1607
1800
  status = f.status;
1801
+ if (typeof f.seq === "number")
1802
+ sentSeqs.add(f.seq);
1608
1803
  }
1804
+ const items = [...byKey.values(), ...unkeyed];
1609
1805
  const cred = credOf(r.url);
1610
- for (const chunk of chunkItems(items, 500, 4 * 1024 * 1024)) {
1806
+ const chunks = items.length ? chunkItems(items, 500, 4 * 1024 * 1024) : [[]];
1807
+ for (const chunk of chunks) {
1611
1808
  const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1612
1809
  method: "POST",
1613
1810
  headers: { "content-type": "application/json", ...cred.credential ? { authorization: `Bearer ${cred.credential}` } : {} },
@@ -1619,8 +1816,8 @@ function createEngine(deps) {
1619
1816
  const body = await res.json().catch(() => ({}));
1620
1817
  r.ackedRev = body.rev ?? r.ackedRev;
1621
1818
  }
1622
- r.pending = [];
1623
- spoolSave(st, r);
1819
+ r.pending = r.pending.filter((p) => !sentSeqs.has(p.seq));
1820
+ scheduleSpoolSave(st, r);
1624
1821
  };
1625
1822
  const scheduleFlush = (st) => {
1626
1823
  if (st.timer)
@@ -1631,12 +1828,27 @@ function createEngine(deps) {
1631
1828
  st.timer = null;
1632
1829
  if (debugEvents)
1633
1830
  log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
1634
- chain = chain.then(() => flush(st)).catch((e) => logError("flush error", e?.message || e));
1831
+ chain = chain.then(() => {
1832
+ flush(st);
1833
+ }).catch((e) => logError("flush error", e?.message || e));
1635
1834
  }, wait);
1636
1835
  };
1637
1836
  const enqueue = (st, entry) => {
1638
1837
  if (!st || !entry)
1639
1838
  return;
1839
+ let h;
1840
+ try {
1841
+ h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
1842
+ } catch {
1843
+ h = "";
1844
+ }
1845
+ if (h && st.sentHash.get(entry.key) === h) {
1846
+ if (debugEvents)
1847
+ log(`enqueue dedupe ${entry.item.type} ${entry.key.slice(0, 40)}`);
1848
+ return;
1849
+ }
1850
+ if (h)
1851
+ st.sentHash.set(entry.key, h);
1640
1852
  st.queue.set(entry.key, entry.item);
1641
1853
  indexMessage(entry);
1642
1854
  if (debugEvents)
@@ -1646,6 +1858,7 @@ function createEngine(deps) {
1646
1858
  scheduleFlush(st);
1647
1859
  };
1648
1860
  const msgIndex = new Map;
1861
+ const tombstoned = new Set;
1649
1862
  const indexMessage = (entry) => {
1650
1863
  if (entry.item.type !== "message")
1651
1864
  return;
@@ -1667,6 +1880,8 @@ function createEngine(deps) {
1667
1880
  };
1668
1881
  const sessionStateOf = (sid) => sessions.get(childToParent.get(sid) ?? sid);
1669
1882
  const reEnqueueMessage = (m) => {
1883
+ if (tombstoned.has(`${m.sid}:${m.data.id}`))
1884
+ return;
1670
1885
  const st = sessionStateOf(m.sid);
1671
1886
  if (st)
1672
1887
  enqueue(st, itemFor("message", m.data, m.sid));
@@ -1683,15 +1898,7 @@ function createEngine(deps) {
1683
1898
  }
1684
1899
  return;
1685
1900
  };
1686
- const stampAgentSwitches = () => {
1687
- const bySession = new Map;
1688
- for (const m of msgIndex.values()) {
1689
- const list = bySession.get(m.sid);
1690
- if (list)
1691
- list.push(m);
1692
- else
1693
- bySession.set(m.sid, [m]);
1694
- }
1901
+ const stampAgentSwitches = (bySession) => {
1695
1902
  for (const [, list] of bySession) {
1696
1903
  list.sort((a, b) => a.created - b.created);
1697
1904
  const users = list.map((m, i) => m.role === "user" ? i : -1).filter((i) => i >= 0);
@@ -1699,13 +1906,14 @@ function createEngine(deps) {
1699
1906
  for (let k = 0;k < users.length; k++) {
1700
1907
  const agent = segmentAgent(list, users[k]);
1701
1908
  const d = list[users[k]].data;
1702
- const meta = d.metadata ??= {};
1703
1909
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
1704
- if (want !== meta.agentSwitch) {
1705
- if (want)
1910
+ if (want !== d.metadata?.agentSwitch) {
1911
+ if (want) {
1912
+ const meta = d.metadata ??= {};
1706
1913
  meta.agentSwitch = want;
1707
- else
1708
- delete meta.agentSwitch;
1914
+ } else if (d.metadata) {
1915
+ delete d.metadata.agentSwitch;
1916
+ }
1709
1917
  reEnqueueMessage(list[users[k]]);
1710
1918
  }
1711
1919
  if (agent)
@@ -1713,15 +1921,7 @@ function createEngine(deps) {
1713
1921
  }
1714
1922
  }
1715
1923
  };
1716
- const stampTaskAgents = () => {
1717
- const bySession = new Map;
1718
- for (const m of msgIndex.values()) {
1719
- const list = bySession.get(m.sid);
1720
- if (list)
1721
- list.push(m);
1722
- else
1723
- bySession.set(m.sid, [m]);
1724
- }
1924
+ const stampTaskAgents = (bySession) => {
1725
1925
  for (const list of bySession.values())
1726
1926
  list.sort((a, b) => a.created - b.created);
1727
1927
  for (const m of msgIndex.values()) {
@@ -1853,9 +2053,28 @@ function createEngine(deps) {
1853
2053
  reEnqueueMessage(m);
1854
2054
  }
1855
2055
  };
1856
- const stampsPass = () => {
1857
- stampAgentSwitches();
1858
- stampTaskAgents();
2056
+ const stampsPass = (st) => {
2057
+ let any = false;
2058
+ for (const it of st.queue.values()) {
2059
+ if (it.type === "message") {
2060
+ any = true;
2061
+ break;
2062
+ }
2063
+ }
2064
+ if (!any)
2065
+ return;
2066
+ const bySession = new Map;
2067
+ for (const m of msgIndex.values()) {
2068
+ const list = bySession.get(m.sid);
2069
+ if (list)
2070
+ list.push(m);
2071
+ else
2072
+ bySession.set(m.sid, [m]);
2073
+ }
2074
+ for (const list of bySession.values())
2075
+ list.sort((a, b) => a.created - b.created);
2076
+ stampAgentSwitches(bySession);
2077
+ stampTaskAgents(bySession);
1859
2078
  stampFileLanguages();
1860
2079
  };
1861
2080
  const mergeEntry = (st, messageID) => {
@@ -1905,8 +2124,11 @@ function createEngine(deps) {
1905
2124
  e.parts.set(part.id, part);
1906
2125
  emitMerged(st, sid, part.messageID);
1907
2126
  };
1908
- const dropMergedMessage = (st, messageID) => {
2127
+ const dropMergedMessage = (st, messageID, evSid) => {
2128
+ const sid = evSid ?? st.sid;
1909
2129
  st.merged.delete(messageID);
2130
+ msgIndex.delete(`${sid}:${messageID}`);
2131
+ tombstoned.add(`${sid}:${messageID}`);
1910
2132
  };
1911
2133
  const dropMergedPart = (st, sid, messageID, partID) => {
1912
2134
  const e = st.merged.get(messageID);
@@ -1934,6 +2156,10 @@ function createEngine(deps) {
1934
2156
  clearTimeout(r.initTimer);
1935
2157
  r.initTimer = null;
1936
2158
  }
2159
+ if (r.backoffResetTimer) {
2160
+ clearTimeout(r.backoffResetTimer);
2161
+ r.backoffResetTimer = null;
2162
+ }
1937
2163
  if (r.reconnectTimer) {
1938
2164
  clearTimeout(r.reconnectTimer);
1939
2165
  r.reconnectTimer = null;
@@ -1943,9 +2169,21 @@ function createEngine(deps) {
1943
2169
  clearTimeout(r.idleDropTimer);
1944
2170
  r.idleDropTimer = null;
1945
2171
  }
1946
- if (r.wsReady)
2172
+ if (r.wsReady && r.shareId) {
1947
2173
  wsSend(r, { op: "delete" });
1948
- else if (r.shareId && r.secret) {
2174
+ if (r.shareId && r.secret) {
2175
+ const sid = r.shareId;
2176
+ const sec = r.secret;
2177
+ setTimeout(() => {
2178
+ fetch(`${r.url}/api/share/${sid}`, {
2179
+ method: "DELETE",
2180
+ headers: { "content-type": "application/json" },
2181
+ body: JSON.stringify({ secret: sec }),
2182
+ signal: AbortSignal.timeout(8000)
2183
+ }).catch(() => {});
2184
+ }, 2000);
2185
+ }
2186
+ } else if (r.shareId && r.secret) {
1949
2187
  fetch(`${r.url}/api/share/${r.shareId}`, {
1950
2188
  method: "DELETE",
1951
2189
  headers: { "content-type": "application/json" },
@@ -1963,6 +2201,17 @@ function createEngine(deps) {
1963
2201
  }, 1000);
1964
2202
  for (const r of st.remotes.values())
1965
2203
  spoolClear(st, r);
2204
+ const stSid = st.sid;
2205
+ const pfx = `${stSid}:`;
2206
+ for (const k of msgIndex.keys())
2207
+ if (k.startsWith(pfx))
2208
+ msgIndex.delete(k);
2209
+ for (const k of tombstoned)
2210
+ if (k.startsWith(pfx))
2211
+ tombstoned.delete(k);
2212
+ for (const [child, parent] of childToParent)
2213
+ if (parent === stSid || child === stSid)
2214
+ childToParent.delete(child);
1966
2215
  sessions.delete(st.sid);
1967
2216
  try {
1968
2217
  chain = chain.then(async () => {
@@ -2022,6 +2271,9 @@ function createEngine(deps) {
2022
2271
  break;
2023
2272
  before = nextBefore;
2024
2273
  }
2274
+ if (out.length >= MAX_PAGES * PAGE_SIZE) {
2275
+ logError(`backfill truncated at ${out.length} messages (page cap) for ${sid.slice(0, 18)} — oldest history not shared`);
2276
+ }
2025
2277
  return out;
2026
2278
  }
2027
2279
  const backfillSession = async (st, client, sid, info) => {
@@ -2266,7 +2518,9 @@ function createEngine(deps) {
2266
2518
  if (st.timer)
2267
2519
  clearTimeout(st.timer);
2268
2520
  st.timer = null;
2269
- chain = chain.then(() => flush(st)).catch((e) => logError("idle flush error", e?.message || e));
2521
+ chain = chain.then(() => {
2522
+ flush(st);
2523
+ }).catch((e) => logError("idle flush error", e?.message || e));
2270
2524
  const epoch = st.statusEpoch;
2271
2525
  const settle = () => {
2272
2526
  st.status = st.badSignal ? "error" : "completed";
@@ -2274,7 +2528,9 @@ function createEngine(deps) {
2274
2528
  if (st.timer)
2275
2529
  clearTimeout(st.timer);
2276
2530
  st.timer = null;
2277
- chain = chain.then(() => flush(st)).then(() => {
2531
+ chain = chain.then(() => {
2532
+ flush(st);
2533
+ }).then(() => {
2278
2534
  armSettleDrop(st);
2279
2535
  }).catch((e) => logError("settle flush error", e?.message || e));
2280
2536
  };
@@ -2305,8 +2561,8 @@ function createEngine(deps) {
2305
2561
  if (!messageID)
2306
2562
  return;
2307
2563
  if (t === "message.removed") {
2308
- dropMergedMessage(st, messageID);
2309
- enqueue(st, { key: `message/${messageID}`, item: messageTombstone(st.sid, messageID) });
2564
+ dropMergedMessage(st, messageID, sid);
2565
+ enqueue(st, { key: `message/${messageID}`, item: messageTombstone(sid, messageID) });
2310
2566
  } else {
2311
2567
  const partID = p.partID;
2312
2568
  if (!partID)
@@ -2417,6 +2673,13 @@ function createEngine(deps) {
2417
2673
  await flushViaHttp(st, r).catch(() => {});
2418
2674
  }
2419
2675
  }
2676
+ for (const r of st.remotes.values()) {
2677
+ if (r.spoolSaveTimer) {
2678
+ clearTimeout(r.spoolSaveTimer);
2679
+ r.spoolSaveTimer = null;
2680
+ spoolSave(st, r);
2681
+ }
2682
+ }
2420
2683
  if (st.metaTimer)
2421
2684
  clearTimeout(st.metaTimer);
2422
2685
  st.metaTimer = null;
@@ -2569,6 +2832,7 @@ function createV2ServerSetup() {
2569
2832
  return () => {};
2570
2833
  }
2571
2834
  const pendingRefresh = new Map;
2835
+ const queuedInbox = new Map;
2572
2836
  const refresh = (sessionID) => {
2573
2837
  let timer = pendingRefresh.get(sessionID);
2574
2838
  if (timer)
@@ -2583,7 +2847,10 @@ function createV2ServerSetup() {
2583
2847
  });
2584
2848
  const messages = await client.session.messages?.({ path: { id: sessionID } });
2585
2849
  const list = messages?.data ?? [];
2850
+ const held = queuedInbox.get(sessionID);
2586
2851
  for (const message of list) {
2852
+ if (held?.has(message.info.id))
2853
+ continue;
2587
2854
  await engine.hooks.event({
2588
2855
  event: { type: "message.updated", properties: { sessionID, info: message.info } }
2589
2856
  });
@@ -2616,6 +2883,33 @@ function createV2ServerSetup() {
2616
2883
  await engine.hooks.event({ event: { type, properties: { sessionID: sid } } });
2617
2884
  continue;
2618
2885
  }
2886
+ if (type.startsWith("session.inbox.")) {
2887
+ const data = event.data ?? {};
2888
+ const inboxID = data.inboxID;
2889
+ if (inboxID) {
2890
+ let set = queuedInbox.get(sid);
2891
+ if (!set) {
2892
+ set = new Set;
2893
+ queuedInbox.set(sid, set);
2894
+ }
2895
+ if (type === "session.inbox.enqueued" && data.item?.delivery === "queue") {
2896
+ set.add(inboxID);
2897
+ } else if (type === "session.inbox.delivered") {
2898
+ set.delete(inboxID);
2899
+ refresh(sid);
2900
+ } else if (type === "session.inbox.cancelled") {
2901
+ set.add(inboxID);
2902
+ } else if (type === "session.inbox.delivery.changed") {
2903
+ if (data.item?.delivery === "queue")
2904
+ set.add(inboxID);
2905
+ else {
2906
+ set.delete(inboxID);
2907
+ refresh(sid);
2908
+ }
2909
+ }
2910
+ }
2911
+ continue;
2912
+ }
2619
2913
  if (type === "message.removed" || type === "message.part.removed") {
2620
2914
  const data = event.data ?? {};
2621
2915
  await engine.hooks.event({