dsh-claude-design 2.5.0 → 2.6.0

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.
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // claude-design: local editor server (prebuilt; do not edit).
3
3
 
4
- import fs7 from "node:fs";
5
- import path6 from "node:path";
4
+ import fs8 from "node:fs";
5
+ import path7 from "node:path";
6
6
 
7
- import { createHash as createHash2, randomBytes, randomUUID } from "node:crypto";
8
- import fs6 from "node:fs";
7
+ import { createHash as createHash3, randomBytes as randomBytes2, randomUUID } from "node:crypto";
8
+ import fs7 from "node:fs";
9
9
  import fsp2 from "node:fs/promises";
10
10
  import http from "node:http";
11
- import path5 from "node:path";
11
+ import path6 from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
14
14
  import { spawn } from "node:child_process";
@@ -281,6 +281,7 @@ async function updateComments(root, change) {
281
281
  var AGENT_GRACE_MS = 6e4;
282
282
  var AGENT_BUSY_MS = 30 * 6e4;
283
283
  var REQUEST_TTL_MS = 30 * 6e4;
284
+ var REQUESTS_KEPT = 32;
284
285
  var WAIT_DEFAULT_S = 300;
285
286
  var WAIT_MAX_S = 3600;
286
287
  var PRESENCE_CHECK_MS = 5e3;
@@ -345,6 +346,7 @@ function createCommentsService(options) {
345
346
  const waiters = [];
346
347
  const hookRunning = /* @__PURE__ */ new Set();
347
348
  const requests = [];
349
+ const requestFates = /* @__PURE__ */ new Map();
348
350
  let lastDoc = emptyDoc();
349
351
  let lastWaitEnded = 0;
350
352
  let lastSent = "";
@@ -357,7 +359,7 @@ function createCommentsService(options) {
357
359
  const listening = waiters.length > 0;
358
360
  const busy = lastDoc.threads.some(
359
361
  (t) => t.status === "open" && t.agent?.status === "delivered" && at - Date.parse(t.agent.deliveredAt ?? t.agent.sentAt) < AGENT_BUSY_MS
360
- );
362
+ ) || [...requestFates.values()].some((f) => f.state === "working" && at - f.at < AGENT_BUSY_MS);
361
363
  const hook = Boolean(agentHook);
362
364
  return { available: listening || busy || hook || at - lastWaitEnded < AGENT_GRACE_MS, listening, busy, hook };
363
365
  }
@@ -406,10 +408,16 @@ data: ${JSON.stringify(snap)}
406
408
  if (!waiter.response.writableEnded) sendJson(waiter.response, 200, body);
407
409
  lastWaitEnded = Date.now();
408
410
  }
411
+ function setRequestState(id, state) {
412
+ requestFates.delete(id);
413
+ requestFates.set(id, { state, at: Date.now() });
414
+ while (requestFates.size > REQUESTS_KEPT) requestFates.delete(requestFates.keys().next().value);
415
+ }
409
416
  function dropStaleRequests() {
410
417
  const oldest = Date.now() - REQUEST_TTL_MS;
411
418
  while (requests.length > 0 && Date.parse(requests[0].sentAt) < oldest) {
412
419
  const dropped = requests.shift();
420
+ setRequestState(dropped.id, "over");
413
421
  log(`comments: dropped request ${dropped.id}: no agent took it in ${REQUEST_TTL_MS / 6e4} minutes`);
414
422
  }
415
423
  }
@@ -436,6 +444,7 @@ data: ${JSON.stringify(snap)}
436
444
  return;
437
445
  }
438
446
  finishWaiter(waiter, { threads: views, ...handed.length > 0 ? { requests: handed } : {} });
447
+ for (const request of handed) setRequestState(request.id, "working");
439
448
  log(`comments: sent to the agent (wait): ${[...views, ...handed].map((v) => v.id).join(", ")}`);
440
449
  await broadcast();
441
450
  return;
@@ -483,6 +492,7 @@ data: ${JSON.stringify(snap)}
483
492
  }
484
493
  for (const request of requests.splice(0)) {
485
494
  hookRunning.add(request.id);
495
+ setRequestState(request.id, "working");
486
496
  const child = spawn(command, {
487
497
  shell: true,
488
498
  cwd: root,
@@ -503,6 +513,7 @@ data: ${JSON.stringify(snap)}
503
513
  settled = true;
504
514
  hookRunning.delete(request.id);
505
515
  requests.unshift(request);
516
+ setRequestState(request.id, "queued");
506
517
  log(`comments: the agent hook ${why} for ${request.id}; the request stays waiting${stderr ? `: ${stderr.trim()}` : ""}`);
507
518
  };
508
519
  child.on("error", (error) => failed(`failed to start (${error.message})`));
@@ -568,16 +579,26 @@ data: ${JSON.stringify(snap)}
568
579
  sendJson(response, 201, { thread: created });
569
580
  await afterChange(Boolean(input.send));
570
581
  }
571
- async function ask(request, response) {
572
- const input = readAgentRequest(await readJson(request));
573
- if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
582
+ function enqueue(input) {
574
583
  if (!presence().available) throw new CommentsError("AGENT_UNAVAILABLE", "no agent is waiting: copy the request for the chat instead", 409);
575
584
  const queued = { id: newId("r"), ...input, sentAt: now() };
576
585
  requests.push(queued);
586
+ setRequestState(queued.id, "queued");
577
587
  log(`comments: request ${queued.id}${input.label ? ` (${input.label})` : ""} for the agent`);
588
+ return queued;
589
+ }
590
+ async function ask(request, response) {
591
+ const input = readAgentRequest(await readJson(request));
592
+ if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
593
+ const queued = enqueue(input);
578
594
  sendJson(response, 202, { request: queued });
579
595
  await deliver();
580
596
  }
597
+ async function sendRequest(input) {
598
+ const queued = enqueue(input);
599
+ await deliver();
600
+ return queued;
601
+ }
581
602
  async function events(request, response, viewer) {
582
603
  response.writeHead(200, {
583
604
  "Content-Type": "text/event-stream; charset=utf-8",
@@ -600,6 +621,7 @@ data: ${JSON.stringify(snap)}
600
621
  const seconds = Number.isFinite(asked) && asked > 0 ? Math.min(asked, WAIT_MAX_S) : WAIT_DEFAULT_S;
601
622
  const waiter = { response, done: false };
602
623
  waiters.push(waiter);
624
+ for (const [id, fate] of requestFates) if (fate.state === "working") setRequestState(id, "over");
603
625
  waiter.timer = setTimeout(() => {
604
626
  finishWaiter(waiter, { threads: [] });
605
627
  void broadcast();
@@ -738,7 +760,12 @@ data: ${JSON.stringify(snap)}
738
760
  for (const client of clients.keys()) client.end();
739
761
  clients.clear();
740
762
  }
741
- return { handle, presence, deliver, close };
763
+ function requestStates() {
764
+ const states = {};
765
+ for (const [id, fate] of requestFates) if (fate.state !== "over") states[id] = fate.state;
766
+ return states;
767
+ }
768
+ return { handle, presence, deliver, sendRequest, requestStates, close };
742
769
  }
743
770
 
744
771
  import fs3 from "node:fs";
@@ -802,10 +829,14 @@ var PortsTaken = class extends Error {
802
829
  this.to = to;
803
830
  }
804
831
  };
832
+ var isBenchmarkRange = (address) => /^198\.1[89]\./.test(address);
805
833
  function lanAddresses() {
806
834
  const out = [];
807
835
  for (const list of Object.values(os2.networkInterfaces())) {
808
- for (const entry of list ?? []) if (entry.family === "IPv4" && !entry.internal && !entry.address.startsWith("169.254.")) out.push(entry.address);
836
+ for (const entry of list ?? []) {
837
+ if (entry.family !== "IPv4" || entry.internal || entry.address.startsWith("169.254.") || isBenchmarkRange(entry.address)) continue;
838
+ out.push(entry.address);
839
+ }
809
840
  }
810
841
  return [...new Set(out)];
811
842
  }
@@ -816,18 +847,38 @@ function answers(host, port) {
816
847
  socket.destroy();
817
848
  resolve(taken);
818
849
  };
819
- socket.setTimeout(300, () => done(true));
850
+ socket.setTimeout(300, () => done(false));
820
851
  socket.once("connect", () => done(true));
821
- socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
852
+ socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "EAFNOSUPPORT", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
822
853
  });
823
854
  }
855
+ async function answersAnyPort(address) {
856
+ const server2 = net.createServer();
857
+ let port;
858
+ try {
859
+ port = await new Promise((resolve, reject) => {
860
+ server2.once("error", reject);
861
+ server2.listen(0, address, () => {
862
+ const bound = server2.address();
863
+ resolve(typeof bound === "object" && bound ? bound.port : 0);
864
+ });
865
+ });
866
+ } catch {
867
+ return false;
868
+ } finally {
869
+ await new Promise((resolve) => server2.close(() => resolve()));
870
+ }
871
+ return port > 0 && answers(address, port);
872
+ }
824
873
  async function listenFree(server2, from, host, first) {
825
874
  if (from === 0) return listen(server2, 0, host);
826
875
  const to = Math.min(from + PORT_TRIES - 1, 65535);
827
876
  const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
828
877
  const ports = first ? [first, ...range.filter((port) => port !== first)] : range;
829
878
  const wildcard = host === "0.0.0.0" || host === "::";
830
- const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lanAddresses()])];
879
+ const lan = lanAddresses().filter((address) => address !== host);
880
+ const answering = await Promise.all(lan.map(answersAnyPort));
881
+ const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lan.filter((_, i) => !answering[i])])];
831
882
  for (const port of ports) {
832
883
  if ((await Promise.all(probes.map((address) => answers(address, port)))).some(Boolean)) continue;
833
884
  try {
@@ -902,21 +953,569 @@ function sendFile(request, response, file, headers) {
902
953
  }
903
954
  var errorText = (error) => error instanceof Error ? error.message : String(error);
904
955
 
905
- import { createHash } from "node:crypto";
906
- import fs5 from "node:fs";
907
- import os3 from "node:os";
908
- import path4 from "node:path";
956
+ var MAX_EDIT = 2e3;
957
+ function editScript(a, b) {
958
+ const n = a.length;
959
+ const m = b.length;
960
+ const limit = Math.min(n + m, MAX_EDIT);
961
+ const offset = limit + 1;
962
+ const v = new Int32Array(2 * limit + 3);
963
+ const trace = [];
964
+ let found = -1;
965
+ for (let d = 0; d <= limit; d++) {
966
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
967
+ for (let k = -d; k <= d; k += 2) {
968
+ let x2 = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1;
969
+ let y2 = x2 - k;
970
+ while (x2 < n && y2 < m && a[x2] === b[y2]) {
971
+ x2++;
972
+ y2++;
973
+ }
974
+ v[offset + k] = x2;
975
+ if (x2 >= n && y2 >= m) {
976
+ found = d;
977
+ break;
978
+ }
979
+ }
980
+ if (found >= 0) break;
981
+ }
982
+ if (found < 0) return null;
983
+ const ops = [];
984
+ let x = n;
985
+ let y = m;
986
+ for (let d = found; d > 0; d--) {
987
+ const prev = trace[d];
988
+ const at = (k2) => prev[k2 + d + 1];
989
+ const k = x - y;
990
+ const prevK = k === -d || k !== d && at(k - 1) < at(k + 1) ? k + 1 : k - 1;
991
+ const prevX = at(prevK);
992
+ const prevY = prevX - prevK;
993
+ while (x > prevX && y > prevY) {
994
+ ops.push("=");
995
+ x--;
996
+ y--;
997
+ }
998
+ ops.push(x === prevX ? "+" : "-");
999
+ x = prevX;
1000
+ y = prevY;
1001
+ }
1002
+ while (x > 0 && y > 0) {
1003
+ ops.push("=");
1004
+ x--;
1005
+ y--;
1006
+ }
1007
+ return ops.reverse();
1008
+ }
1009
+ function hunksOf(base, side, mine) {
1010
+ const ops = editScript(base, side);
1011
+ if (!ops) return null;
1012
+ const out = [];
1013
+ let open = null;
1014
+ let i = 0;
1015
+ let j = 0;
1016
+ for (const op of ops) {
1017
+ if (op === "=") {
1018
+ open = null;
1019
+ i++;
1020
+ j++;
1021
+ continue;
1022
+ }
1023
+ if (!open) out.push(open = { start: i, end: i, lines: [], mine });
1024
+ if (op === "-") open.end = ++i;
1025
+ else open.lines.push(side[j++]);
1026
+ }
1027
+ return out.flatMap(
1028
+ (h) => h.end - h.start > 1 && h.lines.length === h.end - h.start ? h.lines.flatMap((line, k) => {
1029
+ const at = h.start + k;
1030
+ return line === base[at] ? [] : [{ start: at, end: at + 1, lines: [line], mine }];
1031
+ }) : [h]
1032
+ );
1033
+ }
1034
+ function applyHunks(base, start, end, hunks) {
1035
+ const out = [];
1036
+ let at = start;
1037
+ for (const h of hunks) {
1038
+ out.push(...base.slice(at, h.start), ...h.lines);
1039
+ at = h.end;
1040
+ }
1041
+ out.push(...base.slice(at, end));
1042
+ return out;
1043
+ }
1044
+ function mergeText(base, mine, current, at, conflicts) {
1045
+ const lines = base.split("\n");
1046
+ const ours = hunksOf(lines, mine.split("\n"), true);
1047
+ const theirs = ours && hunksOf(lines, current.split("\n"), false);
1048
+ if (!ours || !theirs) return null;
1049
+ const all = [...ours, ...theirs].sort((x, y) => x.start - y.start || x.end - y.end);
1050
+ const out = [];
1051
+ const lost = [];
1052
+ let done = 0;
1053
+ for (let i = 0; i < all.length; ) {
1054
+ const region = [all[i++]];
1055
+ const start = region[0].start;
1056
+ let end = region[0].end;
1057
+ for (; i < all.length; i++) {
1058
+ const h = all[i];
1059
+ if (!(h.start < end || h.start === h.end && start === end && h.start === start)) break;
1060
+ region.push(h);
1061
+ end = Math.max(end, h.end);
1062
+ }
1063
+ out.push(...lines.slice(done, start));
1064
+ const mineLines = applyHunks(lines, start, end, region.filter((h) => h.mine));
1065
+ if (region.every((h) => h.mine === region[0].mine)) out.push(...applyHunks(lines, start, end, region));
1066
+ else {
1067
+ const theirLines = applyHunks(lines, start, end, region.filter((h) => !h.mine));
1068
+ if (mineLines.join("\n") !== theirLines.join("\n")) lost.push(`${at}@${start + 1}`);
1069
+ out.push(...mineLines);
1070
+ }
1071
+ done = end;
1072
+ }
1073
+ out.push(...lines.slice(done));
1074
+ conflicts.push(...lost);
1075
+ return out.join("\n");
1076
+ }
909
1077
 
910
- import crypto from "node:crypto";
1078
+ var utf8 = new TextDecoder("utf-8", { fatal: true });
1079
+ function mergeTextBytes(base, mine, current) {
1080
+ const text = (bytes) => bytes.includes(0) ? null : utf8.decode(bytes);
1081
+ try {
1082
+ const [b, m, c] = [text(base), text(mine), text(current)];
1083
+ if (b === null || m === null || c === null) return null;
1084
+ const merged = mergeText(b, m, c, ".", []);
1085
+ return merged === null ? null : Buffer.from(merged, "utf8");
1086
+ } catch {
1087
+ return null;
1088
+ }
1089
+ }
1090
+
1091
+ import { createHash, randomBytes } from "node:crypto";
911
1092
  import fs4 from "node:fs";
912
1093
  import path3 from "node:path";
1094
+ var HISTORY_DIR = "history";
1095
+ var INDEX = "index.json";
1096
+ var OBJECTS = "objects";
1097
+ var IDLE_MS = 3e4;
1098
+ var SPAN_MS = 5 * 6e4;
1099
+ var MAX_VERSIONS = 500;
1100
+ var MAX_NOTICES = 50;
1101
+ var NOTICE_DAYS = 7;
1102
+ var AGENT = { by: "agent", user: { id: "agent", name: "Agent" } };
1103
+ var SYSTEM = { by: "system", user: { id: "system", name: "" } };
1104
+ var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
1105
+ var SHA = /^[0-9a-f]{64}$/;
1106
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1107
+ function writeAtomic2(file, data) {
1108
+ fs4.mkdirSync(path3.dirname(file), { recursive: true });
1109
+ const tmp = path3.join(path3.dirname(file), `.${path3.basename(file)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
1110
+ try {
1111
+ fs4.writeFileSync(tmp, data);
1112
+ fs4.renameSync(tmp, file);
1113
+ } catch (error) {
1114
+ fs4.rmSync(tmp, { force: true });
1115
+ throw error;
1116
+ }
1117
+ }
1118
+ var emptyIndex = () => ({ format: 1, next: 1, base: {}, versions: [], notices: [] });
1119
+ function readUser(value) {
1120
+ return isRecord2(value) && typeof value.id === "string" && typeof value.name === "string" ? { id: value.id, name: value.name } : null;
1121
+ }
1122
+ var isAuthor = (value) => value === "human" || value === "agent" || value === "system";
1123
+ function parseIndex(raw) {
1124
+ const index = emptyIndex();
1125
+ if (!isRecord2(raw)) return index;
1126
+ if (isRecord2(raw.base)) {
1127
+ for (const [p, s] of Object.entries(raw.base)) if (typeof s === "string" && SHA.test(s)) index.base[p] = s;
1128
+ }
1129
+ if (Array.isArray(raw.versions)) {
1130
+ for (const v of raw.versions) {
1131
+ if (!isRecord2(v) || typeof v.n !== "number" || !isAuthor(v.by) || typeof v.at !== "string" || !isRecord2(v.changes)) continue;
1132
+ const user = readUser(v.user);
1133
+ if (!user) continue;
1134
+ const changes = {};
1135
+ for (const [p, s] of Object.entries(v.changes)) if (s === null || typeof s === "string" && SHA.test(s)) changes[p] = s;
1136
+ const version = { n: v.n, first: typeof v.first === "string" ? v.first : v.at, at: v.at, by: v.by, user, changes };
1137
+ if (typeof v.role === "string") version.role = v.role;
1138
+ if (typeof v.restoredFrom === "number") version.restoredFrom = v.restoredFrom;
1139
+ if (version.restoredFrom !== void 0 && v.restoredPart === true) version.restoredPart = true;
1140
+ if (Array.isArray(v.overwrote)) {
1141
+ const list = [];
1142
+ for (const o of v.overwrote) {
1143
+ const who = isRecord2(o) ? readUser(o.user) : null;
1144
+ if (!isRecord2(o) || !who || typeof o.path !== "string" || o.lost !== null && typeof o.lost !== "string" || typeof o.n !== "number" || !isAuthor(o.by)) continue;
1145
+ list.push({
1146
+ path: o.path,
1147
+ lost: o.lost,
1148
+ n: o.n,
1149
+ by: o.by,
1150
+ user: who,
1151
+ ...o.soft === true ? { soft: true } : {},
1152
+ ...o.chosen === true ? { chosen: true } : {},
1153
+ ...typeof o.base === "string" && SHA.test(o.base) ? { base: o.base } : {}
1154
+ });
1155
+ }
1156
+ if (list.length) version.overwrote = list;
1157
+ }
1158
+ index.versions.push(version);
1159
+ }
1160
+ }
1161
+ index.versions.sort((a, b) => a.n - b.n);
1162
+ const last = index.versions.at(-1);
1163
+ index.next = Math.max(typeof raw.next === "number" ? raw.next : 1, (last?.n ?? 0) + 1);
1164
+ if (Array.isArray(raw.notices)) {
1165
+ for (const n of raw.notices) {
1166
+ if (!isRecord2(n) || typeof n.id !== "string" || typeof n.to !== "string" || typeof n.path !== "string" || typeof n.n !== "number") continue;
1167
+ if (n.kind !== "overwritten" && n.kind !== "overwrote") continue;
1168
+ const by = readUser(n.by);
1169
+ const lost = isRecord2(n.lost) ? n.lost : null;
1170
+ const lostUser = lost ? readUser(lost.user) : null;
1171
+ if (!by || !lost || !lostUser || lost.sha !== null && typeof lost.sha !== "string" || typeof lost.n !== "number" || !isAuthor(lost.by)) continue;
1172
+ index.notices.push({
1173
+ id: n.id,
1174
+ to: n.to,
1175
+ at: typeof n.at === "string" ? n.at : (/* @__PURE__ */ new Date(0)).toISOString(),
1176
+ kind: n.kind,
1177
+ path: n.path,
1178
+ n: n.n,
1179
+ by,
1180
+ byAuthor: isAuthor(n.byAuthor) ? n.byAuthor : "human",
1181
+ lost: { sha: lost.sha, n: lost.n, by: lost.by, user: lostUser },
1182
+ ...n.soft === true ? { soft: true } : {},
1183
+ ...typeof n.base === "string" && SHA.test(n.base) ? { base: n.base } : {}
1184
+ });
1185
+ }
1186
+ }
1187
+ return index;
1188
+ }
1189
+ var History = class {
1190
+ dir;
1191
+ index;
1192
+ /** path → sha: what the files hold after the newest version. */
1193
+ headState;
1194
+ constructor(root) {
1195
+ this.dir = path3.join(root, HISTORY_DIR);
1196
+ let raw = null;
1197
+ try {
1198
+ raw = JSON.parse(fs4.readFileSync(path3.join(this.dir, INDEX), "utf8"));
1199
+ } catch {
1200
+ }
1201
+ this.index = parseIndex(raw);
1202
+ this.headState = this.fold(Infinity);
1203
+ this.index.notices = this.index.notices.filter((n) => (this.headState.get(n.path) ?? null) !== n.lost.sha);
1204
+ }
1205
+ get empty() {
1206
+ return this.index.versions.length === 0 && Object.keys(this.index.base).length === 0;
1207
+ }
1208
+ /** What the files hold now, as far as the history knows (path → sha). */
1209
+ head() {
1210
+ return this.headState;
1211
+ }
1212
+ versions() {
1213
+ return this.index.versions;
1214
+ }
1215
+ version(n) {
1216
+ return this.index.versions.find((v) => v.n === n);
1217
+ }
1218
+ /** The files after the versions numbered up to `upto` (the base and every version at or below it). */
1219
+ fold(upto) {
1220
+ const state = new Map(Object.entries(this.index.base));
1221
+ for (const version of this.index.versions) {
1222
+ if (version.n > upto) break;
1223
+ for (const [p, s] of Object.entries(version.changes)) {
1224
+ if (s === null) state.delete(p);
1225
+ else state.set(p, s);
1226
+ }
1227
+ }
1228
+ return state;
1229
+ }
1230
+ /** The files right after version `n`; null when it is not kept. */
1231
+ stateAt(n) {
1232
+ return this.version(n) ? this.fold(n) : null;
1233
+ }
1234
+ /** The files right before version `n`. */
1235
+ stateBefore(n) {
1236
+ return this.fold(n - 1);
1237
+ }
1238
+ /** The content with this sha, from the objects. */
1239
+ read(sha) {
1240
+ if (!SHA.test(sha)) return null;
1241
+ try {
1242
+ return fs4.readFileSync(path3.join(this.dir, OBJECTS, sha));
1243
+ } catch {
1244
+ return null;
1245
+ }
1246
+ }
1247
+ /** Keeps a content that no version holds (an overwrite's `base`); its sha. */
1248
+ put(bytes) {
1249
+ const sha = sha256(bytes);
1250
+ this.putObject(sha, bytes);
1251
+ return sha;
1252
+ }
1253
+ size(sha) {
1254
+ if (!SHA.test(sha)) return null;
1255
+ try {
1256
+ return fs4.statSync(path3.join(this.dir, OBJECTS, sha)).size;
1257
+ } catch {
1258
+ return null;
1259
+ }
1260
+ }
1261
+ /** The newest version that changed `path`, with what it left there. */
1262
+ lastChange(p) {
1263
+ for (let i = this.index.versions.length - 1; i >= 0; i--) {
1264
+ const version = this.index.versions[i];
1265
+ if (p in version.changes) return { version, sha: version.changes[p] };
1266
+ }
1267
+ return null;
1268
+ }
1269
+ /**
1270
+ * Records changes: path → its content now (null: deleted). Those equal to the head are left out; nothing left, no
1271
+ * version (null). The rest join the open version of the same person, or start a new one.
1272
+ */
1273
+ record(changes, who, extra = {}, now = /* @__PURE__ */ new Date()) {
1274
+ const changed = {};
1275
+ for (const [p, bytes] of changes) {
1276
+ const sha = bytes === null ? null : sha256(bytes);
1277
+ if ((this.headState.get(p) ?? null) === sha) continue;
1278
+ if (bytes !== null && sha !== null) this.putObject(sha, bytes);
1279
+ changed[p] = sha;
1280
+ }
1281
+ if (Object.keys(changed).length === 0) return null;
1282
+ const iso = now.toISOString();
1283
+ const last = this.index.versions.at(-1);
1284
+ const joins = !!last && who.by !== "system" && last.by === who.by && last.user.id === who.user.id && last.restoredFrom === void 0 && extra.restoredFrom === void 0 && now.getTime() - Date.parse(last.at) < IDLE_MS && now.getTime() - Date.parse(last.first) < SPAN_MS;
1285
+ let version;
1286
+ if (joins) {
1287
+ version = last;
1288
+ version.at = iso;
1289
+ Object.assign(version.changes, changed);
1290
+ if (who.user.name) version.user = { ...who.user };
1291
+ } else {
1292
+ version = { n: this.index.next++, first: iso, at: iso, by: who.by, user: { ...who.user }, changes: changed };
1293
+ if (who.role) version.role = who.role;
1294
+ if (extra.restoredFrom !== void 0) version.restoredFrom = extra.restoredFrom;
1295
+ if (extra.restoredFrom !== void 0 && extra.restoredPart) version.restoredPart = true;
1296
+ this.index.versions.push(version);
1297
+ }
1298
+ for (const [p, s] of Object.entries(changed)) {
1299
+ if (s === null) this.headState.delete(p);
1300
+ else this.headState.set(p, s);
1301
+ }
1302
+ this.index.notices = this.index.notices.filter(
1303
+ (n) => !(n.path in changed && (changed[n.path] === n.lost.sha || extra.restoredFrom !== void 0 && n.to === who.user.id))
1304
+ );
1305
+ if (extra.overwrote?.length) {
1306
+ version.overwrote = [...(version.overwrote ?? []).filter((o) => !extra.overwrote.some((x) => x.path === o.path)), ...extra.overwrote];
1307
+ this.notify(version, who, extra.overwrote, now);
1308
+ }
1309
+ this.prune(now);
1310
+ this.save();
1311
+ return version;
1312
+ }
1313
+ /** Notices for an overwrite: to whoever lost their change (the agent's go to the owner), and a hard one's saver unless they chose it. */
1314
+ notify(version, who, overwrote, now) {
1315
+ const at = now.toISOString();
1316
+ for (const o of overwrote) {
1317
+ const lost = { sha: o.lost, n: o.n, by: o.by, user: o.user };
1318
+ const victim = o.by === "agent" ? "local" : o.user.id;
1319
+ const fields = {
1320
+ at,
1321
+ path: o.path,
1322
+ n: version.n,
1323
+ by: { ...who.user },
1324
+ byAuthor: who.by,
1325
+ lost,
1326
+ ...o.soft ? { soft: true } : {},
1327
+ ...o.base ? { base: o.base } : {}
1328
+ };
1329
+ const same = (n) => n.path === o.path && n.n === version.n;
1330
+ this.index.notices = this.index.notices.filter((n) => !same(n));
1331
+ if (victim !== who.user.id) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: victim, kind: "overwritten", ...fields });
1332
+ if (!o.soft && !o.chosen) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: who.user.id, kind: "overwrote", ...fields });
1333
+ }
1334
+ }
1335
+ /** The notices for this person, newest first. */
1336
+ noticesFor(userId) {
1337
+ return this.index.notices.filter((n) => n.to === userId).reverse();
1338
+ }
1339
+ notice(id) {
1340
+ return this.index.notices.find((n) => n.id === id);
1341
+ }
1342
+ /** Dismisses one of this person's notices; false when they have none by that id. */
1343
+ dismiss(userId, id) {
1344
+ const before = this.index.notices.length;
1345
+ this.index.notices = this.index.notices.filter((n) => !(n.id === id && n.to === userId));
1346
+ if (this.index.notices.length === before) return false;
1347
+ this.save();
1348
+ return true;
1349
+ }
1350
+ /**
1351
+ * What to write to bring version `n` back: path → content (null: delete), for `paths` (default: every file it or the
1352
+ * head holds), only where the head differs. null when the version is not kept or a content is missing.
1353
+ */
1354
+ restoreTo(n, paths, tracked = () => true) {
1355
+ const target = this.stateAt(n);
1356
+ if (!target) return null;
1357
+ const out = /* @__PURE__ */ new Map();
1358
+ const all = paths ?? [.../* @__PURE__ */ new Set([...target.keys(), ...this.headState.keys()])];
1359
+ for (const p of all) {
1360
+ if (!tracked(p)) continue;
1361
+ const want = target.get(p) ?? null;
1362
+ if ((this.headState.get(p) ?? null) === want) continue;
1363
+ if (want === null) {
1364
+ out.set(p, null);
1365
+ continue;
1366
+ }
1367
+ const bytes = this.read(want);
1368
+ if (!bytes) return null;
1369
+ out.set(p, bytes);
1370
+ }
1371
+ return out;
1372
+ }
1373
+ /** Each version's changes as added, edited or deleted (what the file held before it decides). */
1374
+ fileChanges() {
1375
+ const out = /* @__PURE__ */ new Map();
1376
+ const state = new Map(Object.entries(this.index.base));
1377
+ for (const version of this.index.versions) {
1378
+ const list = [];
1379
+ for (const [p, s] of Object.entries(version.changes)) {
1380
+ const had = state.has(p);
1381
+ list.push({ path: p, op: s === null ? "deleted" : had ? "edited" : "added" });
1382
+ if (s === null) state.delete(p);
1383
+ else state.set(p, s);
1384
+ }
1385
+ out.set(version.n, list);
1386
+ }
1387
+ return out;
1388
+ }
1389
+ putObject(sha, bytes) {
1390
+ const file = path3.join(this.dir, OBJECTS, sha);
1391
+ if (fs4.existsSync(file)) return;
1392
+ writeAtomic2(file, bytes);
1393
+ }
1394
+ /** Past MAX_VERSIONS the oldest fold into the base; old notices go; contents no one holds are removed. */
1395
+ prune(now) {
1396
+ let folded = false;
1397
+ while (this.index.versions.length > MAX_VERSIONS) {
1398
+ const old = this.index.versions.shift();
1399
+ for (const [p, s] of Object.entries(old.changes)) {
1400
+ if (s === null) delete this.index.base[p];
1401
+ else this.index.base[p] = s;
1402
+ }
1403
+ folded = true;
1404
+ }
1405
+ const oldest = now.getTime() - NOTICE_DAYS * 864e5;
1406
+ const kept = this.index.versions[0]?.n ?? Infinity;
1407
+ const perPerson = /* @__PURE__ */ new Map();
1408
+ this.index.notices = this.index.notices.filter((n) => Date.parse(n.at) >= oldest && n.lost.n >= kept).reverse().filter((n) => {
1409
+ const count = (perPerson.get(n.to) ?? 0) + 1;
1410
+ perPerson.set(n.to, count);
1411
+ return count <= MAX_NOTICES;
1412
+ }).reverse();
1413
+ if (folded) this.collect();
1414
+ }
1415
+ /** Removes the objects that neither the base, a version nor a notice holds (with the base an overwrite was made on). */
1416
+ collect() {
1417
+ const keep = new Set(Object.values(this.index.base));
1418
+ for (const version of this.index.versions) {
1419
+ for (const s of Object.values(version.changes)) if (s) keep.add(s);
1420
+ for (const o of version.overwrote ?? []) if (o.base) keep.add(o.base);
1421
+ }
1422
+ for (const notice of this.index.notices) {
1423
+ if (notice.lost.sha) keep.add(notice.lost.sha);
1424
+ if (notice.base) keep.add(notice.base);
1425
+ }
1426
+ let names;
1427
+ try {
1428
+ names = fs4.readdirSync(path3.join(this.dir, OBJECTS));
1429
+ } catch {
1430
+ return;
1431
+ }
1432
+ for (const name of names) if (SHA.test(name) && !keep.has(name)) fs4.rmSync(path3.join(this.dir, OBJECTS, name), { force: true });
1433
+ }
1434
+ save() {
1435
+ writeAtomic2(path3.join(this.dir, INDEX), `${JSON.stringify(this.index)}
1436
+ `);
1437
+ }
1438
+ };
1439
+ var MAX_DIFF_BYTES = 1024 * 1024;
1440
+ var CONTEXT = 3;
1441
+ var MAX_DIFF_LINES = 3e3;
1442
+ var MAX_LINE = 2e3;
1443
+ var decoder = new TextDecoder("utf-8", { fatal: true });
1444
+ function textOf(bytes) {
1445
+ if (bytes === null) return null;
1446
+ if (bytes.includes(0)) return void 0;
1447
+ try {
1448
+ return decoder.decode(bytes);
1449
+ } catch {
1450
+ return void 0;
1451
+ }
1452
+ }
1453
+ var splitLines = (text) => text === null || text === "" ? [] : text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
1454
+ function diffContents(before, after) {
1455
+ if ((before?.length ?? 0) > MAX_DIFF_BYTES || (after?.length ?? 0) > MAX_DIFF_BYTES) return { tooLarge: true, added: 0, removed: 0, lines: [] };
1456
+ const a = textOf(before);
1457
+ const b = textOf(after);
1458
+ if (a === void 0 || b === void 0) return { binary: true, added: 0, removed: 0, lines: [] };
1459
+ const left = splitLines(a);
1460
+ const right = splitLines(b);
1461
+ let head = 0;
1462
+ while (head < left.length && head < right.length && left[head] === right[head]) head++;
1463
+ let tail = 0;
1464
+ while (tail < left.length - head && tail < right.length - head && left[left.length - 1 - tail] === right[right.length - 1 - tail]) tail++;
1465
+ const middle = editScript(left.slice(head, left.length - tail), right.slice(head, right.length - tail));
1466
+ if (!middle) return { tooLarge: true, added: 0, removed: 0, lines: [] };
1467
+ const ops = [...Array(head).fill("="), ...middle, ...Array(tail).fill("=")];
1468
+ const all = [];
1469
+ let i = 0;
1470
+ let j = 0;
1471
+ let added = 0;
1472
+ let removed = 0;
1473
+ const cut = (s) => s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}\u2026` : s;
1474
+ for (const op of ops) {
1475
+ if (op === "=") all.push([" ", cut(right[j++])]), i++;
1476
+ else if (op === "-") all.push(["-", cut(left[i++])]), removed++;
1477
+ else all.push(["+", cut(right[j++])]), added++;
1478
+ }
1479
+ const keep = new Uint8Array(all.length);
1480
+ all.forEach(([kind], at) => {
1481
+ if (kind === " ") return;
1482
+ for (let x = Math.max(0, at - CONTEXT); x <= Math.min(all.length - 1, at + CONTEXT); x++) keep[x] = 1;
1483
+ });
1484
+ const lines = [];
1485
+ let skipped = 0;
1486
+ let truncated = false;
1487
+ for (let at = 0; at < all.length; at++) {
1488
+ if (!keep[at]) {
1489
+ skipped++;
1490
+ continue;
1491
+ }
1492
+ if (skipped) lines.push(["~", String(skipped)]);
1493
+ skipped = 0;
1494
+ if (lines.length >= MAX_DIFF_LINES) {
1495
+ truncated = true;
1496
+ break;
1497
+ }
1498
+ lines.push(all[at]);
1499
+ }
1500
+ if (skipped && !truncated) lines.push(["~", String(skipped)]);
1501
+ return { added, removed, lines, ...truncated ? { truncated: true } : {} };
1502
+ }
1503
+
1504
+ import { createHash as createHash2 } from "node:crypto";
1505
+ import fs6 from "node:fs";
1506
+ import os3 from "node:os";
1507
+ import path5 from "node:path";
1508
+
1509
+ import crypto from "node:crypto";
1510
+ import fs5 from "node:fs";
1511
+ import path4 from "node:path";
913
1512
  var GUEST_ROLES = ["editor", "commenter", "viewer"];
914
1513
  var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
915
1514
  var SHARING_FILE = ".sharing.json";
916
1515
  var MAX_GUESTS = 200;
917
1516
  var MAX_NAME = 40;
918
1517
  var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
919
- var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1518
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
920
1519
  function fresh() {
921
1520
  return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
922
1521
  }
@@ -936,29 +1535,29 @@ var Sharing = class _Sharing {
936
1535
  file;
937
1536
  state;
938
1537
  constructor(root) {
939
- this.file = path3.join(root, SHARING_FILE);
1538
+ this.file = path4.join(root, SHARING_FILE);
940
1539
  this.state = _Sharing.read(this.file);
941
1540
  }
942
1541
  static read(file) {
943
1542
  let raw;
944
1543
  try {
945
- raw = JSON.parse(fs4.readFileSync(file, "utf8"));
1544
+ raw = JSON.parse(fs5.readFileSync(file, "utf8"));
946
1545
  } catch {
947
1546
  return fresh();
948
1547
  }
949
1548
  const state = fresh();
950
- if (!isRecord2(raw)) return state;
1549
+ if (!isRecord3(raw)) return state;
951
1550
  state.lan = raw.lan === true;
952
1551
  if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
953
- if (isRecord2(raw.links)) {
1552
+ if (isRecord3(raw.links)) {
954
1553
  for (const role of GUEST_ROLES) {
955
1554
  const link = raw.links[role];
956
- if (isRecord2(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
1555
+ if (isRecord3(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
957
1556
  }
958
1557
  }
959
1558
  if (Array.isArray(raw.guests)) {
960
1559
  for (const g of raw.guests) {
961
- if (!isRecord2(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
1560
+ if (!isRecord3(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
962
1561
  state.guests.push({
963
1562
  id: g.id,
964
1563
  secret: g.secret,
@@ -974,9 +1573,9 @@ var Sharing = class _Sharing {
974
1573
  }
975
1574
  save() {
976
1575
  const tmp = `${this.file}.${process.pid}.tmp`;
977
- fs4.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
1576
+ fs5.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
978
1577
  `, { mode: 384 });
979
- fs4.renameSync(tmp, this.file);
1578
+ fs5.renameSync(tmp, this.file);
980
1579
  }
981
1580
  /** The role whose link has this token, while that link is on. */
982
1581
  linkRole(tokenGiven) {
@@ -1066,7 +1665,7 @@ function commentsViewer(viewer) {
1066
1665
  admin: viewer.role === "owner"
1067
1666
  };
1068
1667
  }
1069
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1668
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1070
1669
  var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1071
1670
  var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
1072
1671
  function ownHosts(host, port) {
@@ -1081,7 +1680,7 @@ function localOwner() {
1081
1680
  }
1082
1681
  return { id: "local", name, email: `${name}@localhost` };
1083
1682
  }
1084
- var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash("sha256").update(path4.resolve(root)).digest("hex").slice(0, 12)}`;
1683
+ var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash2("sha256").update(path5.resolve(root)).digest("hex").slice(0, 12)}`;
1085
1684
  var ShareHost = class {
1086
1685
  sharing;
1087
1686
  options;
@@ -1240,13 +1839,13 @@ var ShareHost = class {
1240
1839
  }
1241
1840
  if (method === "GET" && p === "/__share/me") return sendJson2(response, 200, this.me(viewer)), true;
1242
1841
  if (method === "GET" && p === "/__share/boot.js") {
1243
- const body = this.bootScript(viewer);
1842
+ const body = this.bootScript(viewer, new URL(request.url ?? "/", "http://local").searchParams);
1244
1843
  response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
1245
1844
  return true;
1246
1845
  }
1247
1846
  if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
1248
1847
  if (method === "GET" && p === "/__share/ui.js") {
1249
- fs5.stat(this.options.uiFile, (error, stat) => {
1848
+ fs6.stat(this.options.uiFile, (error, stat) => {
1250
1849
  if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
1251
1850
  sendFile(request, response, this.options.uiFile, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": stat.size, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
1252
1851
  });
@@ -1257,24 +1856,27 @@ var ShareHost = class {
1257
1856
  sendJson2(response, 404, { code: "not_found", message: "Not found" });
1258
1857
  return true;
1259
1858
  }
1260
- /** The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). */
1261
- headTags(_viewer) {
1262
- let tags = '<script src="/__share/boot.js"></script>';
1263
- if (fs5.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
1859
+ /**
1860
+ * The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). `query`
1861
+ * ("?…") goes to the boot script, for `globals`.
1862
+ */
1863
+ headTags(_viewer, query = "") {
1864
+ let tags = `<script src="/__share/boot.js${query.replace(/[<>"']/g, "").replace(/&/g, "&amp;")}"></script>`;
1865
+ if (fs6.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
1264
1866
  return tags;
1265
1867
  }
1266
1868
  /** `html` with the head tags first in its <head>; null when it has none. */
1267
- injectHead(html, viewer) {
1869
+ injectHead(html, viewer, query = "") {
1268
1870
  const at = html.search(/<head(\s[^>]*)?>/i);
1269
1871
  if (at < 0) return null;
1270
1872
  const end = html.indexOf(">", at) + 1;
1271
- return html.slice(0, end) + this.headTags(viewer) + html.slice(end);
1873
+ return html.slice(0, end) + this.headTags(viewer, query) + html.slice(end);
1272
1874
  }
1273
1875
  /** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
1274
- bootScript(viewer) {
1876
+ bootScript(viewer, query = new URLSearchParams()) {
1275
1877
  const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
1276
1878
  let js = `${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
1277
- for (const [name, value] of Object.entries(this.options.globals?.(viewer) ?? {})) js += `window[${script(name)}]=${script(value)};`;
1879
+ for (const [name, value] of Object.entries(this.options.globals?.(viewer, query) ?? {})) js += `window[${script(name)}]=${script(value)};`;
1278
1880
  if (viewer.guest) js += GUEST_WATCH;
1279
1881
  return js;
1280
1882
  }
@@ -1411,7 +2013,7 @@ var ShareHost = class {
1411
2013
  } catch {
1412
2014
  return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
1413
2015
  }
1414
- if (!isRecord3(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
2016
+ if (!isRecord4(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
1415
2017
  try {
1416
2018
  return sendJson2(response, 200, await this.share(action));
1417
2019
  } catch (error) {
@@ -1471,6 +2073,333 @@ var ZH = (noun) => ({
1471
2073
  ownerHere: `\u4F60\u5728\u5206\u4EAB${noun.zhThis}\u7684\u7535\u8111\u4E0A\u3002\u4EE5\u6240\u6709\u8005\u8EAB\u4EFD\u6253\u5F00\uFF1A`
1472
2074
  });
1473
2075
 
2076
+ var whoOf = (viewer) => ({ by: "human", user: { id: viewer.user.id, name: viewer.user.name }, role: viewer.role });
2077
+ var MAX_BODY = 64 << 10;
2078
+ var HistoryHost = class {
2079
+ history;
2080
+ options;
2081
+ constructor(options) {
2082
+ this.options = options;
2083
+ this.history = options.history;
2084
+ }
2085
+ /** What the page's boot script tells the panel (window.__baoyuHistory); `preview` on a version's preview page. */
2086
+ bootGlobal(viewer, preview) {
2087
+ const global = { base: this.options.base, canRestore: canEditAs(viewer.role), me: viewer.user.id };
2088
+ const version = preview === void 0 ? void 0 : this.history.version(preview);
2089
+ if (version) {
2090
+ const newest = this.history.versions().at(-1);
2091
+ global.preview = { n: version.n, at: version.at, by: version.by, name: version.user.name, current: version === newest, current_url: this.options.base };
2092
+ }
2093
+ return global;
2094
+ }
2095
+ notices(viewer, label) {
2096
+ const head = this.history.head();
2097
+ const gone = (n) => !head.has(n.path) && this.history.version(n.n)?.changes[n.path] === null;
2098
+ return this.history.noticesFor(viewer.user.id).map((n) => noticeView(n, label, this.history.version(n.lost.n)?.at, gone(n)));
2099
+ }
2100
+ list(viewer) {
2101
+ const label = this.options.labels();
2102
+ const versions = this.history.versions();
2103
+ const changes = this.history.fileChanges();
2104
+ const newest = versions.at(-1);
2105
+ const rows = [...versions].reverse().map((v) => ({
2106
+ n: v.n,
2107
+ first: v.first,
2108
+ at: v.at,
2109
+ by: v.by,
2110
+ name: v.user.name,
2111
+ userId: v.user.id,
2112
+ ...v.role ? { role: v.role } : {},
2113
+ ...v.restoredFrom !== void 0 ? { restoredFrom: v.restoredFrom } : {},
2114
+ ...v.restoredPart ? { restoredPart: true } : {},
2115
+ ...v === newest ? { current: true } : {},
2116
+ preview: this.options.previewUrl(v.n),
2117
+ files: (changes.get(v.n) ?? []).map((c) => ({ path: c.path, op: c.op, label: label(c.path) })),
2118
+ ...v.overwrote?.length ? { overwrote: v.overwrote.map((o) => ({ path: o.path, label: label(o.path), name: o.user.name, by: o.by, n: o.n, ...o.soft ? { soft: true } : {} })) } : {}
2119
+ }));
2120
+ return { me: viewer.user.id, canRestore: canEditAs(viewer.role), rows, notices: this.notices(viewer, label) };
2121
+ }
2122
+ /** Answers `__history…` (`sub` is what follows it); false for a path it doesn't know. */
2123
+ async handle(request, response, sub, viewer) {
2124
+ const method = request.method ?? "GET";
2125
+ const url = new URL(request.url ?? "/", "http://local");
2126
+ if (method === "POST" && !sameOrigin(request)) {
2127
+ request.resume();
2128
+ sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
2129
+ return true;
2130
+ }
2131
+ if (method === "GET" && (sub === "" || sub === "/")) return sendJson2(response, 200, this.list(viewer)), true;
2132
+ if (sub === "/notices") {
2133
+ if (method === "GET") return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
2134
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" }), true;
2135
+ const body = await this.body(request, response);
2136
+ if (!body) return true;
2137
+ if (typeof body.dismiss !== "string") return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {dismiss: id}." }), true;
2138
+ this.history.dismiss(viewer.user.id, body.dismiss);
2139
+ return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
2140
+ }
2141
+ const versionRoute = /^\/(\d+)\/(diff|restore)$/.exec(sub);
2142
+ if (versionRoute) {
2143
+ const n = Number(versionRoute[1]);
2144
+ const version = this.history.version(n);
2145
+ if (!version) return sendJson2(response, 404, { code: "not_found", message: `There is no version ${n} (it may be too old to keep).` }), true;
2146
+ if (versionRoute[2] === "diff") {
2147
+ if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
2148
+ const p = url.searchParams.get("path") ?? "";
2149
+ if (!(p in version.changes)) return sendJson2(response, 404, { code: "not_found", message: `Version ${n} did not change ${p}.` }), true;
2150
+ const before = this.history.stateBefore(n).get(p) ?? null;
2151
+ const after = version.changes[p] ?? null;
2152
+ return sendJson2(response, 200, this.diff(p, before, after)), true;
2153
+ }
2154
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
2155
+ if (!canEditAs(viewer.role)) return sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can bring a version back." }), true;
2156
+ const body = await this.body(request, response);
2157
+ if (!body) return true;
2158
+ const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : void 0;
2159
+ const changes = this.history.restoreTo(n, paths, this.options.tracked);
2160
+ if (!changes) return sendJson2(response, 409, { code: "unavailable", message: `Version ${n} can't be brought back: a file it holds is missing from the history.` }), true;
2161
+ const made = await this.options.apply(changes, whoOf(viewer), { restoredFrom: n });
2162
+ this.options.log(`history: ${viewer.user.name} brought back version ${n}${paths ? ` (${paths.join(", ")})` : ""}${made ? ` as version ${made.n}` : " (nothing to change)"}`);
2163
+ return sendJson2(response, 200, { version: made?.n ?? null, changed: [...changes.keys()] }), true;
2164
+ }
2165
+ const noticeRoute = /^\/notice\/([0-9a-f]{6,32})\/(diff|recover)$/.exec(sub);
2166
+ if (noticeRoute) {
2167
+ const notice = this.history.notice(noticeRoute[1]);
2168
+ if (!notice || notice.to !== viewer.user.id) return sendJson2(response, 404, { code: "not_found", message: "No such notice." }), true;
2169
+ if (noticeRoute[2] === "diff") {
2170
+ if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
2171
+ const now2 = this.history.head().get(notice.path) ?? null;
2172
+ const nowBytes = now2 === null ? null : this.history.read(now2);
2173
+ const back2 = this.recovered(notice, nowBytes);
2174
+ if (now2 && !nowBytes || !back2) return sendJson2(response, 200, { path: notice.path, label: this.options.labels()(notice.path), missing: true, added: 0, removed: 0, lines: [], now: now2 }), true;
2175
+ return sendJson2(response, 200, { ...this.diffBytes(notice.path, nowBytes, back2.bytes), now: now2 }), true;
2176
+ }
2177
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
2178
+ if (!canEditAs(viewer.role)) return request.resume(), sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can change the files." }), true;
2179
+ const body = await this.body(request, response);
2180
+ if (!body) return true;
2181
+ const now = this.history.head().get(notice.path) ?? null;
2182
+ if ("now" in body && body.now !== now) {
2183
+ return sendJson2(response, 409, { code: "changed", message: "This changed while you were looking. Compare again." }), true;
2184
+ }
2185
+ const back = this.recovered(notice, now === null ? null : this.history.read(now));
2186
+ if (!back) return sendJson2(response, 409, { code: "unavailable", message: "That content is no longer in the history." }), true;
2187
+ const made = await this.options.apply(/* @__PURE__ */ new Map([[notice.path, back.bytes]]), whoOf(viewer), { restoredFrom: notice.lost.n, ...back.part ? { restoredPart: true } : {} });
2188
+ this.history.dismiss(viewer.user.id, notice.id);
2189
+ this.options.log(`history: ${viewer.user.name} brought back ${notice.lost.user.name}'s ${back.bytes === null ? "deletion of " : ""}${notice.path} (version ${notice.lost.n}${back.part ? ", what was replaced" : ""})`);
2190
+ return sendJson2(response, 200, { version: made?.n ?? null }), true;
2191
+ }
2192
+ return false;
2193
+ }
2194
+ /** Before → after of one file, with the versions' names for the panel's header. */
2195
+ diff(p, before, after) {
2196
+ const read = (sha) => sha === null ? null : this.history.read(sha);
2197
+ const a = read(before);
2198
+ const b = read(after);
2199
+ if (before && !a || after && !b) return { path: p, label: this.options.labels()(p), missing: true, added: 0, removed: 0, lines: [] };
2200
+ return this.diffBytes(p, a, b);
2201
+ }
2202
+ diffBytes(p, a, b) {
2203
+ const [x, y] = this.options.diffSides ? this.options.diffSides(p, a, b) : [a, b];
2204
+ return { path: p, label: this.options.labels()(p), ...diffContents(x, y) };
2205
+ }
2206
+ /**
2207
+ * What bringing a notice's lost content back writes to its file (`now`: what the file holds): the lost content merged
2208
+ * into it when the notice has a base and the server can merge (`part` when that is not the whole lost content), else
2209
+ * the whole lost content; a deletion deletes it again (bytes null). null: not in the history.
2210
+ */
2211
+ recovered(notice, now) {
2212
+ if (notice.lost.sha === null) return { bytes: null, part: false };
2213
+ const lost = this.history.read(notice.lost.sha);
2214
+ if (!lost) return null;
2215
+ const base = notice.base ? this.history.read(notice.base) : null;
2216
+ const merged = base && this.options.recover ? this.options.recover(notice.path, base, lost, now) : null;
2217
+ return merged ? { bytes: merged, part: !merged.equals(lost) } : { bytes: lost, part: false };
2218
+ }
2219
+ async body(request, response) {
2220
+ try {
2221
+ const raw = (await readBody(request, MAX_BODY)).toString("utf8");
2222
+ const body = raw.trim() ? JSON.parse(raw) : {};
2223
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) return body;
2224
+ } catch {
2225
+ }
2226
+ sendJson2(response, 400, { code: "invalid_argument", message: "Expected a JSON object." });
2227
+ return null;
2228
+ }
2229
+ };
2230
+ function noticeView(n, label, lostAt, gone) {
2231
+ return {
2232
+ ...lostAt ? { lostAt } : {},
2233
+ id: n.id,
2234
+ kind: n.kind,
2235
+ at: n.at,
2236
+ path: n.path,
2237
+ label: label(n.path),
2238
+ n: n.n,
2239
+ name: n.by.name,
2240
+ by: n.byAuthor,
2241
+ lostName: n.lost.user.name,
2242
+ lostBy: n.lost.by,
2243
+ lostN: n.lost.n,
2244
+ ...n.soft ? { soft: true } : {},
2245
+ // Bringing it back puts back only what was replaced (a write with a base); into a deleted file, all of it.
2246
+ ...n.base && !gone ? { part: true } : {},
2247
+ ...gone ? { removed: true } : {},
2248
+ // What was replaced is a deletion: bringing it back deletes the file again.
2249
+ ...n.lost.sha === null ? { deleted: true } : {}
2250
+ };
2251
+ }
2252
+ var plain = (text) => ({ en: text, zh: text });
2253
+
2254
+ var PEER_ID = /^[A-Za-z0-9_-]{8,64}$/;
2255
+ function agentWhere(main2, count, label) {
2256
+ const where = main2 ? label(main2) : null;
2257
+ if (!where || count <= 1) return where;
2258
+ return { en: `${where.en} and ${count - 1} more`, zh: `${where.zh} \u7B49 ${count} \u5904` };
2259
+ }
2260
+ var AGENT_COLOR = "#d25f25";
2261
+ var MAX_PEERS = 64;
2262
+ var MAX_PATCH_BYTES = 16 << 10;
2263
+ var MAX_FIELDS = 12;
2264
+ var MAX_PRESENCE_BYTES = 6 << 10;
2265
+ var BROADCAST_MS = 50;
2266
+ var LEAVE_GRACE_MS = 4e3;
2267
+ var AGENT_LINGER_MS = 2e4;
2268
+ var AGENT_PEER = "agent-claude";
2269
+ var PresenceHub = class {
2270
+ peers = /* @__PURE__ */ new Map();
2271
+ listeners = /* @__PURE__ */ new Set();
2272
+ agent = null;
2273
+ timer = null;
2274
+ /** Adds a page's stream; `send` writes one SSE frame to it. Returns what to call when the stream closes. */
2275
+ join(peer, viewer, send) {
2276
+ let entry = this.peers.get(peer);
2277
+ if (entry && entry.viewer.user.id !== viewer.user.id) {
2278
+ entry = void 0;
2279
+ peer = "";
2280
+ }
2281
+ if (peer && !entry && this.peers.size < MAX_PEERS) {
2282
+ entry = { viewer, presence: {}, updatedAt: Date.now(), streams: 0 };
2283
+ this.peers.set(peer, entry);
2284
+ }
2285
+ if (entry) {
2286
+ entry.streams += 1;
2287
+ if (entry.leaving) clearTimeout(entry.leaving);
2288
+ entry.leaving = void 0;
2289
+ }
2290
+ this.listeners.add(send);
2291
+ send(this.frame());
2292
+ this.schedule();
2293
+ let open = true;
2294
+ return () => {
2295
+ if (!open) return;
2296
+ open = false;
2297
+ this.listeners.delete(send);
2298
+ if (!entry) return;
2299
+ entry.streams -= 1;
2300
+ if (entry.streams > 0) return;
2301
+ const leaving = entry;
2302
+ leaving.leaving = setTimeout(() => {
2303
+ if (this.peers.get(peer) === leaving && leaving.streams === 0) {
2304
+ this.peers.delete(peer);
2305
+ this.schedule();
2306
+ }
2307
+ }, LEAVE_GRACE_MS);
2308
+ leaving.leaving.unref?.();
2309
+ };
2310
+ }
2311
+ /** Merges a page's patch into its presence; false when the peer isn't this person's open page or the patch is bad. */
2312
+ update(peer, viewer, patch) {
2313
+ const entry = this.peers.get(peer);
2314
+ if (!entry || entry.viewer.user.id !== viewer.user.id) return false;
2315
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) return false;
2316
+ const next = { ...entry.presence };
2317
+ for (const [key, value] of Object.entries(patch)) {
2318
+ if (value === null || value === void 0) delete next[key];
2319
+ else next[key] = value;
2320
+ }
2321
+ if (Object.keys(next).length > MAX_FIELDS || Buffer.byteLength(JSON.stringify(next)) > MAX_PRESENCE_BYTES) return false;
2322
+ entry.presence = next;
2323
+ entry.updatedAt = Date.now();
2324
+ this.schedule();
2325
+ return true;
2326
+ }
2327
+ /** The agent changed the artifact: it shows on the list (with `context`, where it worked) for a while. */
2328
+ agentActive(agent = {}) {
2329
+ if (this.agent) clearTimeout(this.agent.timer);
2330
+ const presence = { who: { n: agent.name ?? "Agent", c: AGENT_COLOR } };
2331
+ if (agent.context) presence.context = agent.context;
2332
+ if (agent.where) presence.where = agent.where;
2333
+ const timer = setTimeout(() => {
2334
+ this.agent = null;
2335
+ this.schedule();
2336
+ }, AGENT_LINGER_MS);
2337
+ timer.unref?.();
2338
+ this.agent = { presence, updatedAt: Date.now(), timer };
2339
+ this.schedule();
2340
+ }
2341
+ /** Everyone on the list now. */
2342
+ list() {
2343
+ const views = [];
2344
+ for (const [peer, entry] of this.peers) {
2345
+ const who = entry.presence.who;
2346
+ const presence = { ...entry.presence, who: { ...typeof who === "object" && who !== null && !Array.isArray(who) ? who : {}, n: entry.viewer.user.name } };
2347
+ views.push({ peer, by: entry.viewer.user.id, name: entry.viewer.user.name, role: entry.viewer.role, presence, updatedAt: entry.updatedAt });
2348
+ }
2349
+ if (this.agent) {
2350
+ const name = String(this.agent.presence.who.n ?? "Agent");
2351
+ views.push({ peer: AGENT_PEER, by: "agent", name, kind: "agent", presence: this.agent.presence, updatedAt: this.agent.updatedAt });
2352
+ }
2353
+ return views;
2354
+ }
2355
+ /** Answers POST `__presence` ({peer, patch}); GET gives the list. */
2356
+ async handle(request, response, viewer) {
2357
+ const method = request.method ?? "GET";
2358
+ if (method === "GET") return sendJson2(response, 200, { peers: this.list() });
2359
+ if (method !== "POST") {
2360
+ request.resume();
2361
+ return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" });
2362
+ }
2363
+ if (!sameOrigin(request)) {
2364
+ request.resume();
2365
+ return sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
2366
+ }
2367
+ let body;
2368
+ try {
2369
+ body = JSON.parse((await readBody(request, MAX_PATCH_BYTES)).toString("utf8"));
2370
+ } catch {
2371
+ return sendJson2(response, 400, { code: "invalid_argument", message: `Expected {peer, patch} under ${MAX_PATCH_BYTES} bytes.` });
2372
+ }
2373
+ const { peer, patch } = typeof body === "object" && body !== null ? body : {};
2374
+ if (typeof peer !== "string" || !PEER_ID.test(peer)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {peer, patch}." });
2375
+ if (!this.update(peer, viewer, patch)) return sendJson2(response, 409, { code: "not_joined", message: "That page is not open here (its event stream is closed)." });
2376
+ response.writeHead(204, { "Cache-Control": "no-store" }).end();
2377
+ }
2378
+ /** Ends the timers (the server is closing). */
2379
+ close() {
2380
+ if (this.timer) clearTimeout(this.timer);
2381
+ if (this.agent) clearTimeout(this.agent.timer);
2382
+ for (const entry of this.peers.values()) if (entry.leaving) clearTimeout(entry.leaving);
2383
+ this.peers.clear();
2384
+ this.listeners.clear();
2385
+ }
2386
+ frame() {
2387
+ return `event: peers
2388
+ data: ${JSON.stringify({ peers: this.list() })}
2389
+
2390
+ `;
2391
+ }
2392
+ schedule() {
2393
+ if (this.timer) return;
2394
+ this.timer = setTimeout(() => {
2395
+ this.timer = null;
2396
+ const frame = this.frame();
2397
+ for (const send of this.listeners) send(frame);
2398
+ }, BROADCAST_MS);
2399
+ this.timer.unref?.();
2400
+ }
2401
+ };
2402
+
1474
2403
  var KIND = "";
1475
2404
  var NOUN = "";
1476
2405
  var INDEX_FILE = "";
@@ -1478,6 +2407,8 @@ var LAYOUT;
1478
2407
  var pageScriptSrc;
1479
2408
  var sessionExtras;
1480
2409
  var commentLocation;
2410
+ var kindLabels;
2411
+ var kindAgentContext;
1481
2412
  var SCRIPT = "";
1482
2413
  var EDITOR_DIR = "";
1483
2414
  var MIME = {
@@ -1551,6 +2482,9 @@ var ROUTES = {
1551
2482
  session: "__deck/session"
1552
2483
  };
1553
2484
  var COMMENTS_ROUTE = "__comments";
2485
+ var HISTORY_ROUTE = "__history";
2486
+ var PRESENCE_ROUTE = "__presence";
2487
+ var PREVIEW_PATH = /^v\/(\d{1,9})(\/.*)?$/;
1554
2488
  var ASSET_INDEX = "index.json";
1555
2489
  var BLOB_ID = /^[0-9a-f]{32}$/;
1556
2490
  var BLOB_PATH = /^_blob\/([0-9a-f]{32})$/;
@@ -1561,8 +2495,11 @@ var SSE_KEEPALIVE_MS2 = 25e3;
1561
2495
  var WATCH_DEBOUNCE_MS2 = 300;
1562
2496
  var POLL_MS2 = 2e3;
1563
2497
  var CHANGE_LOG_SIZE = 64;
2498
+ var OVERWRITE_WINDOW_MS = 6e4;
2499
+ var filesOf = (listing, paths) => Object.fromEntries(paths.map((p) => [p, listing.files[p]?.sha256 ?? null]));
2500
+ var authorOf = (who) => ({ id: who.user.id, name: who.user.name, ...who.by === "agent" ? { agent: true } : {} });
1564
2501
  var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
1565
- var sha256 = (bytes) => createHash2("sha256").update(bytes).digest("hex");
2502
+ var sha2562 = (bytes) => createHash3("sha256").update(bytes).digest("hex");
1566
2503
  function contentTypeOf(file) {
1567
2504
  const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
1568
2505
  return MIME[ext] ?? "application/octet-stream";
@@ -1572,6 +2509,7 @@ function isProjectPath(p) {
1572
2509
  if (typeof p !== "string" || !p.startsWith("project/") || p.includes("\\") || p.includes("\0")) return false;
1573
2510
  return p.split("/").every((part) => part !== "" && part !== "." && part !== "..");
1574
2511
  }
2512
+ var SHA256 = /^[0-9a-f]{64}$/;
1575
2513
  var isWriteEntry = (entry) => entry !== null && !("delete" in entry);
1576
2514
  function isWireEntry(value) {
1577
2515
  if (value === null) return true;
@@ -1599,7 +2537,7 @@ function pageCsp(scriptSrc) {
1599
2537
  ].join("; ");
1600
2538
  }
1601
2539
  function versionOf(files) {
1602
- const digest = sha256(
2540
+ const digest = sha2562(
1603
2541
  Object.entries(files).map(([p, f]) => `${p}:${f.sha256}`).join("\n")
1604
2542
  );
1605
2543
  return "local-" + digest.slice(0, 16);
@@ -1610,7 +2548,7 @@ async function collectFiles(root, limit) {
1610
2548
  const seen = /* @__PURE__ */ new Set();
1611
2549
  let truncated = false;
1612
2550
  const visit = async (relDir, descend) => {
1613
- const dir = relDir === "" ? root : path5.join(root, ...relDir.split("/"));
2551
+ const dir = relDir === "" ? root : path6.join(root, ...relDir.split("/"));
1614
2552
  let entries;
1615
2553
  try {
1616
2554
  entries = await fsp2.readdir(dir, { withFileTypes: true });
@@ -1641,8 +2579,8 @@ async function collectFiles(root, limit) {
1641
2579
  return { rels, truncated };
1642
2580
  }
1643
2581
  async function writeFileAtomic(file, bytes) {
1644
- await fsp2.mkdir(path5.dirname(file), { recursive: true });
1645
- const tmp = path5.join(path5.dirname(file), `.${path5.basename(file)}.${randomUUID()}.tmp`);
2582
+ await fsp2.mkdir(path6.dirname(file), { recursive: true });
2583
+ const tmp = path6.join(path6.dirname(file), `.${path6.basename(file)}.${randomUUID()}.tmp`);
1646
2584
  try {
1647
2585
  await fsp2.writeFile(tmp, bytes);
1648
2586
  await fsp2.rename(tmp, file);
@@ -1654,19 +2592,27 @@ async function writeFileAtomic(file, bytes) {
1654
2592
  }
1655
2593
  var Store = class {
1656
2594
  root;
2595
+ history;
1657
2596
  log;
1658
2597
  /** project/<path> → sha256 of the page's last save not yet seen by the watcher ('' for a delete). */
1659
2598
  ownWrites;
2599
+ /** "<user id>\0project/<path>" → when the server last refused that person's save of the file as a conflict. */
2600
+ refused;
1660
2601
  queue;
1661
2602
  seq;
1662
2603
  truncationReported;
1663
- constructor(root, log) {
2604
+ /** The last listing left files out (more than the format's limit): its missing files are not deletes. */
2605
+ truncated;
2606
+ constructor(root, history, log) {
1664
2607
  this.root = root;
2608
+ this.history = history;
1665
2609
  this.log = log;
1666
2610
  this.ownWrites = /* @__PURE__ */ new Map();
2611
+ this.refused = /* @__PURE__ */ new Map();
1667
2612
  this.queue = Promise.resolve();
1668
2613
  this.seq = 0;
1669
2614
  this.truncationReported = false;
2615
+ this.truncated = false;
1670
2616
  }
1671
2617
  /** Run `work` after every save, upload and change check before it, and before the next. */
1672
2618
  exclusive(work) {
@@ -1684,11 +2630,11 @@ var Store = class {
1684
2630
  async diskPath(rel) {
1685
2631
  const realRoot = await fsp2.realpath(this.root).catch(() => null);
1686
2632
  if (realRoot === null) return null;
1687
- const file = path5.join(realRoot, ...rel.split("/"));
1688
- for (let dir = path5.dirname(file); ; dir = path5.dirname(dir)) {
2633
+ const file = path6.join(realRoot, ...rel.split("/"));
2634
+ for (let dir = path6.dirname(file); ; dir = path6.dirname(dir)) {
1689
2635
  const real = await fsp2.realpath(dir).catch(() => null);
1690
2636
  if (real === null) {
1691
- if (path5.dirname(dir) === dir) return null;
2637
+ if (path6.dirname(dir) === dir) return null;
1692
2638
  continue;
1693
2639
  }
1694
2640
  if (real !== dir) return null;
@@ -1700,6 +2646,7 @@ var Store = class {
1700
2646
  async listing() {
1701
2647
  const limit = LAYOUT.maxFiles;
1702
2648
  const { rels, truncated } = await collectFiles(this.root, limit);
2649
+ this.truncated = truncated;
1703
2650
  if (truncated && !this.truncationReported) {
1704
2651
  this.truncationReported = true;
1705
2652
  this.log(`The ${NOUN} folder has more than ${limit} files; the editor lists only the first ${limit}: ${this.root}`);
@@ -1710,7 +2657,7 @@ var Store = class {
1710
2657
  if (file === null) continue;
1711
2658
  const bytes = await fsp2.readFile(file).catch(() => null);
1712
2659
  if (bytes === null) continue;
1713
- files[`project/${rel}`] = { sha256: sha256(bytes), size: bytes.length, contentType: contentTypeOf(rel) };
2660
+ files[`project/${rel}`] = { sha256: sha2562(bytes), size: bytes.length, contentType: contentTypeOf(rel) };
1714
2661
  }
1715
2662
  return { ver: versionOf(files), files };
1716
2663
  }
@@ -1720,11 +2667,14 @@ var Store = class {
1720
2667
  if (file === null) return null;
1721
2668
  return fsp2.readFile(file).catch(() => null);
1722
2669
  }
1723
- /** A save from the page: all entries or none (ifMatch is checked for all before anything is written). */
1724
- publish(batch) {
1725
- return this.exclusive(() => this.publishNow(batch));
2670
+ /**
2671
+ * A save from the page by `who`: all entries or none (ifMatch is checked for all before anything is written). It is
2672
+ * a version in the history under their name; what changed on disk before it is one of its own, Claude's.
2673
+ */
2674
+ publish(batch, who, over = [], merged = []) {
2675
+ return this.exclusive(() => this.publishNow(batch, who, over, merged));
1726
2676
  }
1727
- async publishNow(batch) {
2677
+ async publishNow(batch, who, over, merged) {
1728
2678
  const files = /* @__PURE__ */ new Map();
1729
2679
  let total = 0;
1730
2680
  for (const [p, entry] of Object.entries(batch)) {
@@ -1743,6 +2693,7 @@ var Store = class {
1743
2693
  return { status: 413, body: { code: "too_large", message: `a publish can write at most ${LAYOUT.maxPublishBytes} bytes` } };
1744
2694
  }
1745
2695
  const before = await this.listing();
2696
+ await this.syncDisk(before);
1746
2697
  const conflicts = [];
1747
2698
  for (const [p, entry] of Object.entries(batch)) {
1748
2699
  const expected = entry?.ifMatch;
@@ -1750,24 +2701,121 @@ var Store = class {
1750
2701
  const actual = before.files[p]?.sha256 ?? null;
1751
2702
  if (actual !== expected) conflicts.push({ path: p, actual, expected });
1752
2703
  }
1753
- if (conflicts.length > 0) return { status: 409, body: { code: "conflict", live: before.ver, paths: conflicts } };
2704
+ if (conflicts.length > 0) {
2705
+ const now = Date.now();
2706
+ for (const c of conflicts) this.refused.set(`${who.user.id}\0${c.path}`, now);
2707
+ return { status: 409, body: { code: "conflict", live: before.ver, paths: conflicts } };
2708
+ }
2709
+ for (const m of merged) {
2710
+ if (batch[m.path] !== void 0 && before.files[m.path]?.sha256 === m.sha) this.refused.delete(`${who.user.id}\0${m.path}`);
2711
+ }
2712
+ const chosen = /* @__PURE__ */ new Map();
2713
+ for (const o of over) {
2714
+ if (batch[o.path] === void 0 || before.files[o.path]?.sha256 !== o.sha) continue;
2715
+ this.refused.set(`${who.user.id}\0${o.path}`, Date.now());
2716
+ chosen.set(o.path, o.base);
2717
+ }
2718
+ const overwrote = this.overwrites(batch, before, who, chosen);
1754
2719
  const shas = {};
2720
+ const written = /* @__PURE__ */ new Map();
1755
2721
  for (const [p, entry] of Object.entries(batch)) {
1756
2722
  const file = files.get(p);
1757
2723
  if (!isWriteEntry(entry)) {
1758
2724
  this.ownWrites.set(p, "");
1759
2725
  await fsp2.rm(file, { force: true });
2726
+ written.set(p, null);
1760
2727
  continue;
1761
2728
  }
1762
2729
  const bytes = Buffer.from(entry.base64, "base64");
1763
- const sha = sha256(bytes);
2730
+ const sha = sha2562(bytes);
1764
2731
  this.ownWrites.set(p, sha);
1765
2732
  await writeFileAtomic(file, bytes);
1766
2733
  shas[p] = sha;
2734
+ written.set(p, bytes);
1767
2735
  }
2736
+ const version = this.history.record(written, who, overwrote.length ? { overwrote } : {});
1768
2737
  const after = await this.listing();
1769
2738
  this.seq += 1;
1770
- return { status: 200, body: { version: after.ver, seq: this.seq, shas } };
2739
+ return { status: 200, body: { version: after.ver, seq: this.seq, shas }, version };
2740
+ }
2741
+ /**
2742
+ * The files this save replaces without its page having seen them: the server refused this person's save of the file
2743
+ * as a conflict moments ago (someone else had saved it since their page loaded it), and now the page saves on top of
2744
+ * what that someone saved. (The editors take the newer file and save their own over it.) Their own other tab's save,
2745
+ * and the content the history began with, are no one else's. `chosen`: the files the page says its person saw and
2746
+ * chose to save over (marked as refused above), whose overwrite is `chosen`, with the version the edits started
2747
+ * from as its `base` when the history holds that content (and it is not what was lost, which would bring back nothing).
2748
+ * A page that names no base (Keep mine from a bar or a banner, a save on top of a refused one) gets the file as it was
2749
+ * right before the lost version: what both started from when one saved while the other was editing, so Bring back
2750
+ * returns what that version changed and leaves the saver's other changes.
2751
+ */
2752
+ overwrites(batch, before, who, chosen) {
2753
+ const now = Date.now();
2754
+ const out = [];
2755
+ for (const [p, entry] of Object.entries(batch)) {
2756
+ const key = `${who.user.id}\0${p}`;
2757
+ const at = this.refused.get(key);
2758
+ if (at === void 0) continue;
2759
+ this.refused.delete(key);
2760
+ const lost = before.files[p]?.sha256;
2761
+ if (now - at > OVERWRITE_WINDOW_MS || !lost) continue;
2762
+ if ((isWriteEntry(entry) ? sha2562(Buffer.from(entry.base64, "base64")) : null) === lost) continue;
2763
+ const last = this.history.lastChange(p);
2764
+ if (!last || last.sha !== lost || last.version.by === "system" || last.version.user.id === who.user.id) continue;
2765
+ const base = chosen.get(p) ?? this.history.stateBefore(last.version.n).get(p);
2766
+ out.push({
2767
+ path: p,
2768
+ lost,
2769
+ n: last.version.n,
2770
+ by: last.version.by,
2771
+ user: { ...last.version.user },
2772
+ ...chosen.has(p) ? { chosen: true } : {},
2773
+ ...base && base !== lost && this.history.size(base) !== null ? { base } : {}
2774
+ });
2775
+ }
2776
+ for (const [key, at] of this.refused) if (now - at > OVERWRITE_WINDOW_MS) this.refused.delete(key);
2777
+ return out;
2778
+ }
2779
+ /**
2780
+ * Records what changed on disk since the history last looked, as Claude's (the agent's, or anything else on this
2781
+ * computer; the files as they were when the history began are a version of no one's). Runs inside `exclusive`.
2782
+ */
2783
+ async syncDisk(listing) {
2784
+ const head = this.history.head();
2785
+ const changes = /* @__PURE__ */ new Map();
2786
+ for (const [p, f] of Object.entries(listing.files)) {
2787
+ if (head.get(p) === f.sha256) continue;
2788
+ const bytes = await this.readProjectFile(p);
2789
+ if (bytes !== null) changes.set(p, bytes);
2790
+ }
2791
+ if (!this.truncated) {
2792
+ for (const p of head.keys()) if (!(p in listing.files) && this.fileOf(p) !== null) changes.set(p, null);
2793
+ }
2794
+ if (changes.size === 0) return null;
2795
+ const version = this.history.record(changes, this.history.empty ? SYSTEM : AGENT);
2796
+ if (version) this.log(`history: version ${version.n}${version.by === "agent" ? " (changed on disk)" : " (the files as they were)"}: ${[...changes.keys()].map((p) => p.replace(/^project\//, "")).join(", ")}`);
2797
+ return version;
2798
+ }
2799
+ /** Writes contents from the history (a restore) and records them as `who`'s version. */
2800
+ applyFromHistory(changes, who, extra) {
2801
+ return this.exclusive(async () => {
2802
+ await this.syncDisk(await this.listing());
2803
+ const written = /* @__PURE__ */ new Map();
2804
+ for (const [p, bytes] of changes) {
2805
+ const rel = this.fileOf(p);
2806
+ const file = rel === null ? null : await this.diskPath(rel);
2807
+ if (file === null) continue;
2808
+ if (bytes === null) {
2809
+ this.ownWrites.set(p, "");
2810
+ await fsp2.rm(file, { force: true });
2811
+ } else {
2812
+ this.ownWrites.set(p, sha2562(bytes));
2813
+ await writeFileAtomic(file, bytes);
2814
+ }
2815
+ written.set(p, bytes);
2816
+ }
2817
+ return { version: this.history.record(written, who, extra), paths: [...written.keys()] };
2818
+ });
1771
2819
  }
1772
2820
  async assetIndex() {
1773
2821
  try {
@@ -1784,7 +2832,7 @@ var Store = class {
1784
2832
  return this.exclusive(() => this.putBlobNow(bytes, contentType));
1785
2833
  }
1786
2834
  async putBlobNow(bytes, contentType) {
1787
- const id = sha256(bytes).slice(0, 32);
2835
+ const id = sha2562(bytes).slice(0, 32);
1788
2836
  const type = (contentType.split(";")[0] ?? "").trim().toLowerCase() || "application/octet-stream";
1789
2837
  if (await this.findBlobFile(id) === null) {
1790
2838
  const file = await this.diskPath(`${LAYOUT.blobDir}/${id}.${assetExtensionOf(type)}`);
@@ -1801,7 +2849,7 @@ var Store = class {
1801
2849
  }
1802
2850
  async findBlobFile(id) {
1803
2851
  try {
1804
- const names = await fsp2.readdir(path5.join(this.root, LAYOUT.blobDir));
2852
+ const names = await fsp2.readdir(path6.join(this.root, LAYOUT.blobDir));
1805
2853
  const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
1806
2854
  if (name === void 0) return null;
1807
2855
  const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
@@ -1828,7 +2876,7 @@ var Store = class {
1828
2876
  let sha = "";
1829
2877
  try {
1830
2878
  const file = await this.diskPath(rel);
1831
- if (file !== null && (await fsp2.lstat(file)).isFile()) sha = sha256(await fsp2.readFile(file));
2879
+ if (file !== null && (await fsp2.lstat(file)).isFile()) sha = sha2562(await fsp2.readFile(file));
1832
2880
  } catch {
1833
2881
  sha = "";
1834
2882
  }
@@ -1856,13 +2904,13 @@ function watchArtifact(store, onChange, onError) {
1856
2904
  const directories = /* @__PURE__ */ new Map();
1857
2905
  const visit = (rel, descend) => {
1858
2906
  if (directories.size >= maxDirectories) return;
1859
- const dir = rel === "" ? store.root : path5.join(store.root, ...rel.split("/"));
1860
- if (directories.has(dir) || !fs6.existsSync(dir)) return;
1861
- const info = fs6.lstatSync(dir);
2907
+ const dir = rel === "" ? store.root : path6.join(store.root, ...rel.split("/"));
2908
+ if (directories.has(dir) || !fs7.existsSync(dir)) return;
2909
+ const info = fs7.lstatSync(dir);
1862
2910
  if (!info.isDirectory()) return;
1863
2911
  directories.set(dir, `${info.dev}:${info.ino}`);
1864
2912
  if (!descend) return;
1865
- for (const entry of fs6.readdirSync(dir, { withFileTypes: true })) {
2913
+ for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
1866
2914
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1867
2915
  const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
1868
2916
  if (LAYOUT.descendInto(child)) visit(child, true);
@@ -1879,19 +2927,19 @@ function watchArtifact(store, onChange, onError) {
1879
2927
  if (watchers.has(dir)) continue;
1880
2928
  let watcher;
1881
2929
  try {
1882
- watcher = fs6.watch(dir, (event, name) => {
2930
+ watcher = fs7.watch(dir, (event, name) => {
1883
2931
  if (stopped) return;
1884
2932
  if (name) {
1885
- const changed = path5.join(dir, String(name));
2933
+ const changed = path6.join(dir, String(name));
1886
2934
  if (event === "rename" && watchers.has(changed)) {
1887
2935
  for (const [watchedDir, item] of watchers) {
1888
- if (watchedDir === changed || watchedDir.startsWith(changed + path5.sep)) {
2936
+ if (watchedDir === changed || watchedDir.startsWith(changed + path6.sep)) {
1889
2937
  item.watcher.close();
1890
2938
  watchers.delete(watchedDir);
1891
2939
  }
1892
2940
  }
1893
2941
  }
1894
- const rel = path5.relative(store.root, changed).split(path5.sep).join("/");
2942
+ const rel = path6.relative(store.root, changed).split(path6.sep).join("/");
1895
2943
  if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
1896
2944
  }
1897
2945
  schedule();
@@ -1909,6 +2957,7 @@ function watchArtifact(store, onChange, onError) {
1909
2957
  try {
1910
2958
  refresh();
1911
2959
  const current = await store.listing();
2960
+ await store.syncDisk(current);
1912
2961
  if (seen === null) {
1913
2962
  seen = new Map(Object.entries(current.files).map(([p, f]) => [p, f.sha256]));
1914
2963
  pollSignature = await signature();
@@ -1916,7 +2965,8 @@ function watchArtifact(store, onChange, onError) {
1916
2965
  }
1917
2966
  const external = await store.externalChanges(/* @__PURE__ */ new Set([...seen.keys(), ...Object.keys(current.files)]), seen);
1918
2967
  if (stopped || external.length === 0) return;
1919
- onChange({ type: "external-change", paths: external, ver: (await store.listing()).ver });
2968
+ const after = await store.listing();
2969
+ onChange({ type: "external-change", paths: external, ver: after.ver, files: filesOf(after, external) });
1920
2970
  } catch (error) {
1921
2971
  onError(error);
1922
2972
  }
@@ -1925,7 +2975,7 @@ function watchArtifact(store, onChange, onError) {
1925
2975
  const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
1926
2976
  const parts = [];
1927
2977
  for (const rel of rels) {
1928
- const info = await fsp2.lstat(path5.join(store.root, ...rel.split("/"))).catch(() => null);
2978
+ const info = await fsp2.lstat(path6.join(store.root, ...rel.split("/"))).catch(() => null);
1929
2979
  parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
1930
2980
  }
1931
2981
  return parts.join("\n");
@@ -1956,10 +3006,11 @@ function watchArtifact(store, onChange, onError) {
1956
3006
  var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
1957
3007
  async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
1958
3008
  `), agentHook, lan = false, portGiven = false }) {
1959
- const project = path5.join(root, "project");
3009
+ const project = path6.join(root, "project");
1960
3010
  const stamp = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
1961
3011
  const projectDir = await fsp2.realpath(project);
1962
- const store = new Store(projectDir, stamp);
3012
+ const history = new History(root);
3013
+ const store = new Store(projectDir, history, stamp);
1963
3014
  const owner = localOwner();
1964
3015
  const share = new ShareHost({
1965
3016
  root,
@@ -1969,7 +3020,12 @@ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1
1969
3020
  title: () => artifactTitle(root, projectDir),
1970
3021
  log: stamp,
1971
3022
  createServer: () => http.createServer(handle),
1972
- uiFile: path5.join(EDITOR_DIR, "share-ui.js")
3023
+ uiFile: path6.join(EDITOR_DIR, "share-ui.js"),
3024
+ // The History panel's address and what this person may do there; on a version's page (?at=n), which version.
3025
+ globals: (viewer, query) => {
3026
+ const at = Number(query.get("at") ?? "");
3027
+ return { __baoyuHistory: historyHost.bootGlobal(viewer, query.has("at") && Number.isInteger(at) ? at : void 0) };
3028
+ }
1973
3029
  });
1974
3030
  const comments = createCommentsService({
1975
3031
  root,
@@ -1983,10 +3039,11 @@ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1
1983
3039
  return viewer ? commentsViewer(viewer) : null;
1984
3040
  }
1985
3041
  });
1986
- const grant = randomBytes(24).toString("base64url");
3042
+ const grant = randomBytes2(24).toString("base64url");
1987
3043
  const editorPath = `/${KIND}/${grant}/`;
1988
- const boot = randomBytes(6).toString("hex");
3044
+ const boot = randomBytes2(6).toString("hex");
1989
3045
  const clients = /* @__PURE__ */ new Map();
3046
+ const presence = new PresenceHub();
1990
3047
  const changes = [];
1991
3048
  let changeSeq = 0;
1992
3049
  const pageCache = /* @__PURE__ */ new Map();
@@ -1998,15 +3055,58 @@ data: ${JSON.stringify(event)}
1998
3055
  `;
1999
3056
  const changed = (event, from) => {
2000
3057
  changeSeq += 1;
2001
- changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {} });
3058
+ changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {}, ...event.by ? { by: event.by } : {} });
2002
3059
  if (changes.length > CHANGE_LOG_SIZE) changes.shift();
2003
3060
  for (const [client, id] of clients) if (!from || id !== from) client.write(frame(event));
2004
3061
  };
3062
+ const labels2 = () => {
3063
+ let named;
3064
+ try {
3065
+ named = kindLabels?.(projectDir);
3066
+ } catch {
3067
+ named = void 0;
3068
+ }
3069
+ return (p) => {
3070
+ const rel = p.replace(/^project\//, "");
3071
+ return named?.(rel) ?? plain(rel);
3072
+ };
3073
+ };
3074
+ const agentWorked = (paths) => {
3075
+ const rels = paths.map((p) => p.replace(/^project\//, ""));
3076
+ const main2 = [...paths].reverse().find((p) => !p.endsWith(`/${INDEX_FILE}`) && p !== INDEX_FILE) ?? paths[0];
3077
+ let context = null;
3078
+ try {
3079
+ context = kindAgentContext?.(projectDir, rels) ?? null;
3080
+ } catch {
3081
+ context = null;
3082
+ }
3083
+ presence.agentActive({ context, where: agentWhere(main2, paths.length, labels2()) });
3084
+ };
3085
+ const historyHost = new HistoryHost({
3086
+ history,
3087
+ labels: labels2,
3088
+ base: editorPath,
3089
+ previewUrl: (n) => `${editorPath}v/${n}/`,
3090
+ apply: async (files, who, extra) => {
3091
+ const { version, paths } = await store.applyFromHistory(files, who, extra);
3092
+ if (paths.length) {
3093
+ const listing = await store.listing();
3094
+ changed({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), by: authorOf(who) });
3095
+ }
3096
+ return version;
3097
+ },
3098
+ tracked: (p) => store.fileOf(p) !== null,
3099
+ // A notice whose overwrite named the version its saver's edits started from: what was lost goes back by line into
3100
+ // the file as it is now, the lost lines winning where both changed them; a deleted file gets all of it back.
3101
+ recover: (_path, base, lost, now) => now === null ? lost : mergeTextBytes(base, lost, now),
3102
+ log: stamp
3103
+ });
2005
3104
  const stopWatch = watchArtifact(
2006
3105
  store,
2007
3106
  (event) => {
2008
3107
  stamp(`changed on disk: ${event.paths.join(", ")}${clients.size ? "" : " (no page open)"}`);
2009
- changed(event);
3108
+ changed({ ...event, by: authorOf(AGENT) });
3109
+ agentWorked(event.paths);
2010
3110
  },
2011
3111
  (error) => stamp(`watching failed: ${errorText(error)}`)
2012
3112
  );
@@ -2026,7 +3126,9 @@ data: ${JSON.stringify(event)}
2026
3126
  const missed = missedSince === null ? [] : truncated ? changes : changes.filter((c) => c.seq > missedSince && (!client || c.from !== client));
2027
3127
  if (missed.length) {
2028
3128
  const paths = [...new Set(missed.flatMap((c) => c.paths))];
2029
- response.write(frame({ type: "external-change", paths, ver: (await store.listing()).ver }));
3129
+ const listing = await store.listing();
3130
+ const by = missed.every((c) => c.by?.id === missed[0].by?.id) ? missed[0].by : void 0;
3131
+ response.write(frame({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), ...by ? { by } : {} }));
2030
3132
  } else {
2031
3133
  response.write(`id: ${eventId()}
2032
3134
 
@@ -2034,11 +3136,13 @@ data: ${JSON.stringify(event)}
2034
3136
  }
2035
3137
  clients.set(response, client);
2036
3138
  share.hold(viewer, response);
3139
+ const leave = presence.join(client, viewer, (text) => response.write(text));
2037
3140
  const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
2038
3141
  keepAlive.unref?.();
2039
3142
  const done = () => {
2040
3143
  clearInterval(keepAlive);
2041
3144
  clients.delete(response);
3145
+ leave();
2042
3146
  };
2043
3147
  request.on("close", done);
2044
3148
  response.on("close", done);
@@ -2063,11 +3167,11 @@ data: ${JSON.stringify(event)}
2063
3167
  "Content-Security-Policy": DATA_CSP
2064
3168
  });
2065
3169
  };
2066
- const serveEditor = async (request, response, rest, viewer) => {
3170
+ const serveEditor = async (request, response, rest, viewer, query = "") => {
2067
3171
  const parts = rest === "" ? ["index.html"] : rest.split("/");
2068
3172
  const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
2069
3173
  const realDist = invalid ? null : await fsp2.realpath(EDITOR_DIR).catch(() => null);
2070
- const file = realDist === null ? null : path5.join(realDist, ...parts);
3174
+ const file = realDist === null ? null : path6.join(realDist, ...parts);
2071
3175
  const real = file === null ? null : await fsp2.realpath(file).catch(() => null);
2072
3176
  const info = real === null ? null : await fsp2.lstat(real).catch(() => null);
2073
3177
  if (file === null || real !== file || !info?.isFile()) {
@@ -2078,7 +3182,7 @@ data: ${JSON.stringify(event)}
2078
3182
  response.writeHead(404, { "Cache-Control": "no-store" }).end();
2079
3183
  return;
2080
3184
  }
2081
- const ext = path5.extname(file).toLowerCase();
3185
+ const ext = path6.extname(file).toLowerCase();
2082
3186
  const type = STATIC_TYPES[ext] ?? "application/octet-stream";
2083
3187
  if (ext === ".html") {
2084
3188
  const key = parts.join("/");
@@ -2088,7 +3192,7 @@ data: ${JSON.stringify(event)}
2088
3192
  page = { mtimeMs: info.mtimeMs, html, csp: pageCsp(pageScriptSrc(html)) };
2089
3193
  pageCache.set(key, page);
2090
3194
  }
2091
- const body = Buffer.from(share.injectHead(page.html, viewer) ?? page.html);
3195
+ const body = Buffer.from(share.injectHead(page.html, viewer, query) ?? page.html);
2092
3196
  response.writeHead(200, {
2093
3197
  "Content-Type": type,
2094
3198
  "Content-Length": body.length,
@@ -2108,29 +3212,40 @@ data: ${JSON.stringify(event)}
2108
3212
  "X-Content-Type-Options": "nosniff"
2109
3213
  });
2110
3214
  };
2111
- const publish = async (request, response) => {
3215
+ const publish = async (request, response, viewer) => {
2112
3216
  if (!/^application\/json\b/i.test(request.headers["content-type"] ?? "")) {
2113
3217
  publishError(response, 415, "invalid_content", "expected application/json");
2114
3218
  return;
2115
3219
  }
2116
3220
  const raw = await readBody(request, MAX_PUBLISH_BYTES);
2117
3221
  let files;
3222
+ let over = [];
3223
+ let merged = [];
3224
+ const named = (list) => Array.isArray(list) ? list.filter((o) => typeof o?.path === "string" && typeof o.sha === "string" && SHA256.test(o.sha)).map((o) => ({ path: o.path, sha: o.sha, ...typeof o.base === "string" && SHA256.test(o.base) ? { base: o.base } : {} })) : [];
2118
3225
  try {
2119
3226
  const body = JSON.parse(raw.toString("utf8"));
2120
3227
  if (typeof body?.files !== "object" || body.files === null || Array.isArray(body.files)) throw new Error("files");
2121
3228
  if (!Object.values(body.files).every(isWireEntry)) throw new Error("entry");
2122
3229
  files = body.files;
3230
+ over = named(body.over);
3231
+ merged = named(body.merged);
2123
3232
  } catch {
2124
3233
  publishError(response, 400, "invalid_content", "malformed publish request");
2125
3234
  return;
2126
3235
  }
2127
- const outcome = await store.publish(files);
3236
+ const outcome = await store.publish(files, whoOf(viewer), over, merged);
2128
3237
  const names = Object.keys(files).map((p) => p.replace(/^project\//, ""));
2129
- stamp(`saved from the page (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""}): ${names.join(", ")}`);
3238
+ const made = outcome.version ? ` as version ${outcome.version.n}` : "";
3239
+ stamp(`saved from the page by ${viewer.user.name} (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""})${made}: ${names.join(", ")}`);
2130
3240
  sendJson2(response, outcome.status, outcome.body);
2131
3241
  if (outcome.status === 200 && clients.size > 0) {
2132
3242
  const from = String(request.headers["x-deck-client"] ?? "");
2133
- changed({ type: "external-change", paths: Object.keys(files), ver: String(outcome.body.version ?? "") }, CLIENT_ID.test(from) ? from : void 0);
3243
+ const shas = outcome.body.shas ?? {};
3244
+ const paths = Object.keys(files);
3245
+ changed(
3246
+ { type: "external-change", paths, ver: String(outcome.body.version ?? ""), files: Object.fromEntries(paths.map((p) => [p, shas[p] ?? null])), by: authorOf(whoOf(viewer)) },
3247
+ CLIENT_ID.test(from) ? from : void 0
3248
+ );
2134
3249
  }
2135
3250
  };
2136
3251
  const upload = async (request, response) => {
@@ -2150,8 +3265,87 @@ data: ${JSON.stringify(event)}
2150
3265
  if (viewer.guest && "userName" in extras) extras.userName = viewer.user.name;
2151
3266
  return { canEdit: canEditAs(viewer.role), ...extras, role: viewer.role };
2152
3267
  };
3268
+ const previewRoute = async (request, response, n, rest, viewer) => {
3269
+ const method = request.method ?? "GET";
3270
+ const state = history.stateAt(n);
3271
+ if (!state) {
3272
+ request.resume();
3273
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end(`There is no version ${n} (it may be too old to keep).`);
3274
+ return;
3275
+ }
3276
+ if (rest === ROUTES.publish || rest === ROUTES.assets) {
3277
+ request.resume();
3278
+ publishError(response, 403, "not_writer", "This is an earlier version: open the current one to edit.");
3279
+ return;
3280
+ }
3281
+ if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
3282
+ if (method !== "GET") {
3283
+ request.resume();
3284
+ publishError(response, 403, "not_writer", "This is an earlier version: comment on the current one.");
3285
+ return;
3286
+ }
3287
+ return comments.handle(request, response, rest.slice(COMMENTS_ROUTE.length));
3288
+ }
3289
+ if (method !== "GET" && method !== "HEAD") {
3290
+ response.writeHead(405, { Allow: "GET, HEAD" }).end();
3291
+ return;
3292
+ }
3293
+ if (rest === ROUTES.listing) {
3294
+ const files = {};
3295
+ for (const [p, sha] of [...state].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
3296
+ if (store.fileOf(p) === null) continue;
3297
+ files[p] = { sha256: sha, size: history.size(sha) ?? 0, contentType: contentTypeOf(p) };
3298
+ }
3299
+ return sendJson2(response, 200, { ver: `history-${n}`, files });
3300
+ }
3301
+ if (rest === ROUTES.session) return sendJson2(response, 200, { ...session(viewer), canEdit: false });
3302
+ if (rest === ROUTES.events) {
3303
+ response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", "X-Accel-Buffering": "no" });
3304
+ response.write(": connected\n\n");
3305
+ const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
3306
+ keepAlive.unref?.();
3307
+ request.on("close", () => clearInterval(keepAlive));
3308
+ return;
3309
+ }
3310
+ if (rest.startsWith("project/")) {
3311
+ const sha = store.fileOf(rest) === null ? void 0 : state.get(rest);
3312
+ const bytes = sha === void 0 ? null : history.read(sha);
3313
+ if (bytes === null) {
3314
+ response.writeHead(404, { "Cache-Control": "no-store" }).end();
3315
+ return;
3316
+ }
3317
+ response.writeHead(200, {
3318
+ "Content-Type": contentTypeOf(rest),
3319
+ "Content-Length": bytes.byteLength,
3320
+ "Cache-Control": "no-store",
3321
+ "X-Content-Type-Options": "nosniff",
3322
+ "Content-Security-Policy": DATA_CSP
3323
+ });
3324
+ response.end(method === "HEAD" ? void 0 : bytes);
3325
+ return;
3326
+ }
3327
+ const blob = BLOB_PATH.exec(rest);
3328
+ if (blob) return serveBlob(request, response, blob[1]);
3329
+ return serveEditor(request, response, rest, viewer, `?at=${n}`);
3330
+ };
2153
3331
  const route = async (request, response, rest, viewer) => {
2154
3332
  const method = request.method ?? "GET";
3333
+ const preview = PREVIEW_PATH.exec(rest);
3334
+ if (preview) {
3335
+ if (preview[2] === void 0) {
3336
+ request.resume();
3337
+ response.writeHead(308, { Location: `${editorPath}${rest}/`, "Cache-Control": "no-store" }).end();
3338
+ return;
3339
+ }
3340
+ return previewRoute(request, response, Number(preview[1]), preview[2].slice(1), viewer);
3341
+ }
3342
+ if (rest === PRESENCE_ROUTE) return presence.handle(request, response, viewer);
3343
+ if (rest === HISTORY_ROUTE || rest.startsWith(`${HISTORY_ROUTE}/`)) {
3344
+ if (await historyHost.handle(request, response, rest.slice(HISTORY_ROUTE.length), viewer)) return;
3345
+ request.resume();
3346
+ response.writeHead(404, { "Cache-Control": "no-store" }).end();
3347
+ return;
3348
+ }
2155
3349
  if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
2156
3350
  const sub = rest.slice(COMMENTS_ROUTE.length);
2157
3351
  if (sub === "/events") share.hold(viewer, response);
@@ -2172,7 +3366,7 @@ data: ${JSON.stringify(event)}
2172
3366
  publishError(response, 403, "not_writer", `You can view this ${NOUN} but not edit it.`);
2173
3367
  return;
2174
3368
  }
2175
- if (rest === ROUTES.publish) await publish(request, response);
3369
+ if (rest === ROUTES.publish) await publish(request, response, viewer);
2176
3370
  else await upload(request, response);
2177
3371
  return;
2178
3372
  }
@@ -2263,6 +3457,7 @@ data: ${JSON.stringify(event)}
2263
3457
  stopWatch();
2264
3458
  comments.close();
2265
3459
  share.close();
3460
+ presence.close();
2266
3461
  for (const client of clients.keys()) client.end();
2267
3462
  clients.clear();
2268
3463
  server2.close();
@@ -2279,11 +3474,11 @@ data: ${JSON.stringify(event)}
2279
3474
  }
2280
3475
  function artifactTitle(root, projectDir) {
2281
3476
  try {
2282
- const index = JSON.parse(fs6.readFileSync(path5.join(projectDir, INDEX_FILE), "utf8"));
3477
+ const index = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE), "utf8"));
2283
3478
  for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
2284
3479
  } catch {
2285
3480
  }
2286
- return path5.basename(root);
3481
+ return path6.basename(root);
2287
3482
  }
2288
3483
  var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
2289
3484
  async function serveForeground({ root, port, host, agentHook, lan, portGiven }) {
@@ -2304,18 +3499,18 @@ async function main(argv) {
2304
3499
  }
2305
3500
  if (positional.length !== 1 || flags.has("help")) {
2306
3501
  process.stderr.write(
2307
- `Usage: node ${path5.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
3502
+ `Usage: node ${path6.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
2308
3503
  <root> holds project/${INDEX_FILE}.
2309
3504
  `
2310
3505
  );
2311
3506
  return 2;
2312
3507
  }
2313
- let root = path5.resolve(positional[0]);
2314
- if (path5.basename(root) === "project" && fs6.existsSync(path5.join(root, INDEX_FILE)) && !fs6.existsSync(path5.join(root, "project"))) {
2315
- root = path5.dirname(root);
3508
+ let root = path6.resolve(positional[0]);
3509
+ if (path6.basename(root) === "project" && fs7.existsSync(path6.join(root, INDEX_FILE)) && !fs7.existsSync(path6.join(root, "project"))) {
3510
+ root = path6.dirname(root);
2316
3511
  }
2317
- if (!fs6.statSync(path5.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
2318
- process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path5.join(root, "project", INDEX_FILE)} does not exist.` })}
3512
+ if (!fs7.statSync(path6.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
3513
+ process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path6.join(root, "project", INDEX_FILE)} does not exist.` })}
2319
3514
  `);
2320
3515
  return 2;
2321
3516
  }
@@ -2339,7 +3534,7 @@ async function main(argv) {
2339
3534
  }
2340
3535
  function isMain(moduleUrl) {
2341
3536
  try {
2342
- return Boolean(process.argv[1]) && pathToFileURL(fs6.realpathSync(process.argv[1])).href === moduleUrl;
3537
+ return Boolean(process.argv[1]) && pathToFileURL(fs7.realpathSync(process.argv[1])).href === moduleUrl;
2343
3538
  } catch {
2344
3539
  return false;
2345
3540
  }
@@ -2352,8 +3547,10 @@ function createArtifactServer(kind, moduleUrl) {
2352
3547
  pageScriptSrc = kind.pageScriptSrc;
2353
3548
  sessionExtras = kind.sessionExtras;
2354
3549
  commentLocation = kind.commentLocation;
3550
+ kindLabels = kind.labels;
3551
+ kindAgentContext = kind.agentContext;
2355
3552
  SCRIPT = fileURLToPath(moduleUrl);
2356
- EDITOR_DIR = path5.join(path5.dirname(SCRIPT), "editor");
3553
+ EDITOR_DIR = path6.join(path6.dirname(SCRIPT), "editor");
2357
3554
  const runIfMain = () => {
2358
3555
  if (!isMain(moduleUrl)) return;
2359
3556
  main(process.argv.slice(2)).then(
@@ -2403,7 +3600,7 @@ function commentLocation2(input, projectDir) {
2403
3600
  });
2404
3601
  let boards = [];
2405
3602
  try {
2406
- const listed = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE2), "utf8")).boards;
3603
+ const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).boards;
2407
3604
  if (listed && typeof listed === "object") boards = Object.keys(listed);
2408
3605
  } catch {
2409
3606
  }
@@ -2411,6 +3608,28 @@ function commentLocation2(input, projectDir) {
2411
3608
  const file = projectFile(board);
2412
3609
  return board && file ? { file, board } : { file: "project/canvas.json" };
2413
3610
  }
3611
+ function labels(projectDir) {
3612
+ const titles = /* @__PURE__ */ new Map();
3613
+ try {
3614
+ const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).boards;
3615
+ if (listed && typeof listed === "object") {
3616
+ for (const [file, board] of Object.entries(listed)) {
3617
+ const title = board?.title;
3618
+ if (typeof title === "string" && title.trim()) titles.set(file, title.trim());
3619
+ }
3620
+ }
3621
+ } catch {
3622
+ }
3623
+ return (rel) => {
3624
+ if (rel === INDEX_FILE2) return { en: "Canvas layout", zh: "\u753B\u5E03\u5E03\u5C40" };
3625
+ if (rel.endsWith(".dc.html")) {
3626
+ const name = titles.get(rel) ?? rel.slice(rel.lastIndexOf("/") + 1).replace(/\.dc\.html$/, "");
3627
+ return { en: `Board \xB7 ${name}`, zh: `\u753B\u677F \xB7 ${name}` };
3628
+ }
3629
+ if (rel.startsWith("ds/")) return { en: `Design system \xB7 ${rel.slice(3)}`, zh: `\u8BBE\u8BA1\u7CFB\u7EDF \xB7 ${rel.slice(3)}` };
3630
+ return null;
3631
+ };
3632
+ }
2414
3633
  var server = createArtifactServer(
2415
3634
  {
2416
3635
  kind: KIND2,
@@ -2420,7 +3639,8 @@ var server = createArtifactServer(
2420
3639
  /** Boards run inline scripts, compile JSX with Babel (eval) and load from blob:. */
2421
3640
  pageScriptSrc: () => "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
2422
3641
  sessionExtras: () => ({}),
2423
- commentLocation: commentLocation2
3642
+ commentLocation: commentLocation2,
3643
+ labels
2424
3644
  },
2425
3645
  import.meta.url
2426
3646
  );